Merge branch 'main' into local-review-base-picker

This commit is contained in:
Marian Alexandru Alecu
2026-05-21 11:05:25 +03:00
committed by GitHub
1530 changed files with 117490 additions and 46275 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.
@@ -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()
+66 -48
View File
@@ -1,10 +1,11 @@
import { afterEach, test, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
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"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
@@ -17,12 +18,12 @@ function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
}
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
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())
@@ -44,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"))
@@ -61,7 +62,7 @@ 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")
@@ -95,7 +96,7 @@ 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")
@@ -121,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"))
@@ -142,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"))
@@ -157,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"))
@@ -170,23 +171,26 @@ test("explore agent denies edit and write", async () => {
})
})
test("explore agent asks for external directories and allows Truncate.GLOB", 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"))
expect(explore).toBeDefined()
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action,
).toBe("allow")
},
})
})
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"))
@@ -200,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"))
@@ -226,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"))
@@ -257,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"
@@ -282,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"))
@@ -310,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"
@@ -333,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"
@@ -356,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
@@ -375,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"))
@@ -392,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"
@@ -411,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"
@@ -433,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"
@@ -460,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"
@@ -487,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"))
@@ -516,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)
@@ -528,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"))
@@ -539,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"
@@ -553,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"
@@ -579,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"
@@ -605,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"
@@ -625,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"
@@ -638,6 +642,20 @@ 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 WithInstance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action,
).toBe("allow")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask")
},
})
})
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
@@ -653,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"
@@ -678,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"
@@ -712,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"))
@@ -728,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())
@@ -743,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())
@@ -763,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())
@@ -778,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')
@@ -792,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')
@@ -806,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(
@@ -828,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())
@@ -852,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
@@ -864,7 +882,7 @@ 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")
@@ -887,7 +905,7 @@ 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")
@@ -904,7 +922,7 @@ 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()
@@ -921,7 +939,7 @@ 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")
@@ -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] } },
)
+80 -89
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 { 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", () =>
@@ -151,7 +142,7 @@ describe("Bus (Effect-native)", () => {
}).pipe(provideInstance(dir))
// Dispose from OUTSIDE the instance scope
yield* Effect.promise(() => Instance.disposeAll())
yield* Effect.promise(disposeAllInstances)
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
expect(types).toContain("test.effect.ping")
@@ -2,17 +2,17 @@ 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 { tmpdir } from "../fixture/fixture"
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", () => {
afterEach(() => Instance.disposeAll())
afterEach(() => disposeAllInstances())
test("subscriber via callback facade receives events and cleans up on unsub", async () => {
await using tmp = await tmpdir()
@@ -78,7 +78,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => {
await Bun.sleep(10)
})
await Instance.disposeAll()
await disposeAllInstances()
await Bun.sleep(50)
expect(received).toEqual([1])
+6 -6
View File
@@ -2,8 +2,8 @@ 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 { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const TestEvent = {
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
@@ -11,11 +11,11 @@ const TestEvent = {
}
function withInstance(directory: string, fn: () => Promise<void>) {
return Instance.provide({ directory, fn })
return WithInstance.provide({ directory, fn })
}
describe("Bus", () => {
afterEach(() => Instance.disposeAll())
afterEach(() => disposeAllInstances())
describe("publish + subscribe", () => {
test("subscriber is live immediately after subscribe returns", async () => {
@@ -208,8 +208,8 @@ describe("Bus", () => {
await Bun.sleep(10)
})
// Instance.disposeAll triggers the finalizer which publishes InstanceDisposed
await Instance.disposeAll()
// disposeAllInstances triggers the finalizer which publishes InstanceDisposed
await disposeAllInstances()
await Bun.sleep(50)
expect(received).toContain("test.ping")
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/prompt/traits"
describe("computePromptTraits", () => {
test("normal mode without autocomplete only captures tab", () => {
const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false })
expect(traits.capture).toEqual(["tab"])
expect(traits.suspend).toBe(false)
expect(traits.status).toBeUndefined()
})
test("normal mode with autocomplete captures navigation keys", () => {
const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true })
expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"])
expect(traits.suspend).toBe(false)
expect(traits.status).toBeUndefined()
})
test("shell mode does not suspend the textarea", () => {
// Suspending the textarea would gate every keybinding action
// (backspace, delete-word-backward, arrow movement, etc.) — see
// @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is
// an active editing mode, so suspend must stay off.
const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
expect(traits.suspend).toBe(false)
})
test("shell mode disables capture and labels the prompt", () => {
const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
expect(traits.capture).toBeUndefined()
expect(traits.status).toBe("SHELL")
})
test("disabled suspends regardless of mode", () => {
expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
})
})
@@ -0,0 +1,159 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { Global } from "@opencode-ai/core/global"
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
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 { WithInstance } from "../../../../src/project/with-instance" // kilocode_change
import { disposeAllInstances, tmpdir } from "../../../fixture/fixture"
const worktree = "/tmp/opencode"
const directory = `${worktree}/packages/opencode`
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function json(data: unknown) {
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" },
})
}
function eventSource(): EventSource {
return {
subscribe: async () => () => {},
}
}
function createFetch() {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
switch (url.pathname) {
case "/agent":
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/config":
case "/experimental/resource":
case "/global/config": // kilocode_change
case "/mcp":
case "/provider/auth":
case "/session/status":
return json({})
case "/config/providers":
return json({ providers: {}, default: {} })
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
case "/path":
return json({ home: "", state: "", config: "", worktree, directory })
case "/project/current":
return json({ id: "proj_test" })
case "/provider":
return json({ all: [], default: {}, connected: [] })
case "/session":
return json([])
case "/vcs":
return json({ branch: "main" })
}
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
return { fetch, session }
}
async function mount() {
const calls = createFetch()
let sync!: ReturnType<typeof useSync>
let kv!: ReturnType<typeof useKV>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<ArgsProvider>
<ExitProvider>
<KVProvider>
{/* kilocode_change start */}
<ToastProvider>
{/* kilocode_change end */}
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
<ProjectProvider>
<SyncProvider>
<Probe
onReady={(ctx) => {
sync = ctx.sync
kv = ctx.kv
done()
}}
/>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
{/* kilocode_change start */}
</ToastProvider>
{/* kilocode_change end */}
</KVProvider>
</ExitProvider>
</ArgsProvider>
))
await ready
await wait(() => sync.status === "complete")
return { app, kv, sync, session: calls.session }
}
function Probe(props: { onReady: (ctx: { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }) => void }) {
const kv = useKV()
const sync = useSync()
onMount(() => {
props.onReady({ kv, sync })
})
return <box />
}
describe("tui sync", () => {
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
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)
expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
expect(session.at(-1)?.searchParams.get("path")).toBe("packages/opencode")
kv.set("session_directory_filter_enabled", false)
await sync.session.refresh()
expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
expect(session.at(-1)?.searchParams.get("path")).toBeNull()
} finally {
app.renderer.destroy()
await disposeAllInstances() // kilocode_change
Global.Path.state = previous
}
})
})
@@ -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" })
@@ -0,0 +1,356 @@
import { Database } from "bun:sqlite"
import { mkdir, symlink } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { expect, spyOn, test } from "bun:test"
import { offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed"
import { tmpdir } from "../../fixture/fixture"
type ZedFixtureOptions = {
workspacePaths?: string | null
itemKind?: string
editor?: boolean
selectionStart?: number | null
selectionEnd?: number | null
selections?: Array<{ start: number | null; end: number | null }>
contents?: string
}
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
const dbPath = path.join(dir, "zed.sqlite")
const filePath = path.join(dir, "file.ts")
const contents = options.contents ?? "one\ntwo\nthree"
await Bun.write(filePath, contents)
const db = new Database(dbPath)
db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
db.run("create table panes (pane_id integer, workspace_id integer, active integer)")
db.run("create table items (item_id integer, workspace_id integer, pane_id integer, active integer, kind text)")
db.run("create table editors (item_id integer, workspace_id integer, buffer_path text, contents text)")
db.run("create table editor_selections (editor_id integer, workspace_id integer, start integer, end integer)")
db.run("insert into workspaces values (1, ?, ?)", [options.workspacePaths ?? JSON.stringify([dir]), "2026-04-27"])
db.run("insert into panes values (1, 1, 1)")
db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
if (options.editor !== false) {
db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents])
;(
options.selections ?? [
{
start: options.selectionStart === undefined ? 4 : options.selectionStart,
end: options.selectionEnd === undefined ? 7 : options.selectionEnd,
},
]
).forEach((selection) =>
db.run("insert into editor_selections values (1, 1, ?, ?)", [selection.start, selection.end]),
)
}
db.close()
return { dbPath, filePath }
}
function utf8ByteOffset(text: string, offset: number) {
return new TextEncoder().encode(text.slice(0, offset)).length
}
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
expect(offsetToPosition("Ж\nabc", utf8ByteOffset("Ж\nabc", "Ж\nabc".indexOf("a")))).toEqual({
line: 2,
character: 1,
})
expect(offsetToPosition("😀\nabc", utf8ByteOffset("😀\nabc", "😀\nabc".indexOf("a")))).toEqual({
line: 2,
character: 1,
})
})
test("resolveZedDbPath skips candidates that cannot be stated", async () => {
await using tmp = await tmpdir()
const loop = path.join(tmp.path, "loop")
await symlink(loop, loop)
const home = spyOn(os, "homedir").mockImplementation(() => tmp.path)
const previous = process.env.KILO_ZED_DB
process.env.KILO_ZED_DB = loop
try {
expect(resolveZedDbPath()).toBeUndefined()
} finally {
if (previous === undefined) delete process.env.KILO_ZED_DB
else process.env.KILO_ZED_DB = previous
home.mockRestore()
}
})
test("resolveZedSelection returns active editor selection", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
],
},
})
})
test("resolveZedSelection returns all active editor selections sorted by offset", async () => {
await using tmp = await tmpdir()
const contents = "one\ntwo\nthree\nfour"
const fixture = await writeZedFixture(tmp.path, {
contents,
selections: [
{
start: utf8ByteOffset(contents, contents.indexOf("four")),
end: utf8ByteOffset(contents, contents.indexOf("four") + 4),
},
{
start: utf8ByteOffset(contents, contents.indexOf("two")),
end: utf8ByteOffset(contents, contents.indexOf("two") + 3),
},
],
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
{
text: "four",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 5 },
},
},
],
},
})
})
test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", async () => {
await using tmp = await tmpdir()
const contents = "a\nЖЖЖЖЖЖЖЖЖЖ\nb\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "TARGET",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 7 },
},
},
],
},
})
})
test("resolveZedSelection handles non-ASCII text inside the selected range", async () => {
await using tmp = await tmpdir()
const contents = "a\npre\nвыбор\nz"
const start = contents.indexOf("выбор")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "выбор".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "выбор",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 6 },
},
},
],
},
})
})
test("resolveZedSelection handles emoji before the selected range", async () => {
await using tmp = await tmpdir()
const contents = "😀\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "TARGET",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 7 },
},
},
],
},
})
})
test("resolveZedSelection handles reversed Zed byte offsets", async () => {
await using tmp = await tmpdir()
const contents = "a\nЖЖЖ\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start + "TARGET".length),
selectionEnd: utf8ByteOffset(contents, start),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "TARGET",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 7 },
},
},
],
},
})
})
test("resolveZedSelection returns empty when no workspace matches", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, {
workspacePaths: JSON.stringify([path.join(path.dirname(tmp.path), "other-workspace")]),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
})
test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({
type: "selection",
selection: {
filePath: fixture.filePath,
source: "zed",
ranges: [
{
text: "two",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
],
},
})
})
test("resolveZedSelection prefers the most specific containing Zed workspace", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
const child = path.join(tmp.path, "packages")
const childFile = path.join(child, "child.ts")
await mkdir(child, { recursive: true })
await Bun.write(childFile, "child")
const db = new Database(fixture.dbPath)
db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"])
db.run("insert into panes values (2, 2, 1)")
db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"])
db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"])
db.run("insert into editor_selections values (2, 2, 0, 5)")
db.close()
expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({
type: "selection",
selection: {
filePath: childFile,
source: "zed",
ranges: [
{
text: "child",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 6 },
},
},
],
},
})
})
test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => {
await using tmp = await tmpdir()
const child = path.join(tmp.path, "effect-lab")
await mkdir(child, { recursive: true })
const fixture = await writeZedFixture(child)
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
})
test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false })
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
})
test("resolveZedSelection returns unavailable when the database cannot be queried", async () => {
await using tmp = await tmpdir()
expect(await resolveZedSelection(path.join(tmp.path, "missing.sqlite"), tmp.path)).toEqual({ type: "unavailable" })
})
test("resolveZedSelection returns unavailable when active selection is missing offsets", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, { selectionStart: null, selectionEnd: null })
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
})
@@ -1,93 +0,0 @@
import { Database } from "bun:sqlite"
import path from "node:path"
import { expect, test } from "bun:test"
import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed"
import { tmpdir } from "../../fixture/fixture"
type ZedFixtureOptions = {
workspacePaths?: string | null
itemKind?: string
editor?: boolean
selectionStart?: number | null
selectionEnd?: number | null
}
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
const dbPath = path.join(dir, "zed.sqlite")
const filePath = path.join(dir, "file.ts")
await Bun.write(filePath, "one\ntwo\nthree")
const db = new Database(dbPath)
db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
db.run("create table panes (pane_id integer, workspace_id integer, active integer)")
db.run("create table items (item_id integer, workspace_id integer, pane_id integer, active integer, kind text)")
db.run("create table editors (item_id integer, workspace_id integer, buffer_path text, contents text)")
db.run("create table editor_selections (editor_id integer, workspace_id integer, start integer, end integer)")
db.run("insert into workspaces values (1, ?, ?)", [options.workspacePaths ?? JSON.stringify([dir]), "2026-04-27"])
db.run("insert into panes values (1, 1, 1)")
db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
if (options.editor !== false) {
db.run("insert into editors values (1, 1, ?, ?)", [filePath, "one\ntwo\nthree"])
db.run("insert into editor_selections values (1, 1, ?, ?)", [
options.selectionStart === undefined ? 4 : options.selectionStart,
options.selectionEnd === undefined ? 7 : options.selectionEnd,
])
}
db.close()
return { dbPath, filePath }
}
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
})
test("resolveZedSelection returns active editor selection", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path)
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "two",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 4 },
},
},
})
})
test("resolveZedSelection returns empty when no workspace matches", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, {
workspacePaths: JSON.stringify([path.join(path.dirname(tmp.path), "other-workspace")]),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
})
test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false })
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
})
test("resolveZedSelection returns unavailable when the database cannot be queried", async () => {
await using tmp = await tmpdir()
expect(await resolveZedSelection(path.join(tmp.path, "missing.sqlite"), tmp.path)).toEqual({ type: "unavailable" })
})
test("resolveZedSelection returns unavailable when active selection is missing offsets", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, { selectionStart: null, selectionEnd: null })
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
})
@@ -0,0 +1,228 @@
import { mkdir, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test"
import { createRoot } from "solid-js"
import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor"
import { tmpdir } from "../../fixture/fixture"
import { FakeWebSocket } from "../../lib/websocket"
const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
const originalOpencodePort = process.env.KILO_EDITOR_SSE_PORT
afterEach(() => {
process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort
process.env.KILO_EDITOR_SSE_PORT = originalOpencodePort
})
function nextTick() {
return new Promise<void>((resolve) => queueMicrotask(resolve))
}
function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
let editor!: ReturnType<typeof useEditorContext>
let dispose!: () => void
createRoot((nextDispose) => {
dispose = nextDispose
const Consumer = () => {
editor = useEditorContext()
return null
}
return (
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
<Consumer />
</EditorContextProvider>
)
})
return {
editor,
dispose,
}
}
function createWebSocketImpl(...sockets: FakeWebSocket[]) {
let index = 0
return class {
constructor(url: string, options?: { headers?: Record<string, string> }) {
const socket = sockets[index]
index += 1
expect(socket).toBeDefined()
expect(url).toBe(socket!.url)
expect(options).toEqual(socket!.options)
return socket as unknown as object
}
} as unknown as typeof WebSocket
}
test("useEditorContext reconnect switches editor server by session directory", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const sessionDirectory = path.join(tmp.path, "session")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(sessionDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
await writeFile(
path.join(ideDirectory, "3002.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [sessionDirectory],
}),
)
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 firstSocket = new FakeWebSocket("ws://127.0.0.1:3001")
const secondSocket = new FakeWebSocket("ws://127.0.0.1:3002")
const mounted = mountEditorContext(createWebSocketImpl(firstSocket, secondSocket))
await nextTick()
expect(firstSocket.closed).toBeFalse()
mounted.editor.reconnect(sessionDirectory)
await nextTick()
expect(firstSocket.closed).toBeTrue()
expect(secondSocket.closed).toBeFalse()
mounted.dispose()
})
test("useEditorContext favors configured port over lock files", 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 = "4010"
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:4010")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
mounted.dispose()
})
test("useEditorContext resets selection when reconnecting", 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()
expect(socket.closed).toBeFalse()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.connected()).toBeFalse()
socket.open()
socket.message(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
protocolVersion: "2025-11-25",
serverInfo: { name: "test", version: "0.0.0" },
},
}),
)
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 },
},
},
}),
)
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 },
},
},
],
})
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.selection()).toBeUndefined()
mounted.dispose()
})
test("useEditorContext connects with KILO_EDITOR_SSE_PORT", async () => {
await using tmp = await tmpdir()
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.KILO_EDITOR_SSE_PORT = "4020"
spyOn(process, "cwd").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:4020")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
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({
+4 -97
View File
@@ -1,112 +1,19 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../../fixture/fixture"
import * as App from "../../../src/cli/cmd/tui/app"
import { Rpc } from "@/util/rpc"
import { UI } from "../../../src/cli/ui"
import * as Timeout from "../../../src/util/timeout"
import * as Network from "../../../src/cli/network"
import * as Win32 from "../../../src/cli/cmd/tui/win32"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const stop = new Error("stop")
const seen = {
tui: [] as string[],
}
function setup() {
// Intentionally avoid mock.module() here: Bun keeps module overrides in cache
// and mock.restore() does not reset mock.module values. If this switches back
// to module mocks, later suites can see mocked @/config/tui and fail (e.g.
// plugin-loader tests expecting real TuiConfig.waitForDependencies). See:
// https://github.com/oven-sh/bun/issues/7823 and #12823.
spyOn(App, "tui").mockImplementation(async (input) => {
if (input.directory) seen.tui.push(input.directory)
throw stop
})
spyOn(Rpc, "client").mockImplementation(() => ({
call: async () => ({ url: "http://127.0.0.1" }) as never,
on: () => () => {},
}))
spyOn(UI, "error").mockImplementation(() => {})
spyOn(Timeout, "withTimeout").mockImplementation((input) => input)
spyOn(Network, "resolveNetworkOptions").mockResolvedValue({
mdns: false,
port: 0,
hostname: "127.0.0.1",
mdnsDomain: "opencode.local",
cors: [],
})
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
}
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread"
describe("tui thread", () => {
afterEach(() => {
mock.restore()
})
async function call(project?: string) {
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
_: [],
$0: "kilo", // kilocode_change
project,
prompt: "hi",
model: undefined,
agent: undefined,
session: undefined,
continue: false,
fork: false,
"cloud-fork": undefined, // kilocode_change
cloudFork: undefined, // kilocode_change
port: 0,
hostname: "127.0.0.1",
mdns: false,
"mdns-domain": "kilo.local", // kilocode_change
mdnsDomain: "kilo.local", // kilocode_change
cors: [],
}
return TuiThreadCommand.handler(args)
}
async function check(project?: string) {
setup()
await using tmp = await tmpdir({ git: true })
const cwd = process.cwd()
const pwd = process.env.PWD
const worker = globalThis.Worker
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
seen.tui.length = 0
await fs.symlink(tmp.path, link, type)
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
})
globalThis.Worker = class extends EventTarget {
onerror = null
onmessage = null
onmessageerror = null
postMessage() {}
terminate() {}
} as unknown as typeof Worker
try {
process.chdir(tmp.path)
process.env.PWD = link
await expect(call(project)).rejects.toBe(stop)
expect(seen.tui[0]).toBe(tmp.path)
await fs.symlink(tmp.path, link, type)
expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path)
} finally {
process.chdir(cwd)
if (pwd === undefined) delete process.env.PWD
else process.env.PWD = pwd
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
else delete (process.stdin as { isTTY?: boolean }).isTTY
globalThis.Worker = worker
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
}
}
@@ -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,
@@ -1,67 +1,63 @@
import { test, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Config } from "@/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "@/util/color"
import { AppRuntime } from "../../src/effect/app-runtime"
import { testEffect } from "../lib/effect"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
const agent = <A>(dir: string, fn: (svc: AgentSvc.Interface) => Effect.Effect<A>) =>
Effect.runPromise(provideInstance(dir)(AgentSvc.Service.use(fn)).pipe(Effect.provide(AgentSvc.defaultLayer)))
const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
test("agent color parsed from project config", 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",
agent: {
code: { color: "#FFA500" }, // kilocode_change
plan: { color: "primary" },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
expect(cfg.agent?.["code"]?.color).toBe("#FFA500") // kilocode_change
const writeConfig = (dir: string, agent: Config.Info["agent"]) =>
Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json", // kilocode_change
agent,
}),
),
)
// kilocode_change start
it.live("agent color parsed from project config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
code: { color: "#FFA500" },
plan: { color: "primary" },
})
yield* Effect.gen(function* () {
const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())))
expect(cfg.agent?.["code"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary")
},
})
})
}).pipe(provideInstance(dir))
}),
)
// kilocode_change end
test("Agent.get includes color from config", 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",
agent: {
plan: { color: "#A855F7" },
build: { color: "accent" },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const plan = await agent(tmp.path, (svc) => svc.get("plan"))
// kilocode_change start
it.live("Agent.get includes color from config", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeConfig(dir, {
plan: { color: "#A855F7" },
build: { color: "accent" },
})
yield* Effect.gen(function* () {
const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan"))
expect(plan?.color).toBe("#A855F7")
const build = await agent(tmp.path, (svc) => svc.get("build"))
const build = yield* AgentSvc.Service.use((svc) => svc.get("build"))
expect(build?.color).toBe("accent")
},
})
})
}).pipe(provideInstance(dir))
}),
)
// kilocode_change end
test("Color.hexToAnsiBold converts valid hex to ANSI", () => {
const result = Color.hexToAnsiBold("#FFA500")
+86 -74
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 { 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,9 +65,17 @@ 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)))
const ready = () =>
@@ -98,7 +114,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 +124,7 @@ async function check(map: (dir: string) => string) {
},
})
} finally {
await Instance.disposeAll()
await InstanceRuntime.disposeAllInstances()
;(Global.Path as { config: string }).config = prev
await clear()
}
@@ -116,7 +132,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 +151,7 @@ test("loads JSON config file", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -154,7 +170,7 @@ test("loads shell config field", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -173,7 +189,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 +268,7 @@ test("loads formatter boolean config", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -270,7 +286,7 @@ test("loads lsp boolean config", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -307,7 +323,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 +348,7 @@ test("loads JSONC config file", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -360,7 +376,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()
@@ -390,7 +406,7 @@ 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()
@@ -412,7 +428,7 @@ test("handles environment variable substitution", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -444,7 +460,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 +526,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 +537,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 +557,7 @@ test("handles file inclusion substitution", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -559,7 +576,7 @@ test("handles file inclusion with replacement tokens", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -577,7 +594,7 @@ 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
@@ -594,7 +611,7 @@ test("reports warning for invalid JSON", async () => {
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
@@ -620,7 +637,7 @@ test("handles agent configuration", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -651,7 +668,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 +698,7 @@ test("handles command configuration", async () => {
})
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -706,7 +723,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 +750,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 +782,7 @@ Test agent prompt`,
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -798,7 +815,7 @@ Ordered permissions`,
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -836,7 +853,7 @@ Nested agent prompt`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -885,7 +902,7 @@ Nested command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -930,7 +947,7 @@ Nested command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -968,7 +985,7 @@ Hello from new command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
@@ -983,7 +1000,7 @@ Hello from new command`,
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 +1030,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 +1054,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 +1065,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 +1124,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 +1162,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 +1198,7 @@ Helper subagent prompt`,
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1225,7 +1237,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 +1276,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 +1321,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 +1370,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 +1411,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 +1442,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 +1472,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 +1504,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 +1532,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 +1552,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 +1579,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 +1608,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 +1640,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 +1675,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 +1728,7 @@ test("permission config preserves user key order", async () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1807,7 +1819,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 +1877,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 +1928,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 +1972,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 +2030,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 {
@@ -2173,7 +2185,7 @@ describe("deduplicatePluginOrigins", () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -2205,7 +2217,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
)
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -2237,7 +2249,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 +2273,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 +2315,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 +2375,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 +2410,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 +2444,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,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
import { ProjectID } from "../../src/project/schema"
import type { WorkspaceInfo } from "../../src/control-plane/types"
@@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf
}
}
function adaptor(dir: string) {
function adapter(dir: string) {
return {
name: dir,
description: dir,
@@ -33,19 +33,19 @@ function adaptor(dir: string) {
}
}
describe("control-plane/adaptors", () => {
test("isolates custom adaptors by project", async () => {
describe("control-plane/adapters", () => {
test("isolates custom adapters by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdaptor(one, type, adaptor("/one"))
registerAdaptor(two, type, adaptor("/two"))
registerAdapter(one, type, adapter("/one"))
registerAdapter(two, type, adapter("/two"))
expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({
type: "local",
directory: "/one",
})
expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({
type: "local",
directory: "/two",
})
@@ -54,16 +54,16 @@ describe("control-plane/adaptors", () => {
test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdaptor(id, type, adaptor("/one"))
registerAdapter(id, type, adapter("/one"))
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/one",
})
registerAdaptor(id, type, adaptor("/two"))
registerAdapter(id, type, adapter("/two"))
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/two",
})
@@ -1,56 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { parseSSE } from "../../src/control-plane/sse"
import { resetDatabase } from "../fixture/db"
afterEach(async () => {
await resetDatabase()
})
function stream(chunks: string[]) {
return new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder()
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)))
controller.close()
},
})
}
describe("control-plane/sse", () => {
test("parses JSON events with CRLF and multiline data blocks", async () => {
const events: unknown[] = []
const stop = new AbortController()
await parseSSE(
stream([
'data: {"type":"one","properties":{"ok":true}}\r\n\r\n',
'data: {"type":"two",\r\ndata: "properties":{"n":2}}\r\n\r\n',
]),
stop.signal,
(event) => events.push(event),
)
expect(events).toEqual([
{ type: "one", properties: { ok: true } },
{ type: "two", properties: { n: 2 } },
])
})
test("falls back to sse.message for non-json payload", async () => {
const events: unknown[] = []
const stop = new AbortController()
await parseSSE(stream(["id: abc\nretry: 1500\ndata: hello world\n\n"]), stop.signal, (event) => events.push(event))
expect(events).toEqual([
{
type: "sse.message",
properties: {
data: "hello world",
id: "abc",
retry: 1500,
},
},
])
})
})
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,15 @@
import { expect, test } from "bun:test"
import { expect } from "bun:test"
import { Context, Effect, Layer, Logger } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppRuntime } from "../../src/effect/app-runtime"
import { EffectBridge } from "@/effect/bridge"
import { InstanceRef } from "../../src/effect/instance-ref"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { makeRuntime } from "../../src/effect/run-service"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
return {
@@ -17,56 +20,58 @@ function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
}
}
test("makeRuntime installs EffectLogger through Observability.layer", async () => {
class Dummy extends Context.Service<Dummy, { readonly current: () => Effect.Effect<ReturnType<typeof check>> }>()(
"@test/Dummy",
) {}
it.live("makeRuntime installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
class Dummy extends Context.Service<Dummy, { readonly current: () => Effect.Effect<ReturnType<typeof check>> }>()(
"@test/Dummy",
) {}
const layer = Layer.effect(
Dummy,
Effect.gen(function* () {
return Dummy.of({
current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check),
})
}),
)
const layer = Layer.effect(
Dummy,
Effect.gen(function* () {
return Dummy.of({
current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check),
})
}),
)
const rt = makeRuntime(Dummy, layer)
const current = await rt.runPromise((svc) => svc.current())
const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current()))
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
})
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
test("AppRuntime also installs EffectLogger through Observability.layer", async () => {
const current = await AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check))
it.live("AppRuntime also installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)),
)
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
})
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
test("AppRuntime attaches InstanceRef from ALS", async () => {
await using tmp = await tmpdir({ git: true })
const dir = await Instance.provide({
directory: tmp.path,
fn: () =>
it.live("AppRuntime attaches InstanceRef from ALS", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
),
})
).pipe(provideInstance(dir))
expect(dir).toBe(tmp.path)
})
expect(current).toBe(dir)
}),
)
test("EffectBridge preserves logger and instance context across async boundaries", async () => {
await using tmp = await tmpdir({ git: true })
const result = await Instance.provide({
directory: tmp.path,
fn: () =>
it.live("EffectBridge preserves logger and instance context across async boundaries", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const result = yield* Effect.promise(() =>
AppRuntime.runPromise(
Effect.gen(function* () {
const bridge = yield* EffectBridge.make()
@@ -84,9 +89,10 @@ test("EffectBridge preserves logger and instance context across async boundaries
)
}),
),
})
).pipe(provideInstance(dir))
expect(result.directory).toBe(tmp.path)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
})
expect(result.directory).toBe(dir)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
}),
)
@@ -0,0 +1,65 @@
import { describe, expect } from "bun:test"
import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect"
import { ConfigService } from "../../src/effect/config-service"
import { it } from "../lib/effect"
class TestConfig extends ConfigService.Service<TestConfig>()("@test/ConfigService", {
name: Config.string("NAME"),
token: Config.string("TOKEN").pipe(Config.option),
port: Config.number("PORT").pipe(Config.withDefault(3000)),
}) {}
const fromConfig = (input: Record<string, unknown>) =>
TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
const readConfig = TestConfig.useSync((config) => config)
describe("ConfigService", () => {
it.effect("defaultLayer parses values from the active ConfigProvider", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
fromConfig({
NAME: "kit",
TOKEN: "secret",
PORT: "4096",
}),
),
)
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.some("secret"))
expect(config.port).toBe(4096)
}),
)
it.effect("defaultLayer applies Effect Config defaults", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" })))
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.none())
expect(config.port).toBe(3000)
}),
)
it.effect("layer provides an already parsed service value", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
TestConfig.layer({
name: "direct",
token: Option.some("parsed"),
port: 9000,
}),
),
)
expect(config).toEqual({
name: "direct",
token: Option.some("parsed"),
port: 9000,
} satisfies Context.Service.Shape<typeof TestConfig>)
}),
)
})
@@ -1,482 +1,393 @@
import { afterEach, expect, test } from "bun:test"
import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect"
import { afterEach, expect } from "bun:test"
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 { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
async function access<A, E>(state: InstanceState.InstanceState<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
})
}
const it = testEffect(CrossSpawnSpawner.defaultLayer)
const access = <A, E>(state: InstanceState.InstanceState<A, E>, dir: string) =>
InstanceState.get(state).pipe(provideInstance(dir))
const tmpdirGitScoped = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet())
return dir
})
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
test("InstanceState caches values per directory", async () => {
await using tmp = await tmpdir()
let n = 0
it.live("InstanceState caches values per directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* access(state, dir)
const b = yield* access(state, dir)
const a = yield* Effect.promise(() => access(state, tmp.path))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).toBe(b)
expect(n).toBe(1)
}),
)
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
it.live("InstanceState isolates directories", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
test("InstanceState isolates directories", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
let n = 0
const a = yield* access(state, one)
const b = yield* access(state, two)
const c = yield* access(state, one)
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
)
const a = yield* Effect.promise(() => access(state, one.path))
const b = yield* Effect.promise(() => access(state, two.path))
const c = yield* Effect.promise(() => access(state, one.path))
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
),
)
})
test("InstanceState invalidates on reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
),
)
const a = yield* Effect.promise(() => access(state, tmp.path))
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
),
)
})
test("InstanceState invalidates on disposeAll", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* Effect.promise(() => access(state, one.path))
yield* Effect.promise(() => access(state, two.path))
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one.path, two.path].sort())
}),
),
)
})
test("InstanceState.get reads the current directory lazily", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
it.live("InstanceState invalidates on reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const seen: string[] = []
let n = 0
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
const b = await Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
expect(b).toBe(two.path)
} finally {
await rt.dispose()
}
})
test("InstanceState preserves directory across async boundaries", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await using three = await tmpdir({ git: true })
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const [a, b, c] = await Promise.all([
Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: three.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
} finally {
await rt.dispose()
}
})
test("InstanceState survives high-contention concurrent access", async () => {
const N = 20
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
// Interleave many async hops to maximize chance of ALS corruption
for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const results = await Promise.all(
dirs.map((d) =>
Instance.provide({
directory: d.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
),
)
for (let i = 0; i < N; i++) {
expect(results[i]).toBe(dirs[i].path)
const a = yield* access(state, dir)
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
const b = yield* access(state, dir)
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
)
it.live("InstanceState invalidates on disposeAll", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const seen: string[] = []
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* access(state, one)
yield* access(state, two)
yield* Effect.promise(disposeAllInstances)
expect(seen.sort()).toEqual([one, two].sort())
}),
)
it.live("InstanceState.get reads the current directory lazily", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api {
readonly get: () => Effect.Effect<string>
}
} finally {
await rt.dispose()
for (const d of dirs) await d[Symbol.asyncDispose]()
}
})
test("InstanceState correct after interleaved init and dispose", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
class Test extends Context.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
interface Api {
readonly get: () => Effect.Effect<string>
}
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
}),
})
}),
)
}
class Test extends Context.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => {
await Bun.sleep(5) // slow init
return ctx.directory
}),
)
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
const b = yield* Test.use((svc) => svc.get()).pipe(provideInstance(two))
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
expect(a).toBe(one)
expect(b).toBe(two)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState preserves directory across async boundaries", () =>
Effect.gen(function* () {
const one = yield* tmpdirGitScoped
const two = yield* tmpdirGitScoped
const three = yield* tmpdirGitScoped
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends Context.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const [a, b, c] = yield* Effect.all(
[one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
{ concurrency: "unbounded" },
)
expect(a).toEqual({ directory: one, worktree: one, project: a.project })
expect(b).toEqual({ directory: two, worktree: two, project: b.project })
expect(c).toEqual({ directory: three, worktree: three, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState survives high-contention concurrent access", () =>
Effect.gen(function* () {
const dirs = yield* Effect.all(
Array.from({ length: 20 }, () => tmpdirScoped()),
{ concurrency: "unbounded" },
)
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const results = yield* Effect.all(
dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
{ concurrency: "unbounded" },
)
expect(results).toEqual(dirs)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState correct after interleaved init and dispose", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => {
await Bun.sleep(5)
return ctx.directory
}),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
expect(a).toBe(one)
const [, b] = yield* Effect.all(
[
Effect.promise(() => reloadTestInstance({ directory: one })),
Test.use((svc) => svc.get()).pipe(provideInstance(two)),
],
{ concurrency: "unbounded" },
)
expect(b).toBe(two)
const c = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
expect(c).toBe(one)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState mutation in one directory does not leak to another", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
const s1 = yield* access(state, one)
s1.count = 42
const s2 = yield* access(state, two)
expect(s2.count).toBe(0)
const s1again = yield* access(state, one)
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1)
}),
)
it.live("InstanceState dedupes concurrent lookups", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" })
expect(a).toBe(b)
expect(n).toBe(1)
}),
)
try {
// Init both directories
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
it.live("InstanceState survives deferred resume from the same instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
// Dispose one directory, access the other concurrently
const [, b] = await Promise.all([
Instance.reload({ directory: one.path }),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(b).toBe(two.path)
// Re-access disposed directory - should get fresh state
const c = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(c).toBe(one.path)
} finally {
await rt.dispose()
}
})
test("InstanceState mutation in one directory does not leak to another", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
// Mutate state in directory one
const s1 = yield* Effect.promise(() => access(state, one.path))
s1.count = 42
// Access directory two — should be independent
const s2 = yield* Effect.promise(() => access(state, two.path))
expect(s2.count).toBe(0)
// Confirm directory one still has the mutation
const s1again = yield* Effect.promise(() => access(state, one.path))
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1) // same reference
}),
),
)
})
test("InstanceState dedupes concurrent lookups", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
)
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
test("InstanceState survives deferred resume from the same instance context", async () => {
await using tmp = await tmpdir({ git: true })
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResume") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const gate = await Effect.runPromise(Deferred.make<void>())
const fiber = await Instance.provide({
directory: tmp.path,
fn: () => Promise.resolve(rt.runFork(Test.use((svc) => svc.get(gate)))),
})
await Instance.provide({
directory: tmp.path,
fn: () => Effect.runPromise(Deferred.succeed(gate, void 0)),
})
const exit = await Effect.runPromise(Fiber.await(fiber))
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value).toBe(tmp.path)
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
} finally {
await rt.dispose()
}
})
test("InstanceState survives deferred resume outside ALS when InstanceRef is set", async () => {
await using tmp = await tmpdir({ git: true })
class Test extends Context.Service<Test, Api>()("@test/DeferredResume") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResumeOutside") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const gate = await Effect.runPromise(Deferred.make<void>())
// Provide InstanceRef so the fiber carries the context even when
// the deferred is resolved from outside Instance.provide ALS.
const fiber = await Instance.provide({
directory: tmp.path,
fn: () =>
Promise.resolve(
rt.runFork(Test.use((svc) => svc.get(gate)).pipe(Effect.provideService(InstanceRef, Instance.current))),
),
})
// Resume from outside any Instance.provide — ALS is NOT set here
await Effect.runPromise(Deferred.succeed(gate, void 0))
const exit = await Effect.runPromise(Fiber.await(fiber))
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) {
expect(exit.value).toBe(tmp.path)
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
} finally {
await rt.dispose()
}
})
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined).pipe(provideInstance(dir))
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
}).pipe(Effect.provide(Test.layer))
}),
)
it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
}
class Test extends Context.Service<Test, Api>()("@test/DeferredResumeOutside") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
yield* Deferred.await(gate)
return yield* InstanceState.get(state)
}),
})
}),
)
}
yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
yield* Deferred.succeed(gate, undefined)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
}).pipe(Effect.provide(Test.layer))
}),
)
@@ -1,46 +1,89 @@
import { expect, test } from "bun:test"
import { expect } from "bun:test"
import { Effect, Layer, Context } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { ProjectID } from "../../src/project/schema"
import { it } from "../lib/effect"
class Shared extends Context.Service<Shared, { readonly id: number }>()("@test/Shared") {}
const testDirectory = "/tmp/opencode-test"
test("makeRuntime shares dependent layers through the shared memo map", async () => {
let n = 0
it.live("makeRuntime shares dependent layers through the shared memo map", () =>
Effect.gen(function* () {
let n = 0
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
}),
)
class One extends Context.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends Context.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
const { runPromise: runOne } = makeRuntime(One, one)
const { runPromise: runTwo } = makeRuntime(Two, two)
expect(yield* Effect.promise(() => runOne((svc) => svc.get()))).toBe(1)
expect(yield* Effect.promise(() => runTwo((svc) => svc.get()))).toBe(1)
expect(n).toBe(1)
}),
)
it.live("makeRuntime inherits InstanceRef from the current fiber", () =>
Effect.gen(function* () {
class NeedsInstance extends Context.Service<
NeedsInstance,
{ readonly directory: () => Effect.Effect<string | undefined> }
>()("@test/NeedsInstance") {}
const runtime = makeRuntime(
NeedsInstance,
Layer.succeed(
NeedsInstance,
NeedsInstance.of({
directory: () =>
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
}),
),
)
const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory()))
expect(actual).toBe(testDirectory)
}).pipe(
Effect.provideService(InstanceRef, {
directory: testDirectory,
worktree: testDirectory,
project: {
id: ProjectID.global,
worktree: testDirectory,
time: { created: 0, updated: 0 },
sandboxes: [],
},
}),
)
class One extends Context.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends Context.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
const { runPromise: runOne } = makeRuntime(One, one)
const { runPromise: runTwo } = makeRuntime(Two, two)
expect(await runOne((svc) => svc.get())).toBe(1)
expect(await runTwo((svc) => svc.get())).toBe(1)
expect(n).toBe(1)
})
),
)
+68 -55
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"
import { Runner } from "@/effect/runner"
import { it } from "../lib/effect"
@@ -115,8 +115,16 @@ describe("Runner", () => {
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
const started = yield* Deferred.make<void>()
const fiber = yield* runner
.ensureRunning(
Effect.gen(function* () {
yield* Deferred.succeed(started, void 0)
return yield* Effect.never.pipe(Effect.as("never"))
}),
)
.pipe(Effect.forkChild)
yield* Deferred.await(started)
expect(runner.busy).toBe(true)
expect(runner.state._tag).toBe("Running")
@@ -190,58 +198,52 @@ describe("Runner", () => {
}),
)
test("cancel does not deadlock when replacement work starts before interrupted run exits", async () => {
function defer() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
it.live(
"cancel does not deadlock when replacement work starts before interrupted run exits",
Effect.gen(function* () {
const s = yield* Scope.Scope
const hit = yield* Deferred.make<void>()
const hold = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
function fail(ms: number, msg: string) {
return new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(msg)), ms)
})
}
yield* Effect.gen(function* () {
const runner = Runner.make<string>(s)
const first = Effect.never.pipe(
Effect.onInterrupt(() => Deferred.succeed(hit, undefined)),
Effect.ensuring(Deferred.await(hold)),
Effect.as("first"),
)
const s = await Effect.runPromise(Scope.make())
const hit = defer()
const hold = defer()
const done = defer()
try {
const runner = Runner.make<string>(s)
const first = Effect.never.pipe(
Effect.onInterrupt(() => Effect.sync(() => hit.resolve())),
Effect.ensuring(Effect.promise(() => hold.promise)),
Effect.as("first"),
const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild)
yield* Effect.sleep("10 millis")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
yield* Deferred.await(hit).pipe(Effect.timeout("250 millis"))
const b = yield* runner.ensureRunning(Deferred.await(done).pipe(Effect.as("second"))).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(runner.busy).toBe(true)
yield* Deferred.succeed(hold, undefined)
const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis"))
expect(Exit.isSuccess(stopExit)).toBe(true)
expect(runner.busy).toBe(true)
yield* Deferred.succeed(done, undefined)
expect(yield* Fiber.join(b).pipe(Effect.timeout("250 millis"))).toBe("second")
expect(runner.busy).toBe(false)
const exit = yield* Fiber.join(a)
expect(Exit.isFailure(exit)).toBe(true)
}).pipe(
Effect.ensuring(
Effect.all([Deferred.succeed(hold, undefined), Deferred.succeed(done, undefined)], { discard: true }).pipe(
Effect.ignore,
),
),
)
const a = Effect.runPromiseExit(runner.ensureRunning(first))
await Bun.sleep(10)
const stop = Effect.runPromise(runner.cancel)
await Promise.race([hit.promise, fail(250, "cancel did not interrupt running work")])
const b = Effect.runPromise(runner.ensureRunning(Effect.promise(() => done.promise).pipe(Effect.as("second"))))
expect(runner.busy).toBe(true)
hold.resolve()
await Promise.race([stop, fail(250, "cancel deadlocked while replacement run was active")])
expect(runner.busy).toBe(true)
done.resolve()
expect(await b).toBe("second")
expect(runner.busy).toBe(false)
const exit = await a
expect(Exit.isFailure(exit)).toBe(true)
} finally {
hold.resolve()
done.resolve()
await Promise.race([Effect.runPromise(Scope.close(s, Exit.void)), fail(1000, "runner scope did not close")])
}
})
}),
)
// --- shell semantics ---
@@ -261,14 +263,25 @@ describe("Runner", () => {
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
const started = yield* Deferred.make<void>()
const fiber = yield* runner
.ensureRunning(
Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
return yield* Effect.never.pipe(Effect.as("x"))
}),
)
.pipe(Effect.forkChild)
yield* Deferred.await(started).pipe(Effect.timeout("250 millis"))
yield* Effect.gen(function* () {
while (runner.state._tag !== "Running") yield* Effect.yieldNow
}).pipe(Effect.timeout("250 millis"))
const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
yield* runner.cancel
yield* Fiber.await(fiber)
yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
}),
)
@@ -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")
+58 -57
View File
@@ -5,11 +5,12 @@ 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 { provideInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
const init = () => run(File.Service.use((svc) => svc.init()))
@@ -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()
@@ -938,12 +939,12 @@ describe("file/index Filesystem patterns", () => {
},
})
await Instance.disposeAll()
await disposeAllInstances()
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,8 @@ 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"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
@@ -54,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")
@@ -65,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(
@@ -82,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")
@@ -96,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")
@@ -111,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")
@@ -121,15 +123,15 @@ describe("File.list path traversal protection", () => {
})
})
describe("Instance.containsPath", () => {
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(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true)
},
})
})
@@ -139,15 +141,15 @@ describe("Instance.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
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
// sibling package should also be accessible
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
// worktree root itself
expect(Instance.containsPath(tmp.path)).toBe(true)
expect(containsPath(tmp.path, Instance.current)).toBe(true)
},
})
})
@@ -155,11 +157,11 @@ describe("Instance.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(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other-project")).toBe(false)
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
expect(containsPath("/tmp/other-project", Instance.current)).toBe(false)
},
})
})
@@ -167,10 +169,10 @@ describe("Instance.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(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
},
})
})
@@ -178,12 +180,12 @@ describe("Instance.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)
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
},
})
})
@@ -191,13 +193,13 @@ describe("Instance.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
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other")).toBe(false)
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
expect(containsPath("/tmp/other", Instance.current)).toBe(false)
},
})
})
+5 -4
View File
@@ -3,12 +3,13 @@ import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Bus } from "../../src/bus"
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(
@@ -147,7 +148,7 @@ function ready(directory: string) {
describeWatcher("FileWatcher", () => {
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
test("publishes root create, update, and delete events", async () => {
@@ -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"
+2 -2
View File
@@ -1,9 +1,9 @@
import { rm } from "fs/promises"
import { Instance } from "../../src/project/instance"
import { Database } from "@/storage/db"
import { disposeAllInstances } from "./fixture"
export async function resetDatabase() {
await Instance.disposeAll().catch(() => undefined)
await disposeAllInstances().catch(() => undefined)
Database.close()
await rm(Database.Path, { force: true }).catch(() => undefined)
await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined)
+56 -11
View File
@@ -1,17 +1,48 @@
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
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 {
return p.replace(/\0/g, "")
@@ -120,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>(
@@ -139,10 +170,9 @@ export function provideTmpdirInstance<A, E, R>(
yield* Effect.addFinalizer(() =>
provided
? Effect.promise(() =>
Instance.provide({
directory: path,
fn: () => Instance.dispose(),
}),
runTestInstanceStore((store) =>
store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))),
),
).pipe(Effect.ignore)
: Effect.void,
)
@@ -152,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> },
+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,18 +1,18 @@
// kilocode_change - new file
import { afterEach, test, expect } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
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"
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
test("code agent allows global config directory reads by default", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const code = await Agent.get("code")
@@ -0,0 +1,75 @@
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
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>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
afterEach(async () => {
await disposeAllInstances()
})
test("ask agent honors user MCP allow over generated ask rule", async () => {
await using tmp = await tmpdir({
config: {
mcp: {
context7: { type: "local", command: ["context7"] },
},
permission: {
"context7_query-docs": { "*": "allow" },
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const ask = await load(tmp.path, (svc) => svc.get("ask"))
expect(ask).toBeDefined()
expect(Permission.evaluate("context7_query-docs", "*", ask!.permission).action).toBe("allow")
},
})
})
test("plan agent honors user bash allow over read-only deny default", async () => {
await using tmp = await tmpdir({
config: {
permission: {
bash: { "cargo search *": "allow" },
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const plan = await load(tmp.path, (svc) => svc.get("plan"))
expect(plan).toBeDefined()
expect(Permission.evaluate("bash", "cargo search serde", plan!.permission).action).toBe("allow")
},
})
})
test("plan agent still hard-denies non-plan edits after user edit allow", async () => {
await using tmp = await tmpdir({
config: {
permission: {
edit: { "src/output.log": "allow" },
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const plan = await load(tmp.path, (svc) => svc.get("plan"))
expect(plan).toBeDefined()
expect(Permission.evaluate("edit", "src/output.log", plan!.permission).action).toBe("deny")
expect(Permission.evaluate("edit", ".kilo/plans/fix.md", plan!.permission).action).toBe("allow")
},
})
})
@@ -1,12 +1,12 @@
// kilocode_change - new file
import { afterEach, test, expect } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
function action(name: string, ruleset: Permission.Ruleset) {
@@ -15,7 +15,7 @@ function action(name: string, ruleset: Permission.Ruleset) {
test("skill tool available for non-system native agents and denied for system agents", async () => {
await using tmp = await tmpdir()
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const allow = ["code", "plan", "debug", "orchestrator", "ask", "general", "explore"]
@@ -31,9 +31,8 @@ function askRulesetWithMcp(servers: string[], user: Permission.Ruleset = []) {
const sanitized = key.replace(/[^a-zA-Z0-9_-]/g, "_")
mcpRules[sanitized + "_*"] = "ask"
}
// Mirrors agent.ts merge order: user, ask-specific (with mcpRules), user denies last
// Mirrors Ask agent merge order: defaults, ask-specific guard, user config, user denies last.
return Permission.merge(
user,
Permission.fromConfig({
"*": "deny",
bash: readOnlyBash,
@@ -53,6 +52,7 @@ function askRulesetWithMcp(servers: string[], user: Permission.Ruleset = []) {
codebase_search: "allow",
...mcpRules,
}),
user,
user.filter((r) => r.action === "deny"),
)
}
@@ -257,6 +257,13 @@ describe("Ask agent MCP permissions", () => {
expect(result.action).toBe("ask")
})
test("user config allow overrides MCP ask rules", () => {
const allow = Permission.fromConfig({ "my-server_read_file": "allow" })
const ruleset = askRulesetWithMcp(["my-server"], allow)
const result = Permission.evaluate("my-server_read_file", "*", ruleset)
expect(result.action).toBe("allow")
})
test("MCP tools disabled without server config", () => {
const ruleset = askRuleset()
const result = Permission.disabled(["my-server_sometool"], ruleset)
@@ -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,18 +1,18 @@
import { afterEach, test, expect } from "bun:test"
import path from "path"
import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { BUILTIN_SKILLS } from "../../src/kilocode/skills/builtin"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
test("built-in skills are present in empty project", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
@@ -30,7 +30,7 @@ test("built-in skills are present in empty project", async () => {
test("built-in skill has correct metadata", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const skill = await Skill.get("kilo-config")
@@ -62,7 +62,7 @@ User-provided content.
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const skill = await Skill.get("kilo-config")
@@ -0,0 +1,59 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import { KiloRunAuto } from "../../src/kilocode/cli/run-auto"
describe("KiloRunAuto", () => {
test("tracks task child sessions without allowing unrelated sessions", () => {
const state = KiloRunAuto.create("ses_root")
expect(KiloRunAuto.allowed(state, "ses_root")).toBe(true)
expect(KiloRunAuto.allowed(state, "ses_child")).toBe(false)
KiloRunAuto.track(state, {
type: "tool",
tool: "task",
sessionID: "ses_root",
state: {
metadata: {
sessionId: "ses_child",
},
},
})
expect(KiloRunAuto.allowed(state, "ses_child")).toBe(true)
expect(KiloRunAuto.allowed(state, "ses_other")).toBe(false)
})
test("ignores malformed or non-root task metadata", () => {
const state = KiloRunAuto.create("ses_root")
KiloRunAuto.track(state, {
type: "tool",
tool: "task",
sessionID: "ses_root",
state: {
metadata: {
sessionId: "",
},
},
})
KiloRunAuto.track(state, {
type: "tool",
tool: "task",
sessionID: "ses_other",
state: {
metadata: {
sessionId: "ses_wrong",
},
},
})
KiloRunAuto.track(state, {
type: "text",
sessionID: "ses_root",
state: {},
})
expect(KiloRunAuto.allowed(state, "ses_wrong")).toBe(false)
expect(KiloRunAuto.allowed(state, "")).toBe(false)
})
})
@@ -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,124 @@
import { describe, expect, test } from "bun:test"
import { CodexAuthExpiredError, refreshCodexAuth } from "../../src/kilocode/provider/codex-refresh"
import type { PluginInput } from "@kilocode/plugin"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
type Auth = {
type: "oauth"
refresh: string
access: string
expires: number
accountId?: string
}
const expired = (): Auth => ({
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: 0,
})
function plugin(persist: (auth: Auth) => void): PluginInput {
const set = async (req: { body: Auth }) => {
persist(req.body)
}
return {
client: {
auth: { set },
},
} as unknown as PluginInput
}
describe("Codex auth refresh", () => {
test("serializes expired Codex auth as ProviderAuthError", () => {
const result = MessageV2.fromError(new CodexAuthExpiredError(), { providerID: ProviderID.make("openai") })
expect(result).toStrictEqual({
name: "ProviderAuthError",
data: {
providerID: "openai",
message:
"Your ChatGPT sign-in expired or was revoked. Sign in with ChatGPT again to continue using Codex models.",
},
})
})
test("coalesces concurrent refreshes and persists rotated tokens", async () => {
const calls: string[] = []
const writes: Auth[] = []
const first = expired()
const second = expired()
const refresh = async (token: string) => {
calls.push(token)
await new Promise((resolve) => setTimeout(resolve, 1))
return { id_token: "", access_token: "next-access", refresh_token: "next-refresh", expires_in: 60 }
}
const [a, b] = await Promise.all([
refreshCodexAuth({
input: plugin((auth) => writes.push(auth)),
getAuth: async () => first,
auth: first,
refresh,
account: () => undefined,
}),
refreshCodexAuth({
input: plugin((auth) => writes.push(auth)),
getAuth: async () => second,
auth: second,
refresh,
account: () => undefined,
}),
])
expect(calls).toEqual(["old-refresh"])
expect(writes).toHaveLength(1)
expect(a.access).toBe("next-access")
expect(b.refresh).toBe("next-refresh")
expect(first.access).toBe("next-access")
expect(second.access).toBe("next-access")
})
test("uses a newer stored token after refresh 401", async () => {
const fresh = {
type: "oauth" as const,
access: "fresh-access",
refresh: "fresh-refresh",
expires: Date.now() + 60_000,
}
const auth = expired()
let count = 0
const getAuth = async () => {
count++
return count === 1 ? auth : fresh
}
const result = await refreshCodexAuth({
input: plugin(() => {}),
getAuth,
auth,
refresh: async () => {
throw new Error("Token refresh failed: 401")
},
account: () => undefined,
})
expect(result).toBe(fresh)
})
test("throws reauth error when refresh 401 has no newer stored token", async () => {
const auth = expired()
await expect(
refreshCodexAuth({
input: plugin(() => {}),
getAuth: async () => auth,
auth,
refresh: async () => {
throw new Error("Token refresh failed: 401")
},
account: () => undefined,
}),
).rejects.toBeInstanceOf(CodexAuthExpiredError)
})
})
@@ -0,0 +1,406 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { APICallError } from "ai"
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 { KiloCompactionPayloadRecovery } from "../../src/kilocode/session/compaction-payload-recovery"
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 { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import * as SessionProcessorModule from "../../src/session/processor"
import { Session as SessionNs } from "../../src/session/session"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionCompaction } from "../../src/session/compaction"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { ProviderTest } from "../fake/provider"
import { tmpdir } from "../fixture/fixture"
const sessionID = SessionID.make("ses_payload_recovery")
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
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([]),
}),
)
function base(id: MessageID) {
return {
id: PartID.ascending(),
messageID: id,
sessionID,
}
}
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) {
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: "end_turn",
}
await svc.updateMessage(msg)
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)))
},
raw: () => Effect.die("raw not implemented in test LLM"),
}),
),
}
}
function reply(
text: string,
capture?: (input: LLM.StreamInput) => void,
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> {
return (input) => {
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 runtime(layer: Layer.Layer<LLM.Service>, config = Config.defaultLayer) {
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: 100_000, output: 32_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(config),
),
)
}
afterEach(() => {
mock.restore()
})
describe("KiloCompactionPayloadRecovery", () => {
test("detects Kilo gateway payload-size errors", () => {
const error = new MessageV2.ContextOverflowError({
message: "Request Entity Too Large",
responseBody: "Request Entity Too Large\n\nFUNCTION_PAYLOAD_TOO_LARGE",
}).toObject()
expect(KiloCompactionPayloadRecovery.matches(error)).toBe(true)
})
test("strips media and marks completed tool outputs compacted", async () => {
const user: MessageV2.WithParts = {
info: {
id: userID,
role: "user",
sessionID,
agent: "build",
model: { providerID, modelID },
time: { created: Date.now() },
},
parts: [
{
...base(userID),
type: "file",
mime: "image/png",
filename: "screen.png",
url: "data:image/png;base64,abc",
},
],
}
const tool: MessageV2.ToolPart = {
...base(assistantID),
type: "tool",
callID: "call-1",
tool: "bash",
state: {
status: "completed",
input: {},
output: "large output",
title: "Bash",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
}
const assistant: MessageV2.WithParts = {
info: {
id: assistantID,
role: "assistant",
parentID: userID,
sessionID,
mode: "build",
agent: "build",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID,
providerID,
time: { created: Date.now() },
},
parts: [tool],
}
const updated: MessageV2.Part[] = []
await Effect.runPromise(
KiloCompactionPayloadRecovery.strip({
messages: [user, assistant],
update: (part) => Effect.sync(() => updated.push(part)).pipe(Effect.as(part)),
}),
)
expect(updated).toHaveLength(2)
expect(updated[0]).toMatchObject({
type: "text",
text: "[Attached image/png: screen.png]",
})
expect(updated[1]?.type).toBe("tool")
if (updated[1]?.type === "tool" && updated[1].state.status === "completed") {
expect(updated[1].state.time.compacted).toBeNumber()
}
})
test("retries compaction without media and tool outputs after payload-size failure", async () => {
await using tmp = await tmpdir({ git: true })
const stub = llm()
const captures: string[] = []
stub.push((input) => {
captures.push(JSON.stringify(input.messages))
return Stream.make({ type: "start" } as LLM.Event).pipe(
Stream.concat(
Stream.fail(
new APICallError({
message: "Request Entity Too Large",
url: "https://api.kilo.ai/api/openrouter/responses",
requestBodyValues: {},
statusCode: 413,
responseHeaders: { "content-type": "text/plain" },
responseBody: "Request Entity Too Large\n\nFUNCTION_PAYLOAD_TOO_LARGE",
isRetryable: false,
}),
),
),
)
})
stub.push(
reply("summary", (input) => {
captures.push(JSON.stringify(input.messages))
}),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const old = await user(session.id, "old image turn")
await svc.updatePart({
id: PartID.ascending(),
messageID: old.id,
sessionID: session.id,
type: "file",
mime: "image/png",
filename: "old.png",
url: `data:image/png;base64,${"a".repeat(8_000)}`,
})
const oldReply = await assistant(session.id, old.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: oldReply.id,
sessionID: session.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "bash",
state: {
status: "completed",
input: {},
output: "old output".repeat(10_000),
title: "old",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
await user(session.id, "latest turn")
const keep = await user(session.id, "preserved tail turn")
const keepReply = await assistant(session.id, keep.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: keepReply.id,
sessionID: session.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "bash",
state: {
status: "completed",
input: {},
output: "keep output",
title: "keep",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = runtime(stub.layer, Config.defaultLayer)
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(captures).toHaveLength(2)
expect(captures[0]).toContain("Attached image/png: old.png")
expect(captures[0]).toContain("old output")
expect(captures[0]).not.toContain("keep output")
expect(captures[1]).not.toContain("data:image/png;base64")
expect(captures[1]).not.toContain("old output")
expect(captures[1]).not.toContain("keep output")
expect(captures[1]).toContain("Attached image/png: old.png")
expect(captures[1]).toContain("Old tool result content cleared")
const tools = (await svc.messages({ sessionID: session.id }))
.flatMap((msg) => msg.parts)
.filter((part): part is MessageV2.ToolPart => part.type === "tool")
expect(tools).toHaveLength(2)
expect(tools[0]?.type).toBe("tool")
if (tools[0]?.state.status === "completed") expect(tools[0].state.time.compacted).toBeNumber()
expect(tools[1]?.type).toBe("tool")
if (tools[1]?.state.status === "completed") expect(tools[1].state.time.compacted).toBeUndefined()
} finally {
await rt.dispose()
}
},
})
})
})
@@ -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,12 +1,12 @@
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 { WithInstance } from "../../src/project/with-instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
await Config.invalidate()
})
@@ -31,7 +31,7 @@ Valid agent prompt`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
@@ -59,7 +59,7 @@ Broken agent prompt`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
@@ -90,7 +90,7 @@ Valid command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
@@ -117,7 +117,7 @@ Broken command template`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
@@ -141,7 +141,7 @@ Broken agent`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
@@ -165,7 +165,7 @@ Broken command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
@@ -183,7 +183,7 @@ Broken command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
@@ -204,7 +204,7 @@ Broken command`,
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
@@ -221,7 +221,7 @@ Broken command`,
config: { model: "test/model" },
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
@@ -2,13 +2,13 @@
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 { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
describe("ConfigValidation.check", () => {
@@ -17,7 +17,7 @@ describe("ConfigValidation.check", () => {
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),
})
@@ -29,7 +29,7 @@ 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),
})
@@ -42,7 +42,7 @@ 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),
})
@@ -57,7 +57,7 @@ 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),
})
@@ -77,7 +77,7 @@ description: A test command
Do something useful`,
)
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
})
@@ -98,7 +98,7 @@ subtask: "not-a-boolean"
Do something`,
)
const result = await Instance.provide({
const result = await WithInstance.provide({
directory: tmp.path,
fn: () => ConfigValidation.check(filepath),
})
@@ -119,7 +119,7 @@ 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),
})
@@ -132,7 +132,7 @@ 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),
})
@@ -144,7 +144,7 @@ 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),
})
@@ -169,7 +169,7 @@ 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
@@ -8,14 +8,15 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { Npm } from "@opencode-ai/core/npm"
import { toIndexingConfigInput } from "@kilocode/kilo-indexing/config"
import { Account } from "../../../src/account/account"
import { Auth } from "../../../src/auth"
import { Config } from "../../../src/config/config"
import { ConfigMarkdown } from "../../../src/config/markdown"
import { Env } from "../../../src/env"
import { Instance } from "../../../src/project/instance"
import { KiloIndexing } from "../../../src/kilocode/indexing"
import { WithInstance } from "../../../src/project/with-instance"
import { Filesystem } from "../../../src/util/filesystem"
import { tmpdir } from "../../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
const infra = CrossSpawnSpawner.defaultLayer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
@@ -43,26 +44,62 @@ 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))
}
describe("kilocode indexing config", () => {
afterEach(async () => {
await Instance.disposeAll()
await clear(true)
})
const cfg: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: true,
},
indexing: {
provider: "ollama",
vectorStore: "qdrant",
ollama: {
baseUrl: "http://127.0.0.1:1",
},
},
}
test("does not inherit global indexing enabled into project config", async () => {
afterEach(async () => {
delete process.env.KILO_MD_TEST
await clear()
await disposeAllInstances()
})
describe("markdown substitutions", () => {
test("applies file and env substitutions to parsed markdown body", async () => {
process.env.KILO_MD_TEST = "env content"
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "body.md"), "file content")
await Filesystem.write(
path.join(dir, "SKILL.md"),
["---", "name: test", "description: Test", "---", "{file:body.md}", "{env:KILO_MD_TEST}"].join("\n"),
)
},
})
const md = await ConfigMarkdown.parse(path.join(tmp.path, "SKILL.md"))
expect(md.content).toContain("file content")
expect(md.content).toContain("env content")
})
})
describe("kilocode indexing config", () => {
test("keeps global indexing enabled in global config", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir()
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, {
@@ -73,18 +110,62 @@ describe("kilocode indexing config", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const global = await Effect.runPromise(
Config.Service.use((svc) => svc.getGlobal()).pipe(Effect.scoped, Effect.provide(layer)),
)
expect(config.indexing?.provider).toBe("ollama")
expect(config.indexing?.enabled).toBeUndefined()
expect(toIndexingConfigInput(config.indexing).enabled).toBe(false)
expect(global.indexing?.enabled).toBe(true)
},
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
await clear()
await disposeAllInstances()
}
})
test("uses global indexing enabled when project enablement is unset", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true, config: cfg })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
try {
await writeConfig(globalTmp.path, {
$schema: "https://app.kilo.ai/config.json",
indexing: {
enabled: true,
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const global = await Effect.runPromise(
Config.Service.use((svc) => svc.getGlobal()).pipe(Effect.scoped, Effect.provide(layer)),
)
const config = await load()
const input = KiloIndexing.input(config.indexing, global.indexing)
expect(input.enabled).toBe(true)
},
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("global indexing enabled applies when project indexing is disabled", async () => {
const input = KiloIndexing.input({ enabled: false }, { enabled: true })
expect(input.enabled).toBe(true)
})
})
@@ -11,9 +11,9 @@ 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 { tmpdir } from "../../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
const infra = CrossSpawnSpawner.defaultLayer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
@@ -41,13 +41,13 @@ 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 Instance.disposeAll()
await clear(true)
await clear()
await disposeAllInstances()
})
test("does not hard-enable indexing plugin when default plugins are disabled", async () => {
@@ -67,7 +67,7 @@ describe("kilocode default indexing plugin", () => {
},
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { Config } from "../../../src/config/config"
describe("Config.Info experimental speech-to-text", () => {
test("parses speech-to-text enablement and model", () => {
const parsed = Config.Info.zod.parse({
experimental: {
speech_to_text: true,
speech_to_text_model: "openai/gpt-4o-mini-transcribe",
},
})
expect(parsed.experimental?.speech_to_text).toBe(true)
expect(parsed.experimental?.speech_to_text_model).toBe("openai/gpt-4o-mini-transcribe")
})
test("preserves explicit disabled speech-to-text", () => {
const parsed = Config.Info.zod.parse({ experimental: { speech_to_text: false } })
expect(parsed.experimental?.speech_to_text).toBe(false)
})
test("keeps existing experimental defaults", () => {
const parsed = Config.Info.zod.parse({ experimental: { speech_to_text: true } })
expect(parsed.experimental?.openTelemetry).toBe(true)
})
})
@@ -13,13 +13,13 @@ import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { provideTmpdirInstance } from "../fixture/fixture"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
Log.init({ print: false })
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
const ref = {
@@ -27,6 +27,15 @@ describe("Config.Info — null sentinels for custom provider deletes", () => {
expect(parsed.success).toBe(true)
})
it("accepts a null provider value", () => {
const parsed = Config.Info.zod.safeParse({
provider: {
myprovider: null,
},
})
expect(parsed.success).toBe(true)
})
it("accepts a null variant value inside a model", () => {
const parsed = Config.Info.zod.safeParse({
provider: {
@@ -76,6 +85,29 @@ describe("KilocodeConfig.mergeConfig — custom provider model/variant deletion"
expect("model-gone" in models).toBe(false)
})
it("drops a provider when the patch sets it to null", () => {
const existing = {
provider: {
myprovider: {
name: "My Provider",
models: { keep: { name: "Keep" } },
},
openai: {
name: "OpenAI",
},
},
} as unknown as Config.Info
const patch = {
provider: {
myprovider: null,
},
} as unknown as Config.Info
const merged = KilocodeConfig.mergeConfig(existing, patch)
expect(merged.provider?.openai).toBeDefined()
expect("myprovider" in (merged.provider ?? {})).toBe(false)
})
it("drops a variant from an existing model when the patch sets it to null", () => {
const existing = {
provider: {
@@ -8,8 +8,8 @@ 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 { tmpdir } from "../fixture/fixture"
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"
import { Format } from "../../src/format"
@@ -34,7 +34,7 @@ afterAll(async () => {
})
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
const resolve = () =>
@@ -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()
@@ -11,7 +11,7 @@ import fs from "fs/promises"
import os from "os"
import path from "path"
import iconv from "iconv-lite"
import { Encoding } from "../../src/kilocode/encoding"
import * as Encoding from "../../src/kilocode/encoding"
const BOM = {
utf8: Buffer.from([0xef, 0xbb, 0xbf]),
@@ -341,3 +341,25 @@ describe("Encoding.read / Encoding.readSync / Encoding.write", () => {
})
})
})
describe("Encoding.write with existing parent directories", () => {
test("creates parent and writes file", async () => {
await tmp(async (dir) => {
const filepath = path.join(dir, "subdir", "test.txt")
await Encoding.write(filepath, "hello")
const text = await fs.readFile(filepath, "utf8")
expect(text).toBe("hello")
})
})
test("writes into existing directory (Windows EEXIST resiliency)", async () => {
await tmp(async (dir) => {
const existing = path.join(dir, "exists")
await fs.mkdir(existing, { recursive: true })
const filepath = path.join(existing, "test.txt")
await Encoding.write(filepath, "hello")
const text = await fs.readFile(filepath, "utf8")
expect(text).toBe("hello")
})
})
})
@@ -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")
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("KiloSessionPrompt.ensurePlanDir", () => {
test("creates a missing plan directory", async () => {
await using tmp = await tmpdir({})
const dir = path.join(tmp.path, ".kilo", "plans")
await KiloSessionPrompt.ensurePlanDir(dir)
const stat = await fs.stat(dir)
expect(stat.isDirectory()).toBe(true)
})
test("is idempotent when the directory already exists", async () => {
await using tmp = await tmpdir({})
const dir = path.join(tmp.path, ".kilo", "plans")
await fs.mkdir(dir, { recursive: true })
await expect(KiloSessionPrompt.ensurePlanDir(dir)).resolves.toBeUndefined()
const stat = await fs.stat(dir)
expect(stat.isDirectory()).toBe(true)
})
test("creates intermediate parent directories", async () => {
await using tmp = await tmpdir({})
const dir = path.join(tmp.path, "deep", "nested", ".kilo", "plans")
await KiloSessionPrompt.ensurePlanDir(dir)
const stat = await fs.stat(dir)
expect(stat.isDirectory()).toBe(true)
})
})
@@ -3,6 +3,8 @@ import { Effect } from "effect"
import path from "path"
import type { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
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"
@@ -42,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 Instance.dispose()
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
@@ -66,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 Instance.dispose()
await InstanceRuntime.disposeInstance(Instance.current)
}
},
})
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import {
hasKiloIndexingAuth,
resolveKiloIndexingAuth,
shouldDefaultIndexingToKilo,
} from "../../src/kilocode/indexing-auth"
describe("Kilo indexing auth resolution", () => {
test("detects auth from explicit indexing Kilo config", () => {
const auth = resolveKiloIndexingAuth({
config: { indexing: { kilo: { apiKey: "idx-token", baseUrl: "https://idx.test", organizationId: "org_idx" } } },
})
expect(auth).toEqual({ apiKey: "idx-token", baseUrl: "https://idx.test", organizationId: "org_idx" })
expect(hasKiloIndexingAuth({ config: { indexing: { kilo: { apiKey: "idx-token" } } } })).toBe(true)
})
test("detects auth from provider config, provider state, auth storage, and env", () => {
expect(
resolveKiloIndexingAuth({ config: { provider: { kilo: { options: { apiKey: "cfg-token" } } } } }).apiKey,
).toBe("cfg-token")
expect(resolveKiloIndexingAuth({ provider: { options: { kilocodeToken: "provider-token" } } }).apiKey).toBe(
"provider-token",
)
expect(resolveKiloIndexingAuth({ auth: { type: "oauth", access: "oauth-token", accountId: "org_oauth" } })).toEqual(
{
apiKey: "oauth-token",
organizationId: "org_oauth",
},
)
expect(resolveKiloIndexingAuth({ env: { KILO_API_KEY: "env-token", KILO_ORG_ID: "org_env" } })).toEqual({
apiKey: "env-token",
organizationId: "org_env",
})
})
test("defaults to Kilo only when no provider or other embedder config is present", () => {
const auth = { apiKey: "kilo-token" }
expect(shouldDefaultIndexingToKilo({}, auth)).toBe(true)
expect(shouldDefaultIndexingToKilo({ provider: "openai" }, auth)).toBe(false)
expect(shouldDefaultIndexingToKilo({ openai: { apiKey: "openai-key" } }, auth)).toBe(false)
expect(shouldDefaultIndexingToKilo({ ollama: { baseUrl: "http://localhost:11434" } }, auth)).toBe(false)
})
})
@@ -2,16 +2,16 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"
import { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
import type { Config } from "../../src/config/config"
import { GlobalBus } from "../../src/bus/global"
import { AppRuntime } from "../../src/effect/app-runtime"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const fetch = global.fetch
const cfg: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
@@ -41,6 +41,29 @@ const off: Partial<Config.Info> = {
},
},
}
const kilo: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: true,
},
indexing: {
enabled: true,
vectorStore: "qdrant",
},
}
const implicitOpenAi: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: true,
},
indexing: {
enabled: true,
vectorStore: "qdrant",
openai: {
apiKey: "openai-token",
},
},
}
const configDir = process.env["KILO_CONFIG_DIR"]
const disabled = process.env["KILO_DISABLE_CODEBASE_INDEXING"]
const error = new Error("test indexing initialization failed")
@@ -67,7 +90,8 @@ afterEach(async () => {
else process.env["KILO_CONFIG_DIR"] = configDir
if (disabled === undefined) delete process.env["KILO_DISABLE_CODEBASE_INDEXING"]
else process.env["KILO_DISABLE_CODEBASE_INDEXING"] = disabled
await Instance.disposeAll()
global.fetch = fetch
await disposeAllInstances()
})
describe("indexing startup degradation", () => {
@@ -158,16 +182,15 @@ describe("indexing startup degradation", () => {
GlobalBus.on("event", on)
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
await called(init)
expect((await KiloIndexing.current()).state).toBe("In Progress")
},
})
await Instance.disposeAll()
await disposeAllInstances()
gate.resolve({ requiresRestart: false })
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -186,9 +209,8 @@ describe("indexing startup degradation", () => {
process.env["KILO_CONFIG_DIR"] = tmp.path
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
const status = await wait(() => KiloIndexing.current(), "Error")
@@ -211,9 +233,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: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
await called(init)
@@ -234,9 +255,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: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
const status = await KiloIndexing.current()
@@ -252,6 +272,78 @@ describe("indexing startup degradation", () => {
})
})
test("enriches Kilo provider config from env auth", 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
const org = process.env.KILO_ORG_ID
await using tmp = await tmpdir({ git: true, config: kilo })
process.env["KILO_CONFIG_DIR"] = tmp.path
process.env.KILO_API_KEY = "kilo-token"
process.env.KILO_ORG_ID = "org_123"
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await called(init)
expect(init.mock.calls[0]?.[0]).toMatchObject({
embedderProvider: "kilo",
kiloApiKey: "kilo-token",
kiloOrganizationId: "org_123",
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
if (org === undefined) delete process.env.KILO_ORG_ID
else process.env.KILO_ORG_ID = org
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
await using tmp = await tmpdir({ git: true, config: implicitOpenAi })
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: "openai",
openAiKey: "openai-token",
})
},
})
} finally {
if (key === undefined) delete process.env.KILO_API_KEY
else process.env.KILO_API_KEY = key
init.mockRestore()
}
})
test("stays disabled when VS Code starts without a workspace folder", async () => {
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
@@ -259,9 +351,8 @@ describe("indexing startup degradation", () => {
const init = spyOn(CodeIndexManager.prototype, "initialize")
try {
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
const status = await KiloIndexing.current()
@@ -1,11 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import type { Config } from "../../src/config/config"
import { AppRuntime } from "../../src/effect/app-runtime"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const cfg: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
@@ -27,7 +25,7 @@ const configDir = process.env["KILO_CONFIG_DIR"]
afterEach(async () => {
if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"]
else process.env["KILO_CONFIG_DIR"] = configDir
await Instance.disposeAll()
await disposeAllInstances()
})
describe("indexing worktree disable", () => {
@@ -37,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: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
const status = await KiloIndexing.current()
@@ -58,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: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
const status = await KiloIndexing.current()
@@ -0,0 +1,123 @@
// kilocode_change - new file
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, FileSystem, Layer } from "effect"
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 { 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 = TestConfig.layer()
const instructionLayer = (global: Partial<Global.Interface>) =>
Instruction.layer.pipe(
Layer.provide(configLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Global.layerWith(global)),
)
const provideInstruction =
(global: Partial<Global.Interface>) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(Effect.provide(instructionLayer(global)))
const write = (filepath: string, content: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(filepath), { recursive: true })
yield* fs.writeFileString(filepath, content)
})
const writeFiles = (dir: string, files: Record<string, string>) =>
Effect.all(
Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)),
{ discard: true },
)
const tmpWithFiles = (files: Record<string, string>) =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeFiles(dir, files)
return dir
})
const withConfigDir =
(value: string | undefined) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const original = process.env["KILO_CONFIG_DIR"]
if (value === undefined) delete process.env["KILO_CONFIG_DIR"]
else process.env["KILO_CONFIG_DIR"] = value
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (original === undefined) delete process.env["KILO_CONFIG_DIR"]
else process.env["KILO_CONFIG_DIR"] = original
}),
)
return yield* self
})
describe("Instruction.systemPaths KILO_CONFIG_DIR profile fallback", () => {
it.live("prefers KILO_CONFIG_DIR AGENTS.md over global when both exist", () =>
Effect.gen(function* () {
const profileTmp = yield* tmpWithFiles({ "AGENTS.md": "# Profile Instructions" })
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
const projectTmp = yield* tmpdirScoped()
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(projectTmp),
provideInstruction({ home: globalTmp, config: globalTmp }),
withConfigDir(profileTmp),
)
}),
)
it.live("falls back to global AGENTS.md when KILO_CONFIG_DIR has no AGENTS.md", () =>
Effect.gen(function* () {
const profileTmp = yield* tmpdirScoped()
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
const projectTmp = yield* tmpdirScoped()
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(projectTmp),
provideInstruction({ home: globalTmp, config: globalTmp }),
withConfigDir(profileTmp),
)
}),
)
it.live("uses global AGENTS.md when KILO_CONFIG_DIR is not set", () =>
Effect.gen(function* () {
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
const projectTmp = yield* tmpdirScoped()
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(projectTmp),
provideInstruction({ home: globalTmp, config: globalTmp }),
withConfigDir(undefined),
)
}),
)
})
@@ -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", () => {
@@ -17,29 +17,30 @@ const real = await import("@kilocode/kilo-gateway")
mock.module("@kilocode/kilo-gateway", () => ({
...real,
fetchKiloModels: async () => ({
"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 },
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 { WithInstance } from "../../src/project/with-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 { ModelsDev } from "../../src/provider/models"
import { Auth } from "../../src/auth"
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
@@ -50,12 +51,11 @@ function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
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, ModelsDev.Data
// holds a lazy singleton whose resolved object gets mutated in-place by get(),
// 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")
ModelsDev.Data.reset()
await using base = await tmpdir({
init: async (dir) => {
@@ -68,7 +68,7 @@ test("kilo loader keeps paid models without auth and when config apiKey is prese
},
})
const none = await Instance.provide({
const none = await WithInstance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
@@ -91,7 +91,7 @@ test("kilo loader keeps paid models without auth and when config apiKey is prese
},
})
const count = await Instance.provide({
const count = await WithInstance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
@@ -103,7 +103,6 @@ test("kilo loader keeps paid models without auth and when config apiKey is prese
test("kilo loader keeps paid models without auth and when auth exists", async () => {
await Auth.remove("kilo")
ModelCache.clear("kilo")
ModelsDev.Data.reset()
await using base = await tmpdir({
init: async (dir) => {
@@ -116,7 +115,7 @@ test("kilo loader keeps paid models without auth and when auth exists", async ()
},
})
const none = await Instance.provide({
const none = await WithInstance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
@@ -150,7 +149,7 @@ test("kilo loader keeps paid models without auth and when auth exists", async ()
}),
)
const count = await Instance.provide({
const count = await WithInstance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
@@ -0,0 +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.
import { test, expect, mock } from "bun:test"
import path from "path"
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 { WithInstance } from "../../src/project/with-instance"
import { ModelCache } from "../../src/provider/model-cache"
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 WithInstance.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 })
})
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)
})
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { plain, session, supports, tui } from "../../src/kilocode/cli/logo"
describe("kilocode logo", () => {
test("falls back on remote terminals", () => {
expect(supports({ SSH_TTY: "/dev/pts/0" }, "linux")).toBe(false)
expect(supports({ SSH_CLIENT: "127.0.0.1 12345 22" }, "linux")).toBe(false)
expect(supports({ SSH_CONNECTION: "127.0.0.1 12345 127.0.0.1 22" }, "linux")).toBe(false)
})
test("allows Windows Terminal locally", () => {
expect(supports({}, "win32")).toBe(true)
expect(supports({ WT_SESSION: "session" }, "linux")).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({ SSH_TTY: "/dev/pts/0" }, "linux").join("\n")).not.toContain("🬺🬏")
expect(plain({ SSH_TTY: "/dev/pts/0" }, "linux").join("\n")).not.toContain("🬁🬬")
})
test("formats child session exit logo", () => {
const out = session("Title", "ses_test", "<dim>", "<reset>", { SSH_TTY: "/dev/pts/0" }, "linux")
expect(out).toContain("<dim>Title<reset>")
expect(out).not.toContain("🬺🬏")
})
})
@@ -9,9 +9,10 @@ import { TsClient } from "../../src/kilocode/ts-client"
import { TsCheck } from "../../src/kilocode/ts-check"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance, type InstanceContext } from "../../src/project/instance"
import { disposeAllInstances } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
// Typescript.spawn doesn't use ctx, so a cast-through is fine for these tests.
@@ -15,11 +15,13 @@ mock.module("@kilocode/kilo-gateway", () => ({
fetchKiloModels: async (options: any) => {
captured = options
return {
"test-model": {
id: "test-model",
name: "Test Model",
cost: { input: 0.001, output: 0.002 },
limit: { context: 128000, output: 4096 },
models: {
"test-model": {
id: "test-model",
name: "Test Model",
cost: { input: 0.001, output: 0.002 },
limit: { context: 128000, output: 4096 },
},
},
}
},
@@ -33,7 +35,7 @@ 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 { WithInstance } from "../../src/project/with-instance"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
@@ -48,18 +50,17 @@ test("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", asy
)
},
})
await Instance.provide({
// 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",
})
await WithInstance.provide({
directory: tmp.path,
init: 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",
})
},
fn: async () => {
// Reset captured and cache
captured = undefined
@@ -87,17 +88,16 @@ test("model fetch without OAuth accountId does not set kilocodeOrganizationId",
)
},
})
await Instance.provide({
// 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,
})
await WithInstance.provide({
directory: tmp.path,
init: 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,
})
},
fn: async () => {
captured = undefined
ModelCache.clear("kilo")
@@ -122,17 +122,16 @@ test("ModelCache.clear removes cached entry so next fetch hits the network", asy
)
},
})
await Instance.provide({
await Auth.set("kilo", {
type: "oauth",
access: "token-clear-test",
refresh: "refresh-clear",
expires: Date.now() + 3600000,
accountId: "org-clear",
})
await WithInstance.provide({
directory: tmp.path,
init: async () => {
await Auth.set("kilo", {
type: "oauth",
access: "token-clear-test",
refresh: "refresh-clear",
expires: Date.now() + 3600000,
accountId: "org-clear",
})
},
fn: async () => {
// Populate cache
captured = undefined
@@ -22,8 +22,8 @@ describe("model info panel price formatting", () => {
expect(fmtCachedPrice({ input: 0, output: 0, cache: { read: 0, write: 0 } })).toBe("Free")
})
test("fmtCachedPrice returns N/A without cache read", () => {
expect(fmtCachedPrice({ input: 3, output: 15, cache: { read: 0, write: 0 } })).toBe("N/A")
test("fmtCachedPrice returns null without cache read", () => {
expect(fmtCachedPrice({ input: 3, output: 15, cache: { read: 0, write: 0 } })).toBeNull()
})
test("avgPrice uses cache weighted formula when cache read exists", () => {
@@ -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")
}),
),
)
}),
),
)
@@ -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"
@@ -21,7 +22,8 @@ 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 Config.invalidate()
await InstanceRuntime.disposeAllInstances()
})
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
@@ -8,13 +8,13 @@ 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 { tmpdir } from "../../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
import { ConfigProtection } from "../../../src/kilocode/permission/config-paths"
const runtime = ManagedRuntime.make(
@@ -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("\\", "/")
@@ -125,13 +125,13 @@ async function wait(count: number) {
}
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
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,6 +7,7 @@ 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"
@@ -21,7 +22,8 @@ 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 Config.invalidate()
await InstanceRuntime.disposeAllInstances()
})
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
@@ -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,6 +7,7 @@ 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"
@@ -21,7 +22,8 @@ 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 Config.invalidate()
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: {
@@ -369,7 +370,7 @@ describe("plan_exit detection", () => {
})
const session = await Session.get(seeded.sessionID)
const plan = Session.plan(session)
const plan = Session.plan(session, Instance.current)
await fs.mkdir(path.dirname(plan), { recursive: true })
await Bun.write(plan, "Do implementation step 1")
@@ -7,6 +7,7 @@ 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"
@@ -71,7 +72,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(() => {})
@@ -181,7 +182,7 @@ async function latestUser(sessionID: SessionID) {
}
async function sessions() {
return Array.fromAsync(Session.list())
return AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
}
async function waitQuestion(sessionID: string) {
@@ -296,6 +297,34 @@ describe("plan follow-up", () => {
await expect(pending).resolves.toBe("break")
}))
test("ask - Continue here option carries mode: code so VS Code picker updates immediately", () =>
withInstance(async () => {
const seeded = await seed({ text: "1. Build" })
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const item = await waitQuestion(seeded.sessionID)
expect(item).toBeDefined()
if (!item) return
const q = item.questions[0]
expect(q).toBeDefined()
if (!q) return
const continueOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_CONTINUE)
expect(continueOpt?.mode).toBe("code")
// Start new session should not carry a mode (it opens a new session — the
// current picker is irrelevant once the session switches).
const newOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_NEW_SESSION)
expect(newOpt?.mode).toBeUndefined()
await question.reject(item.id)
await expect(pending).resolves.toBe("break")
}))
test("ask - hides custom answer row on VS Code where the main prompt input handles typed replies", () =>
withInstance(async () => {
const prev = process.env.KILO_CLIENT
@@ -604,7 +633,7 @@ describe("plan follow-up", () => {
if (!newSessionID || !next) throw new Error("expected follow-up session")
expect(next.id).toBe(newSessionID)
expect(next.parentID).toBeUndefined()
const planPath = Session.plan(await Session.get(seeded.sessionID))
const planPath = Session.plan(await Session.get(seeded.sessionID), Instance.current)
const messages = await Session.messages({ sessionID: newSessionID })
const user = messages.find((item) => item.info.role === "user")
expect(user?.info.role).toBe("user")
@@ -673,12 +702,12 @@ describe("plan follow-up", () => {
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(),
})
@@ -698,7 +727,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(),
})
@@ -712,9 +741,9 @@ 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)),
fn: async () => Session.plan(await Session.get(seeded.sessionID), Instance.current),
})
const messages = await Session.messages({ sessionID: next.id })
const user = messages.find((item) => item.info.role === "user")
@@ -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)
@@ -70,11 +70,23 @@ 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 WithInstance.provide({
directory: tmp.path,
fn: async () => {
await save({ provider: { missing: null } } as any)
await expect(fs.access(path.join(tmp.path, ".kilo", "kilo.json"))).rejects.toThrow()
},
})
})
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)
@@ -93,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(),
})
@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test"
import { Session as SessionNs } from "@/session/session"
import type { Provider } from "@/provider/provider"
function createModel(opts: {
context: number
output: number
input?: number
cost?: Provider.Model["cost"]
npm?: string
}): Provider.Model {
return {
id: "test-model",
providerID: "test",
name: "Test",
limit: {
context: opts.context,
input: opts.input,
output: opts.output,
},
cost: opts.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: opts.npm ?? "@ai-sdk/anthropic" },
options: {},
} as Provider.Model
}
const baseUsage = {
inputTokens: 1_000_000,
outputTokens: 100_000,
totalTokens: 1_100_000,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
}
const model = () =>
createModel({
context: 100_000,
output: 32_000,
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
})
const kilo = { id: "kilo" } as Provider.Info
// Calculated cost for the `model()` + `baseUsage` pair: 1M input * $3 + 100k output * $15 = 3 + 1.5
const fallback = 3 + 1.5
describe("KiloSession.providerCost — Anthropic Messages / OpenAI Responses", () => {
test("uses usage.raw.cost_details.upstream_inference_cost for Anthropic Messages via OpenRouter", () => {
const result = SessionNs.getUsage({
model: model(),
provider: kilo,
usage: {
...baseUsage,
// `convertAnthropicUsage` copies the verbatim provider usage onto `raw`.
// Top-level `cost` is the OpenRouter fee and must be ignored.
raw: {
input_tokens: 1,
output_tokens: 1121,
cache_creation_input_tokens: 5385,
cache_read_input_tokens: 106831,
cost: 0.0057550875,
is_byok: true,
cost_details: {
upstream_inference_cost: 0.11510175,
},
},
},
})
expect(result.cost).toBe(0.11510175)
})
test("uses usage.raw.cost_details.upstream_inference_cost for OpenAI Responses via OpenRouter", () => {
const result = SessionNs.getUsage({
model: model(),
provider: kilo,
usage: {
...baseUsage,
// `convertOpenAIResponsesUsage` copies the verbatim provider usage onto `raw`.
raw: {
input_tokens: 622051,
input_tokens_details: { cached_tokens: 594944 },
output_tokens: 304,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 622355,
cost: 0.0439847,
is_byok: true,
cost_details: {
upstream_inference_cost: 0.879694,
upstream_inference_input_cost: 0.866014,
upstream_inference_output_cost: 0.01368,
},
},
},
})
expect(result.cost).toBe(0.879694)
})
test("ignores raw `cost` when no upstream_inference_cost is reported", () => {
const result = SessionNs.getUsage({
model: model(),
provider: kilo,
usage: {
...baseUsage,
raw: {
cost: 0.5,
// cost_details missing
},
},
})
expect(result.cost).toBe(fallback)
})
})
describe("KiloSession.providerCost — Vercel AI Gateway", () => {
test("uses metadata.gateway.marketCost", () => {
const result = SessionNs.getUsage({
model: model(),
provider: kilo,
usage: baseUsage,
metadata: {
gateway: {
// Strings, exactly as emitted by the AI Gateway. `cost` is the gateway fee,
// which Kilo doesn't pass on to end users — must be ignored.
cost: "0",
marketCost: "0.35349075",
},
},
})
expect(result.cost).toBe(0.35349075)
})
test("ignores metadata.gateway.cost when marketCost is missing", () => {
const result = SessionNs.getUsage({
model: model(),
provider: kilo,
usage: baseUsage,
metadata: {
gateway: {
cost: "0.123",
},
},
})
expect(result.cost).toBe(fallback)
})
})
describe("KiloSession.providerCost — fallback", () => {
test("falls back to calculated cost when no provider cost is reported", () => {
const result = SessionNs.getUsage({
model: model(),
provider: kilo,
usage: baseUsage,
// No metadata, no usage.raw — should fall back
})
expect(result.cost).toBe(fallback)
})
})
@@ -0,0 +1,101 @@
// kilocode_change - new file
// Verifies that:
// 1. ModelCache.failedProviders() surfaces providers that encountered errors.
// 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 * 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 { WithInstance } from "../../src/project/with-instance"
import { ModelCache } from "../../src/provider/model-cache"
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 WithInstance.provide({ directory: tmp.path, fn })
}
test("failedProviders returns empty array when no fetch has occurred", () => {
ModelCache.clear("kilo")
expect(ModelCache.failedProviders()).not.toContain("kilo")
})
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 },
},
},
}
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.getFailure("kilo")).toBeUndefined()
expect(ModelCache.failedProviders()).not.toContain("kilo")
})
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 })
})
test("clear removes failure state", async () => {
stubbedResult = { models: {}, error: { kind: "network" } }
ModelCache.clear("kilo")
await withInstance(() => ModelCache.fetch("kilo"))
expect(ModelCache.failedProviders()).toContain("kilo")
ModelCache.clear("kilo")
expect(ModelCache.failedProviders()).not.toContain("kilo")
expect(ModelCache.getFailure("kilo")).toBeUndefined()
})
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()
})
@@ -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")
@@ -0,0 +1,70 @@
/**
* Contract tests for Kilo-specific fields on QuestionOption / QuestionInfo.
*
* packages/opencode/src/question/index.ts is a shared upstream file.
* Two Kilo additions to the Option schema have been silently dropped by
* upstream merges more than once:
*
* 1. labelKey / descriptionKey — lost during the opencode v1.3.x
* effectify refactor (cec1255b36), restored in PR #9246.
* 2. mode — lost in the same merge cycle (c37f85386f + 5bb42b6bdb),
* restored in the ionized-emmental branch.
*
* The plan follow-up "Continue here" option relies on `mode: "code"` being
* present at the schema level so Effect Schema's decodeUnknownSync does not
* strip the field before the question is published via SSE, and so the
* generated SDK / OpenAPI spec expose the field to VS Code.
*
* These tests catch regressions at the source level, before a runtime test
* could even run.
*/
import { describe, test, expect } from "bun:test"
import { Schema } from "effect"
import fs from "node:fs"
import path from "node:path"
import { Option } from "../../src/question"
const SOURCE = path.resolve(import.meta.dir, "../../src/question/index.ts")
describe("QuestionOption schema — Kilo-specific field contract", () => {
test("Option class accepts and round-trips the mode field", () => {
const raw = { label: "Continue here", description: "Implement the plan in this session", mode: "code" }
const decoded = Schema.decodeUnknownSync(Option)(raw)
expect(decoded.mode).toBe("code")
})
test("mode is optional — Option without it decodes cleanly", () => {
const raw = { label: "Start new session", description: "Fresh session" }
const decoded = Schema.decodeUnknownSync(Option)(raw)
expect(decoded.mode).toBeUndefined()
})
test("Option class accepts and round-trips labelKey and descriptionKey", () => {
const raw = {
label: "Continue here",
description: "Implement the plan in this session",
labelKey: "plan.followup.answer.continue",
descriptionKey: "plan.followup.answer.continue.description",
}
const decoded = Schema.decodeUnknownSync(Option)(raw)
expect(decoded.labelKey).toBe("plan.followup.answer.continue")
expect(decoded.descriptionKey).toBe("plan.followup.answer.continue.description")
})
// Static source checks — guard the kilocode_change markers so a conflict
// resolution that drops the fields is caught immediately.
test("source declares mode as an optional field inside a kilocode_change block", () => {
const src = fs.readFileSync(SOURCE, "utf-8")
expect(src).toMatch(/kilocode_change start[^\n]*hint to UI clients/)
expect(src).toMatch(/mode:\s*Schema\.optional\(Schema\.String\)/)
expect(src).toMatch(/kilocode_change end/)
})
test("source declares labelKey and descriptionKey inside a kilocode_change block", () => {
const src = fs.readFileSync(SOURCE, "utf-8")
expect(src).toMatch(/kilocode_change start[^\n]*i18n keys/)
expect(src).toMatch(/labelKey:\s*Schema\.optional\(Schema\.String\)/)
expect(src).toMatch(/descriptionKey:\s*Schema\.optional\(Schema\.String\)/)
})
})
@@ -0,0 +1,177 @@
// kilocode_change - new file
import { afterEach, describe, expect, mock, test } from "bun:test"
type Event = {
type: string
properties: Record<string, unknown>
}
function feed<T>() {
const list: T[] = []
const wait: Array<() => void> = []
const state = { done: false }
return {
push(item: T) {
list.push(item)
while (wait.length) wait.shift()?.()
},
end() {
state.done = true
while (wait.length) wait.shift()?.()
},
async *stream() {
while (!state.done || list.length) {
if (list.length) {
yield list.shift() as T
continue
}
await new Promise<void>((resolve) => wait.push(resolve))
}
},
}
}
function task(child: string): Event {
return {
type: "message.part.updated",
properties: {
part: {
id: "prt_task",
type: "tool",
tool: "task",
sessionID: "ses_root",
state: {
status: "running",
input: {
description: "inspect bug",
prompt: "check child permissions",
subagent_type: "general",
},
metadata: {
sessionId: child,
},
time: { start: 0 },
},
},
},
}
}
function permission(id: string, sessionID: string): Event {
return {
type: "permission.asked",
properties: {
id,
sessionID,
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm *"],
},
}
}
function idle(): Event {
return {
type: "session.status",
properties: {
sessionID: "ses_root",
status: { type: "idle" },
},
}
}
function args() {
return {
_: [],
$0: "kilo",
message: ["hi"],
command: undefined,
continue: false,
session: "ses_root",
fork: false,
"cloud-fork": false,
cloudFork: false,
share: false,
model: undefined,
agent: undefined,
format: "json",
file: undefined,
title: undefined,
attach: "http://127.0.0.1:4096",
password: undefined,
dir: undefined,
port: undefined,
variant: undefined,
thinking: false,
auto: true,
"dangerously-skip-permissions": false,
dangerouslySkipPermissions: false,
"--": [],
}
}
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
afterEach(() => {
if (tty) {
Object.defineProperty(process.stdin, "isTTY", tty)
return
}
delete (process.stdin as { isTTY?: boolean }).isTTY
})
async function run(sdk: Record<string, unknown>) {
mock.module("@kilocode/sdk/v2", () => ({
createKiloClient: () => sdk,
}))
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
})
const key = JSON.stringify({ time: Date.now(), rand: Math.random() })
const { RunCommand } = await import(`../../src/cli/cmd/run?${key}`)
return RunCommand.handler(args() as never)
}
describe("cli run auto permissions", () => {
test("auto approves tracked subagent permissions and ignores unrelated sessions", async () => {
const q = feed<Event>()
const calls: Array<{ requestID: string; reply: string }> = []
const done = Promise.withResolvers<void>()
const sdk = {
config: {
get: async () => ({ data: { share: "manual" } }),
},
event: {
subscribe: async () => ({ stream: q.stream() }),
},
permission: {
reply: async (input: { requestID: string; reply: string }) => {
calls.push(input)
if (input.requestID === "perm_child") done.resolve()
return { data: true }
},
},
session: {
prompt: async () => {
q.push(task("ses_child"))
q.push(permission("perm_other", "ses_other"))
q.push(permission("perm_child", "ses_child"))
q.push(idle())
await Promise.race([done.promise, new Promise((resolve) => setTimeout(resolve, 25))])
q.end()
return { data: undefined }
},
},
}
await run(sdk)
expect(calls).toEqual([{ requestID: "perm_child", reply: "once" }])
})
})
@@ -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"
@@ -34,6 +34,13 @@ const baseCtx = {
} satisfies Tool.Context
describe("tool.semantic_search", () => {
test("describes code snippet results", async () => {
const tool = await initTool()
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 () => {
const tool = await initTool()
expect(rt.runPromise(tool.execute({ query: "" }, baseCtx))).rejects.toThrow("query is required")
@@ -41,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">> = []
@@ -82,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([])
@@ -103,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([
@@ -159,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,146 @@
import { describe, expect, test } from "bun:test"
import { OpenApi } from "effect/unstable/httpapi"
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)]),
)
}
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 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 = [
"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(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("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
@@ -0,0 +1,600 @@
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)))
},
raw: () => Effect.die("raw not implemented in test LLM"),
}),
),
}
}
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,15 +1,15 @@
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"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
function taskPart(input: { messageID: string; sessionID: string; childSessionID: string }): MessageV2.ToolPart {
@@ -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,23 +1,24 @@
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"
import { Session } from "../../src/session/session"
import { SessionTable } from "../../src/session/session.sql"
import { Database, eq } from "../../src/storage/db"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
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" })
@@ -36,7 +37,7 @@ describe("Kilo Session.list", () => {
db.update(SessionTable).set({ project_id: project }).where(eq(SessionTable.id, session.id)).run()
})
const sessions = [...Session.list({ directory: tmp.path })]
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list({ directory: tmp.path })))
const ids = sessions.map((item) => item.id)
expect(ids).toContain(session.id)
@@ -0,0 +1,129 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { Snapshot } from "../../src/snapshot"
const sessionID = SessionID.make("session")
const patch =
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n"
function blob(size: number) {
return "x".repeat(size)
}
function part(tool: string, metadata: Record<string, unknown>): MessageV2.Part {
return {
id: PartID.make(`p-${tool}`),
sessionID,
messageID: MessageID.make("m-assistant"),
type: "tool",
callID: `call-${tool}`,
tool,
state: {
status: "completed",
input: {},
output: "ok",
title: tool,
metadata,
time: { start: 0, end: 1 },
},
} as MessageV2.Part
}
describe("session message metadata stripping", () => {
test("keeps bounded edit filediff patches and strips heavy fields", () => {
const input = part("edit", {
diff: blob(200_000),
filediff: {
file: "a.ts",
patch,
before: blob(200_000),
after: blob(200_000),
additions: 1,
deletions: 1,
},
diagnostics: {},
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.diff).toBeUndefined()
expect(meta.filediff.before).toBeUndefined()
expect(meta.filediff.after).toBeUndefined()
expect(meta.filediff.patch).toBe(patch)
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
test("keeps bounded write filediff patches and strips heavy fields", () => {
const input = part("write", {
diff: blob(200_000),
filediff: {
file: "README.md",
patch,
before: blob(200_000),
after: blob(200_000),
additions: 1,
deletions: 1,
},
diagnostics: {},
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.diff).toBeUndefined()
expect(meta.filediff.before).toBeUndefined()
expect(meta.filediff.after).toBeUndefined()
expect(meta.filediff.patch).toBe(patch)
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
test("keeps bounded apply_patch per-file patches and strips heavy fields", () => {
const input = part("apply_patch", {
diff: blob(200_000),
files: [
{
filePath: "/tmp/a.ts",
relativePath: "a.ts",
type: "update",
patch,
before: blob(200_000),
after: blob(200_000),
additions: 1,
deletions: 1,
},
],
diagnostics: {},
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.diff).toBeUndefined()
expect(meta.files[0].before).toBeUndefined()
expect(meta.files[0].after).toBeUndefined()
expect(meta.files[0].patch).toBe(patch)
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
test("drops oversized tool patches from hydrated session metadata", () => {
const wide = `Index: a.ts\n--- a.ts\n+++ a.ts\n@@ -1,1 +1,1 @@\n-${blob(Snapshot.MAX_DIFF_SIZE)}\n+${blob(Snapshot.MAX_DIFF_SIZE)}\n`
const input = part("apply_patch", {
files: [
{
filePath: "/tmp/a.ts",
relativePath: "a.ts",
type: "update",
patch: wide,
additions: 1,
deletions: 1,
},
],
})
const stripped = MessageV2.stripPartMetadata(input) as Extract<MessageV2.Part, { type: "tool" }>
const meta = stripped.state.status === "completed" ? stripped.state.metadata : {}
expect(meta.files[0].patch).toBeUndefined()
expect(JSON.stringify(stripped).length).toBeLessThan(10_000)
})
})
@@ -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)
})
})
@@ -265,6 +265,80 @@ describe("session processor empty tool-calls", () => {
),
)
it.effect("treats provider finish errors without details as retryable API errors", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const test = yield* TestLLM
const processors = yield* SessionProcessor.Service
const session = yield* Session.Service
yield* test.reply(
{ type: "start" },
{ type: "start-step" } as LLM.Event,
{
type: "finish-step",
finishReason: "error",
usage: usage(),
providerMetadata: undefined,
} as LLM.Event,
{ type: "finish" } as LLM.Event,
)
const chat = yield* session.create({})
const parent = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: chat.id,
agent: "code",
model: ref,
time: { created: Date.now() },
})
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: chat.id,
parentID: parent.id,
mode: "code",
agent: "code",
path: { cwd: path.resolve(dir), root: path.resolve(dir) },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
time: { created: Date.now() },
}
yield* session.updateMessage(msg)
const mdl = model()
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})
const input: LLM.StreamInput = {
user: parent as MessageV2.User,
sessionID: chat.id,
model: mdl,
agent: { name: "code", mode: "primary", permission: [], options: {} } as any,
system: [],
messages: [],
tools: {},
}
const result = yield* handle.process(input)
expect(result).toBe("stop")
expect(handle.message.finish).toBe("error")
expect(handle.message.error?.name).toBe("APIError")
if (handle.message.error?.name !== "APIError") return
expect(handle.message.error.data.isRetryable).toBe(true)
expect(handle.message.error.data.message).toContain("provider ended the response with an error")
}),
{ git: true },
),
)
it.effect("adds generic warning when model stops after text length finish", () =>
provideTmpdirInstance(
(dir) =>
@@ -0,0 +1,83 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import { KiloSessionProcessor } from "../../src/kilocode/session/processor"
import type { MessageV2 } from "../../src/session/message-v2"
const REVIEW_COMMANDS = ["review", "local-review", "local-review-uncommitted"] as const
const expected = (command: (typeof REVIEW_COMMANDS)[number]) => ({
mode: "review" as const,
feature: "code_reviews" as const,
command,
})
describe("KiloSessionProcessor.reviewTelemetry", () => {
for (const command of REVIEW_COMMANDS) {
test(`returns telemetry for ${command}`, () => {
expect(KiloSessionProcessor.reviewTelemetry(command)).toEqual(expected(command))
})
}
test("returns undefined for an unrelated command", () => {
expect(KiloSessionProcessor.reviewTelemetry("init")).toBeUndefined()
})
test("returns undefined for an undefined command", () => {
expect(KiloSessionProcessor.reviewTelemetry(undefined)).toBeUndefined()
})
})
describe("KiloSessionProcessor.markReviewTelemetry", () => {
for (const command of REVIEW_COMMANDS) {
test(`stamps text parts with telemetry for ${command}`, () => {
const parts: Array<{ type: string; metadata?: Record<string, unknown> }> = [
{ type: "text", metadata: { existing: "keep" } },
{ type: "file" },
{ type: "text" },
]
const tel = KiloSessionProcessor.markReviewTelemetry(parts, command)
expect(tel).toEqual(expected(command))
expect(parts[0].metadata).toEqual({ existing: "keep", ...expected(command) })
expect(parts[1].metadata).toBeUndefined()
expect(parts[2].metadata).toEqual({ ...expected(command) })
})
}
test("does nothing for an unrelated command", () => {
const parts: Array<{ type: string; metadata?: Record<string, unknown> }> = [{ type: "text" }]
expect(KiloSessionProcessor.markReviewTelemetry(parts, "init")).toBeUndefined()
expect(parts[0].metadata).toBeUndefined()
})
test("does nothing for an undefined command", () => {
const parts: Array<{ type: string; metadata?: Record<string, unknown> }> = [{ type: "text" }]
expect(KiloSessionProcessor.markReviewTelemetry(parts, undefined)).toBeUndefined()
expect(parts[0].metadata).toBeUndefined()
})
})
describe("KiloSessionProcessor.extractReviewTelemetry", () => {
for (const command of REVIEW_COMMANDS) {
test(`recovers ${command} telemetry from marked text parts`, () => {
const parts: Array<{ type: string; metadata?: Record<string, unknown> }> = [{ type: "text" }]
KiloSessionProcessor.markReviewTelemetry(parts, command)
const round = KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])
expect(round).toEqual(expected(command))
})
}
test("returns undefined when parts have no review metadata", () => {
const parts: Array<{ type: string; metadata?: Record<string, unknown> }> = [
{ type: "text" },
{ type: "text", metadata: { foo: "bar" } },
]
expect(KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])).toBeUndefined()
})
test("returns undefined when command in metadata is unknown", () => {
const parts: Array<{ type: string; metadata?: Record<string, unknown> }> = [
{ type: "text", metadata: { mode: "review", feature: "code_reviews", command: "unknown" } },
]
expect(KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])).toBeUndefined()
})
})

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