refactor: merge main

This commit is contained in:
Catriel Müller
2026-05-28 11:18:39 -03:00
1372 changed files with 112457 additions and 41226 deletions
+22 -11
View File
@@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s
```typescript
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => {
it.live("does the thing", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* MyService.Service
const out = yield* svc.run()
expect(out).toEqual("ok")
}),
),
it.instance("does the thing", () =>
Effect.gen(function* () {
const svc = yield* MyService.Service
const out = yield* svc.run()
expect(out).toEqual("ok")
}),
)
})
```
@@ -111,6 +108,7 @@ describe("my service", () => {
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context.
- Most integration-style tests in this package use `it.live(...)`.
### Effect Fixtures
@@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test.
Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path:
```typescript
import { TestInstance } from "../fixture/fixture"
it.instance("uses the temp directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(test.directory).toContain("opencode-test-")
}),
)
```
Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime.
### Style
@@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst
- Keep the test body inside `Effect.gen(function* () { ... })`.
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
@@ -22,9 +22,10 @@ const _typeCheck: _AssertAgentImplementsACPAgent = true
describe("acp.agent interface compliance", () => {
// Extract method names from the ACPAgent interface type
type ACPAgentMethods = keyof ACPAgent
type ACPRuntimeMethod = ACPAgentMethods | "resumeSession" | "closeSession" // kilocode_change
// Methods that the SDK's router explicitly checks for at runtime
const sdkCheckedMethods: ACPAgentMethods[] = [
const sdkCheckedMethods: ACPRuntimeMethod[] = [ // kilocode_change
// Required
"initialize",
"newSession",
@@ -34,10 +35,11 @@ describe("acp.agent interface compliance", () => {
"loadSession",
"setSessionMode",
"authenticate",
// Unstable - SDK checks these with unstable_ prefix
// Capability-gated methods checked by the SDK router
"listSessions",
"resumeSession",
"closeSession",
"unstable_forkSession",
"unstable_resumeSession",
"unstable_setSessionModel",
]
@@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@kilocode/sdk/v2"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { tmpdir } from "../fixture/fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@@ -58,6 +59,7 @@ function toolEvent(
raw: opts.raw,
}
const payload: EventMessagePartUpdated = {
id: `evt_${opts.callID}`,
type: "message.part.updated",
properties: {
sessionID: sessionId,
@@ -264,7 +266,7 @@ function createFakeAgent() {
describe("acp.agent event subscription", () => {
test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, updates, stop } = createFakeAgent()
@@ -299,7 +301,7 @@ describe("acp.agent event subscription", () => {
test("does not emit user_message_chunk for live prompt parts", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
@@ -339,7 +341,7 @@ describe("acp.agent event subscription", () => {
test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, chunks, stop } = createFakeAgent()
@@ -391,7 +393,7 @@ describe("acp.agent event subscription", () => {
test("does not create additional event subscriptions on repeated loadSession()", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, calls, stop } = createFakeAgent()
@@ -413,7 +415,7 @@ describe("acp.agent event subscription", () => {
test("permission.asked events are handled and replied", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const permissionReplies: string[] = []
@@ -452,7 +454,7 @@ describe("acp.agent event subscription", () => {
test("permission prompt on session A does not block message updates for session B", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const permissionReplies: string[] = []
@@ -539,7 +541,7 @@ describe("acp.agent event subscription", () => {
test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
@@ -573,7 +575,7 @@ describe("acp.agent event subscription", () => {
test("emits synthetic pending before first running update for any tool", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
@@ -618,7 +620,7 @@ describe("acp.agent event subscription", () => {
test("does not emit duplicate synthetic pending after replayed running tool", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent()
@@ -677,7 +679,7 @@ describe("acp.agent event subscription", () => {
test("clears bash snapshot marker on pending state", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
+59 -54
View File
@@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { Global } from "@opencode-ai/core/global"
@@ -23,7 +23,7 @@ afterEach(async () => {
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agents = await load(tmp.path, (svc) => svc.list())
@@ -45,7 +45,7 @@ test("returns default native agents when no config", async () => {
// kilocode_change start - renamed from "build" to "code"
test("code agent has correct default properties", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const code = await load(tmp.path, (svc) => svc.get("code"))
@@ -62,10 +62,10 @@ test("code agent has correct default properties", async () => {
// kilocode_change start - ask agent tests
test("ask agent has correct default properties", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const ask = await Agent.get("ask")
const ask = await load(tmp.path, (svc) => svc.get("ask"))
expect(ask).toBeDefined()
expect(ask?.mode).toBe("primary")
expect(ask?.native).toBe(true)
@@ -96,10 +96,10 @@ test("ask agent denies edit/write/bash even when user config adds a specific edi
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const ask = await Agent.get("ask")
const ask = await load(tmp.path, (svc) => svc.get("ask"))
expect(ask).toBeDefined()
// user config must not leak edit capability into ask mode — even for the
// specific path the user allowed, ask mode must still deny it
@@ -122,7 +122,7 @@ test("ask agent denies edit/write/bash even when user config adds a specific edi
// kilocode_change start
test("plan agent denies edits except .kilo/plans/* and .opencode/plans/*", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const plan = await load(tmp.path, (svc) => svc.get("plan"))
@@ -143,7 +143,7 @@ test("plan agent user config allows cannot re-enable non-plan edits", async () =
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const plan = await load(tmp.path, (svc) => svc.get("plan"))
@@ -158,7 +158,7 @@ test("plan agent user config allows cannot re-enable non-plan edits", async () =
test("explore agent denies edit and write", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const explore = await load(tmp.path, (svc) => svc.get("explore"))
@@ -174,7 +174,7 @@ test("explore agent denies edit and write", async () => {
test("explore agent asks for external directories and allows whitelisted external paths", async () => {
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const explore = await load(tmp.path, (svc) => svc.get("explore"))
@@ -190,7 +190,7 @@ test("explore agent asks for external directories and allows whitelisted externa
test("general agent denies todo tools", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const general = await load(tmp.path, (svc) => svc.get("general"))
@@ -204,7 +204,7 @@ test("general agent denies todo tools", async () => {
test("compaction agent denies all permissions", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const compaction = await load(tmp.path, (svc) => svc.get("compaction"))
@@ -230,7 +230,7 @@ test("custom agent from config creates new agent", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent"))
@@ -261,7 +261,7 @@ test("custom agent config overrides native agent properties", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -286,7 +286,7 @@ test("agent disable removes agent from list", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const explore = await load(tmp.path, (svc) => svc.get("explore"))
@@ -314,7 +314,7 @@ test("agent permission config merges with defaults", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -337,7 +337,7 @@ test("global permission config applies to all agents", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -360,7 +360,7 @@ test("agent steps/maxSteps config sets steps property", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const code = await load(tmp.path, (svc) => svc.get("code")) // kilocode_change
@@ -379,7 +379,7 @@ test("agent mode can be overridden", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const explore = await load(tmp.path, (svc) => svc.get("explore"))
@@ -396,7 +396,7 @@ test("agent name can be overridden", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -415,7 +415,7 @@ test("agent prompt can be set from config", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -437,7 +437,7 @@ test("unknown agent properties are placed into options", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -464,7 +464,7 @@ test("agent options merge correctly", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -491,7 +491,7 @@ test("multiple custom agents can be defined", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agentA = await load(tmp.path, (svc) => svc.get("agent_a"))
@@ -520,7 +520,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name)
@@ -532,7 +532,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
test("Agent.get returns undefined for non-existent agent", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist"))
@@ -543,7 +543,7 @@ test("Agent.get returns undefined for non-existent agent", async () => {
test("default permission includes doom_loop and external_directory as ask", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -557,7 +557,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn
test("webfetch is allowed by default", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -583,7 +583,7 @@ test("legacy tools config converts to permissions", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -609,7 +609,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () =>
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -629,7 +629,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -644,7 +644,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
test("global tmp directory children are allowed for external_directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
@@ -671,7 +671,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -696,7 +696,7 @@ test("explicit Truncate.GLOB deny is respected", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change start - renamed from "build" to "code"
@@ -730,7 +730,7 @@ description: Permission skill.
process.env.KILO_TEST_HOME = tmp.path
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
@@ -746,7 +746,7 @@ description: Permission skill.
test("defaultAgent returns build when no default_agent config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
@@ -761,7 +761,7 @@ test("defaultAgent respects default_agent config set to plan", async () => {
default_agent: "plan",
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
@@ -781,7 +781,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
@@ -796,7 +796,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => {
default_agent: "explore",
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent')
@@ -810,7 +810,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () =
default_agent: "compaction",
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden')
@@ -824,7 +824,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn
default_agent: "does_not_exist",
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow(
@@ -846,7 +846,7 @@ test("defaultAgent returns plan when code is disabled and default_agent not set"
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
@@ -870,7 +870,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// kilocode_change - all primary agents are disabled
@@ -882,11 +882,16 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
// kilocode_change start - Backward compatibility tests for "build" -> "code" rename
test("Agent.get('build') returns code agent for backward compatibility", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const code = await Agent.get("code")
const [build, code] = await load(tmp.path, (svc) =>
Effect.gen(function* () {
const build = yield* svc.get("build")
const code = yield* svc.get("code")
return [build, code] as const
}),
)
expect(build).toBeDefined()
expect(build).toBe(code)
expect(build?.name).toBe("code")
@@ -905,10 +910,10 @@ test("agent.build config applies to code agent for backward compatibility", asyn
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const code = await Agent.get("code")
const code = await load(tmp.path, (svc) => svc.get("code"))
expect(code).toBeDefined()
expect(code?.temperature).toBe(0.8)
expect(code?.color).toBe("#00FF00")
@@ -922,10 +927,10 @@ test("default_agent: 'build' returns code agent for backward compatibility", asy
default_agent: "build",
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agent = await Agent.defaultAgent()
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
expect(agent).toBe("code")
},
})
@@ -939,12 +944,12 @@ test("agent.build disable removes code agent for backward compatibility", async
},
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const code = await Agent.get("code")
const code = await load(tmp.path, (svc) => svc.get("code"))
expect(code).toBeUndefined()
const agents = await Agent.list()
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).not.toContain("code")
},
@@ -0,0 +1,28 @@
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { Agent } from "../../src/agent/agent"
import { Plugin } from "../../src/plugin"
import { testEffect } from "../lib/effect"
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin
// up — those services hang during scope teardown on Windows and aren't needed
// to verify plugin → config hook → Agent.list.
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer))
it.instance(
"plugin-registered agents appear in Agent.list",
() =>
Effect.gen(function* () {
yield* Plugin.Service.use((p) => p.init())
const agents = yield* Agent.Service.use((svc) => svc.list())
const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name)
expect(added?.description).toBe(PLUGIN_AGENT.description)
expect(added?.mode).toBe(PLUGIN_AGENT.mode)
}),
{ config: { plugin: [pluginUrl] } },
)
+79 -88
View File
@@ -2,9 +2,8 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const TestEvent = {
@@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node)
const it = testEffect(live)
describe("Bus (Effect-native)", () => {
it.live("publish + subscribe stream delivers events", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const done = yield* Deferred.make<void>()
it.instance("publish + subscribe stream delivers events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const done = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Ping, { value: 2 })
yield* Deferred.await(done)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Ping, { value: 2 })
yield* Deferred.await(done)
expect(received).toEqual([1, 2])
}),
),
expect(received).toEqual([1, 2])
}),
)
it.live("subscribe filters by event type", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const pings: number[] = []
const done = yield* Deferred.make<void>()
it.instance("subscribe filters by event type", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const pings: number[] = []
const done = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
pings.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
pings.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* Deferred.await(done)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* Deferred.await(done)
expect(pings).toEqual([42])
}),
),
expect(pings).toEqual([42])
}),
)
it.live("subscribeAll receives all types", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const types: string[] = []
const done = yield* Deferred.make<void>()
it.instance("subscribeAll receives all types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const types: string[] = []
const done = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
Effect.sync(() => {
types.push(evt.type)
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
Effect.sync(() => {
types.push(evt.type)
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Pong, { message: "hi" })
yield* Deferred.await(done)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Pong, { message: "hi" })
yield* Deferred.await(done)
expect(types).toContain("test.effect.ping")
expect(types).toContain("test.effect.pong")
}),
),
expect(types).toContain("test.effect.ping")
expect(types).toContain("test.effect.pong")
}),
)
it.live("multiple subscribers each receive the event", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const a: number[] = []
const b: number[] = []
const doneA = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
it.instance("multiple subscribers each receive the event", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const a: number[] = []
const b: number[] = []
const doneA = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
a.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
a.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
b.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => {
b.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void)
}),
).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 99 })
yield* Deferred.await(doneA)
yield* Deferred.await(doneB)
yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 99 })
yield* Deferred.await(doneA)
yield* Deferred.await(doneB)
expect(a).toEqual([99])
expect(b).toEqual([99])
}),
),
expect(a).toEqual([99])
expect(b).toEqual([99])
}),
)
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
@@ -2,13 +2,13 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
function withInstance(directory: string, fn: () => Promise<void>) {
return Instance.provide({ directory, fn })
return WithInstance.provide({ directory, fn })
}
describe("Bus integration: acquireRelease subscriber pattern", () => {
+2 -2
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const TestEvent = {
@@ -11,7 +11,7 @@ const TestEvent = {
}
function withInstance(directory: string, fn: () => Promise<void>) {
return Instance.provide({ directory, fn })
return WithInstance.provide({ directory, fn })
}
describe("Bus", () => {
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
describe("recentConnectedWorkspaces", () => {
test("returns unique connected workspaces after filtering missing and inactive entries", () => {
const workspaces = [
{ id: "wrk_a", name: "alpha" },
{ id: "wrk_b", name: "beta" },
{ id: "wrk_c", name: "gamma" },
{ id: "wrk_d", name: "delta" },
{ id: "wrk_e", name: "epsilon" },
]
const status = {
wrk_a: "connected",
wrk_b: "disconnected",
wrk_c: "error",
wrk_d: "connected",
wrk_e: "connected",
} as const
const { recent } = recentConnectedWorkspaces({
sessions: [
{ time: { updated: 900 } },
{ workspaceID: "wrk_b", time: { updated: 800 } },
{ workspaceID: "wrk_a", time: { updated: 700 } },
{ workspaceID: "wrk_a", time: { updated: 600 } },
{ workspaceID: "wrk_missing", time: { updated: 500 } },
{ workspaceID: "wrk_c", time: { updated: 400 } },
{ workspaceID: "wrk_d", time: { updated: 300 } },
{ workspaceID: "wrk_e", time: { updated: 200 } },
],
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
status: (workspaceID) => status[workspaceID as keyof typeof status],
})
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
})
test("omits the active workspace before limiting recent workspaces", () => {
const workspaces = [
{ id: "wrk_a", name: "alpha" },
{ id: "wrk_b", name: "beta" },
{ id: "wrk_c", name: "gamma" },
{ id: "wrk_d", name: "delta" },
]
const { recent, hasMore } = recentConnectedWorkspaces({
sessions: [
{ workspaceID: "wrk_a", time: { updated: 400 } },
{ workspaceID: "wrk_b", time: { updated: 300 } },
{ workspaceID: "wrk_c", time: { updated: 200 } },
{ workspaceID: "wrk_d", time: { updated: 100 } },
],
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
status: () => "connected",
limit: 3,
omitWorkspaceID: "wrk_a",
})
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"])
expect(hasMore).toBe(false)
})
})
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider"
describe("providerOptions", () => {
test("includes a synthetic Other option for custom providers", () => {
expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({
title: "Other",
description: "Custom provider",
category: "Providers",
})
})
test("does not use Other as the generic provider category", () => {
expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers")
})
test("does not collide with a configured provider named other", () => {
const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value)
expect(new Set(values).size).toBe(values.length)
})
test("normalizes and validates custom provider ids", () => {
expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider")
expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider")
expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider")
expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined()
expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined()
})
})
@@ -10,7 +10,7 @@ import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project"
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
import { ToastProvider } from "../../../../src/cli/cmd/tui/ui/toast" // kilocode_change
import { Instance } from "../../../../src/project/instance" // kilocode_change
import { WithInstance } from "../../../../src/project/with-instance" // kilocode_change
import { disposeAllInstances, tmpdir } from "../../../fixture/fixture"
const worktree = "/tmp/opencode"
@@ -138,7 +138,7 @@ describe("tui sync", () => {
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
const { app, kv, sync, session } = await Instance.provide({ directory: tmp.path, fn: mount }) // kilocode_change
const { app, kv, sync, session } = await WithInstance.provide({ directory: tmp.path, fn: mount }) // kilocode_change
try {
expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
@@ -0,0 +1,48 @@
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
})
// Regression for PR #25522: when an effectCmd handler does
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
// the inner runPromise creates a fresh fiber after `await` whose Effect context
// has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS
// callback boundary (Node ALS persists across awaits, Effect's fiber context
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite.
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
expect(source).toContain("Instance.restore(ctx")
})
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
await using dir = await tmpdir({ git: true })
await provideTestInstance({
directory: dir.path,
fn: () =>
Effect.runPromise(
Effect.promise(async () => {
await new Promise((r) => setTimeout(r, 5))
const current = await Effect.runPromise(
Effect.sync(() => {
try {
return Instance.current
} catch {
return undefined
}
}),
)
expect(current?.directory).toBe(dir.path)
}),
),
})
})
@@ -1,29 +1,37 @@
import { test, expect } from "bun:test"
import { parseGitHubRemote } from "../../src/cli/cmd/github"
// kilocode_change start: rebrand fixtures off upstream repo path
test("parses https URL with .git suffix", () => {
expect(parseGitHubRemote("https://github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("https://github.com/Kilo-Org/kilocode.git")).toEqual({
owner: "Kilo-Org",
repo: "kilocode",
})
})
test("parses https URL without .git suffix", () => {
expect(parseGitHubRemote("https://github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("https://github.com/Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
test("parses git@ URL with .git suffix", () => {
expect(parseGitHubRemote("git@github.com:sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("git@github.com:Kilo-Org/kilocode.git")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
test("parses git@ URL without .git suffix", () => {
expect(parseGitHubRemote("git@github.com:sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("git@github.com:Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
test("parses ssh:// URL with .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("ssh://git@github.com/Kilo-Org/kilocode.git")).toEqual({
owner: "Kilo-Org",
repo: "kilocode",
})
})
test("parses ssh:// URL without .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("ssh://git@github.com/Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
// kilocode_change end
test("parses http URL", () => {
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
@@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) {
} as unknown as typeof WebSocket
}
function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") {
socket.message(
JSON.stringify({
jsonrpc: "2.0",
method: "selection_changed",
params: {
text,
filePath,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
}),
)
}
function expectedSelection(filePath: string, text = "foo") {
return {
filePath,
source: "websocket" as const,
ranges: [
{
text,
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
}
}
test("useEditorContext reconnect switches editor server by session directory", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
@@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a
await nextTick()
expect(firstSocket.closed).toBeFalse()
sendSelection(firstSocket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.reconnect(sessionDirectory)
await nextTick()
expect(firstSocket.closed).toBeTrue()
expect(secondSocket.closed).toBeFalse()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
@@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => {
mounted.dispose()
})
test("useEditorContext resets selection when reconnecting", async () => {
test("useEditorContext clears selection when reconnecting", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
@@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => {
},
}),
)
socket.message(
JSON.stringify({
jsonrpc: "2.0",
method: "selection_changed",
params: {
text: "foo",
filePath: path.join(startupDirectory, "file.ts"),
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
}),
)
sendSelection(socket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.server()).toEqual({
protocolVersion: "2025-11-25",
serverInfo: { name: "test", version: "0.0.0" },
})
expect(mounted.editor.selection()).toEqual({
filePath: path.join(startupDirectory, "file.ts"),
source: "websocket",
ranges: [
{
text: "foo",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
],
})
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("pending")
mounted.editor.markSelectionSent()
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
test("useEditorContext preserves selection for the next reconnect when requested", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.KILO_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:3001")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
sendSelection(socket, path.join(startupDirectory, "file.ts"))
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
mounted.editor.markSelectionSent()
mounted.editor.preserveSelectionFromNewSession()
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
expect(mounted.editor.labelState()).toBe("sent")
mounted.editor.reconnect(startupDirectory)
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.labelState()).toBe("none")
mounted.dispose()
})
@@ -179,7 +179,8 @@ export default {
}
})
test(
// kilocode_change - skipped flaky test on Windows #9496
test.skipIf(process.platform === "win32")(
"times out hanging plugin cleanup on dispose",
async () => {
await using tmp = await tmpdir({
@@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string })
function vcs(branch: string): Event {
return {
id: `evt_vcs_${branch}`,
type: "vcs.branch.updated",
properties: {
branch,
@@ -34,6 +35,7 @@ function vcs(branch: string): Event {
function update(version: string): Event {
return {
id: `evt_update_${version}`,
type: "installation.update-available",
properties: {
version,
+250 -82
View File
@@ -7,13 +7,15 @@ import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
@@ -41,6 +43,12 @@ const emptyAuth = Layer.mock(Auth.Service)({
const testFlock = EffectFlock.defaultLayer
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(Option.none()),
})
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
@@ -48,7 +56,7 @@ const layer = Config.layer.pipe(
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(Npm.defaultLayer),
Layer.provide(noopNpm),
)
const it = testEffect(layer)
@@ -57,11 +65,23 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
const saveGlobal = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
Effect.runPromise(
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
Effect.map((result) => result.info),
Effect.scoped,
Effect.provide(layer),
),
)
const clear = async (wait = false) => {
await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
if (wait) await InstanceRuntime.disposeAllInstances()
}
const listDirs = () =>
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
// kilocode_change start
const warnings = () =>
Effect.runPromise(Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)))
// kilocode_change end
const ready = () =>
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
@@ -98,7 +118,7 @@ async function check(map: (dir: string) => string) {
$schema: "https://opencode.ai/config.json",
snapshot: false,
})
await Instance.provide({
await WithInstance.provide({
directory: map(tmp.path),
fn: async () => {
const cfg = await load()
@@ -108,7 +128,7 @@ async function check(map: (dir: string) => string) {
},
})
} finally {
await disposeAllInstances()
await InstanceRuntime.disposeAllInstances()
;(Global.Path as { config: string }).config = prev
await clear()
}
@@ -116,7 +136,7 @@ async function check(map: (dir: string) => string) {
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -135,7 +155,7 @@ test("loads JSON config file", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -154,7 +174,7 @@ test("loads shell config field", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -173,7 +193,7 @@ test("updates config and preserves empty shell sentinel", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await save({ shell: "" })
@@ -252,7 +272,7 @@ test("loads formatter boolean config", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -270,7 +290,7 @@ test("loads lsp boolean config", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -307,7 +327,7 @@ test("ignores legacy tui keys in opencode config", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -332,7 +352,7 @@ test("loads JSONC config file", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -360,7 +380,7 @@ test("jsonc overrides json in the same directory", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -370,6 +390,7 @@ test("jsonc overrides json in the same directory", async () => {
})
})
// kilocode_change start
test("prefers .kilo directory config over legacy .kilocode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -390,14 +411,15 @@ test("prefers .kilo directory config over legacy .kilocode", async () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.model).toBe("new/model")
},
})
})
// kilocode_change end
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
@@ -412,7 +434,7 @@ test("handles environment variable substitution", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -444,7 +466,7 @@ test("preserves env variables when adding $schema to config", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -510,6 +532,7 @@ test("resolves env templates in account config with account token", async () =>
Layer.provide(emptyAuth),
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
try {
@@ -520,7 +543,7 @@ test("resolves env templates in account config with account token", async () =>
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
}),
),
).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise)
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
if (originalControlToken !== undefined) {
process.env["KILO_CONSOLE_TOKEN"] = originalControlToken
@@ -540,7 +563,7 @@ test("handles file inclusion substitution", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -559,7 +582,7 @@ test("handles file inclusion with replacement tokens", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -568,6 +591,7 @@ test("handles file inclusion with replacement tokens", async () => {
})
})
// kilocode_change start
test("validates config schema and reports warning on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -577,33 +601,36 @@ test("validates config schema and reports warning on invalid fields", async () =
})
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
// kilocode_change - invalid schema surfaces as warnings, not a throw
// invalid schema surfaces as warnings, not a throw
await load()
const warnings = await Config.warnings()
expect(warnings.length).toBeGreaterThan(0)
const issues = await warnings()
expect(issues.length).toBeGreaterThan(0)
},
})
})
// kilocode_change end
// kilocode_change start
test("reports warning for invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "kilo.json"), "{ invalid json }")
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
// kilocode_change - invalid JSON surfaces as a warning, not a throw
// invalid JSON surfaces as a warning, not a throw
await load()
const warnings = await Config.warnings()
expect(warnings.length).toBeGreaterThan(0)
const issues = await warnings()
expect(issues.length).toBeGreaterThan(0)
},
})
})
// kilocode_change end
test("handles agent configuration", async () => {
await using tmp = await tmpdir({
@@ -620,7 +647,7 @@ test("handles agent configuration", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -651,7 +678,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -681,7 +708,7 @@ test("handles command configuration", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -706,7 +733,7 @@ test("migrates autoshare to share field", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -733,7 +760,7 @@ test("migrates mode field to agent field", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -765,7 +792,7 @@ Test agent prompt`,
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -798,7 +825,7 @@ Ordered permissions`,
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -836,7 +863,7 @@ Nested agent prompt`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -885,7 +912,7 @@ Nested command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -930,7 +957,7 @@ Nested command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -948,6 +975,7 @@ Nested command template`,
})
})
// kilocode_change start
test("prefers .kilo commands over legacy .kilocode commands", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -968,10 +996,10 @@ Hello from new command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.command?.["hello"]).toEqual({
description: "New command",
@@ -980,10 +1008,11 @@ Hello from new command`,
},
})
})
// kilocode_change end
test("gets config directories", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await listDirs()
@@ -1013,7 +1042,7 @@ test("does not try to install dependencies in read-only KILO_CONFIG_DIR", async
process.env.KILO_CONFIG_DIR = tmp.extra
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await load()
@@ -1037,11 +1066,6 @@ test("installs dependencies in writable KILO_CONFIG_DIR", async () => {
const prev = process.env.KILO_CONFIG_DIR
process.env.KILO_CONFIG_DIR = tmp.extra
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(Option.none()),
})
const testLayer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
@@ -1053,7 +1077,7 @@ test("installs dependencies in writable KILO_CONFIG_DIR", async () => {
)
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
@@ -1112,7 +1136,7 @@ test("resolves scoped npm plugins in config", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1150,7 +1174,7 @@ test("merges plugin arrays from global and local configs", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -1186,7 +1210,7 @@ Helper subagent prompt`,
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1225,7 +1249,7 @@ test("merges instructions arrays from global and local configs", async () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -1264,7 +1288,7 @@ test("deduplicates duplicate instructions from global and local configs", async
},
})
await Instance.provide({
await WithInstance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -1309,7 +1333,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -1358,7 +1382,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const cfg = await load()
@@ -1399,7 +1423,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1430,7 +1454,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1460,7 +1484,7 @@ test("migrates legacy write tool to edit permission", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1492,7 +1516,7 @@ test("managed settings override user settings", async () => {
share: "disabled",
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1520,7 +1544,7 @@ test("managed settings override project settings", async () => {
disabled_providers: ["openai"],
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1540,7 +1564,7 @@ test("missing managed settings file is not an error", async () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1567,7 +1591,7 @@ test("migrates legacy edit tool to edit permission", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1596,7 +1620,7 @@ test("migrates legacy patch tool to edit permission", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1628,7 +1652,7 @@ test("migrates mixed legacy tools config", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1663,7 +1687,7 @@ test("merges legacy tools with existing permission config", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1716,7 +1740,7 @@ test("permission config preserves user key order", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1767,6 +1791,73 @@ test("Effect config parser preserves permission order while rejecting unknown to
// MCP config merging tests
// kilocode_change start - regression for `env` alias on local MCP entries
test("local mcp accepts `env` as an alias for `environment`", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
mcp: {
context7: {
type: "local",
command: ["npx", "-y", "@upstash/context7-mcp"],
env: { CONTEXT7_API_KEY: "test-key" },
enabled: true,
},
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.mcp?.context7).toEqual({
type: "local",
command: ["npx", "-y", "@upstash/context7-mcp"],
environment: { CONTEXT7_API_KEY: "test-key" },
enabled: true,
})
},
})
})
test("local mcp prefers `environment` over `env` when both are present", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
mcp: {
context7: {
type: "local",
command: ["npx", "-y", "@upstash/context7-mcp"],
environment: { CONTEXT7_API_KEY: "from-environment" },
env: { CONTEXT7_API_KEY: "from-env" },
},
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.mcp?.context7).toEqual({
type: "local",
command: ["npx", "-y", "@upstash/context7-mcp"],
environment: { CONTEXT7_API_KEY: "from-environment" },
})
},
})
})
// kilocode_change end
test("project config can override MCP server enabled status", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1807,7 +1898,7 @@ test("project config can override MCP server enabled status", async () => {
// kilocode_change end
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1865,7 +1956,7 @@ test("MCP config deep merges preserving base config properties", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1916,7 +2007,7 @@ test("local .kilo config can override MCP from project config", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1960,7 +2051,7 @@ test("project config overrides remote well-known config", async () => {
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(Npm.defaultLayer),
Layer.provide(noopNpm),
)
try {
@@ -2018,7 +2109,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(Npm.defaultLayer),
Layer.provide(noopNpm),
)
try {
@@ -2037,6 +2128,83 @@ test("wellknown URL with trailing slash is normalized", async () => {
}
})
test("wellknown remote_config supports templated env vars in headers", async () => {
const originalFetch = globalThis.fetch
const originalToken = process.env.TEST_TOKEN
let wellknownFetchedUrl: string | undefined
let remoteFetchedUrl: string | undefined
let remoteHeaders: HeadersInit | undefined
globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
if (urlStr.includes(".well-known/opencode")) {
wellknownFetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
remote_config: {
url: "https://config.example.com/opencode.json",
headers: {
Authorization: "Bearer {env:TEST_TOKEN}",
},
},
}),
{ status: 200 },
),
)
}
if (urlStr.includes("config.example.com")) {
remoteFetchedUrl = urlStr
remoteHeaders = init?.headers
return Promise.resolve(
new Response(
JSON.stringify({
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
}),
{ status: 200 },
),
)
}
return originalFetch(url, init)
}) as unknown as typeof fetch
const fakeAuth = Layer.mock(Auth.Service)({
all: () =>
Effect.succeed({
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
}),
})
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
try {
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode")
expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json")
expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" })
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
),
{ git: true },
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
globalThis.fetch = originalFetch
if (originalToken === undefined) delete process.env.TEST_TOKEN
else process.env.TEST_TOKEN = originalToken
}
})
describe("resolvePluginSpec", () => {
test("keeps package specs unchanged", async () => {
await using tmp = await tmpdir()
@@ -2173,7 +2341,7 @@ describe("deduplicatePluginOrigins", () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -2205,7 +2373,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -2237,7 +2405,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const directories = await listDirs()
@@ -2261,7 +2429,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
try {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// Should still get default config (from global or defaults)
@@ -2303,7 +2471,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// The relative instruction should be skipped without error
@@ -2363,7 +2531,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
process.env["KILO_CONFIG_DIR"] = configDirTmp.path
await Instance.provide({
await WithInstance.provide({
directory: projectTmp.path,
fn: async () => {
const config = await load()
@@ -2398,7 +2566,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => {
try {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -2432,7 +2600,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
+47 -4
View File
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
@@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
import { ConfigPlugin } from "@/config/plugin"
const wintest = process.platform === "win32" ? test : test.skip
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
const clear = async (wait = false) => {
await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
if (wait) await InstanceRuntime.disposeAllInstances()
}
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
beforeEach(async () => {
@@ -89,7 +92,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const server = await load()
@@ -630,3 +633,43 @@ test("merges plugin_enabled flags across config layers", async () => {
"local.plugin": true,
})
})
test("silently skips malformed tui.json — load failures degrade to {}", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",')
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
},
})
const config = await getTuiConfig(tmp.path)
// Project tui.json is malformed → silently skipped (logs a warning)
// .opencode/tui.json (lower precedence in this path) still loads
expect(config.theme).toBe("fallback")
})
test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// tui.json exists as a DIRECTORY rather than a file → readFileString fails
// with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes
// that through catchCause → log + skip, so a fallback layer should still load.
await fs.mkdir(path.join(dir, "tui.json"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
},
})
const config = await getTuiConfig(tmp.path)
// Did NOT crash; .opencode/tui.json (lower precedence) still loads.
expect(config.theme).toBe("fallback")
})
test("missing tui.json — silently treated as empty (ENOENT path)", async () => {
await using tmp = await tmpdir({})
// No tui.json anywhere. Should not throw.
const config = await getTuiConfig(tmp.path)
expect(config).toBeDefined()
// No theme set anywhere.
expect(config.theme).toBeUndefined()
})
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { $ } from "bun"
import fs from "node:fs/promises"
import Http from "node:http"
import path from "node:path"
@@ -6,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
@@ -14,12 +15,12 @@ import { Database } from "@/storage/db"
import { ProjectID } from "@/project/schema"
import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session as SessionNs } from "@/session/session"
import { SessionID, MessageID, PartID } from "@/session/schema"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { EventSequenceTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -29,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
void Log.init({ print: false })
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
WorkspaceOld.defaultLayer,
WorkspaceOld.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
),
SessionNs.defaultLayer,
)
const it = testEffect(testServerLayer)
@@ -101,22 +107,36 @@ afterEach(async () => {
async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
await using tmp = await tmpdir({ git: true })
return Instance.provide({
return WithInstance.provide({
directory: tmp.path,
fn: () => fn(tmp.path),
})
}
async function initGitRepo(dir: string) {
await fs.mkdir(dir, { recursive: true })
await $`git init`.cwd(dir).quiet()
await $`git config core.autocrlf false`.cwd(dir).quiet() // kilocode_change - align test repos with Git service patch behavior
await $`git config core.fsmonitor false`.cwd(dir).quiet()
await $`git config commit.gpgsign false`.cwd(dir).quiet()
await $`git config user.email "test@opencode.test"`.cwd(dir).quiet()
await $`git config user.name "Test"`.cwd(dir).quiet()
await fs.writeFile(path.join(dir, "tracked.txt"), "base\n")
await $`git add tracked.txt`.cwd(dir).quiet()
await $`git commit -m "base"`.cwd(dir).quiet()
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
const winSkip = process.platform === "win32" ? test.skip : test // kilocode_change - git patch application is covered on Linux CI
const isWorkspaceSyncing = (id: WorkspaceID) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
const startWorkspaceSyncing = (projectID: ProjectID) => {
@@ -316,48 +336,24 @@ function sessionSequence(sessionID: SessionID) {
)?.seq
}
function eventRows(sessionID: SessionID) {
function sessionSequenceOwner(sessionID: SessionID) {
return Database.use((db) =>
db
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.ownerID
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
function replaceSessionEvents(sessionID: SessionID, count: number) {
Database.use((db) => {
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
if (count === 0) return
db.insert(EventSequenceTable)
.values({ aggregate_id: sessionID, seq: count - 1 })
.run()
db.insert(EventTable)
.values(
Array.from({ length: count }, (_, i) => ({
id: `evt_${unique(`manual-${i}`)}`,
aggregate_id: sessionID,
seq: i,
type: sessionUpdatedType(),
data: { sessionID, info: { title: `manual ${i}` } },
})),
)
.run()
})
}
describe("workspace-old schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
})
@@ -374,17 +370,6 @@ describe("workspace-old schemas and exports", () => {
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
test("validates session restore input", () => {
const input = {
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
sessionID: SessionID.descending("ses_schema_restore"),
}
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
})
})
describe("workspace-old CRUD", () => {
@@ -650,6 +635,177 @@ describe("workspace-old CRUD", () => {
expect(await getWorkspace(info.id)).toBeUndefined()
})
})
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-prev-local")
const targetType = unique("warp-target-local")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
})
})
winSkip("sessionWarp applies source workspace patch to local target workspace", async () => { // kilocode_change
await withInstance(async (dir) => {
const previousType = unique("warp-patch-prev-local")
const targetType = unique("warp-patch-target-local")
const previousDir = path.join(dir, "warp-patch-prev-local")
const targetDir = path.join(dir, "warp-patch-target-local")
await initGitRepo(previousDir)
await initGitRepo(targetDir)
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
await $`git add new.txt`.cwd(previousDir).quiet() // kilocode_change - avoid unrelated untracked patch path
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
})
})
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(Instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
})
})
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
const calls: FetchCall[] = []
let historySessionID: SessionID | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
calls.push(call)
if (call.url.pathname === "/warp-source/sync/history") {
return yield* HttpServerResponse.json([
{
id: `evt_${unique("warp-source-history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from source history" } },
},
])
}
if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch")
if (call.url.pathname === "/warp-target/sync/replay")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/sync/steal")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true })
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
"POST /warp-source/sync/history",
"GET /warp-source/vcs/diff/raw",
"POST /warp-target/vcs/apply",
"POST /warp-target/sync/replay",
"POST /warp-target/sync/steal",
])
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
expect(calls[2].json).toEqual({ patch: "remote patch" })
expect(calls[3].json).toMatchObject({
directory: "remote-target-dir",
events: [
{
aggregateID: session.id,
seq: 0,
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
},
{
aggregateID: session.id,
seq: historyNextSeq,
type: sessionUpdatedType(),
},
],
})
expect(calls[4].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
}),
{ git: true },
)
})
})
})
describe("workspace-old sync state", () => {
@@ -957,7 +1113,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history")
}),
)
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
@@ -1105,7 +1261,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse")
}),
)
expect(
@@ -1214,313 +1370,3 @@ describe("workspace-old waitForSync", () => {
})
}, 7000)
})
describe("workspace-old sessionRestore", () => {
test("throws when the workspace is missing", async () => {
await withInstance(async () => {
await expect(
restoreWorkspaceSession({
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
sessionID: SessionID.descending("ses_restore_missing_workspace"),
}),
).rejects.toThrow("Workspace not found: wrk_restore_missing")
})
})
test("throws when switching a missing session fails", async () => {
await withInstance(async (dir) => {
const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
).rejects.toThrow("NotFoundError")
await removeWorkspace(info.id)
})
})
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
if (call.url.pathname === "/restore/sync/replay") {
replay.push(call)
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(
Instance.project.id,
type,
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adapter,
)
const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24)
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
expect(result).toEqual({ total: 3 })
expect(replay).toHaveLength(3)
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
"/restore/sync/replay",
"/restore/sync/replay",
"/restore/sync/replay",
])
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
expect(
replay.flatMap((call) =>
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
),
).toEqual(Array.from({ length: 25 }, (_, i) => i))
expect(
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
).toMatchObject({
seq: 24,
type: sessionUpdatedType(),
data: { sessionID: session.id, info: { workspaceID: info.id } },
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
expect((replay[0].json as { directory: string }).directory).toBe("")
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.text("replay failed", { status: 503 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11)
const error = yield* Effect.flip(
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
)
expect((error as Error).message).toContain(
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
)
expect(replay).toHaveLength(1)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("local restore replays batches and emits progress", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
),
)
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
yield* sessionSvc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: msg.id,
type: "text",
text: `message ${i}`,
})
}
const before = eventRows(session.id)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
})
@@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceStore } from "../../src/project/instance-store"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
@@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () =>
)
const a = yield* access(state, dir)
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
const b = yield* access(state, dir)
expect(a).not.toBe(b)
@@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () =>
const [, b] = yield* Effect.all(
[
Effect.promise(() => InstanceStore.reloadInstance({ directory: one })),
Effect.promise(() => reloadTestInstance({ directory: one })),
Test.use((svc) => svc.get()).pipe(provideInstance(two)),
],
{ concurrency: "unbounded" },
@@ -5,6 +5,7 @@ import fs from "fs/promises"
import path from "path"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { provideInstance, tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
@@ -30,7 +31,7 @@ describe("file fsmonitor", () => {
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await status()
@@ -55,7 +56,7 @@ describe("file fsmonitor", () => {
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await read("tracked.txt")
+55 -54
View File
@@ -5,6 +5,7 @@ import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Filesystem } from "@/util/filesystem"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
@@ -28,7 +29,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "Hello World", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("test.txt")
@@ -41,7 +42,7 @@ describe("file/index Filesystem patterns", () => {
test("reads with Filesystem.exists() check", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// Non-existent file should return empty content
@@ -57,7 +58,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("test.txt")
@@ -71,7 +72,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "empty.txt")
await fs.writeFile(filepath, "", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("empty.txt")
@@ -86,7 +87,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "multiline.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("multiline.txt")
@@ -103,7 +104,7 @@ describe("file/index Filesystem patterns", () => {
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await fs.writeFile(filepath, binaryContent)
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("image.png")
@@ -120,7 +121,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "binary.so")
await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("binary.so")
@@ -137,7 +138,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.json")
await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
expect(await Filesystem.mimeType(filepath)).toContain("application/json")
@@ -161,7 +162,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, `test.${ext}`)
await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
expect(await Filesystem.mimeType(filepath)).toContain(mime)
@@ -175,7 +176,7 @@ describe("file/index Filesystem patterns", () => {
test("reads .gitignore via Filesystem.exists() and readText()", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
@@ -193,7 +194,7 @@ describe("file/index Filesystem patterns", () => {
test("reads .ignore file similarly", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const ignorePath = path.join(tmp.path, ".ignore")
@@ -208,7 +209,7 @@ describe("file/index Filesystem patterns", () => {
test("handles missing .gitignore gracefully", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
@@ -226,7 +227,7 @@ describe("file/index Filesystem patterns", () => {
test("reads untracked files via Filesystem.readText()", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const untrackedPath = path.join(tmp.path, "untracked.txt")
@@ -247,7 +248,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "readonly.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nonExistentPath = path.join(tmp.path, "does-not-exist.txt")
@@ -264,7 +265,7 @@ describe("file/index Filesystem patterns", () => {
test("handles errors in Filesystem.readArrayBuffer()", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nonExistentPath = path.join(tmp.path, "does-not-exist.bin")
@@ -279,7 +280,7 @@ describe("file/index Filesystem patterns", () => {
const _filepath = path.join(tmp.path, "broken.png")
// Don't create the file
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// read() handles missing images gracefully
@@ -297,7 +298,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.ts")
await fs.writeFile(filepath, "export const value = 1", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("test.ts")
@@ -312,7 +313,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.mts")
await fs.writeFile(filepath, "export const value = 1", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("test.mts")
@@ -327,7 +328,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.sh")
await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("test.sh")
@@ -342,7 +343,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "Dockerfile")
await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("Dockerfile")
@@ -357,7 +358,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "simple text", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("test.txt")
@@ -372,7 +373,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "test.jpg")
await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("test.jpg")
@@ -387,7 +388,7 @@ describe("file/index Filesystem patterns", () => {
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
@@ -398,7 +399,7 @@ describe("file/index Filesystem patterns", () => {
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
@@ -416,7 +417,7 @@ describe("file/index Filesystem patterns", () => {
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await status()
@@ -433,7 +434,7 @@ describe("file/index Filesystem patterns", () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await status()
@@ -454,7 +455,7 @@ describe("file/index Filesystem patterns", () => {
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await fs.rm(filepath)
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await status()
@@ -477,7 +478,7 @@ describe("file/index Filesystem patterns", () => {
await fs.rm(path.join(tmp.path, "remove.txt"))
await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await status()
@@ -491,7 +492,7 @@ describe("file/index Filesystem patterns", () => {
test("returns empty for non-git project", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await status()
@@ -503,7 +504,7 @@ describe("file/index Filesystem patterns", () => {
test("returns empty for clean repo", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await status()
@@ -526,7 +527,7 @@ describe("file/index Filesystem patterns", () => {
for (let i = 0; i < 512; i++) modified[i] = i % 256
await fs.writeFile(filepath, modified)
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await status()
@@ -547,7 +548,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8")
await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await list()
@@ -571,7 +572,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await list()
@@ -596,7 +597,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await list()
@@ -615,7 +616,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8")
await fs.mkdir(path.join(tmp.path, "build"))
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await list()
@@ -635,7 +636,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await list("sub")
@@ -650,7 +651,7 @@ describe("file/index Filesystem patterns", () => {
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(list("../outside")).rejects.toThrow("Access denied")
@@ -662,7 +663,7 @@ describe("file/index Filesystem patterns", () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await list()
@@ -693,7 +694,7 @@ describe("file/index Filesystem patterns", () => {
test("empty query returns files", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -707,7 +708,7 @@ describe("file/index Filesystem patterns", () => {
test("search works before explicit init", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await search({ query: "main", type: "file" })
@@ -719,7 +720,7 @@ describe("file/index Filesystem patterns", () => {
test("empty query returns dirs sorted with hidden last", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -739,7 +740,7 @@ describe("file/index Filesystem patterns", () => {
test("fuzzy matches file names", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -753,7 +754,7 @@ describe("file/index Filesystem patterns", () => {
test("type filter returns only files", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -770,7 +771,7 @@ describe("file/index Filesystem patterns", () => {
test("type filter returns only directories", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -787,7 +788,7 @@ describe("file/index Filesystem patterns", () => {
test("respects limit", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -801,7 +802,7 @@ describe("file/index Filesystem patterns", () => {
test("query starting with dot prefers hidden files", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -816,7 +817,7 @@ describe("file/index Filesystem patterns", () => {
test("search refreshes after init when files change", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -840,7 +841,7 @@ describe("file/index Filesystem patterns", () => {
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "modified content\n", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("file.txt")
@@ -864,7 +865,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(filepath, "after\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("staged.txt")
@@ -881,7 +882,7 @@ describe("file/index Filesystem patterns", () => {
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("clean.txt")
@@ -902,7 +903,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: one.path,
fn: async () => {
await init()
@@ -913,7 +914,7 @@ describe("file/index Filesystem patterns", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: two.path,
fn: async () => {
await init()
@@ -929,7 +930,7 @@ describe("file/index Filesystem patterns", () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -943,7 +944,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
await fs.rm(path.join(tmp.path, "before.ts"))
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await init()
@@ -5,6 +5,7 @@ import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { containsPath } from "../../src/project/instance-context"
import { provideInstance, tmpdir } from "../fixture/fixture"
@@ -55,7 +56,7 @@ describe("File.read path traversal protection", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
@@ -66,7 +67,7 @@ describe("File.read path traversal protection", () => {
test("rejects deeply nested traversal", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
@@ -83,7 +84,7 @@ describe("File.read path traversal protection", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("valid.txt")
@@ -97,7 +98,7 @@ describe("File.list path traversal protection", () => {
test("rejects ../ traversal attempting to list /etc", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
@@ -112,7 +113,7 @@ describe("File.list path traversal protection", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await list("subdir")
@@ -126,7 +127,7 @@ describe("containsPath", () => {
test("returns true for path inside directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
@@ -140,7 +141,7 @@ describe("containsPath", () => {
const subdir = path.join(tmp.path, "packages", "lib")
await fs.mkdir(subdir, { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: subdir,
fn: () => {
// .opencode at worktree root, but we're running from packages/lib
@@ -156,7 +157,7 @@ describe("containsPath", () => {
test("returns false for path outside both directory and worktree", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
@@ -168,7 +169,7 @@ describe("containsPath", () => {
test("returns false for path with .. escaping worktree", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
@@ -179,7 +180,7 @@ describe("containsPath", () => {
test("handles directory === worktree (running from repo root)", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.directory).toBe(Instance.worktree)
@@ -192,7 +193,7 @@ describe("containsPath", () => {
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
await using tmp = await tmpdir() // no git: true
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: () => {
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
+3 -2
View File
@@ -9,6 +9,7 @@ import { Config } from "@/config/config"
import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -28,7 +29,7 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return Instance.provide({
return WithInstance.provide({
directory,
fn: async () => {
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
@@ -193,7 +194,7 @@ describeWatcher("FileWatcher", () => {
await withWatcher(tmp.path, Effect.void)
// Now write a file — no watcher should be listening
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: () =>
Effect.runPromise(
@@ -0,0 +1,6 @@
// Separate file because every export in `agent-plugin.ts` must be a function.
export const PLUGIN_AGENT = {
name: "plugin_added",
description: "Added by a plugin via the config hook",
mode: "subagent",
} as const
@@ -0,0 +1,12 @@
// Every export in this file must be a plugin function — `getLegacyPlugins`
// (src/plugin/index.ts) throws on anything else. Test constants live in
// `agent-plugin.constants.ts`.
export default async () => ({
config: async (cfg: { agent?: Record<string, unknown> }) => {
cfg.agent = cfg.agent ?? {}
cfg.agent["plugin_added"] = {
description: "Added by a plugin via the config hook",
mode: "subagent",
}
},
})
+24
View File
@@ -0,0 +1,24 @@
import { Config } from "@/config/config"
import { emptyConsoleState } from "@/config/console-state"
import { Effect, Layer } from "effect"
export function make(overrides: Partial<Config.Interface> = {}) {
return Config.Service.of({
get: () => Effect.succeed({}),
getGlobal: () => Effect.succeed({}),
getConsoleState: () => Effect.succeed(emptyConsoleState),
update: () => Effect.void,
updateGlobal: (config) => Effect.succeed({ info: config, changed: false }),
invalidate: () => Effect.void,
directories: () => Effect.succeed([]),
waitForDependencies: () => Effect.void,
warnings: () => Effect.succeed([]),
...overrides,
})
}
export function layer(overrides?: Partial<Config.Interface>) {
return Layer.succeed(Config.Service, make(overrides))
}
export * as TestConfig from "./config"
+54 -14
View File
@@ -1,21 +1,47 @@
import { $ } from "bun"
import * as Observability from "@opencode-ai/core/effect/observability"
import * as fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Context } from "effect"
import { Effect, Context, Layer, ManagedRuntime } from "effect"
import type * as PlatformError from "effect/PlatformError"
import type * as Scope from "effect/Scope"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { InstanceStore } from "../../src/project/instance-store"
import { Instance } from "../../src/project/instance"
import { TestLLMServer } from "../lib/llm-server"
import { remove as cleanup } from "../kilocode/cleanup" // kilocode_change
// Re-export for test ergonomics. The implementation lives next to the runtime
// it consumes; see `InstanceStore.disposeAllInstances` for the rationale.
export { disposeAllInstances } from "../../src/project/instance-store"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const testInstanceRuntime = ManagedRuntime.make(
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)),
)
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory }))
try {
if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
return await Instance.restore(ctx, () => input.fn())
} finally {
await runTestInstanceStore((store) => store.dispose(ctx))
}
}
export async function reloadTestInstance(input: { directory: string }) {
return runTestInstanceStore((store) => store.reload(input))
}
export async function disposeAllInstances() {
await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
}
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
@@ -125,12 +151,12 @@ export const provideInstance =
(directory: string) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.contextWith((services: Context.Context<R>) =>
Effect.promise<A>(async () =>
Instance.provide({
directory,
fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
}),
),
Effect.promise<A>(async () => {
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
return Instance.restore(ctx, () =>
Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))),
)
}),
)
export function provideTmpdirInstance<A, E, R>(
@@ -144,10 +170,9 @@ export function provideTmpdirInstance<A, E, R>(
yield* Effect.addFinalizer(() =>
provided
? Effect.promise(() =>
Instance.provide({
directory: path,
fn: () => InstanceStore.disposeInstance(Instance.current),
}),
runTestInstanceStore((store) =>
store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))),
),
).pipe(Effect.ignore)
: Effect.void,
)
@@ -157,6 +182,21 @@ export function provideTmpdirInstance<A, E, R>(
})
}
export class TestInstance extends Context.Service<TestInstance, { readonly directory: string }>()("@test/Instance") {}
export const withTmpdirInstance =
(options?: { git?: boolean; config?: Partial<Config.Info> }) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped(options)
return yield* InstanceStore.Service.use((store) =>
store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))),
)
}).pipe(
Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))),
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
export function provideTmpdirServer<A, E, R>(
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
@@ -285,6 +285,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
count: opts.state?.session?.count ?? (() => 0),
diff: opts.state?.session?.diff ?? (() => []),
todo: opts.state?.session?.todo ?? (() => []),
processes: opts.state?.session?.processes ?? (() => []), // kilocode_change
messages: opts.state?.session?.messages ?? (() => []),
status: opts.state?.session?.status ?? (() => undefined),
permission: opts.state?.session?.permission ?? (() => []),
+47
View File
@@ -114,6 +114,53 @@ describe("Git", () => {
})
})
test("patch() returns capped native patch output", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")
await withGit(async (rt) => {
const [patch, all, capped] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))),
])
expect(patch.truncated).toBe(false)
expect(patch.text).toContain("diff --git")
expect(patch.text).toContain("-before")
expect(patch.text).toContain("+after")
expect(all.truncated).toBe(false)
expect(all.text).toContain("diff --git")
expect(all.text).toContain("other.txt")
expect(all.text).toContain("+new")
expect(capped.truncated).toBe(true)
expect(capped.text).toBe("")
})
})
test("patchUntracked() and statUntracked() handle added files", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")
await withGit(async (rt) => {
const [patch, stat] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))),
])
expect(patch.truncated).toBe(false)
expect(patch.text).toContain("diff --git")
expect(patch.text).toContain("+one")
expect(patch.text).toContain("+two")
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
})
})
test("show() returns empty text for binary blobs", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
@@ -1,25 +1,18 @@
// kilocode_change - new file
import { afterEach, test, expect } from "bun:test"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { expect } from "bun:test"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { Global } from "@opencode-ai/core/global"
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(Agent.defaultLayer)
test("code agent allows global config directory reads by default", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const code = await Agent.get("code")
expect(code).toBeDefined()
expect(Permission.evaluate("external_directory", `${Global.Path.config}/*`, code!.permission).action).toBe(
"allow",
)
},
})
})
it.instance("code agent allows global config directory reads by default", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const code = yield* agent.get("code")
expect(code).toBeDefined()
expect(Permission.evaluate("external_directory", `${Global.Path.config}/*`, code!.permission).action).toBe("allow")
}),
)
@@ -2,7 +2,7 @@ import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
@@ -25,7 +25,7 @@ test("ask agent honors user MCP allow over generated ask rule", async () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const ask = await load(tmp.path, (svc) => svc.get("ask"))
@@ -44,7 +44,7 @@ test("plan agent honors user bash allow over read-only deny default", async () =
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const plan = await load(tmp.path, (svc) => svc.get("plan"))
@@ -63,7 +63,7 @@ test("plan agent still hard-denies non-plan edits after user edit allow", async
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const plan = await load(tmp.path, (svc) => svc.get("plan"))
@@ -1,38 +1,33 @@
// kilocode_change - new file
import { afterEach, test, expect } from "bun:test"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { expect } from "bun:test"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(Agent.defaultLayer)
function action(name: string, ruleset: Permission.Ruleset) {
return Permission.evaluate("skill", name, ruleset).action
}
test("skill tool available for non-system native agents and denied for system agents", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const allow = ["code", "plan", "debug", "orchestrator", "ask", "general", "explore"]
for (const name of allow) {
const agent = await Agent.get(name)
expect(agent).toBeDefined()
expect(action("using-superpowers", agent!.permission)).toBe("allow")
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(false)
}
it.instance("skill tool available for non-system native agents and denied for system agents", () =>
Effect.gen(function* () {
const svc = yield* Agent.Service
const allow = ["code", "plan", "debug", "orchestrator", "ask", "general", "explore"]
for (const name of allow) {
const agent = yield* svc.get(name)
expect(agent).toBeDefined()
expect(action("using-superpowers", agent!.permission)).toBe("allow")
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(false)
}
const deny = ["compaction", "title", "summary"]
for (const name of deny) {
const agent = await Agent.get(name)
expect(agent).toBeDefined()
expect(action("using-superpowers", agent!.permission)).toBe("deny")
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(true)
}
},
})
})
const deny = ["compaction", "title", "summary"]
for (const name of deny) {
const agent = yield* svc.get(name)
expect(agent).toBeDefined()
expect(action("using-superpowers", agent!.permission)).toBe("deny")
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(true)
}
}),
)
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { Result, Schema } from "effect"
import { Params } from "@/kilocode/tool/background-process"
import { toJsonSchema } from "@/util/effect-zod"
const accepts = (input: unknown) => Result.isSuccess(Schema.decodeUnknownResult(Params)(input))
describe("BackgroundProcessTool", () => {
test("emits a root object JSON schema", () => {
const json = toJsonSchema(Params) as { type?: unknown; anyOf?: unknown; properties?: Record<string, unknown> }
expect(json.type).toBe("object")
expect(json.anyOf).toBeUndefined()
expect(json.properties?.action).toEqual(
expect.objectContaining({ enum: ["start", "list", "status", "logs", "stop", "restart"] }),
)
})
test("validates action-specific required fields", () => {
expect(accepts({ action: "list" })).toBe(true)
expect(accepts({ action: "start", command: "bun run dev", ready: { pattern: "ready" } })).toBe(true)
expect(accepts({ action: "start" })).toBe(false)
expect(accepts({ action: "stop", id: "bgp01" })).toBe(true)
expect(accepts({ action: "stop" })).toBe(false)
})
})
@@ -0,0 +1,156 @@
import { describe, expect } from "bun:test"
import { Bus } from "@/bus"
import { BackgroundProcess } from "@/kilocode/background-process"
import { SessionID } from "@/session/schema"
import { Shell } from "@/shell/shell"
import { Effect } from "effect"
import path from "path"
import { TestInstance } from "../fixture/fixture"
import { it } from "../lib/effect"
function quote(input: string) {
const value = input.replaceAll("\\", "/")
if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`
return `'${value.replaceAll("'", "'\\''")}'`
}
async function script(dir: string, name: string, source: string) {
const file = path.join(dir, name)
await Bun.write(file, source)
const bin = quote(process.execPath)
const arg = quote(file)
if (Shell.ps(Shell.acceptable())) return `& ${bin} ${arg}`
return `${bin} ${arg}`
}
function update(sessionID: SessionID) {
const state: { off?: () => void; timer?: ReturnType<typeof setTimeout> } = {}
const promise = new Promise<BackgroundProcess.Info>((resolve, reject) => {
state.timer = setTimeout(() => {
state.off?.()
reject(new Error("timed out waiting for process update"))
}, 5_000)
state.off = Bus.subscribe(BackgroundProcess.Event.Updated, (event) => {
const info = event.properties.info
if (info.sessionID !== sessionID) return
if (!info.output.includes("tick")) return
state.off?.()
if (state.timer) clearTimeout(state.timer)
resolve(info)
})
})
return {
promise,
dispose() {
state.off?.()
if (state.timer) clearTimeout(state.timer)
},
}
}
describe("BackgroundProcess", () => {
it.instance("starts, reports readiness, and stops a process", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = yield* Effect.promise(() =>
script(
test.directory,
"ready.mjs",
`console.log("ready")
setInterval(() => {}, 1_000)
`,
),
)
const info = yield* Effect.promise(() =>
BackgroundProcess.start({
sessionID,
command,
cwd: test.directory,
description: "test server",
ready: { pattern: "ready", timeout: 5_000 },
}),
)
expect(info.status).toBe("ready")
expect(info.output).toContain("ready")
const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
expect(list.map((item) => item.id)).toContain(info.id)
const stopped = yield* Effect.promise(() => BackgroundProcess.stop(info.id))
expect(stopped?.status).toBe("stopped")
if (process.platform !== "win32") {
expect(stopped?.exitCode).toBeUndefined()
expect(stopped?.signal).toBe("SIGTERM")
}
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
const next = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
expect(next).toEqual([])
}),
)
it.instance("publishes output updates from process callbacks", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const command = yield* Effect.promise(() =>
script(
test.directory,
"tick.mjs",
`console.log("ready")
setTimeout(() => console.log("tick"), 200)
setInterval(() => {}, 1_000)
`,
),
)
const wait = update(sessionID)
const info = yield* Effect.promise(() =>
BackgroundProcess.start({
sessionID,
command,
cwd: test.directory,
ready: { pattern: "ready", timeout: 5_000 },
}),
)
try {
const event = yield* Effect.promise(() => wait.promise)
expect(event.id).toBe(info.id)
expect(event.output).toContain("tick")
} finally {
wait.dispose()
yield* Effect.promise(() => BackgroundProcess.stop(info.id))
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
}
}),
)
it.instance("rejects invalid readiness patterns before launching", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const err = yield* Effect.promise(async () => {
try {
await BackgroundProcess.start({
sessionID,
command: "printf 'ready\n'",
cwd: test.directory,
ready: { pattern: "[", timeout: 1_000 },
})
} catch (err) {
return err
}
})
expect(err).toBeInstanceOf(Error)
expect((err as Error).message).toContain("Invalid ready pattern")
const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
expect(list).toEqual([])
}),
)
})
@@ -1,8 +1,8 @@
// regression test for bash permission metadata.command
import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { ShellTool } from "../../src/tool/shell"
import { WithInstance } from "../../src/project/with-instance"
import { tmpdir } from "../fixture/fixture"
import { Shell } from "../../src/shell/shell"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -49,10 +49,10 @@ const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "
describe("bash permission metadata.command", () => {
test("permission prompt shows raw command without tool name prefix", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const bash = await runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
const bash = await runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init())))
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const command = "echo hello"
await Effect.runPromise(bash.execute({ command, description: "Echo hello" }, capture(requests)))
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test"
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"
import { tmpdir } from "os"
import { join } from "path"
const script = join(import.meta.dir, "..", "..", "bin", "kilo")
describe("bin/kilo tree-sitter resources", () => {
async function setup(root: string, nested: boolean) {
const dir = nested
? join(root, "node_modules", "@kilocode", "cli-darwin-arm64", "bin")
: join(root, "node_modules", "@kilocode", "cli", "bin")
const wasm = join(dir, "tree-sitter")
const bin = join(dir, nested ? "kilo" : ".kilo")
const log = join(root, nested ? "nested-env.txt" : "cached-env.txt")
await mkdir(wasm, { recursive: true })
await writeFile(join(wasm, "tree-sitter.wasm"), "wasm")
await writeFile(bin, "binary")
return { bin, log, wasm, wrapper: join(dir, "kilo") }
}
async function run(root: string, bin: string | undefined, log: string, wrapper?: string) {
const capture = `
const kiloFs = require("fs")
const kiloChild = require("child_process")
const log = process.argv[1]
const wrapper = process.argv[2]
const realpathSync = kiloFs.realpathSync
kiloFs.realpathSync = (file) => wrapper && file === __filename ? wrapper : realpathSync(file)
kiloChild.spawnSync = () => {
kiloFs.writeFileSync(log, process.env.KILO_TREE_SITTER_WASM_DIR || "")
return { status: 0 }
}
`
const source = (await Bun.file(script).text()).replace(/^#!.*\n/, "")
return Bun.spawnSync(["node", "--input-type=commonjs", "--eval", capture + source, log, wrapper ?? ""], {
cwd: root,
env: {
PATH: process.env.PATH ?? "",
...(bin ? { KILO_BIN_PATH: bin } : {}),
},
})
}
test("exports co-located tree-sitter WASM dir for optional package binary", async () => {
const root = await mkdtemp(join(tmpdir(), "kilo-bin-tree-sitter-"))
try {
const item = await setup(root, true)
const proc = await run(root, item.bin, item.log)
expect(proc.exitCode).toBe(0)
expect(await Bun.file(item.log).text()).toBe(item.wasm)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test("exports co-located tree-sitter WASM dir for cached postinstall binary", async () => {
const root = await mkdtemp(join(tmpdir(), "kilo-bin-tree-sitter-"))
try {
const item = await setup(root, false)
const proc = await run(root, undefined, item.log, item.wrapper)
expect(proc.exitCode).toBe(0)
expect(await Bun.file(item.log).text()).toBe(item.wasm)
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
@@ -1,21 +1,20 @@
import { afterEach, test, expect } from "bun:test"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance"
import { BUILTIN_SKILLS } from "../../src/kilocode/skills/builtin"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, CrossSpawnSpawner.defaultLayer))
test("built-in skills are present in empty project", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
it.instance(
"built-in skills are present in empty project",
() =>
Effect.gen(function* () {
const skill = yield* Skill.Service
const skills = yield* skill.all()
for (const builtin of BUILTIN_SKILLS) {
const found = skills.find((s) => s.name === builtin.name)
expect(found).toBeDefined()
@@ -23,33 +22,34 @@ test("built-in skills are present in empty project", async () => {
expect(found!.description).toBe(builtin.description)
expect(found!.content.length).toBeGreaterThan(0)
}
},
})
})
}),
{ git: true },
)
test("built-in skill has correct metadata", async () => {
await using tmp = await tmpdir({ git: true })
it.instance(
"built-in skill has correct metadata",
() =>
Effect.gen(function* () {
const skill = yield* Skill.Service
const item = yield* skill.get("kilo-config")
expect(item).toBeDefined()
expect(item!.name).toBe("kilo-config")
expect(item!.location).toBe(Skill.BUILTIN_LOCATION)
expect(item!.content).toContain("kilo")
}),
{ git: true },
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skill = await Skill.get("kilo-config")
expect(skill).toBeDefined()
expect(skill!.name).toBe("kilo-config")
expect(skill!.location).toBe(Skill.BUILTIN_LOCATION)
expect(skill!.content).toContain("kilo")
},
})
})
test("user skill overrides built-in with same name", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".kilo", "skill", "kilo-config")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
it.instance(
"user skill overrides built-in with same name",
() =>
Effect.gen(function* () {
const instance = yield* TestInstance
const dir = path.join(instance.directory, ".kilo", "skill", "kilo-config")
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "SKILL.md"),
`---
name: kilo-config
description: User override of kilo-config.
---
@@ -58,18 +58,15 @@ description: User override of kilo-config.
User-provided content.
`,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skill = await Skill.get("kilo-config")
expect(skill).toBeDefined()
expect(skill!.description).toBe("User override of kilo-config.")
expect(skill!.location).not.toBe(Skill.BUILTIN_LOCATION)
expect(skill!.location).toContain(path.join("skill", "kilo-config", "SKILL.md"))
},
})
})
const skill = yield* Skill.Service
const item = yield* skill.get("kilo-config")
expect(item).toBeDefined()
expect(item!.description).toBe("User override of kilo-config.")
expect(item!.location).not.toBe(Skill.BUILTIN_LOCATION)
expect(item!.location).toContain(path.join("skill", "kilo-config", "SKILL.md"))
}),
{ git: true },
)
@@ -0,0 +1,161 @@
import { test, expect, describe } from "bun:test"
import { Provider } from "../../../src/provider/provider"
import { formatTable, formatMarkdown, handle, isTextModel } from "../../../src/kilocode/cli/cmd/roll-call"
const base = {
input: { text: false, audio: false, image: false, video: false, pdf: false },
output: { text: false, audio: false, image: false, video: false, pdf: false },
}
function caps(opts: { input?: Partial<typeof base.input>; output?: Partial<typeof base.output> }) {
return {
capabilities: {
...base,
input: { ...base.input, ...opts.input },
output: { ...base.output, ...opts.output },
},
} as Provider.Model
}
describe("formatTable", () => {
test("formats simple table correctly", () => {
const rows = [
["kilo/test-model", "YES", "Hello!", "1000ms"],
["kilo/another-model", "NO", "(Error)", "500ms"],
]
const result = formatTable(rows, 120)
expect(result.header).toContain("Model")
expect(result.header).toContain("Access")
expect(result.header).toContain("Snippet")
expect(result.header).toContain("Latency")
expect(result.separator).toMatch(/^-+$/)
expect(result.rows).toHaveLength(2)
})
test("truncates long snippets", () => {
const rows = [["model", "YES", "A".repeat(200), "100ms"]]
const result = formatTable(rows, 80)
const start = result.rows[0].indexOf("AAA")
expect(start).toBeGreaterThanOrEqual(0)
})
test("strips ANSI codes from cells", () => {
const rows = [["\x1b[31mmodel\x1b[0m", "YES", "text", "100ms"]]
const result = formatTable(rows, 120)
expect(result.rows[0]).not.toContain("\x1b[")
expect(result.rows[0]).toContain("model")
})
test("handles empty rows", () => {
const result = formatTable([], 120)
expect(result.rows).toHaveLength(0)
expect(result.header).toContain("Model")
})
test("handles special characters in cells", () => {
const rows = [
["model\nwith\nnewlines", "YES", "text\ttab", "100ms"],
["model\r\nwindows", "YES", "text", "100ms"],
]
const result = formatTable(rows, 120)
expect(result.rows[0]).not.toContain("\n")
expect(result.rows[0]).not.toContain("\t")
expect(result.rows[1]).not.toContain("\r")
})
test("adjusts column widths for terminal", () => {
const rows = [["very-long-model-name-here", "YES", "short", "100ms"]]
const wide = formatTable(rows, 200)
const narrow = formatTable(rows, 60)
expect(wide.header.indexOf("Snippet")).toBeGreaterThanOrEqual(0)
expect(narrow.header.indexOf("Snippet")).toBeGreaterThanOrEqual(0)
})
})
describe("isTextModel", () => {
test("accepts text-in text-out model", () => {
expect(isTextModel(caps({ input: { text: true }, output: { text: true } }))).toBe(true)
})
test("accepts multimodal model with text capability", () => {
expect(isTextModel(caps({ input: { text: true, image: true }, output: { text: true } }))).toBe(true)
})
test("rejects audio-in text-out model", () => {
expect(isTextModel(caps({ input: { audio: true }, output: { text: true } }))).toBe(false)
})
test("rejects text-in image-out model", () => {
expect(isTextModel(caps({ input: { text: true }, output: { image: true } }))).toBe(false)
})
test("rejects embedding model", () => {
expect(isTextModel(caps({ input: { text: true } }))).toBe(false)
})
})
describe("formatMarkdown", () => {
test("produces valid markdown table", () => {
const rows = [
["openai/gpt-4o", "YES", "Hello!", "500ms"],
["openai/gpt-3.5", "NO", "(timeout)", "25000ms"],
]
const md = formatMarkdown(rows)
const lines = md.split("\n")
expect(lines[0]).toMatch(/^\|.*Model.*\|.*Access.*\|.*Snippet.*\|.*Latency.*\|$/)
expect(lines[1]).toMatch(/^\| -+ \| -+ \| -+ \| -+ \|$/)
expect(lines).toHaveLength(4)
})
test("handles empty rows", () => {
const md = formatMarkdown([])
const lines = md.split("\n")
expect(lines).toHaveLength(2)
})
test("escapes pipe characters in cells", () => {
const rows = [["model", "YES", "hello | world", "100ms"]]
const md = formatMarkdown(rows)
expect(md).toContain("hello \\| world")
expect(md.split("\n")[2].match(/(?<!\\)\|/g)?.length).toBe(5)
})
})
describe("handle", () => {
test("does not print progress before markdown output", async () => {
const logs: string[] = []
const print = console.log
const code = process.exitCode
console.log = (msg?: unknown) => {
logs.push(String(msg))
}
try {
await handle({
prompt: "Hello",
timeout: 1,
filter: "test",
parallel: 1,
output: "md",
verbose: true,
quiet: false,
list: async () => ({}),
})
} finally {
console.log = print
process.exitCode = code
}
expect(logs).toEqual([formatMarkdown([])])
})
})
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import path from "path"
const root = path.join(__dirname, "..", "..")
const files = [
"src/cli/cmd/tui/feature-plugins/home/tips-view.tsx",
"src/cli/cmd/run.ts",
"src/config/config.ts",
"src/server/routes/instance/httpapi/public.ts",
"src/mcp/index.ts",
]
const command = /opencode\s+(--[a-z-]+|run|serve|auth|upgrade|agent|github|mcp)\b/g
describe("Kilo command branding", () => {
test("user-facing command help uses the `kilo` binary name", async () => {
const results = await Promise.all(
files.map(async (file) => ({
file,
matches: [...(await Bun.file(path.join(root, file)).text()).matchAll(command)].map((match) => match[0]),
})),
)
expect(results.filter((result) => result.matches.length > 0)).toEqual([])
})
})
@@ -1,4 +1,4 @@
import { describe, expect, test, mock, beforeEach } from "bun:test"
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test"
import type { GitContext } from "@/kilocode/commit-message/types"
// Mock dependencies before importing the module under test.
@@ -8,7 +8,6 @@ import type { GitContext } from "@/kilocode/commit-message/types"
const realLog = await import("@opencode-ai/core/util/log")
const realProvider = await import("@/provider/provider")
const realLLM = await import("@/session/llm")
const realAgent = await import("@/agent/agent")
const realGitContext = await import("@/kilocode/commit-message/git-context")
@@ -50,19 +49,6 @@ mock.module("@/provider/provider", () => ({
},
}))
mock.module("@/session/llm", () => ({
...realLLM,
LLM: {
...realLLM.LLM,
stream: async () => ({
textStream: (async function* () {
yield mockStreamText
})(),
text: Promise.resolve(mockStreamText),
}),
},
}))
mock.module("@/agent/agent", () => ({
...realAgent,
Agent: {},
@@ -78,10 +64,13 @@ mock.module("@opencode-ai/core/util/log", () => ({
}),
}))
import { generateCommitMessage } from "../../../src/kilocode/commit-message/generate"
import { CommitMessageRuntime, generateCommitMessage } from "../../../src/kilocode/commit-message/generate"
const stream = spyOn(CommitMessageRuntime, "generate").mockImplementation(async () => mockStreamText)
describe("commit-message.generate", () => {
beforeEach(() => {
stream.mockImplementation(async () => mockStreamText)
mockStreamText = "feat(src): add hello world logging"
mockGitContext = { ...defaultGitContext }
captured = { path: "" }
@@ -8,7 +8,7 @@ import { Config } from "../../src/config/config"
import { KiloCompactionPayloadRecovery } from "../../src/kilocode/session/compaction-payload-recovery"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Snapshot } from "../../src/snapshot"
import { LLM } from "../../src/session/llm"
@@ -125,7 +125,6 @@ function llm() {
const stream = typeof item === "function" ? item(input) : item
return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
},
raw: () => Effect.die("raw not implemented in test LLM"),
}),
),
}
@@ -306,7 +305,7 @@ describe("KiloCompactionPayloadRecovery", () => {
}),
)
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
@@ -18,7 +18,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Filesystem } from "../../src/util/filesystem"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture"
@@ -58,7 +58,7 @@ test(".gitignore in .kilo config dir includes pnpm and yarn lockfile patterns",
const kilo = path.join(dir, ".kilo")
await fs.mkdir(kilo, { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: dir,
fn: async () => {
await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
@@ -1,13 +1,17 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { AppRuntime } from "../../src/effect/app-runtime"
import { WithInstance } from "../../src/project/with-instance"
import { Filesystem } from "../../src/util/filesystem"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
const warnings = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings()))
afterEach(async () => {
await disposeAllInstances()
await Config.invalidate()
await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
})
describe("config resilience", () => {
@@ -31,10 +35,10 @@ Valid agent prompt`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
const cfg = await load()
expect(cfg.agent?.["skip"]).toBeUndefined()
expect(cfg.agent?.["keep"]).toMatchObject({
@@ -59,11 +63,11 @@ Broken agent prompt`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
await load()
const warns = await warnings()
expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("mode"))).toBe(true)
},
@@ -90,10 +94,10 @@ Valid command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
const cfg = await load()
expect(cfg.command?.["skip"]).toBeUndefined()
expect(cfg.command?.["keep"]).toEqual({
@@ -117,11 +121,11 @@ Broken command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
await load()
const warns = await warnings()
expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("subtask"))).toBe(true)
},
@@ -141,11 +145,11 @@ Broken agent`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
await load()
const warns = await warnings()
expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true)
},
@@ -165,11 +169,11 @@ Broken command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
await load()
const warns = await warnings()
expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true)
},
@@ -183,11 +187,11 @@ Broken command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
const warns = await Config.warnings()
const cfg = await load()
const warns = await warnings()
// Config loading should not crash
expect(cfg).toBeDefined()
@@ -204,11 +208,11 @@ Broken command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
const warns = await Config.warnings()
const cfg = await load()
const warns = await warnings()
expect(cfg).toBeDefined()
expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("invalid"))).toBe(true)
@@ -221,11 +225,11 @@ Broken command`,
config: { model: "test/model" },
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
const warns = await Config.warnings()
await load()
const warns = await warnings()
expect(warns).toEqual([])
},
@@ -2,8 +2,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { ConfigValidation } from "../../src/kilocode/config-validation"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Config } from "../../src/config/config"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Filesystem } from "../../src/util/filesystem"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -11,15 +12,17 @@ afterEach(async () => {
await disposeAllInstances()
})
const check = (filepath: string) => ConfigValidation.check(filepath)
describe("ConfigValidation.check", () => {
test("returns empty string for non-config files", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "src", "index.ts")
await Filesystem.write(filepath, "export const x = 1")
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toBe("")
})
@@ -29,9 +32,9 @@ describe("ConfigValidation.check", () => {
const filepath = path.join(tmp.path, "kilo.json")
await Filesystem.write(filepath, JSON.stringify({ model: "anthropic/claude-sonnet-4-20250514" }))
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toContain("config_validation")
expect(result).toContain("validated successfully")
@@ -42,9 +45,9 @@ describe("ConfigValidation.check", () => {
const filepath = path.join(tmp.path, "kilo.json")
await Filesystem.write(filepath, '{ "model": "test/model" "extra": true }')
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toContain("config_validation")
expect(result).toContain("ERROR")
@@ -57,9 +60,9 @@ describe("ConfigValidation.check", () => {
// Config.Info uses .strict() so unknown fields produce errors
await Filesystem.write(filepath, JSON.stringify({ notAField: true }))
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toContain("config_validation")
expect(result).toContain("WARNING")
@@ -77,9 +80,9 @@ description: A test command
Do something useful`,
)
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toContain("config_validation")
expect(result).toContain("validated successfully")
@@ -98,9 +101,9 @@ subtask: "not-a-boolean"
Do something`,
)
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toContain("config_validation")
expect(result).toContain("WARNING")
@@ -119,9 +122,9 @@ description: A helper agent
You are a helpful agent.`,
)
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toContain("config_validation")
expect(result).toContain("validated successfully")
@@ -132,9 +135,9 @@ You are a helpful agent.`,
const filepath = path.join(tmp.path, "AGENTS.md")
await Filesystem.write(filepath, "# Project agents")
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toBe("")
})
@@ -144,9 +147,9 @@ You are a helpful agent.`,
const filepath = path.join(tmp.path, ".kilo", "plans", "plan.md")
await Filesystem.write(filepath, "# Plan")
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
fn: () => check(filepath),
})
expect(result).toBe("")
})
@@ -169,12 +172,12 @@ Broken agent`,
const filepath = path.join(tmp.path, "kilo.json")
await Filesystem.write(filepath, JSON.stringify({ model: "anthropic/claude-sonnet-4-20250514" }))
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// Force config load to populate warnings
await Config.get()
return ConfigValidation.check(filepath)
await AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
return check(filepath)
},
})
expect(result).toContain("Pre-existing config issues")
@@ -14,7 +14,8 @@ import { Config } from "../../../src/config/config"
import { ConfigMarkdown } from "../../../src/config/markdown"
import { Env } from "../../../src/env"
import { KiloIndexing } from "../../../src/kilocode/indexing"
import { Instance } from "../../../src/project/instance"
import { KilocodeConfig } from "../../../src/kilocode/config/config"
import { WithInstance } from "../../../src/project/with-instance"
import { Filesystem } from "../../../src/util/filesystem"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -44,8 +45,8 @@ const layer = Config.layer.pipe(
)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
const clear = () =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
async function writeConfig(dir: string, config: object, name = "kilo.json") {
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
@@ -67,8 +68,8 @@ const cfg: Partial<Config.Info> = {
afterEach(async () => {
delete process.env.KILO_MD_TEST
await clear()
await disposeAllInstances()
await clear(true)
})
describe("markdown substitutions", () => {
@@ -98,7 +99,8 @@ describe("kilocode indexing config", () => {
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear(true)
await clear()
await disposeAllInstances()
try {
await writeConfig(globalTmp.path, {
@@ -109,7 +111,7 @@ describe("kilocode indexing config", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -123,7 +125,8 @@ describe("kilocode indexing config", () => {
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
await clear()
await disposeAllInstances()
}
})
@@ -133,7 +136,8 @@ describe("kilocode indexing config", () => {
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear(true)
await clear()
await disposeAllInstances()
try {
await writeConfig(globalTmp.path, {
@@ -143,7 +147,7 @@ describe("kilocode indexing config", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const global = await Effect.runPromise(
@@ -156,7 +160,8 @@ describe("kilocode indexing config", () => {
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
await clear()
await disposeAllInstances()
}
})
@@ -164,4 +169,23 @@ describe("kilocode indexing config", () => {
const input = KiloIndexing.input({ enabled: false }, { enabled: true })
expect(input.enabled).toBe(true)
})
test("accepts delete sentinels for indexing model overrides", () => {
const patch = Config.Info.zod.parse({ indexing: { model: null, dimension: null } })
const merged = KilocodeConfig.mergeConfig(
{
indexing: {
provider: "openai",
model: "text-embedding-3-large",
dimension: 3072,
},
},
patch,
)
const input = KiloIndexing.input(patch.indexing)
expect(merged.indexing).toEqual({ provider: "openai" })
expect(input.modelId).toBeUndefined()
expect(input.modelDimension).toBeUndefined()
})
})
@@ -6,12 +6,15 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { Account } from "../../../src/account/account"
import { Auth } from "../../../src/auth"
import { Config } from "../../../src/config/config"
import type { ConfigPlugin } from "../../../src/config/plugin"
import { KilocodeDefaultPlugins } from "../../../src/kilocode/config/default-plugins"
import { INDEXING_PLUGIN } from "../../../src/kilocode/indexing-feature"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { Env } from "../../../src/env"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Filesystem } from "../../../src/util/filesystem"
import { Instance } from "../../../src/project/instance"
import { WithInstance } from "../../../src/project/with-instance"
import { Npm } from "@opencode-ai/core/npm"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -41,13 +44,35 @@ const layer = Config.layer.pipe(
)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
const clear = () =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
describe("kilocode default indexing plugin", () => {
afterEach(async () => {
await clear()
await disposeAllInstances()
await clear(true)
})
test("injects indexing without registering an external plugin origin", () => {
const config: { plugin?: ConfigPlugin.Spec[]; plugin_origins?: ConfigPlugin.Origin[] } = {}
KilocodeDefaultPlugins.apply(config, { disabled: false })
expect(hasIndexingPlugin(config.plugin ?? [])).toBe(true)
expect(config.plugin_origins).toBeUndefined()
})
test("removes a persisted indexing marker from external plugin origins", () => {
const external: ConfigPlugin.Origin = { spec: "global-plugin", source: "global", scope: "global" }
const config = {
plugin: [INDEXING_PLUGIN, external.spec],
plugin_origins: [{ spec: INDEXING_PLUGIN, source: "global", scope: "global" as const }, external],
}
KilocodeDefaultPlugins.apply(config, { disabled: true })
expect(config.plugin).toEqual([INDEXING_PLUGIN, external.spec])
expect(config.plugin_origins).toEqual([external])
})
test("does not hard-enable indexing plugin when default plugins are disabled", async () => {
@@ -67,7 +92,7 @@ describe("kilocode default indexing plugin", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test"
import { Config } from "../../../src/config/config"
describe("Config.Info experimental speech-to-text model", () => {
test("parses the selected speech-to-text model", () => {
const parsed = Config.Info.zod.parse({
experimental: {
speech_to_text_model: "openai/gpt-4o-mini-transcribe",
},
})
expect(parsed.experimental?.speech_to_text_model).toBe("openai/gpt-4o-mini-transcribe")
})
test("keeps existing experimental defaults", () => {
const parsed = Config.Info.zod.parse({ experimental: { speech_to_text_model: "google/chirp-3" } })
expect(parsed.experimental?.openTelemetry).toBe(true)
})
})
@@ -94,9 +94,14 @@ describe("daemon manager", () => {
expect(started.state?.port).toBeGreaterThanOrEqual(Daemon.PortRange.start)
expect(started.state?.port).toBeLessThanOrEqual(Daemon.PortRange.end)
const blocked = await fetch(`${started.state!.url}/global/health`)
const blocked = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`)
expect(blocked.status).toBe(401)
const config = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`, {
headers: { authorization: `Basic ${started.state!.token}` },
})
expect(config.status).toBe(200)
const health = await fetch(`${started.state!.url}/global/health`, {
headers: { authorization: `Basic ${started.state!.token}` },
})
@@ -8,7 +8,7 @@ import { afterAll, afterEach, describe, test, expect } from "bun:test"
import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { LSP } from "../../src/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -69,7 +69,7 @@ describe("edit tool permission filediff metadata", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "new.txt")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
@@ -103,7 +103,7 @@ describe("edit tool permission filediff metadata", () => {
const filepath = path.join(tmp.path, "existing.txt")
await Bun.write(filepath, "line one\nline two\nline three\n")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
@@ -137,7 +137,7 @@ describe("edit tool permission filediff metadata", () => {
const filepath = path.join(tmp.path, "diff-check.txt")
await Bun.write(filepath, "alpha\nbeta\ngamma\n")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
@@ -168,7 +168,7 @@ describe("edit tool permission filediff metadata", () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "result-new.txt")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
@@ -196,7 +196,7 @@ describe("edit tool permission filediff metadata", () => {
const filepath = path.join(tmp.path, "result-edit.txt")
await Bun.write(filepath, "before\n")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
@@ -1,7 +1,18 @@
import { describe, it, expect } from "bun:test"
import { clean } from "../../src/kilocode/enhance-prompt"
import { clean, INSTRUCTION } from "../../src/kilocode/enhance-prompt"
describe("enhance-prompt", () => {
describe("instruction", () => {
it("treats question-shaped drafts as prompts to rewrite", () => {
expect(INSTRUCTION).toContain("never as a request to answer")
expect(INSTRUCTION).toContain("rewrite it into a clearer question or request without answering it")
})
it("improves instruction-shaped drafts instead of following them", () => {
expect(INSTRUCTION).toContain("improve those instructions instead of following them")
})
})
describe("clean", () => {
it("trims whitespace", () => {
expect(clean(" hello world ")).toBe("hello world")
@@ -3,7 +3,8 @@ import { Effect } from "effect"
import path from "path"
import type { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { WithInstance } from "../../src/project/with-instance"
import { SessionID, MessageID } from "../../src/session/schema"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { Tool } from "../../src/tool/tool"
@@ -43,13 +44,13 @@ describe("kilocode external directory boundaries", () => {
const file = path.join(outer.path, "outside.txt")
const { items, ctx } = asks()
await Instance.provide({
await WithInstance.provide({
directory: repo.path,
fn: async () => {
try {
await assertExternalDirectory(ctx, file)
} finally {
await InstanceStore.disposeInstance(Instance.current)
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
@@ -67,13 +68,13 @@ describe("kilocode external directory boundaries", () => {
const file = path.join(outer.path, "outside-root.txt")
const { items, ctx } = asks()
await Instance.provide({
await WithInstance.provide({
directory: root,
fn: async () => {
try {
await assertExternalDirectory(ctx, file)
} finally {
await InstanceStore.disposeInstance(Instance.current)
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { Server } from "../../src/server/server"
import { registerDisposer } from "../../src/effect/instance-registry"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const experimental = Flag.KILO_EXPERIMENTAL_HTTPAPI
const root = Global.Path.config
function app(value: boolean) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = value
return value ? Server.Default().app : Server.Legacy().app
}
async function update(target: ReturnType<typeof app>, provider: "kilo" | "openrouter") {
return target.request("/global/config", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ indexing: { provider } }),
})
}
async function provider(target: ReturnType<typeof app>, directory: string) {
const response = await target.request("/config", { headers: { "x-kilo-directory": directory } })
return (await response.json()).indexing?.provider as string | undefined
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
;(Global.Path as { config: string }).config = root
await disposeAllInstances()
await resetDatabase()
})
describe("global config refresh", () => {
for (const value of [false, true]) {
test(`${value ? "httpapi" : "legacy"} update reloads existing instance before responding`, async () => {
await using config = await tmpdir()
await using workspace = await tmpdir({ config: { formatter: false, lsp: false } })
;(Global.Path as { config: string }).config = config.path
await disposeAllInstances()
const target = app(value)
expect((await update(target, "openrouter")).status).toBe(200)
expect(await provider(target, workspace.path)).toBe("openrouter")
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const unregister = registerDisposer(async (directory) => {
if (directory !== workspace.path) return
started.resolve()
await release.promise
})
try {
const pending = update(target, "kilo")
await started.promise
const early = await Promise.race([pending.then(() => true), Bun.sleep(10).then(() => false)])
expect(early).toBe(false)
release.resolve()
expect((await pending).status).toBe(200)
expect(await provider(target, workspace.path)).toBe("kilo")
} finally {
release.resolve()
unregister()
}
})
test(`${value ? "httpapi" : "legacy"} update ignores disposal notification failures`, async () => {
await using config = await tmpdir()
;(Global.Path as { config: string }).config = config.path
await disposeAllInstances()
const target = app(value)
const listener = () => {
throw new Error("listener failed")
}
GlobalBus.on("event", listener)
try {
expect((await update(target, "kilo")).status).toBe(200)
} finally {
GlobalBus.off("event", listener)
}
})
}
})
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
import { normalizeIndexingStatus } from "@kilocode/kilo-indexing/status"
import type { Config } from "../../src/config/config"
import { GlobalBus } from "../../src/bus/global"
import { getBootstrapRunEffect } from "../../src/effect/app-runtime"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { Instance } from "../../src/project/instance"
import { IndexingWorker } from "../../src/kilocode/indexing-worker-client"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -42,6 +43,17 @@ const off: Partial<Config.Info> = {
},
},
}
const inactive: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: true,
},
indexing: {
enabled: false,
provider: "ollama",
vectorStore: "qdrant",
},
}
const kilo: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
@@ -65,10 +77,42 @@ const implicitOpenAi: Partial<Config.Info> = {
},
},
}
const staleKilo: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: true,
},
indexing: {
enabled: true,
provider: "kilo",
model: "custom/model",
dimension: 2048,
vectorStore: "qdrant",
},
}
const configDir = process.env["KILO_CONFIG_DIR"]
const disabled = process.env["KILO_DISABLE_CODEBASE_INDEXING"]
const error = new Error("test indexing initialization failed")
function inline(directory: string, root: string, hooks: IndexingWorker.Hooks): IndexingWorker.Driver {
const manager = new CodeIndexManager(directory, root)
const progress = manager.onProgressUpdate.on(() => hooks.status(normalizeIndexingStatus(manager)))
const telemetry = manager.onTelemetry.on(hooks.telemetry)
return {
async init(input) {
await manager.initialize(input)
return normalizeIndexingStatus(manager)
},
search: (query, directoryPrefix) => manager.searchIndex(query, directoryPrefix),
async dispose() {
progress.dispose()
telemetry.dispose()
manager.dispose()
},
}
}
async function wait(read: () => Promise<KiloIndexing.Status>, state: KiloIndexing.Status["state"]) {
for (const _ of Array.from({ length: 100 })) {
const status = await read()
@@ -86,7 +130,12 @@ async function called(init: ReturnType<typeof spyOn<CodeIndexManager, "initializ
throw new Error("indexing initialization did not start")
}
beforeEach(() => {
IndexingWorker.override(inline)
})
afterEach(async () => {
IndexingWorker.override()
if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"]
else process.env["KILO_CONFIG_DIR"] = configDir
if (disabled === undefined) delete process.env["KILO_DISABLE_CODEBASE_INDEXING"]
@@ -183,9 +232,8 @@ describe("indexing startup degradation", () => {
GlobalBus.on("event", on)
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: await getBootstrapRunEffect(),
fn: async () => {
await called(init)
expect((await KiloIndexing.current()).state).toBe("In Progress")
@@ -204,16 +252,16 @@ describe("indexing startup degradation", () => {
}
})
test("keeps degraded indexing queryable but unavailable", async () => {
test("keeps degraded indexing queryable but releases its failed engine", async () => {
const init = spyOn(CodeIndexManager.prototype, "initialize").mockRejectedValue(error)
const dispose = spyOn(CodeIndexManager.prototype, "dispose")
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: await getBootstrapRunEffect(),
fn: async () => {
const status = await wait(() => KiloIndexing.current(), "Error")
@@ -222,9 +270,11 @@ describe("indexing startup degradation", () => {
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("boot failure")).toEqual([])
expect(dispose).toHaveBeenCalledTimes(1)
},
})
} finally {
dispose.mockRestore()
init.mockRestore()
}
})
@@ -236,9 +286,8 @@ describe("indexing startup degradation", () => {
const init = spyOn(CodeIndexManager.prototype, "initialize").mockImplementation(() => gate.promise)
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: await getBootstrapRunEffect(),
fn: async () => {
await called(init)
@@ -259,9 +308,8 @@ describe("indexing startup degradation", () => {
process.env["KILO_CONFIG_DIR"] = tmp.path
const init = spyOn(CodeIndexManager.prototype, "initialize")
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: await getBootstrapRunEffect(),
fn: async () => {
const status = await KiloIndexing.current()
@@ -277,6 +325,33 @@ describe("indexing startup degradation", () => {
})
})
test("does not allocate an engine when indexing configuration is disabled", async () => {
const created: string[] = []
IndexingWorker.override((directory, root, hooks) => {
created.push(directory)
return inline(directory, root, hooks)
})
await using tmp = await tmpdir({ git: true, config: inactive })
process.env["KILO_CONFIG_DIR"] = tmp.path
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const status = await wait(() => KiloIndexing.current(), "Disabled")
expect(status).toMatchObject({
state: "Disabled",
message: "Indexing disabled.",
})
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("disabled")).toEqual([])
expect(created).toEqual([])
},
})
})
test("enriches Kilo provider config from env auth", async () => {
global.fetch = (() =>
Promise.resolve(
@@ -300,9 +375,8 @@ describe("indexing startup degradation", () => {
process.env.KILO_ORG_ID = "org_123"
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: await getBootstrapRunEffect(),
fn: async () => {
await called(init)
expect(init.mock.calls[0]?.[0]).toMatchObject({
@@ -324,6 +398,125 @@ describe("indexing startup degradation", () => {
}
})
test("falls back from unsupported stored Kilo models to the hosted default", async () => {
global.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
defaultModel: "mistralai/mistral-embed-2312",
models: [
{ id: "mistralai/mistral-embed-2312", name: "Mistral Embed 2312", dimension: 1024, scoreThreshold: 0.35 },
],
aliases: {},
}),
),
)) as unknown as typeof global.fetch
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
const key = process.env.KILO_API_KEY
await using tmp = await tmpdir({ git: true, config: staleKilo })
process.env["KILO_CONFIG_DIR"] = tmp.path
process.env.KILO_API_KEY = "kilo-token"
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await called(init)
expect(init.mock.calls[0]?.[0]).toMatchObject({
embedderProvider: "kilo",
modelId: "mistralai/mistral-embed-2312",
modelDimension: 1024,
searchMinScore: 0.35,
})
},
})
} finally {
if (key === undefined) delete process.env.KILO_API_KEY
else process.env.KILO_API_KEY = key
init.mockRestore()
}
})
test("keeps configured dimensions for supported Kilo models", async () => {
global.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
defaultModel: "mistralai/mistral-embed-2312",
models: [
{ id: "mistralai/mistral-embed-2312", name: "Mistral Embed 2312", dimension: 1024, scoreThreshold: 0.35 },
{
id: "openai/text-embedding-3-small",
name: "OpenAI Text Embedding 3 Small",
dimension: 1536,
scoreThreshold: 0.4,
},
],
aliases: {},
}),
),
)) as unknown as typeof global.fetch
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
const key = process.env.KILO_API_KEY
const config: Partial<Config.Info> = {
...staleKilo,
indexing: {
...staleKilo.indexing,
model: "openai/text-embedding-3-small",
dimension: 256,
},
}
await using tmp = await tmpdir({ git: true, config })
process.env["KILO_CONFIG_DIR"] = tmp.path
process.env.KILO_API_KEY = "kilo-token"
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await called(init)
expect(init.mock.calls[0]?.[0]).toMatchObject({
embedderProvider: "kilo",
modelId: "openai/text-embedding-3-small",
modelDimension: 256,
})
},
})
} finally {
if (key === undefined) delete process.env.KILO_API_KEY
else process.env.KILO_API_KEY = key
init.mockRestore()
}
})
test("does not execute stored Kilo models when the hosted catalog is unavailable", async () => {
global.fetch = (() => Promise.resolve(new Response(undefined, { status: 500 }))) as unknown as typeof global.fetch
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
const key = process.env.KILO_API_KEY
await using tmp = await tmpdir({ git: true, config: staleKilo })
process.env["KILO_CONFIG_DIR"] = tmp.path
process.env.KILO_API_KEY = "kilo-token"
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await called(init)
expect(init.mock.calls[0]?.[0]).toMatchObject({ embedderProvider: "kilo" })
expect(init.mock.calls[0]?.[0].modelId).toBeUndefined()
expect(init.mock.calls[0]?.[0].modelDimension).toBeUndefined()
},
})
} finally {
if (key === undefined) delete process.env.KILO_API_KEY
else process.env.KILO_API_KEY = key
init.mockRestore()
}
})
test("does not default to Kilo when an existing provider config is present", async () => {
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
const key = process.env.KILO_API_KEY
@@ -333,9 +526,8 @@ describe("indexing startup degradation", () => {
process.env.KILO_API_KEY = "kilo-token"
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: await getBootstrapRunEffect(),
fn: async () => {
await called(init)
expect(init.mock.calls[0]?.[0]).toMatchObject({
@@ -358,9 +550,8 @@ describe("indexing startup degradation", () => {
const init = spyOn(CodeIndexManager.prototype, "initialize")
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: await getBootstrapRunEffect(),
fn: async () => {
const status = await KiloIndexing.current()
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import { IndexingWorker } from "../../src/kilocode/indexing-worker-client"
import { tmpdir } from "../fixture/fixture"
test("runs indexing engine requests in its worker", async () => {
await using tmp = await tmpdir()
const failures: unknown[] = []
const engine = IndexingWorker.create(tmp.path, tmp.path, {
status() {},
telemetry() {},
failure(err) {
failures.push(err)
},
})
try {
const status = await engine.init({ enabled: false, embedderProvider: "openai" })
expect(status.state).toBe("Disabled")
} finally {
await engine.dispose()
}
expect(failures).toEqual([])
})
@@ -1,9 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import type { Config } from "../../src/config/config"
import { getBootstrapRunEffect } from "../../src/effect/app-runtime"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const cfg: Partial<Config.Info> = {
@@ -36,9 +35,8 @@ describe("indexing worktree disable", () => {
const dir = `${tmp.path}/.kilo/worktrees/feature`
await mkdir(dir, { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: dir,
init: await getBootstrapRunEffect(),
fn: async () => {
const status = await KiloIndexing.current()
@@ -57,9 +55,8 @@ describe("indexing worktree disable", () => {
const dir = `${tmp.path}/.kilocode/worktrees/feature`
await mkdir(dir, { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: dir,
init: await getBootstrapRunEffect(),
fn: async () => {
const status = await KiloIndexing.current()
@@ -6,29 +6,15 @@ import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Config } from "@/config/config"
import { emptyConsoleState } from "@/config/console-state"
import { Instruction } from "../../src/session/instruction"
import { Global } from "@opencode-ai/core/global"
import { TestConfig } from "../fixture/config"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
const configLayer = Layer.succeed(
Config.Service,
Config.Service.of({
get: () => Effect.succeed({}),
getGlobal: () => Effect.succeed({}),
getConsoleState: () => Effect.succeed(emptyConsoleState),
update: () => Effect.void,
updateGlobal: (config) => Effect.succeed(config),
invalidate: () => Effect.void,
directories: () => Effect.succeed([]),
waitForDependencies: () => Effect.void,
warnings: () => Effect.succeed([]),
}),
)
const configLayer = TestConfig.layer()
const instructionLayer = (global: Partial<Global.Interface>) =>
Instruction.layer.pipe(
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from "bun:test"
import { describe, it, expect, afterEach, beforeEach } from "bun:test"
import { buildKiloHeaders, getFeatureHeader, getEditorNameHeader } from "@kilocode/kilo-gateway"
import { HEADER_FEATURE, ENV_FEATURE, ENV_VERSION, DEFAULT_EDITOR_NAME } from "@kilocode/kilo-gateway"
import { HEADER_FEATURE, ENV_FEATURE, ENV_EDITOR_NAME, ENV_VERSION, DEFAULT_EDITOR_NAME } from "@kilocode/kilo-gateway"
describe("getFeatureHeader", () => {
const original = process.env[ENV_FEATURE]
@@ -31,6 +31,11 @@ describe("getFeatureHeader", () => {
describe("getEditorNameHeader", () => {
const originalVersion = process.env[ENV_VERSION]
const originalEditor = process.env[ENV_EDITOR_NAME]
beforeEach(() => {
delete process.env[ENV_EDITOR_NAME]
})
afterEach(() => {
if (originalVersion === undefined) {
@@ -38,6 +43,12 @@ describe("getEditorNameHeader", () => {
} else {
process.env[ENV_VERSION] = originalVersion
}
if (originalEditor === undefined) {
delete process.env[ENV_EDITOR_NAME]
} else {
process.env[ENV_EDITOR_NAME] = originalEditor
}
})
it("returns default editor name without version when KILOCODE_VERSION is not set", () => {
@@ -1,169 +1,139 @@
// kilocode_change - new file
//
// Tests that the kilo custom loader keeps paid models visible without authentication.
// Mocks fetchKiloModels from @kilocode/kilo-gateway to avoid real network
// calls (which fail on Windows CI).
// Tests that unauthenticated Kilo models are assembled with paid models and autoloaded anonymously.
import { test, expect, mock } from "bun:test"
import path from "path"
import { unlink } from "fs/promises"
// Bun's mock.module() is process-wide and permanent — it replaces the module
// for ALL test files in the same runner process. To avoid breaking other tests
// that import @kilocode/kilo-gateway, we spread the real exports and only
// override fetchKiloModels with a stub that returns both free and paid models.
const real = await import("@kilocode/kilo-gateway")
mock.module("@kilocode/kilo-gateway", () => ({
...real,
fetchKiloModels: async () => ({
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 128000, output: 4096 },
},
"paid-model": {
id: "paid-model",
name: "Paid Model",
cost: { input: 1.0, output: 2.0 },
limit: { context: 128000, output: 4096 },
},
},
}),
}))
import { tmpdir } from "../fixture/fixture"
import { Global } from "@opencode-ai/core/global"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
import { ModelCache } from "../../src/provider/model-cache"
import { expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { kiloCustomLoaders } from "../../src/kilocode/provider/provider"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { ModelsDev } from "../../src/provider/models"
import { Provider } from "../../src/provider/provider"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
const item = providers[ProviderID.kilo]
expect(item).toBeDefined()
return Object.values(item.models).filter((model) => model.cost.input > 0).length
const input = {
id: "kilo",
env: ["KILO_API_KEY"],
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 128000, output: 4096 },
},
"paid-model": {
id: "paid-model",
name: "Paid Model",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
test("kilo loader keeps paid models without auth and when config apiKey is present", async () => {
// Reset state that may be stale from other test files sharing this process.
// Auth.set from other tests persists in the shared auth.json,
// and ModelCache keeps fetched models in a TTL map.
// ModelsDev.Data was removed in v1.14.33 — instance-store disposal handles cache invalidation.
await Auth.remove("kilo")
ModelCache.clear("kilo")
const seed: Record<string, ModelsDev.Provider> = {
apertis: {
id: "apertis",
name: "Apertis",
env: ["APERTIS_API_KEY"],
models: {},
},
}
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
const auth = Layer.mock(Auth.Service)({
get: () => Effect.succeed(undefined),
})
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
const files = Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
...fs,
readJson: () => Effect.succeed(seed),
stat: () => fs.stat(import.meta.path),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
provider: {
kilo: {
options: {
apiKey: "test-key",
},
function load(data?: { auth?: object; config?: object; env?: Record<string, string | undefined> }) {
return kiloCustomLoaders({
auth: () => Effect.succeed(data?.auth),
config: () => Effect.succeed(data?.config ?? {}),
env: () => Effect.succeed(data?.env ?? {}),
get: () => Effect.succeed(undefined),
}).kilo(input)
}
function layer() {
const cfg = TestConfig.layer()
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: () =>
Effect.succeed({
models: {
"paid-model": {
id: "paid-model",
name: "Paid Model",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}),
)
},
})
}),
)
const cache = Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(cfg),
Layer.provide(auth),
Layer.provide(models),
)
return Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(files),
Layer.provide(cfg),
Layer.provide(auth),
Layer.provide(cache),
)
}
const count = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
const it = testEffect(Layer.empty)
expect(none).toBeGreaterThan(0)
expect(count).toBeGreaterThan(0)
})
it.live("assembles paid Kilo models without auth", () =>
Effect.gen(function* () {
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(Effect.provide(layer()))
const kilo = Provider.fromModelsDevProvider(providers.kilo)
test("kilo loader keeps paid models without auth and when auth exists", async () => {
await Auth.remove("kilo")
ModelCache.clear("kilo")
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
const authPath = path.join(Global.Path.data, "auth.json")
let prev: string | undefined
try {
prev = await Filesystem.readText(authPath)
} catch {}
try {
await Filesystem.write(
authPath,
JSON.stringify({
kilo: {
type: "api",
key: "test-key",
},
}),
)
const count = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
expect(kilo.models["paid-model"]).toMatchObject({
id: "paid-model",
providerID: "kilo",
cost: { input: 1, output: 2 },
})
}),
)
expect(none).toBeGreaterThan(0)
expect(count).toBeGreaterThan(0)
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
try {
await unlink(authPath)
} catch {}
}
}
})
it.effect("enables a paid catalog anonymously without auth", () =>
Effect.gen(function* () {
const result = yield* load()
expect(result.autoload).toBe(true)
expect(result.options).toEqual({ apiKey: "anonymous" })
}),
)
it.effect("enables a paid catalog when config apiKey is present", () =>
Effect.gen(function* () {
const result = yield* load({ config: { provider: { kilo: { options: { apiKey: "test-key" } } } } })
expect(result.autoload).toBe(true)
expect(result.options).toEqual({})
}),
)
it.effect("enables a paid catalog when auth exists", () =>
Effect.gen(function* () {
const result = yield* load({ auth: { type: "api", key: "test-key" } })
expect(result.autoload).toBe(true)
expect(result.options).toEqual({})
}),
)
@@ -1,53 +1,53 @@
// kilocode_change - new file
// Integration: when fetchKiloModels returns a 401 error result, ModelCache
// surfaces the failure and caches empty models (allowing re-auth via /connect).
// The real 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts.
// When the injected Kilo models source returns a 401 error result, ModelCache surfaces
// the failure and caches empty models (allowing re-auth via /connect).
// The real fetchKiloModels 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts.
import { test, expect, mock } from "bun:test"
import path from "path"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as Log from "@opencode-ai/core/util/log"
Log.init({ print: false })
// Simulate a 401 typed error result from the gateway
mock.module("@kilocode/kilo-gateway", () => ({
fetchKiloModels: async () => ({
models: {},
error: { kind: "unauthorized", status: 401 },
}),
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
}))
mock.module("opencode-copilot-auth", () => ({ default: () => ({}) }))
mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) }))
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" })
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "kilo.json"), CONFIG)
},
})
return Instance.provide({ directory: tmp.path, fn })
}
test("401 from gateway sets provider as failed in ModelCache", async () => {
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.failedProviders()).toContain("kilo")
expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
const auth = Layer.mock(Auth.Service)({
get: () => Effect.succeed(undefined),
})
test("401 from gateway caches empty models (not undefined)", async () => {
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
const cached = ModelCache.get("kilo")
expect(cached).toBeDefined()
expect(Object.keys(cached!)).toHaveLength(0)
})
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: () => Effect.succeed({ models: {}, error: { kind: "unauthorized", status: 401 } }),
}),
)
const layer = Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(TestConfig.layer()),
Layer.provide(auth),
Layer.provide(models),
)
const it = testEffect(layer)
it.live("401 from Kilo models sets provider as failed in ModelCache", () =>
Effect.gen(function* () {
const cache = yield* ModelCache.Service
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
}),
)
it.live("401 from Kilo models caches empty models (not undefined)", () =>
Effect.gen(function* () {
const cache = yield* ModelCache.Service
yield* cache.fetch("kilo")
expect(yield* cache.get("kilo")).toEqual({})
}),
)
@@ -0,0 +1,95 @@
// kilocode_change - new file
import { expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Auth } from "../../src/auth"
import { Bus } from "../../src/bus"
import type { Config } from "../../src/config/config"
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
import { ProjectID } from "../../src/project/schema"
import { Session } from "../../src/session/session"
import { SessionID } from "../../src/session/schema"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
function layer(overrides: Partial<Config.Interface> = {}) {
return Layer.merge(
KiloSessions.layer.pipe(Layer.provideMerge(Bus.layer), Layer.provide(TestConfig.layer(overrides))),
Auth.defaultLayer,
)
}
it.instance("initializes once per instance through Config.Service", () => {
let reads = 0
return Effect.gen(function* () {
const sessions = yield* KiloSessions.Service
yield* sessions.init()
yield* sessions.init()
expect(reads).toBe(1)
}).pipe(
Effect.provide(
layer({
getGlobal: () =>
Effect.sync(() => {
reads += 1
return {}
}),
}),
),
)
})
it.instance("does not duplicate created-session subscribers when init is repeated", () => {
const calls: string[] = []
const fetch: typeof globalThis.fetch = Object.assign(
async (input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
if (url.endsWith("/api/session")) {
calls.push(url)
return Response.json({ id: "remote-1", ingestPath: "/api/ingest/session-1" })
}
return new Response("{}", { status: 200 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
const id = SessionID.descending("session-created")
return Effect.gen(function* () {
const auth = yield* Auth.Service
const bus = yield* Bus.Service
const sessions = yield* KiloSessions.Service
yield* auth.set("kilo", { type: "api", key: "test-token" })
yield* sessions.init()
yield* sessions.init()
yield* Effect.sleep(50)
yield* bus.publish(Session.Event.Created, {
sessionID: id,
info: {
id,
slug: "test",
projectID: ProjectID.make("project-test"),
directory: "/tmp/test",
title: "test",
version: "test",
time: { created: Date.now(), updated: Date.now() },
},
})
yield* Effect.sleep(50)
expect(calls).toHaveLength(1)
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove("kilo").pipe(Effect.orDie)
request.mockRestore()
}),
),
Effect.provide(layer()),
)
})
@@ -0,0 +1,160 @@
import { describe, expect, test } from "bun:test"
import { localReviewCommand, localReviewUncommittedCommand, parseReviewCommand } from "../../src/kilocode/review/command"
describe("review command parsing", () => {
test("parses review slash commands", () => {
expect(parseReviewCommand("/review")).toBe("review")
expect(parseReviewCommand("/local-review -- focus tests")).toBe("local-review")
expect(parseReviewCommand("/local-review-uncommitted focus tests")).toBe("local-review-uncommitted")
expect(parseReviewCommand("/test")).toBeUndefined()
expect(parseReviewCommand("local-review")).toBeUndefined()
})
})
describe("local-review command", () => {
const cmd = localReviewCommand()
test("exposes a static string template", () => {
expect(cmd.name).toBe("local-review")
expect(typeof cmd.template).toBe("string")
})
test("template includes $ARGUMENTS for raw user input", () => {
expect(cmd.template).toContain("$ARGUMENTS")
})
test("hints expose $ARGUMENTS as the only placeholder", () => {
expect(cmd.hints).toEqual(["$ARGUMENTS"])
})
test("template documents free-form argument handling", () => {
const text = cmd.template as string
expect(text).toContain("Empty input")
expect(text).toContain("literal free-form text")
expect(text).toContain("Clearly requested base")
expect(text).toContain("Base plus guidance")
expect(text).toContain("Everything else")
expect(text).toContain("ambiguous input as review instructions")
expect(text).not.toContain("<base> -- <instructions>")
expect(text).not.toContain("-- <instructions>")
})
test("template documents the default base priority", () => {
const text = cmd.template as string
expect(text).toContain("origin/main")
expect(text).toContain("origin/master")
expect(text).toContain("origin/dev")
expect(text).toContain("origin/develop")
expect(text).toContain("local `main`")
expect(text).toContain("local `master`")
expect(text).toContain("local `dev`")
expect(text).toContain("local `develop`")
expect(text).toContain("fall back to `main`")
expect(text).toContain("Review.getBaseBranch()")
})
test("template instructs the model to validate the base before reviewing", () => {
const text = cmd.template as string
expect(text).toContain("git merge-base HEAD <base>")
expect(text).toMatch(/no common history|not found/i)
})
test("template avoids dereferencing untracked symlinks", () => {
const text = cmd.template as string
expect(text).toContain("verify it is not a symlink")
expect(text).toContain("do not follow the link")
})
test("template tells the model not to edit files", () => {
const text = cmd.template as string
expect(text).toContain("DO NOT modify any files")
})
test("template applies the review-pr high-signal review focus", () => {
const text = cmd.template as string
expect(text).toContain("Review only these things")
expect(text).toContain("deploy safety")
expect(text).toContain("duplicated code or duplicated logic")
expect(text).toContain("dead code caused by the reviewed changes")
expect(text).toContain("Do not review these things")
expect(text).toContain("code style")
expect(text).toContain("generic refactors with no bug or product risk")
})
test("template applies the review-pr parallel review tracks", () => {
const text = cmd.template as string
expect(text).toContain("spawn six sub-agents in parallel")
expect(text).toContain("security")
expect(text).toContain("performance")
expect(text).toContain("business logic")
expect(text).toContain("NO_FINDINGS")
})
})
describe("local-review-uncommitted command", () => {
const cmd = localReviewUncommittedCommand()
test("exposes a static string template", () => {
expect(cmd.name).toBe("local-review-uncommitted")
expect(typeof cmd.template).toBe("string")
})
test("template includes $ARGUMENTS for raw user input", () => {
expect(cmd.template).toContain("$ARGUMENTS")
})
test("hints expose $ARGUMENTS as the only placeholder", () => {
expect(cmd.hints).toEqual(["$ARGUMENTS"])
})
test("template includes $ARGUMENTS in a user input section", () => {
const text = cmd.template as string
expect(text).toContain("## User Input\n\n$ARGUMENTS")
})
test("template documents free-form user guidance", () => {
const text = cmd.template as string
expect(text).toContain("literal free-form review guidance")
expect(text).toContain("never changes the diff scope")
expect(text).toContain("no base branch selection")
expect(text).toContain("MUST NOT override the diff scope")
})
test("template documents the uncommitted scope and key git commands", () => {
const text = cmd.template as string
expect(text).toMatch(/git\b[^\n]*\bdiff HEAD/)
expect(text).toMatch(/git\b[^\n]*\bdiff --cached/)
expect(text).toContain("git ls-files --others --exclude-standard")
})
test("template avoids dereferencing untracked symlinks", () => {
const text = cmd.template as string
expect(text).toContain("verify it is not a symlink")
expect(text).toContain("do not follow the link")
})
test("template tells the model not to edit files", () => {
const text = cmd.template as string
expect(text).toContain("DO NOT modify any files")
})
test("template applies the review-pr high-signal review focus", () => {
const text = cmd.template as string
expect(text).toContain("Review only these things")
expect(text).toContain("deploy safety")
expect(text).toContain("duplicated code or duplicated logic")
expect(text).toContain("dead code caused by the reviewed changes")
expect(text).toContain("Do not review these things")
expect(text).toContain("code style")
expect(text).toContain("generic refactors with no bug or product risk")
})
test("template applies the review-pr parallel review tracks", () => {
const text = cmd.template as string
expect(text).toContain("spawn six sub-agents in parallel")
expect(text).toContain("security")
expect(text).toContain("performance")
expect(text).toContain("business logic")
expect(text).toContain("NO_FINDINGS")
})
})
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { plain, session, supports, tui } from "../../src/kilocode/cli/logo"
describe("kilocode logo", () => {
test("allows remote terminals", () => {
expect(supports({ SSH_TTY: "/dev/pts/0" }, "linux")).toBe(true)
expect(supports({ SSH_CLIENT: "127.0.0.1 12345 22" }, "linux")).toBe(true)
expect(supports({ SSH_CONNECTION: "127.0.0.1 12345 127.0.0.1 22" }, "linux")).toBe(true)
})
test("falls back on old Windows terminals", () => {
expect(supports({}, "win32")).toBe(false)
expect(supports({ ANSICON: "1" }, "win32")).toBe(false)
expect(supports({ ConEmuPID: "123" }, "win32")).toBe(false)
})
test("allows modern Windows terminals", () => {
expect(supports({ WT_SESSION: "session" }, "win32")).toBe(true)
expect(supports({ TERM_PROGRAM: "vscode" }, "win32")).toBe(true)
expect(supports({ WEZTERM_PANE: "1" }, "win32")).toBe(true)
expect(supports({ TERM_PROGRAM: "WezTerm" }, "win32")).toBe(true)
})
test("allows an override", () => {
expect(supports({ KILO_UNICODE_LOGO: "1", SSH_TTY: "/dev/pts/0" }, "linux")).toBe(true)
expect(supports({ KILO_UNICODE_LOGO: "0" }, "linux")).toBe(false)
})
test("uses modern and fallback logo variants", () => {
expect(tui({ KILO_UNICODE_LOGO: "1" }, "linux").join("\n")).toContain("🬺🬏")
expect(tui({}, "win32").join("\n")).not.toContain("🬺🬏")
expect(plain({}, "win32").join("\n")).not.toContain("🬁🬬")
})
test("formats child session exit logo", () => {
const out = session("Title", "ses_test", "<dim>", "<reset>", {}, "win32")
expect(out).toContain("<dim>Title<reset>")
expect(out).not.toContain("🬺🬏")
})
})
@@ -0,0 +1,193 @@
// kilocode_change - new file
import { expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
type Hit = { readonly url: string }
const auth = Layer.mock(Auth.Service)({
get: () => Effect.succeed(undefined),
})
const it = testEffect(Layer.empty)
function layer(
hits: Ref.Ref<Hit[]>,
cfg = TestConfig.layer(),
access = auth,
gates?: { readonly started: Deferred.Deferred<void>; readonly wait: Deferred.Deferred<void> },
) {
const http = HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(hits, (list) => [...list, { url: request.url }])
const count = (yield* Ref.get(hits)).length
if (gates && count === 1) {
yield* Deferred.succeed(gates.started, undefined)
yield* Deferred.await(gates.wait)
}
return HttpClientResponse.fromWeb(
request,
Response.json({ data: [{ id: `apertis-${count}`, owned_by: "apertis" }] }),
)
}),
)
return Layer.fresh(ModelCache.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
Layer.provide(cfg),
Layer.provide(access),
Layer.provide(ModelCache.kiloModelsLayer),
)
}
it.live("fetches Apertis models through the injected HttpClient", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const models = yield* ModelCache.Service.use((cache) =>
cache.fetch("apertis", { apiKey: "test-key", baseURL: "https://apertis.test/v1" }),
).pipe(Effect.provide(layer(hits)))
expect(Object.keys(models)).toEqual(["apertis-1"])
expect((yield* Ref.get(hits)).map((hit) => hit.url)).toEqual(["https://apertis.test/v1/models"])
}),
)
it.live("reuses cached values and refresh invalidates the provider cell", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const run = ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const first = yield* cache.fetch("apertis", { apiKey: "test-key" })
const cached = yield* cache.fetch("apertis", { apiKey: "test-key" })
const refreshed = yield* cache.refresh("apertis", { apiKey: "test-key" })
return { first, cached, refreshed }
}),
).pipe(Effect.provide(layer(hits)))
const out = yield* run
expect(Object.keys(out.first)).toEqual(["apertis-1"])
expect(Object.keys(out.cached)).toEqual(["apertis-1"])
expect(Object.keys(out.refreshed)).toEqual(["apertis-2"])
expect((yield* Ref.get(hits)).length).toBe(2)
}),
)
it.live("keeps concurrent request options isolated", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
const out = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const first = yield* cache
.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
.pipe(Effect.forkChild)
yield* Deferred.await(started)
const second = yield* cache
.fetch("apertis", { apiKey: "second", baseURL: "https://second.test/v1" })
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* Deferred.succeed(wait, undefined)
const firstModels = yield* Fiber.join(first)
const secondModels = yield* Fiber.join(second)
return { first: firstModels, second: secondModels, current: yield* cache.get("apertis") }
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
expect(Object.keys(out.first)).toEqual(["apertis-1"])
expect(Object.keys(out.second)).toEqual(["apertis-2"])
expect(out.current).toEqual(out.second)
expect((yield* Ref.get(hits)).map((hit) => hit.url)).toEqual([
"https://first.test/v1/models",
"https://second.test/v1/models",
])
}),
)
it.live("does not let an older fetch override a newer refresh", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
const models = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const stale = yield* cache
.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
.pipe(Effect.forkChild)
yield* Deferred.await(started)
const fresh = yield* cache.refresh("apertis", { apiKey: "second", baseURL: "https://second.test/v1" })
yield* Deferred.succeed(wait, undefined)
yield* Fiber.join(stale)
return { fresh, current: yield* cache.get("apertis") }
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
expect(models.current).toEqual(models.fresh)
expect(Object.keys(models.current ?? {})).toEqual(["apertis-2"])
}),
)
it.live("does not restore a fetch that was cleared while pending", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
const current = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const pending = yield* cache
.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
.pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* cache.clear("apertis")
yield* Deferred.succeed(wait, undefined)
yield* Fiber.join(pending)
return yield* cache.get("apertis")
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
expect(current).toBeUndefined()
}),
)
it.live("exposes the most recently refreshed provider value", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const models = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
const refreshed = yield* cache.refresh("apertis", { apiKey: "second", baseURL: "https://second.test/v1" })
const current = yield* cache.get("apertis")
return { refreshed, current }
}),
).pipe(Effect.provide(layer(hits)))
expect(models.current).toEqual(models.refreshed)
expect(Object.keys(models.current ?? {})).toEqual(["apertis-2"])
}),
)
it.live("does not resolve auth or config for unsupported providers", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const configs = yield* Ref.make(0)
const auths = yield* Ref.make(0)
const cfg = TestConfig.layer({
get: () => Ref.update(configs, (count) => count + 1).pipe(Effect.as({})),
})
const access = Layer.mock(Auth.Service)({
get: () => Ref.update(auths, (count) => count + 1).pipe(Effect.as(undefined)),
})
const models = yield* ModelCache.Service.use((cache) => cache.fetch("openai")).pipe(
Effect.provide(layer(hits, cfg, access)),
)
expect(models).toEqual({})
expect(yield* Ref.get(configs)).toBe(0)
expect(yield* Ref.get(auths)).toBe(0)
expect(yield* Ref.get(hits)).toEqual([])
}),
)
@@ -2,163 +2,111 @@
// When a user logs in via OAuth and selects an enterprise organization, the model fetch
// should use the organization-specific endpoint, not the personal endpoint.
import { test, expect, mock } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as Log from "@opencode-ai/core/util/log"
Log.init({ print: false })
// Capture the options passed to fetchKiloModels
let captured: any = undefined
mock.module("@kilocode/kilo-gateway", () => ({
fetchKiloModels: async (options: any) => {
captured = options
return {
models: {
"test-model": {
id: "test-model",
name: "Test Model",
cost: { input: 0.001, output: 0.002 },
limit: { context: 128000, output: 4096 },
},
},
}
},
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
}))
// Mock default plugins to prevent actual installations during tests
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
test("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
type Options = Parameters<ModelCache.KiloModels["fetch"]>[0]
function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefined>) {
const auth = Layer.mock(Auth.Service)({
get: (id) => Effect.succeed(id === "kilo" ? info : undefined),
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
// Simulate an OAuth login where user selected an enterprise organization
await Auth.set("kilo", {
type: "oauth",
access: "test-oauth-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
accountId: "org-enterprise-123",
})
}).pipe(Effect.asVoid),
fn: async () => {
// Reset captured and cache
captured = undefined
ModelCache.clear("kilo")
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: (options) =>
Ref.set(captured, options).pipe(
Effect.as({
models: {
"test-model": {
id: "test-model",
name: "Test Model",
cost: { input: 0.001, output: 0.002 },
limit: { context: 128000, output: 4096 },
},
},
}),
),
}),
)
return Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(TestConfig.layer()),
Layer.provide(auth),
Layer.provide(models),
)
}
// Trigger model fetch through the cache
await ModelCache.fetch("kilo")
const it = testEffect(Layer.empty)
// The fetchKiloModels call should have received the organization ID
expect(captured).toBeDefined()
expect(captured.kilocodeToken).toBe("test-oauth-token")
expect(captured.kilocodeOrganizationId).toBe("org-enterprise-123")
},
})
})
it.live("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", () =>
Effect.gen(function* () {
const captured = yield* Ref.make<Options | undefined>(undefined)
const info = new Auth.Oauth({
type: "oauth",
access: "test-oauth-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
accountId: "org-enterprise-123",
})
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured)))
expect(yield* Ref.get(captured)).toMatchObject({
kilocodeToken: "test-oauth-token",
kilocodeOrganizationId: "org-enterprise-123",
})
}),
)
test("model fetch without OAuth accountId does not set kilocodeOrganizationId", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
// Simulate an OAuth login for a personal account (no accountId)
await Auth.set("kilo", {
type: "oauth",
access: "test-personal-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
})
}).pipe(Effect.asVoid),
fn: async () => {
captured = undefined
ModelCache.clear("kilo")
it.live("model fetch without OAuth accountId does not set kilocodeOrganizationId", () =>
Effect.gen(function* () {
const captured = yield* Ref.make<Options | undefined>(undefined)
const info = new Auth.Oauth({
type: "oauth",
access: "test-personal-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
})
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured)))
expect(yield* Ref.get(captured)).toMatchObject({ kilocodeToken: "test-personal-token" })
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBeUndefined()
}),
)
await ModelCache.fetch("kilo")
it.live("ModelCache.clear removes cached entry so next fetch hits the network", () =>
Effect.gen(function* () {
const captured = yield* Ref.make<Options | undefined>(undefined)
const info = new Auth.Oauth({
type: "oauth",
access: "token-clear-test",
refresh: "refresh-clear",
expires: Date.now() + 3600000,
accountId: "org-clear",
})
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* Ref.get(captured)).toBeDefined()
expect(captured).toBeDefined()
expect(captured.kilocodeToken).toBe("test-personal-token")
expect(captured.kilocodeOrganizationId).toBeUndefined()
},
})
})
yield* Ref.set(captured, undefined)
yield* cache.fetch("kilo")
expect(yield* Ref.get(captured)).toBeUndefined()
expect(yield* cache.get("kilo")).toBeDefined()
test("ModelCache.clear removes cached entry so next fetch hits the network", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
await Auth.set("kilo", {
type: "oauth",
access: "token-clear-test",
refresh: "refresh-clear",
expires: Date.now() + 3600000,
accountId: "org-clear",
})
}).pipe(Effect.asVoid),
fn: async () => {
// Populate cache
captured = undefined
ModelCache.clear("kilo")
await ModelCache.fetch("kilo")
expect(captured).toBeDefined()
yield* cache.clear("kilo")
expect(yield* cache.get("kilo")).toBeUndefined()
// Verify cache is populated — second fetch should NOT call fetchKiloModels
captured = undefined
await ModelCache.fetch("kilo")
expect(captured).toBeUndefined()
expect(ModelCache.get("kilo")).toBeDefined()
// Clear the cache
ModelCache.clear("kilo")
// get() should return undefined after clear
expect(ModelCache.get("kilo")).toBeUndefined()
// Next fetch should call fetchKiloModels again
captured = undefined
await ModelCache.fetch("kilo")
expect(captured).toBeDefined()
},
})
})
yield* cache.fetch("kilo")
expect(yield* Ref.get(captured)).toBeDefined()
}),
).pipe(Effect.provide(layer(info, captured)))
}),
)
@@ -0,0 +1,74 @@
import { expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Env } from "../../src/env"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, CrossSpawnSpawner.defaultLayer))
function withNvidiaKey<A, E, R>(self: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const env = yield* Env.Service
yield* env.set("NVIDIA_API_KEY", "test-api-key")
yield* Effect.addFinalizer(() => env.remove("NVIDIA_API_KEY"))
return yield* self
})
}
it.live("nvidia provider includes KiloCode billing origin header", () =>
provideTmpdirInstance(() =>
withNvidiaKey(
Provider.Service.use((provider) =>
Effect.gen(function* () {
const providers = yield* provider.list()
const headers = providers[ProviderID.make("nvidia")].options.headers
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
expect(headers["X-Title"]).toBe("Kilo Code")
expect(headers["X-BILLING-INVOKE-ORIGIN"]).toBe("KiloCode")
}),
),
),
),
)
it.live("nvidia billing origin header can be overridden from config", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
provider: {
nvidia: {
options: {
headers: {
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
},
},
},
},
}),
),
)
return yield* withNvidiaKey(
Provider.Service.use((provider) =>
Effect.gen(function* () {
const providers = yield* provider.list()
const headers = providers[ProviderID.make("nvidia")].options.headers
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
expect(headers["X-Title"]).toBe("Kilo Code")
expect(headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin")
}),
),
)
}),
),
)
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import path from "path"
const root = path.join(__dirname, "..", "..")
describe("Kilo OAuth branding", () => {
test("Codex OAuth browser flow uses Kilo branding", async () => {
const src = await Bun.file(path.join(root, "src", "plugin", "codex.ts")).text()
expect(src).toContain('originator: "kilo"')
expect(src).toContain("return to Kilo")
expect(src).not.toContain('originator: "opencode"')
expect(src).not.toContain("return to OpenCode")
})
test("MCP OAuth callback page uses Kilo branding", async () => {
const src = await Bun.file(path.join(root, "src", "mcp", "oauth-callback.ts")).text()
expect(src).toContain("return to Kilo")
expect(src).not.toContain("return to OpenCode")
})
})
@@ -4,6 +4,7 @@ import path from "path"
import { Effect, Fiber, Layer } from "effect"
import { Bus } from "../../../src/bus"
import * as Config from "../../../src/config/config"
import { InstanceRuntime } from "../../../src/project/instance-runtime"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../../src/permission"
@@ -13,7 +14,11 @@ import { provideTmpdirInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const bus = Bus.layer
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
bus,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
afterAll(async () => {
@@ -21,7 +26,10 @@ afterAll(async () => {
for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) {
await fs.rm(path.join(dir, file), { force: true }).catch(() => {})
}
await Config.invalidate(true)
await Effect.runPromise(
Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)),
)
await InstanceRuntime.disposeAllInstances()
})
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
@@ -8,11 +8,11 @@ import { Agent } from "../../../src/agent/agent"
import { Config } from "../../../src/config/config"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { Instance } from "../../../src/project/instance"
import { WithInstance } from "../../../src/project/with-instance"
import { MessageID, SessionID } from "../../../src/session/schema"
import { Shell } from "../../../src/shell/shell"
import { Truncate } from "../../../src/tool/truncate"
import { BashTool } from "../../../src/tool/bash"
import { ShellTool } from "../../../src/tool/shell"
import { Plugin } from "../../../src/plugin"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
import { ConfigProtection } from "../../../src/kilocode/permission/config-paths"
@@ -51,7 +51,7 @@ const ps =
Shell.acceptable.reset()
const init = () => runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
const init = () => runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init())))
const quote = (text: string) => `"${text.replaceAll('"', '\\"')}"`
const glob = (file: string) =>
process.platform === "win32" ? AppFileSystem.normalizePathPattern(file) : file.replaceAll("\\", "/")
@@ -131,7 +131,7 @@ afterEach(async () => {
describe("external_directory allow config protection", () => {
test("allows file-tool external_directory requests for global config paths", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await immediate(
@@ -151,7 +151,7 @@ describe("external_directory allow config protection", () => {
test("allows read-only bash external_directory requests for global config paths", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await immediate(
@@ -183,7 +183,7 @@ describe("external_directory allow config protection", () => {
test("keeps unknown bash external_directory requests for global config paths protected", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const pending = Permission.ask({
@@ -214,7 +214,7 @@ describe("bash external_directory access metadata", () => {
test("emits read access metadata for cat external files", async () => {
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const bash = await init()
@@ -242,7 +242,7 @@ describe("bash external_directory access metadata", () => {
withShell(item, async () => {
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const bash = await init()
@@ -269,7 +269,7 @@ describe("bash external_directory access metadata", () => {
test("does not emit read access metadata for mutating external file commands", async () => {
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const bash = await init()
@@ -300,7 +300,7 @@ describe("bash external_directory access metadata", () => {
test("does not emit read access metadata for mixed read and write external commands", async () => {
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const bash = await init()
@@ -7,13 +7,19 @@ import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { SessionID } from "../../../src/session/schema"
import * as Config from "../../../src/config/config"
import { InstanceRuntime } from "../../../src/project/instance-runtime"
import { Global } from "@opencode-ai/core/global"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const bus = Bus.layer
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
Config.defaultLayer,
bus,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
afterAll(async () => {
@@ -21,7 +27,10 @@ afterAll(async () => {
for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) {
await fs.rm(path.join(dir, file), { force: true }).catch(() => {})
}
await Config.invalidate(true)
await Effect.runPromise(
Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)),
)
await InstanceRuntime.disposeAllInstances()
})
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
@@ -733,7 +742,8 @@ describe("saveAlwaysRules", () => {
yield* reply({ requestID: PermissionID.make("permission_saved_always"), reply: "always" })
yield* Fiber.join(fiber)
const cfg = yield* Effect.promise(() => Config.get())
const config = yield* Config.Service
const cfg = yield* config.get()
expect(cfg.permission?.bash).toMatchObject({ "kilo-permission-8353 test": "allow" })
expect(cfg.permission?.bash).not.toMatchObject({ "kilo-permission-8353 *": "allow" })
@@ -1,24 +1,28 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { Instance } from "../../../src/project/instance"
import { WithInstance } from "../../../src/project/with-instance"
import { Session } from "../../../src/session/session"
import { tmpdir } from "../../fixture/fixture"
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
afterEach(() => {
delete process.env["KILO_EXPERIMENTAL_HTTPAPI"]
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
})
async function app() {
async function app(experimental = false) {
const { Server } = await import("../../../src/server/server")
return Server.Default().app
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
describe("POST /permission/:requestID/reply", () => {
test("returns 404 when requestID is not pending", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const server = await app()
@@ -40,7 +44,7 @@ describe("POST /permission/:requestID/reply", () => {
test("returns 200 for an accepted reply", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const server = await app()
@@ -78,7 +82,7 @@ describe("POST /permission/:requestID/reply", () => {
test("returns 404 when replying to an already-answered request (double-reply)", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const server = await app()
@@ -119,13 +123,12 @@ describe("POST /permission/:requestID/reply", () => {
})
test("returns 404 for unknown replies when experimental HttpApi is enabled", async () => {
process.env["KILO_EXPERIMENTAL_HTTPAPI"] = "1"
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const server = await app()
const server = await app(true)
const response = await server.request("/permission/permission_missing/reply", {
method: "POST",
@@ -143,7 +146,7 @@ describe("POST /permission/:requestID/always-rules", () => {
test("returns 404 when requestID is not pending", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const server = await app()
@@ -164,7 +167,7 @@ describe("POST /permission/:requestID/always-rules", () => {
test("returns 200 for an accepted save", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const server = await app()
@@ -7,13 +7,18 @@ import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { SessionID } from "../../../src/session/schema"
import * as Config from "../../../src/config/config"
import { InstanceRuntime } from "../../../src/project/instance-runtime"
import { Global } from "@opencode-ai/core/global"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const bus = Bus.layer
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
const env = Layer.mergeAll(
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
bus,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
afterAll(async () => {
@@ -21,7 +26,10 @@ afterAll(async () => {
for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) {
await fs.rm(path.join(dir, file), { force: true }).catch(() => {})
}
await Config.invalidate(true)
await Effect.runPromise(
Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)),
)
await InstanceRuntime.disposeAllInstances()
})
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
@@ -5,6 +5,7 @@ import { Identifier } from "../../src/id/id"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { PlanFollowup } from "../../src/kilocode/plan-followup"
import { Question } from "../../src/question"
import { Session } from "../../src/session/session"
@@ -22,7 +23,7 @@ const model = {
async function withInstance(fn: () => Promise<void>) {
await using tmp = await tmpdir({ git: true })
await Instance.provide({ directory: tmp.path, fn })
await WithInstance.provide({ directory: tmp.path, fn })
}
async function seed(input: {
@@ -144,6 +145,49 @@ describe("plan_exit detection", () => {
await expect(pending).resolves.toBe("break")
}))
test("JetBrains client enables plan follow-up with custom answer", () =>
withInstance(async () => {
const prev = process.env.KILO_CLIENT
try {
process.env.KILO_CLIENT = "jetbrains"
const seeded = await seed({
text: "Here is the plan",
tools: [
{
tool: "plan_exit",
input: {},
output: "Plan is ready. Ending planning turn.",
},
],
})
expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(true)
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const question = await waitQuestion(seeded.sessionID)
expect(question).toBeDefined()
if (!question) return
expect(question.questions[0].question).toBe("Ready to implement?")
expect(question.questions[0].header).toBe("Implement")
expect(question.questions[0].custom).toBe(true)
expect(question.questions[0].options.map((item) => item.label)).toEqual([
PlanFollowup.ANSWER_NEW_SESSION,
PlanFollowup.ANSWER_CONTINUE,
])
expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe("code")
await Question.reject(question.id)
await expect(pending).resolves.toBe("break")
} finally {
if (prev === undefined) delete process.env.KILO_CLIENT
else process.env.KILO_CLIENT = prev
}
}))
test("PlanFollowup.ask triggers and continue works with plan_exit", () =>
withInstance(async () => {
const seeded = await seed({
@@ -7,10 +7,10 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { formatTodos, generateHandover, PlanFollowup, PlanFollowupRuntime } from "../../src/kilocode/plan-followup"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "../../src/provider/provider"
import { Question } from "../../src/question"
import { Session } from "../../src/session/session"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { AppRuntime } from "../../src/effect/app-runtime"
import { SessionStatus } from "../../src/session/status"
@@ -71,7 +71,7 @@ const savedKey = `${saved.providerID}/${saved.modelID}`
async function withInstance(fn: () => Promise<void>) {
await using tmp = await tmpdir({ git: true })
await fs.rm(statePath, { force: true }).catch(() => {})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await fs.rm(statePath, { force: true }).catch(() => {})
@@ -234,17 +234,15 @@ function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) {
(opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any,
)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: Promise.resolve(text),
} as any)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue(text)
return {
agentSpy,
modelSpy,
llmSpy,
handoverSpy,
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
},
}
}
@@ -567,16 +565,14 @@ describe("plan follow-up", () => {
return fakeModel
},
)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: Promise.resolve(
"## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer",
),
} as any)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue(
"## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer",
)
using _mocks = {
llmSpy,
handoverSpy,
[Symbol.dispose]() {
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
},
}
using _loop = {
@@ -625,7 +621,7 @@ describe("plan follow-up", () => {
expect(added).toHaveLength(1)
expect(created).toHaveLength(1)
expect(loop).toHaveBeenCalledTimes(1)
expect(_mocks.llmSpy).toHaveBeenCalledTimes(1)
expect(_mocks.handoverSpy).toHaveBeenCalledTimes(1)
const newSessionID = created[0]
const next = added[0]
@@ -664,9 +660,7 @@ describe("plan follow-up", () => {
await using other = await tmpdir({ git: true })
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async () => undefined as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: Promise.resolve(""),
} as any)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("")
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
id: MessageID.make("msg_test"),
@@ -694,19 +688,19 @@ describe("plan follow-up", () => {
[Symbol.dispose]() {
get.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
},
}
const dir = other.path
const seeded = await Instance.provide({
const seeded = await WithInstance.provide({
directory: dir,
fn: async () => seed({ text: "1. Add API\n2. Add tests" }),
})
const before = await Instance.provide({
const before = await WithInstance.provide({
directory: dir,
fn: async () => sessions(),
})
@@ -726,7 +720,7 @@ describe("plan follow-up", () => {
})
await expect(pending).resolves.toBe("break")
const after = await Instance.provide({
const after = await WithInstance.provide({
directory: dir,
fn: async () => sessions(),
})
@@ -740,7 +734,7 @@ describe("plan follow-up", () => {
expect(next?.parentID).toBeUndefined()
if (next) {
const planPath = await Instance.provide({
const planPath = await WithInstance.provide({
directory: dir,
fn: async () => Session.plan(await Session.get(seeded.sessionID), Instance.current),
})
@@ -983,12 +977,12 @@ describe("plan follow-up", () => {
const deferred = Promise.withResolvers<string>()
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: deferred.promise.then((t) => {
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() =>
deferred.promise.then((text) => {
handoverResolvedAt = performance.now()
return t
return text
}),
} as any)
)
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
id: MessageID.make("msg_test"),
@@ -1016,7 +1010,7 @@ describe("plan follow-up", () => {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
unsub()
},
@@ -1070,9 +1064,7 @@ describe("plan follow-up", () => {
const deferred = Promise.withResolvers<string>()
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: deferred.promise,
} as any)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise)
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
id: MessageID.make("msg_test"),
@@ -1094,7 +1086,7 @@ describe("plan follow-up", () => {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
unsub()
},
@@ -1169,9 +1161,7 @@ describe("plan follow-up", () => {
const deferred = Promise.withResolvers<string>()
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: deferred.promise,
} as any)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise)
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
id: MessageID.make("msg_test"),
@@ -1193,7 +1183,7 @@ describe("plan follow-up", () => {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
created()
status()
@@ -1315,16 +1305,16 @@ describe("plan follow-up", () => {
expect(result).toBe("- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task")
})
test("generateHandover - returns empty string on LLM.stream failure", () =>
test("generateHandover - returns empty string on LLM stream failure", () =>
withInstance(async () => {
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable"))
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("provider unavailable"))
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
@@ -1332,22 +1322,16 @@ describe("plan follow-up", () => {
expect(result).toBe("")
}))
test("generateHandover - returns empty string on stream.text rejection", () =>
test("generateHandover - returns empty string on text stream rejection", () =>
withInstance(async () => {
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const textPromise = new Promise<string>((_, reject) => {
setTimeout(() => reject(new Error("stream aborted")), 0)
})
textPromise.catch(() => {})
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: textPromise,
} as any)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("stream aborted"))
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
handoverSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
@@ -1362,7 +1346,7 @@ describe("plan follow-up", () => {
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("## Discoveries\n\nFallback works")
expect(mocks.agentSpy).toHaveBeenCalledWith("compaction")
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
expect(mocks.handoverSpy).toHaveBeenCalledTimes(1)
}))
test("generateHandover - returns LLM output on success", () =>
@@ -1371,6 +1355,6 @@ describe("plan follow-up", () => {
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("## Discoveries\n\nKey finding here")
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
expect(mocks.handoverSpy).toHaveBeenCalledTimes(1)
}))
})
@@ -12,7 +12,7 @@ import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { Env } from "../../src/env"
import { Npm } from "@opencode-ai/core/npm"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Filesystem } from "../../src/util/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture"
@@ -56,7 +56,7 @@ async function writeConfig(dir: string, config: unknown) {
test("project config update creates .kilo/kilo.json and reloads it", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await save({ model: "updated/model" } as any)
@@ -72,7 +72,7 @@ test("project config update creates .kilo/kilo.json and reloads it", async () =>
test("project config update skips empty delete-only writes when no config exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await save({ provider: { missing: null } } as any)
@@ -86,7 +86,7 @@ test("project config update prefers existing root kilo.json", async () => {
await using tmp = await tmpdir()
await writeConfig(tmp.path, { username: "alice" })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await save({ model: "updated/model" } as any)
@@ -105,7 +105,7 @@ test("project config update patches ancestor .kilo/kilo.json from nested directo
await fs.mkdir(path.join(tmp.path, ".kilo"), { recursive: true })
await writeConfig(path.join(tmp.path, ".kilo"), { username: "alice" })
await Instance.provide({
await WithInstance.provide({
directory: child,
fn: async () => {
await save({ model: "updated/model" } as any)
@@ -2,7 +2,7 @@ import { test, expect, describe } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { getKiloProjectId } from "../../src/kilocode/project-id"
describe("project-id", () => {
@@ -16,7 +16,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -33,7 +33,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -49,7 +49,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -65,7 +65,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -82,7 +82,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -112,7 +112,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -136,7 +136,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -158,7 +158,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -182,7 +182,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -208,7 +208,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -234,7 +234,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -258,7 +258,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -284,7 +284,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -297,7 +297,7 @@ describe("project-id", () => {
test("returns undefined when no config and no git origin", async () => {
await using tmp = await tmpdir({ git: true })
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -308,7 +308,7 @@ describe("project-id", () => {
test("returns undefined for non-git directory", async () => {
await using tmp = await tmpdir()
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -327,7 +327,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -354,7 +354,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -373,12 +373,12 @@ describe("project-id", () => {
},
})
const id1 = await Instance.provide({
const id1 = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
const id2 = await Instance.provide({
const id2 = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -397,7 +397,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -420,7 +420,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -444,7 +444,7 @@ describe("project-id", () => {
},
})
const id = await Instance.provide({
const id = await WithInstance.provide({
directory: tmp.path,
fn: () => getKiloProjectId(),
})
@@ -4,98 +4,152 @@
// 2. ModelCache.getFailure() returns the typed error for a failed provider.
// 3. Clear removes failure state.
import { test, expect, mock } from "bun:test"
import path from "path"
import { beforeEach, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as Log from "@opencode-ai/core/util/log"
Log.init({ print: false })
// Stub fetchKiloModels to return controlled typed results.
let stubbedResult: { models: Record<string, any>; error?: { kind: string; status?: number } } = { models: {} }
mock.module("@kilocode/kilo-gateway", () => ({
fetchKiloModels: async () => stubbedResult,
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
}))
mock.module("opencode-copilot-auth", () => ({ default: () => ({}) }))
mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) }))
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import type { Provider } from "../../src/provider/models"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" })
type Failure = { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
type Result = { models: Provider["models"]; error?: Failure }
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "kilo.json"), CONFIG)
},
})
return Instance.provide({ directory: tmp.path, fn })
let result: Result = { models: {} }
let error: Error | undefined
const auth = Layer.mock(Auth.Service)({
get: () => Effect.succeed(undefined),
})
function layer() {
const models = Layer.succeed(
ModelCache.KiloModelsService,
ModelCache.KiloModelsService.of({
fetch: () => (error ? Effect.fail(error) : Effect.succeed(result)),
}),
)
return Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(TestConfig.layer()),
Layer.provide(auth),
Layer.provide(models),
)
}
test("failedProviders returns empty array when no fetch has occurred", () => {
ModelCache.clear("kilo")
expect(ModelCache.failedProviders()).not.toContain("kilo")
const it = testEffect(Layer.empty)
beforeEach(() => {
result = { models: {} }
error = undefined
})
test("getFailure returns undefined when fetch succeeds", async () => {
stubbedResult = {
models: {
"test/model": {
id: "test/model",
name: "Test",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
it.live("failedProviders returns empty array when no fetch has occurred", () =>
ModelCache.Service.use((cache) =>
Effect.gen(function* () {
expect(yield* cache.failedProviders()).not.toContain("kilo")
}),
).pipe(Effect.provide(layer())),
)
it.live("getFailure returns undefined when fetch succeeds", () =>
Effect.gen(function* () {
result = {
models: {
"test/model": {
id: "test/model",
name: "Test",
attachment: false,
reasoning: false,
release_date: "",
temperature: true,
tool_call: true,
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
},
}
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.getFailure("kilo")).toBeUndefined()
expect(ModelCache.failedProviders()).not.toContain("kilo")
})
}
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.getFailure("kilo")).toBeUndefined()
expect(yield* cache.failedProviders()).not.toContain("kilo")
}),
).pipe(Effect.provide(layer()))
}),
)
test("failedProviders includes provider after auth error", async () => {
stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } }
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.failedProviders()).toContain("kilo")
expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
})
it.live("failedProviders includes provider after auth error", () =>
Effect.gen(function* () {
result = { models: {}, error: { kind: "unauthorized", status: 401 } }
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
}),
).pipe(Effect.provide(layer()))
}),
)
test("clear removes failure state", async () => {
stubbedResult = { models: {}, error: { kind: "network" } }
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.failedProviders()).toContain("kilo")
it.live("gateway rejection remains recoverable through the Effect error channel", () =>
Effect.gen(function* () {
error = new Error("gateway failed")
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const models = yield* cache.fetch("kilo").pipe(Effect.catch(() => Effect.succeed({})))
expect(models).toEqual({})
}),
).pipe(Effect.provide(layer()))
}),
)
ModelCache.clear("kilo")
expect(ModelCache.failedProviders()).not.toContain("kilo")
expect(ModelCache.getFailure("kilo")).toBeUndefined()
})
it.live("clear removes failure state", () =>
Effect.gen(function* () {
result = { models: {}, error: { kind: "network" } }
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
yield* cache.clear("kilo")
expect(yield* cache.failedProviders()).not.toContain("kilo")
expect(yield* cache.getFailure("kilo")).toBeUndefined()
}),
).pipe(Effect.provide(layer()))
}),
)
test("failure state is cleared when subsequent fetch succeeds", async () => {
stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } }
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.failedProviders()).toContain("kilo")
stubbedResult = {
models: {
"test/model": {
id: "test/model",
name: "Test",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.failedProviders()).not.toContain("kilo")
expect(ModelCache.getFailure("kilo")).toBeUndefined()
})
it.live("failure state is cleared when subsequent refresh succeeds", () =>
Effect.gen(function* () {
result = { models: {}, error: { kind: "unauthorized", status: 401 } }
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo")
expect(yield* cache.failedProviders()).toContain("kilo")
result = {
models: {
"test/model": {
id: "test/model",
name: "Test",
attachment: false,
reasoning: false,
release_date: "",
temperature: true,
tool_call: true,
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
yield* cache.refresh("kilo")
expect(yield* cache.failedProviders()).not.toContain("kilo")
expect(yield* cache.getFailure("kilo")).toBeUndefined()
}),
).pipe(Effect.provide(layer()))
}),
)
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Question } from "../../src/question"
import { MessageID, SessionID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"
@@ -9,7 +9,7 @@ import { tmpdir } from "../fixture/fixture"
describe("Question.dismissAll", () => {
test("rejects pending asks for the target session and clears them", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const sesA = SessionID.make("ses_a")
@@ -99,7 +99,7 @@ describe("Question.dismissAll", () => {
test("is a no-op when no questions exist", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Question.dismissAll("ses_missing")
@@ -114,7 +114,7 @@ describe("Question.dismissAll", () => {
// the user manually dismisses it. Verify the pre-emptive hasFollowup check
// rejects with RejectedError before any pending entry is registered.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_ask")
@@ -39,6 +39,7 @@ function asked(id: number): Event {
sessionID: "ses_test",
id: `req_${id}`,
message: "Connection refused",
restored: false,
time: { created: 0 },
},
}
@@ -4,7 +4,7 @@ import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent"
import { SemanticSearchTool } from "../../src/kilocode/tool/semantic-search"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -37,9 +37,8 @@ describe("tool.semantic_search", () => {
test("describes code snippet results", async () => {
const tool = await initTool()
expect(tool.description).toContain("Find code snippets most relevant")
expect(tool.description).toContain("Returns matching content with file paths, line ranges, and relevance scores")
expect(tool.description).not.toContain("Find files most relevant")
expect(tool.description).toContain("Find code snippets by semantic meaning")
expect(tool.description).toContain("Search for an exact symbol")
})
test("throws when query is empty", async () => {
@@ -49,7 +48,7 @@ describe("tool.semantic_search", () => {
test("asks permission and forwards normalized relative path to indexing search", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
@@ -90,7 +89,7 @@ describe("tool.semantic_search", () => {
test("searches entire workspace when path is omitted", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
@@ -111,7 +110,7 @@ describe("tool.semantic_search", () => {
test("formats and normalizes search results", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const search = spyOn(KiloIndexing, "search").mockResolvedValue([
@@ -167,7 +166,7 @@ describe("tool.semantic_search", () => {
test("rejects paths outside the workspace", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test"
import { OpenApi } from "effect/unstable/httpapi"
import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process"
import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway"
import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental"
import { PublicApi } from "../../../src/server/routes/instance/httpapi/public"
import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session"
import { Server } from "../../../src/server/server"
const methods = ["get", "post", "put", "delete", "patch"] as const
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
function effectOpenApi() {
return (effectSpec ??= OpenApi.fromApi(PublicApi))
}
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
return Object.entries(spec.paths)
.flatMap(([path, item]) =>
methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
)
.sort()
}
function stableSchema(input: unknown): string {
return JSON.stringify(sortSchema(input))
}
function sortSchema(input: unknown): unknown {
if (Array.isArray(input)) return input.map(sortSchema)
if (!input || typeof input !== "object") return input
return Object.fromEntries(
Object.entries(input)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, sortSchema(value)]),
)
}
function isRecord(input: unknown): input is Record<string, unknown> {
return !!input && typeof input === "object" && !Array.isArray(input)
}
type Operation = {
responses?: unknown
}
function providerSchema(input: unknown) {
if (!input || typeof input !== "object" || !("components" in input)) return undefined
const components = input.components
if (!components || typeof components !== "object" || !("schemas" in components)) return undefined
const schemas = components.schemas
if (!schemas || typeof schemas !== "object" || !("Config" in schemas)) return undefined
const config = schemas.Config
if (!config || typeof config !== "object" || !("properties" in config)) return undefined
const props = config.properties
if (!props || typeof props !== "object" || !("provider" in props)) return undefined
const provider = props.provider
if (!provider || typeof provider !== "object" || !("additionalProperties" in provider)) return undefined
return provider.additionalProperties
}
function indexingNullableFields(input: unknown) {
if (!isRecord(input) || !isRecord(input.components) || !isRecord(input.components.schemas)) return []
const indexing = input.components.schemas.IndexingConfig
if (!isRecord(indexing) || !isRecord(indexing.properties)) return []
const properties = indexing.properties
return ["model", "dimension"].filter((key) => JSON.stringify(properties[key]).includes('"null"'))
}
function responseSchema(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string
method: (typeof methods)[number]
status: string
contentType: string
}) {
const responses = input.spec.paths[input.path]?.[input.method]?.responses
if (!responses || typeof responses !== "object" || !(input.status in responses)) return undefined
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
const response = (responses as Record<string, unknown>)[input.status]
if (!response || typeof response !== "object" || !("content" in response)) return undefined
const content = (response as { content?: unknown }).content
if (!content || typeof content !== "object" || !(input.contentType in content)) return undefined
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response content lookup.
const body = (content as Record<string, unknown>)[input.contentType]
if (!body || typeof body !== "object" || !("schema" in body)) return undefined
return body.schema
}
describe("Kilo HttpApi bridge", () => {
test("mirrors Kilo overlay routes in Hono and Effect specs", async () => {
const hono = new Set(openApiRouteKeys(await Server.openapiHono()))
const effect = new Set(openApiRouteKeys(effectOpenApi()))
const kilo = [
`GET ${BackgroundProcessPaths.list}`,
"GET /background-process/{processID}",
"GET /background-process/{processID}/logs",
"POST /background-process/{processID}/stop",
"POST /background-process/{processID}/restart",
"POST /background-process/session/{sessionID}/stop",
"POST /permission/allow-everything",
"POST /enhance-prompt",
"POST /commit-message",
`GET ${ExperimentalPaths.worktreeDiff}`,
`GET ${ExperimentalPaths.worktreeDiffFile}`,
`GET ${ExperimentalPaths.worktreeDiffSummary}`,
"GET /network",
"POST /network/{requestID}/reply",
"POST /network/{requestID}/reject",
`GET ${KiloGatewayPaths.modes}`,
`POST ${KiloGatewayPaths.fim}`,
`POST ${KiloGatewayPaths.audioTranscriptions}`,
"POST /remote/enable",
"POST /remote/disable",
"GET /remote/status",
`POST ${SessionPaths.viewed}`,
"POST /telemetry/capture",
"POST /telemetry/setEnabled",
"GET /suggestion",
"POST /suggestion/{requestID}/accept",
"POST /suggestion/{requestID}/dismiss",
"POST /kilocode/heap/snapshot",
"POST /kilocode/skill/remove",
"POST /kilocode/agent/remove",
"POST /kilocode/session-import/project",
"POST /kilocode/session-import/session",
"POST /kilocode/session-import/message",
"POST /kilocode/session-import/part",
]
expect(kilo.filter((route) => !hono.has(route))).toEqual([])
expect(kilo.filter((route) => !effect.has(route))).toEqual([])
expect(hono.has("POST /background-process")).toBe(false)
expect(effect.has("POST /background-process")).toBe(false)
expect(effect.has("GET /indexing/status")).toBe(true)
})
test("documents cloud session import separately from id lookup", () => {
const effect = effectOpenApi()
expect(effect.paths["/kilo/cloud/session/import"]?.post).toBeDefined()
expect(effect.paths["/kilo/cloud/session/{id}"]?.get).toBeDefined()
expect(KiloGatewayPaths.cloudSessionImport).not.toBe(KiloGatewayPaths.cloudSession)
})
test("matches nullable provider delete sentinels", async () => {
const hono = await Server.openapiHono()
const effect = effectOpenApi()
expect(stableSchema(providerSchema(effect))).toBe(stableSchema(providerSchema(hono)))
})
test("keeps nullable indexing model reset sentinels in both APIs", async () => {
const hono = await Server.openapiHono()
const effect = effectOpenApi()
expect(indexingNullableFields(effect)).toEqual(["model", "dimension"])
expect(indexingNullableFields(hono)).toEqual(["model", "dimension"])
})
test("matches Kilo FIM SSE response schema", async () => {
const hono = await Server.openapiHono()
const effect = effectOpenApi()
const input = {
path: KiloGatewayPaths.fim,
method: "post" as const,
status: "200",
contentType: "text/event-stream",
}
expect(stableSchema(responseSchema({ spec: effect, ...input }))).toBe(
stableSchema(responseSchema({ spec: hono, ...input })),
)
})
})
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { OpenApi } from "effect/unstable/httpapi"
import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway"
import { PublicApi } from "../../../src/server/routes/instance/httpapi/public"
type Schema = {
anyOf?: Schema[]
properties?: Record<string, Schema>
type?: string
}
type Body = {
content?: Record<string, { schema?: Schema }>
}
describe("Kilo PublicApi OpenAPI contract", () => {
test("keeps personal organization resets nullable", () => {
const spec = OpenApi.fromApi(PublicApi)
const body = spec.paths[KiloGatewayPaths.organization]?.post?.requestBody as Body | undefined
const schema = body?.content?.["application/json"]?.schema
const props = schema?.properties
expect(props?.organizationId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
})
})
@@ -2,7 +2,7 @@
import { describe, expect, test } from "bun:test"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { Instance } from "../../../src/project/instance"
import { WithInstance } from "../../../src/project/with-instance"
import { Server } from "../../../src/server/server"
import { Session } from "../../../src/session/session"
import { tmpdir } from "../../fixture/fixture"
@@ -11,7 +11,7 @@ describe("permission.allowEverything endpoint", () => {
test("disables global allow-all and removes wildcard from config", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const app = Server.Default().app
@@ -58,7 +58,7 @@ describe("permission.allowEverything endpoint", () => {
test("disables session-scoped allow-all without touching global config", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const app = Server.Default().app
@@ -16,6 +16,7 @@ import { Env } from "../../src/env"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../../src/format"
import { Git } from "../../src/git"
import { KiloSession } from "../../src/kilocode/session"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { LSP } from "../../src/lsp/lsp"
@@ -146,6 +147,7 @@ function makeHttp() {
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -0,0 +1,599 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { WithInstance } from "../../src/project/with-instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Snapshot } from "../../src/snapshot"
import { KiloCompactionChunks } from "../../src/kilocode/session/compaction-chunks"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionCompaction } from "../../src/session/compaction"
import * as SessionProcessorModule from "../../src/session/processor"
import type { SessionProcessor } from "../../src/session/processor"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Session as SessionNs } from "../../src/session/session"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { ProviderTest } from "../fake/provider"
import { tmpdir } from "../fixture/fixture"
const providerID = ProviderID.make("test")
const modelID = ModelID.make("test-model")
const ref = { providerID, modelID }
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: Parameters<SessionNs.Interface["messages"]>[0]) {
return run(SessionNs.Service.use((svc) => svc.messages(input)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
async function user(sessionID: SessionID, text: string) {
const msg = await svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
})
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
async function assistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "build",
agent: "build",
path: { cwd: root, root },
cost: 0,
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID,
providerID,
parentID,
time: { created: Date.now() },
finish: "stop",
}
await svc.updateMessage(msg)
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
function llm() {
const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
> = []
return {
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
queue.push(stream)
},
layer: Layer.succeed(
LLM.Service,
LLM.Service.of({
stream: (input) => {
const item = queue.shift() ?? Stream.empty
const stream = typeof item === "function" ? item(input) : item
return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
},
}),
),
}
}
function reply(text: string, capture?: (input: LLM.StreamInput) => void) {
return (input: LLM.StreamInput) => {
capture?.(input)
return Stream.make(
{ type: "start" } as LLM.Event,
{ type: "text-start", id: "txt-0" } as LLM.Event,
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
{ type: "text-end", id: "txt-0" } as LLM.Event,
{
type: "finish-step",
finishReason: "stop",
rawFinishReason: "stop",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
)
}
}
function overflow() {
return Stream.make(
{ type: "start" } as LLM.Event,
{
type: "finish-step",
finishReason: "stop",
rawFinishReason: "stop",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 20_000,
outputTokens: 1,
totalTokens: 20_001,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 20_000,
outputTokens: 1,
totalTokens: 20_001,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
)
}
function runtime(layer: Layer.Layer<LLM.Service>, context = 7_000) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } })
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
}),
),
),
)
}
function fakeRuntime() {
const calls: string[] = []
const outputs: number[] = []
const bus = Bus.layer
const processor = Layer.effect(
SessionProcessorModule.SessionProcessor.Service,
Effect.gen(function* () {
const sessions = yield* SessionNs.Service
return SessionProcessorModule.SessionProcessor.Service.of({
create: Effect.fn("TestSessionProcessor.create")((input) =>
Effect.succeed({
get message() {
return input.assistantMessage
},
updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
process: Effect.fn("TestSessionProcessor.process")((stream: LLM.StreamInput) =>
Effect.gen(function* () {
outputs.push(input.model.limit.output)
calls.push(JSON.stringify(stream.messages))
const text = stream.messages.some((msg) =>
JSON.stringify(msg).includes("Create a new anchored summary"),
)
? "final summary"
: calls.length === 1
? "chunk one"
: "chunk two"
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "text",
text,
})
input.assistantMessage.finish = "stop"
return "continue" as const
}),
),
} satisfies SessionProcessor.Handle),
),
})
}),
)
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 1_000 } })
return {
calls,
outputs,
rt: ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(bus),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
}),
),
),
),
}
}
function liveRuntime(layer: Layer.Layer<LLM.Service>, context = 10_000) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } })
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
}),
),
),
)
}
afterEach(() => {
mock.restore()
})
describe("KiloCompactionChunks", () => {
test("splits oversized history into chronological chunks", async () => {
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 7_000, output: 1_000 } })
const sessionID = SessionID.make("ses_chunks_split")
const messages: MessageV2.WithParts[] = Array.from({ length: 4 }, (_, index) => ({
info: {
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
},
parts: [
{
id: PartID.ascending(),
messageID: MessageID.ascending(),
sessionID,
type: "text",
text: `${index}: ${"x".repeat(8_000)}`,
},
],
}))
const chunks = await Effect.runPromise(KiloCompactionChunks.split({ messages, model, size: 2_000 }))
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.flatMap((chunk) => chunk.messages.map((msg) => msg.info.id))).toEqual(
messages.map((msg) => msg.info.id),
)
})
test("falls back to chunk workers after the first compaction overflows", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(10_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
const second = await user(session.id, "second " + "c".repeat(10_000))
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries
.flatMap((msg) => msg.parts)
.filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(calls.at(-1)).toContain("Create a new anchored summary")
expect(summaries).toHaveLength(1)
expect(parts.map((part) => part.text)).toEqual(["final summary"])
} finally {
await rt.dispose()
}
},
})
})
test("uses chunk fallback before sending oversized normal compaction", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(10_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
const second = await user(session.id, "second " + "c".repeat(10_000))
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls[0]).toContain("Summarize conversation chunk")
expect(calls[0]).not.toContain("Create a new anchored summary")
} finally {
await rt.dispose()
}
},
})
})
test("uses a worker even when fallback selection produces one oversized chunk", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(20_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries
.flatMap((msg) => msg.parts)
.filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThan(0)
expect(calls[0]).toContain("Summarize conversation chunk")
expect(summaries).toHaveLength(1)
expect(parts.map((part) => part.text)).toEqual(["final summary"])
} finally {
await rt.dispose()
}
},
})
})
test("serializes oversized fallback chunks before summarizing", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "single huge request " + "a".repeat(80_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls[0]).toContain("compacted transcript")
expect(calls[0]).toContain("Text truncated for compaction")
expect(calls[0]).toContain("Summarize conversation chunk")
} finally {
await rt.dispose()
}
},
})
})
test("caps worker output budget below oversized model output limit", async () => {
const { rt, calls, outputs } = fakeRuntime()
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(1_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(1_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThan(0)
expect(outputs.every((value) => value <= 2_048)).toBe(true)
} finally {
await rt.dispose()
}
},
})
})
test("compacts oversized replay turns after overflow compaction", async () => {
const stub = llm()
const calls: string[] = []
stub.push(reply("history summary"))
stub.push(reply("replay summary", (input) => calls.push(JSON.stringify(input.messages))))
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const old = await user(session.id, "old context")
await assistant(session.id, old.id, tmp.path, "old reply")
const large = await user(session.id, "large replay " + "x".repeat(40_000))
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
})
const rt = liveRuntime(stub.layer)
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const replay = all.findLast((msg) => msg.info.role === "user" && msg.info.id !== large.id)
const part = replay?.parts.find((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls).toHaveLength(1)
expect(calls[0]).toContain("Summarize conversation chunk 1 of 1")
expect(part?.text).toContain("compacted representation")
expect(part?.text).toContain("replay summary")
} finally {
await rt.dispose()
}
},
})
})
})
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -72,7 +72,7 @@ describe("Session.fork child session remapping", () => {
"forked session gets its own copy of child sessions",
async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const parent = await Session.create({ title: "parent" })
@@ -138,7 +138,7 @@ describe("Session.fork child session remapping", () => {
"nested child sessions are also remapped",
async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// grandchild -> child -> parent
@@ -225,7 +225,7 @@ describe("Session.fork child session remapping", () => {
"non-task tool parts are not affected",
async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const parent = await Session.create({ title: "parent" })
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { ProjectTable } from "../../src/project/project.sql"
import { ProjectID } from "../../src/project/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
@@ -18,7 +18,7 @@ afterEach(async () => {
describe("Kilo Session.list", () => {
test("includes directory matches from legacy project ids", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "legacy-session" })
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"
import { Config } from "@/config/config"
import type { Provider } from "@/provider/provider"
import type { MessageV2 } from "@/session/message-v2"
import { isOverflow } from "@/session/overflow"
function cfg(compaction?: Config.Info["compaction"]) {
return Config.Info.zod.parse({ compaction })
}
function model(opts: { context: number; output: number; input?: number }): Provider.Model {
return {
id: "test-model",
providerID: "test",
name: "Test",
limit: {
context: opts.context,
input: opts.input,
output: opts.output,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
capabilities: {
toolcall: true,
attachment: false,
reasoning: false,
temperature: true,
input: { text: true, image: false, audio: false, video: false },
output: { text: true, image: false, audio: false, video: false },
},
api: { npm: "@ai-sdk/anthropic" },
options: {},
} as Provider.Model
}
function tokens(count: number): MessageV2.Assistant["tokens"] {
return { input: count, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
}
describe("Kilo auto-compaction threshold", () => {
test("triggers at the configured context percentage", () => {
const conf = cfg({ threshold_percent: 75 })
const mdl = model({ context: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(149_999) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(true)
})
test("keeps the reserved safety trigger when it is lower", () => {
const conf = cfg({ threshold_percent: 95 })
const mdl = model({ context: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(167_999) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(168_000) })).toBe(true)
})
test("uses a model input limit when present", () => {
const conf = cfg({ threshold_percent: 75 })
const mdl = model({ context: 400_000, input: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(149_999) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(true)
})
test("ignores a cleared threshold", () => {
const conf = cfg({ threshold_percent: null })
const mdl = model({ context: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(168_000) })).toBe(true)
})
test("still respects disabled auto-compaction", () => {
const conf = cfg({ auto: false, threshold_percent: 75 })
const mdl = model({ context: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(false)
})
})
@@ -84,7 +84,6 @@ const llm = Layer.unwrap(
const item = queue.shift() ?? Stream.empty
return item
},
raw: () => Effect.die("raw not implemented in TestLLM"),
}),
),
Layer.succeed(TestLLM, TestLLM.of({ reply })),
@@ -83,7 +83,6 @@ const llm = Layer.unwrap(
const item = queue.shift() ?? Stream.empty
return item
},
raw: () => Effect.die("raw not implemented in TestLLM"),
}),
),
Layer.succeed(TestLLM, TestLLM.of({ push })),
@@ -96,7 +96,6 @@ const llm = Layer.unwrap(
const item = queue.shift() ?? Stream.fail(new Error("unexpected extra llm call"))
return item
},
raw: () => Effect.die("raw not implemented in TestLLM"),
}),
),
Layer.succeed(TestLLM, TestLLM.of({ push, calls: Effect.sync(() => calls) })),
@@ -81,3 +81,45 @@ describe("KiloSessionProcessor.extractReviewTelemetry", () => {
expect(KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])).toBeUndefined()
})
})
describe("KiloSessionProcessor.suggestionReviewTelemetry", () => {
test("returns suggest-sourced telemetry for accepted review commands", () => {
expect(
KiloSessionProcessor.suggestionReviewTelemetry({
accepted: { prompt: "/local-review-uncommitted --focus telemetry" },
}),
).toEqual({ ...expected("local-review-uncommitted"), tool: "suggest" })
})
test("returns undefined for accepted non-review commands", () => {
expect(KiloSessionProcessor.suggestionReviewTelemetry({ accepted: { prompt: "/test" } })).toBeUndefined()
})
test("returns undefined when accepted prompt is not a slash command", () => {
expect(KiloSessionProcessor.suggestionReviewTelemetry({ accepted: { prompt: "Run tests" } })).toBeUndefined()
})
test("returns undefined when accepted metadata is missing", () => {
expect(KiloSessionProcessor.suggestionReviewTelemetry({ dismissed: true })).toBeUndefined()
})
})
describe("KiloSessionProcessor.extractSuggestionReviewTelemetry", () => {
test("recovers review telemetry from completed suggest tool metadata", () => {
const parts = [
{
type: "tool",
tool: "suggest",
state: {
status: "completed",
metadata: { accepted: { prompt: "/local-review" } },
},
},
]
expect(KiloSessionProcessor.extractSuggestionReviewTelemetry(parts as unknown as MessageV2.Part[])).toEqual({
...expected("local-review"),
tool: "suggest",
})
})
})
@@ -15,6 +15,7 @@ import { Env } from "../../src/env"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../../src/format"
import { Git } from "../../src/git"
import { LSP } from "../../src/lsp/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
@@ -139,6 +140,7 @@ function makeHttp() {
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -6,7 +6,7 @@ import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
import { Suggestion } from "../../src/kilocode/suggestion"
import { Question } from "../../src/question"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionCompaction } from "../../src/session/compaction"
@@ -246,7 +246,7 @@ describe("session prompt queue", () => {
// scope() hides that marker, runLoop never processes the compaction task and
// instead retries the same oversized request until compaction is exhausted.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Queued compaction regression" })
@@ -412,7 +412,7 @@ describe("session prompt queue", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Queued prompt regression" })
@@ -529,7 +529,7 @@ describe("session prompt queue", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Queued cancel regression" })
@@ -588,7 +588,7 @@ describe("session prompt queue", () => {
const dismissed = Promise.withResolvers<void>()
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Suggestion unblock regression" })
@@ -633,7 +633,7 @@ describe("session prompt queue", () => {
const rejected = Promise.withResolvers<void>()
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Question unblock regression" })
@@ -687,7 +687,7 @@ describe("session prompt queue", () => {
// hasFollowup=true and reject synchronously, before any pending entry or
// Shown event is published.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_suggestion")
@@ -748,7 +748,7 @@ describe("session prompt queue", () => {
test("auto-dismisses a question shown after a queued prompt", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_question")
@@ -5,30 +5,16 @@ import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Config } from "../../../src/config/config"
import { emptyConsoleState } from "../../../src/config/console-state"
import { Instruction } from "../../../src/session/instruction"
import { MessageID } from "../../../src/session/schema"
import { Global } from "@opencode-ai/core/global"
import { provideTmpdirInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
import { TestConfig } from "../../fixture/config"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
const configLayer = Layer.succeed(
Config.Service,
Config.Service.of({
get: () => Effect.succeed({}),
getGlobal: () => Effect.succeed({}),
getConsoleState: () => Effect.succeed(emptyConsoleState),
update: () => Effect.void,
updateGlobal: (config) => Effect.succeed(config),
invalidate: () => Effect.void,
directories: () => Effect.succeed([]),
waitForDependencies: () => Effect.void,
warnings: () => Effect.succeed([]),
}),
)
const configLayer = TestConfig.layer()
const layer = (dir: string) =>
Instruction.layer.pipe(
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import { Effect, Stream } from "effect"
import { KiloLLM } from "@/kilocode/session/llm"
import type { LLM } from "@/session/llm"
describe("kilocode.session.llm.text", () => {
test("joins text delta events", async () => {
const out = await Effect.runPromise(
KiloLLM.text(
Stream.make(
{ type: "text-delta", id: "text", text: "hello ", delta: "hello " } as LLM.Event,
{ type: "text-delta", id: "text", text: "world", delta: "world" } as LLM.Event,
),
),
)
expect(out).toBe("hello world")
})
test("fails on error events after partial text", async () => {
const err = new Error("provider unavailable")
const text = KiloLLM.text(
Stream.make(
{ type: "text-delta", id: "text", text: "partial", delta: "partial" } as LLM.Event,
{ type: "error", error: err } as LLM.Event,
),
)
await expect(Effect.runPromise(text)).rejects.toThrow("provider unavailable")
})
test("fails on abort events", async () => {
const text = KiloLLM.text(Stream.make({ type: "abort" } as LLM.Event))
await expect(Effect.runPromise(text)).rejects.toMatchObject({ name: "AbortError" })
})
})
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Session as SessionNs } from "@/session/session"
import { AppRuntime } from "../../../src/effect/app-runtime"
import { Bus } from "../../../src/bus"
import { KiloSession } from "../../../src/kilocode/session"
import { WithInstance } from "../../../src/project/with-instance"
import { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../../src/session/schema"
const projectRoot = path.join(__dirname, "../../..")
void Log.init({ print: false })
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function remove(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
describe("session platform attribution", () => {
test("child sessions inherit the root platform override", async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const root = await create({ platform: "agent-manager" })
const child = await create({ parentID: root.id, title: "child" })
const attr = KiloSession.attribution(child.id)
expect(KiloSession.getPlatformOverride(root.id)).toBe("agent-manager")
expect(KiloSession.getPlatformOverride(child.id)).toBe("agent-manager")
expect(KiloSession.resolvePlatform(child.id)).toBe("agent-manager")
expect(attr.rootID).toBe(root.id)
expect(attr.feature).toBe("agent-manager")
await remove(root.id)
},
})
})
})
describe("step-finish token propagation via Bus event", () => {
test(
"non-zero tokens propagate through PartUpdated event",
async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const info = await create({})
const messageID = MessageID.ascending()
await updateMessage({
id: messageID,
sessionID: info.id,
role: "user",
time: { created: Date.now() },
agent: "user",
model: { providerID: "test", modelID: "test" },
tools: {},
mode: "",
} as unknown as MessageV2.Info)
let received: MessageV2.Part | undefined
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
received = event.properties.part as MessageV2.Part
})
const tokens = {
total: 1500,
input: 500,
output: 800,
reasoning: 200,
cache: { read: 100, write: 50 },
}
const part = {
id: PartID.ascending(),
messageID,
sessionID: info.id,
type: "step-finish" as const,
reason: "stop",
cost: 0.005,
tokens,
}
await updatePart(part)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(received).toBeDefined()
expect(received!.type).toBe("step-finish")
const finish = received as MessageV2.StepFinishPart
expect(finish.tokens.input).toBe(500)
expect(finish.tokens.output).toBe(800)
expect(finish.tokens.reasoning).toBe(200)
expect(finish.tokens.total).toBe(1500)
expect(finish.tokens.cache.read).toBe(100)
expect(finish.tokens.cache.write).toBe(50)
expect(finish.cost).toBe(0.005)
expect(received).not.toBe(part)
unsub()
await remove(info.id)
},
})
},
{ timeout: 30000 },
)
})
@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Session as SessionNs } from "@/session/session"
import { Bus } from "../../../src/bus"
import * as Log from "@opencode-ai/core/util/log"
import { WithInstance } from "../../../src/project/with-instance"
import { AppRuntime } from "../../../src/effect/app-runtime"
import { tmpdir } from "../../fixture/fixture"
import type { SessionID } from "../../../src/session/schema"
const projectRoot = path.join(__dirname, "../../..")
void Log.init({ print: false })
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
function remove(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
describe("session.created event", () => {
test("should emit session.created event when session is created", async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
let eventReceived = false
let receivedInfo: SessionNs.Info | undefined
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
eventReceived = true
receivedInfo = event.properties.info as SessionNs.Info
})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsub()
expect(eventReceived).toBe(true)
expect(receivedInfo).toBeDefined()
expect(receivedInfo?.id).toBe(info.id)
expect(receivedInfo?.projectID).toBe(info.projectID)
expect(receivedInfo?.directory).toBe(info.directory)
expect(receivedInfo?.path).toBe(info.path)
expect(receivedInfo?.title).toBe(info.title)
await remove(info.id)
},
})
})
test("session.created event should be emitted before session.updated", async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const events: string[] = []
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
events.push("created")
})
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
events.push("updated")
})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsubCreated()
unsubUpdated()
expect(events).toContain("created")
expect(events).toContain("updated")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
await remove(info.id)
},
})
})
})
describe("Session", () => {
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
const info = await WithInstance.provide({
directory: tmp.path,
fn: () => create({ title: "remove-without-instance" }),
})
await expect(async () => {
await remove(info.id)
}).not.toThrow()
let missing = false
await get(info.id).catch(() => {
missing = true
})
expect(missing).toBe(true)
})
})
@@ -1,7 +1,7 @@
import { test, expect } from "bun:test"
import { $ } from "bun"
import { Snapshot } from "../../src/snapshot"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Filesystem } from "../../src/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"
@@ -22,7 +22,7 @@ async function bootstrap() {
test("diffFull returns cached result for same hash pair", async () => {
await using tmp = await bootstrap()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
@@ -45,7 +45,7 @@ test("diffFull returns cached result for same hash pair", async () => {
test("diffFull returns empty array when from === to", async () => {
await using tmp = await bootstrap()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const hash = await Snapshot.track()
@@ -59,7 +59,7 @@ test("diffFull returns empty array when from === to", async () => {
test("diffFull concurrent calls for same pair share one result", async () => {
await using tmp = await bootstrap()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
@@ -14,7 +14,7 @@
import { test, expect, afterEach, mock } from "bun:test"
import { $ } from "bun"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session/session"
import { Snapshot } from "../../src/snapshot"
@@ -45,7 +45,7 @@ test("pathological diffFull workload finishes quickly and does not block abort",
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})

Some files were not shown because too many files have changed in this diff Show More