mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into local-review-base-picker
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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] } },
|
||||
)
|
||||
@@ -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])
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
+12
-12
@@ -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)
|
||||
})
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user