mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
refactor: merge main
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.
|
||||
|
||||
@@ -22,9 +22,10 @@ const _typeCheck: _AssertAgentImplementsACPAgent = true
|
||||
describe("acp.agent interface compliance", () => {
|
||||
// Extract method names from the ACPAgent interface type
|
||||
type ACPAgentMethods = keyof ACPAgent
|
||||
type ACPRuntimeMethod = ACPAgentMethods | "resumeSession" | "closeSession" // kilocode_change
|
||||
|
||||
// Methods that the SDK's router explicitly checks for at runtime
|
||||
const sdkCheckedMethods: ACPAgentMethods[] = [
|
||||
const sdkCheckedMethods: ACPRuntimeMethod[] = [ // kilocode_change
|
||||
// Required
|
||||
"initialize",
|
||||
"newSession",
|
||||
@@ -34,10 +35,11 @@ describe("acp.agent interface compliance", () => {
|
||||
"loadSession",
|
||||
"setSessionMode",
|
||||
"authenticate",
|
||||
// Unstable - SDK checks these with unstable_ prefix
|
||||
// Capability-gated methods checked by the SDK router
|
||||
"listSessions",
|
||||
"resumeSession",
|
||||
"closeSession",
|
||||
"unstable_forkSession",
|
||||
"unstable_resumeSession",
|
||||
"unstable_setSessionModel",
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@kilocode/sdk/v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
@@ -58,6 +59,7 @@ function toolEvent(
|
||||
raw: opts.raw,
|
||||
}
|
||||
const payload: EventMessagePartUpdated = {
|
||||
id: `evt_${opts.callID}`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
@@ -264,7 +266,7 @@ function createFakeAgent() {
|
||||
describe("acp.agent event subscription", () => {
|
||||
test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, updates, stop } = createFakeAgent()
|
||||
@@ -299,7 +301,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("does not emit user_message_chunk for live prompt parts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
@@ -339,7 +341,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, chunks, stop } = createFakeAgent()
|
||||
@@ -391,7 +393,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("does not create additional event subscriptions on repeated loadSession()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, calls, stop } = createFakeAgent()
|
||||
@@ -413,7 +415,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("permission.asked events are handled and replied", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const permissionReplies: string[] = []
|
||||
@@ -452,7 +454,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("permission prompt on session A does not block message updates for session B", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const permissionReplies: string[] = []
|
||||
@@ -539,7 +541,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
@@ -573,7 +575,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("emits synthetic pending before first running update for any tool", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
@@ -618,7 +620,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("does not emit duplicate synthetic pending after replayed running tool", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent()
|
||||
@@ -677,7 +679,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("clears bash snapshot marker on pending state", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -23,7 +23,7 @@ afterEach(async () => {
|
||||
|
||||
test("returns default native agents when no config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await load(tmp.path, (svc) => svc.list())
|
||||
@@ -45,7 +45,7 @@ test("returns default native agents when no config", async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
test("code agent has correct default properties", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const code = await load(tmp.path, (svc) => svc.get("code"))
|
||||
@@ -62,10 +62,10 @@ test("code agent has correct default properties", async () => {
|
||||
// kilocode_change start - ask agent tests
|
||||
test("ask agent has correct default properties", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = await Agent.get("ask")
|
||||
const ask = await load(tmp.path, (svc) => svc.get("ask"))
|
||||
expect(ask).toBeDefined()
|
||||
expect(ask?.mode).toBe("primary")
|
||||
expect(ask?.native).toBe(true)
|
||||
@@ -96,10 +96,10 @@ test("ask agent denies edit/write/bash even when user config adds a specific edi
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = await Agent.get("ask")
|
||||
const ask = await load(tmp.path, (svc) => svc.get("ask"))
|
||||
expect(ask).toBeDefined()
|
||||
// user config must not leak edit capability into ask mode — even for the
|
||||
// specific path the user allowed, ask mode must still deny it
|
||||
@@ -122,7 +122,7 @@ test("ask agent denies edit/write/bash even when user config adds a specific edi
|
||||
// kilocode_change start
|
||||
test("plan agent denies edits except .kilo/plans/* and .opencode/plans/*", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const plan = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
@@ -143,7 +143,7 @@ test("plan agent user config allows cannot re-enable non-plan edits", async () =
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const plan = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
@@ -158,7 +158,7 @@ test("plan agent user config allows cannot re-enable non-plan edits", async () =
|
||||
|
||||
test("explore agent denies edit and write", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -174,7 +174,7 @@ test("explore agent denies edit and write", async () => {
|
||||
test("explore agent asks for external directories and allows whitelisted external paths", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncate")
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -190,7 +190,7 @@ test("explore agent asks for external directories and allows whitelisted externa
|
||||
|
||||
test("general agent denies todo tools", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const general = await load(tmp.path, (svc) => svc.get("general"))
|
||||
@@ -204,7 +204,7 @@ test("general agent denies todo tools", async () => {
|
||||
|
||||
test("compaction agent denies all permissions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const compaction = await load(tmp.path, (svc) => svc.get("compaction"))
|
||||
@@ -230,7 +230,7 @@ test("custom agent from config creates new agent", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent"))
|
||||
@@ -261,7 +261,7 @@ test("custom agent config overrides native agent properties", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -286,7 +286,7 @@ test("agent disable removes agent from list", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -314,7 +314,7 @@ test("agent permission config merges with defaults", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -337,7 +337,7 @@ test("global permission config applies to all agents", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -360,7 +360,7 @@ test("agent steps/maxSteps config sets steps property", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const code = await load(tmp.path, (svc) => svc.get("code")) // kilocode_change
|
||||
@@ -379,7 +379,7 @@ test("agent mode can be overridden", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -396,7 +396,7 @@ test("agent name can be overridden", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -415,7 +415,7 @@ test("agent prompt can be set from config", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -437,7 +437,7 @@ test("unknown agent properties are placed into options", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -464,7 +464,7 @@ test("agent options merge correctly", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -491,7 +491,7 @@ test("multiple custom agents can be defined", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agentA = await load(tmp.path, (svc) => svc.get("agent_a"))
|
||||
@@ -520,7 +520,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name)
|
||||
@@ -532,7 +532,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
|
||||
|
||||
test("Agent.get returns undefined for non-existent agent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist"))
|
||||
@@ -543,7 +543,7 @@ test("Agent.get returns undefined for non-existent agent", async () => {
|
||||
|
||||
test("default permission includes doom_loop and external_directory as ask", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -557,7 +557,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn
|
||||
|
||||
test("webfetch is allowed by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -583,7 +583,7 @@ test("legacy tools config converts to permissions", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -609,7 +609,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () =>
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -629,7 +629,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -644,7 +644,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
||||
|
||||
test("global tmp directory children are allowed for external_directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -671,7 +671,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -696,7 +696,7 @@ test("explicit Truncate.GLOB deny is respected", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change start - renamed from "build" to "code"
|
||||
@@ -730,7 +730,7 @@ description: Permission skill.
|
||||
process.env.KILO_TEST_HOME = tmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -746,7 +746,7 @@ description: Permission skill.
|
||||
|
||||
test("defaultAgent returns build when no default_agent config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -761,7 +761,7 @@ test("defaultAgent respects default_agent config set to plan", async () => {
|
||||
default_agent: "plan",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -781,7 +781,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -796,7 +796,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => {
|
||||
default_agent: "explore",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent')
|
||||
@@ -810,7 +810,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () =
|
||||
default_agent: "compaction",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden')
|
||||
@@ -824,7 +824,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn
|
||||
default_agent: "does_not_exist",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow(
|
||||
@@ -846,7 +846,7 @@ test("defaultAgent returns plan when code is disabled and default_agent not set"
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -870,7 +870,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change - all primary agents are disabled
|
||||
@@ -882,11 +882,16 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
|
||||
// kilocode_change start - Backward compatibility tests for "build" -> "code" rename
|
||||
test("Agent.get('build') returns code agent for backward compatibility", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
const code = await Agent.get("code")
|
||||
const [build, code] = await load(tmp.path, (svc) =>
|
||||
Effect.gen(function* () {
|
||||
const build = yield* svc.get("build")
|
||||
const code = yield* svc.get("code")
|
||||
return [build, code] as const
|
||||
}),
|
||||
)
|
||||
expect(build).toBeDefined()
|
||||
expect(build).toBe(code)
|
||||
expect(build?.name).toBe("code")
|
||||
@@ -905,10 +910,10 @@ test("agent.build config applies to code agent for backward compatibility", asyn
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const code = await Agent.get("code")
|
||||
const code = await load(tmp.path, (svc) => svc.get("code"))
|
||||
expect(code).toBeDefined()
|
||||
expect(code?.temperature).toBe(0.8)
|
||||
expect(code?.color).toBe("#00FF00")
|
||||
@@ -922,10 +927,10 @@ test("default_agent: 'build' returns code agent for backward compatibility", asy
|
||||
default_agent: "build",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await Agent.defaultAgent()
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
expect(agent).toBe("code")
|
||||
},
|
||||
})
|
||||
@@ -939,12 +944,12 @@ test("agent.build disable removes code agent for backward compatibility", async
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const code = await Agent.get("code")
|
||||
const code = await load(tmp.path, (svc) => svc.get("code"))
|
||||
expect(code).toBeUndefined()
|
||||
const agents = await Agent.list()
|
||||
const agents = await load(tmp.path, (svc) => svc.list())
|
||||
const names = agents.map((a) => a.name)
|
||||
expect(names).not.toContain("code")
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
|
||||
|
||||
// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin
|
||||
// up — those services hang during scope teardown on Windows and aren't needed
|
||||
// to verify plugin → config hook → Agent.list.
|
||||
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer))
|
||||
|
||||
it.instance(
|
||||
"plugin-registered agents appear in Agent.list",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Plugin.Service.use((p) => p.init())
|
||||
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||
const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name)
|
||||
expect(added?.description).toBe(PLUGIN_AGENT.description)
|
||||
expect(added?.mode).toBe(PLUGIN_AGENT.mode)
|
||||
}),
|
||||
{ config: { plugin: [pluginUrl] } },
|
||||
)
|
||||
@@ -2,9 +2,8 @@ import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = {
|
||||
@@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node)
|
||||
const it = testEffect(live)
|
||||
|
||||
describe("Bus (Effect-native)", () => {
|
||||
it.live("publish + subscribe stream delivers events", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
it.instance("publish + subscribe stream delivers events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
yield* Deferred.await(done)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
}),
|
||||
),
|
||||
expect(received).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscribe filters by event type", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pings: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
it.instance("subscribe filters by event type", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pings: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(pings).toEqual([42])
|
||||
}),
|
||||
),
|
||||
expect(pings).toEqual([42])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscribeAll receives all types", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const types: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
it.instance("subscribeAll receives all types", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const types: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||
Effect.sync(() => {
|
||||
types.push(evt.type)
|
||||
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||
Effect.sync(() => {
|
||||
types.push(evt.type)
|
||||
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain("test.effect.pong")
|
||||
}),
|
||||
),
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain("test.effect.pong")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("multiple subscribers each receive the event", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
it.instance("multiple subscribers each receive the event", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(doneA)
|
||||
yield* Deferred.await(doneB)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(doneA)
|
||||
yield* Deferred.await(doneB)
|
||||
|
||||
expect(a).toEqual([99])
|
||||
expect(b).toEqual([99])
|
||||
}),
|
||||
),
|
||||
expect(a).toEqual([99])
|
||||
expect(b).toEqual([99])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return Instance.provide({ directory, fn })
|
||||
return WithInstance.provide({ directory, fn })
|
||||
}
|
||||
|
||||
describe("Bus integration: acquireRelease subscriber pattern", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const TestEvent = {
|
||||
@@ -11,7 +11,7 @@ const TestEvent = {
|
||||
}
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return Instance.provide({ directory, fn })
|
||||
return WithInstance.provide({ directory, fn })
|
||||
}
|
||||
|
||||
describe("Bus", () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
|
||||
|
||||
describe("recentConnectedWorkspaces", () => {
|
||||
test("returns unique connected workspaces after filtering missing and inactive entries", () => {
|
||||
const workspaces = [
|
||||
{ id: "wrk_a", name: "alpha" },
|
||||
{ id: "wrk_b", name: "beta" },
|
||||
{ id: "wrk_c", name: "gamma" },
|
||||
{ id: "wrk_d", name: "delta" },
|
||||
{ id: "wrk_e", name: "epsilon" },
|
||||
]
|
||||
const status = {
|
||||
wrk_a: "connected",
|
||||
wrk_b: "disconnected",
|
||||
wrk_c: "error",
|
||||
wrk_d: "connected",
|
||||
wrk_e: "connected",
|
||||
} as const
|
||||
|
||||
const { recent } = recentConnectedWorkspaces({
|
||||
sessions: [
|
||||
{ time: { updated: 900 } },
|
||||
{ workspaceID: "wrk_b", time: { updated: 800 } },
|
||||
{ workspaceID: "wrk_a", time: { updated: 700 } },
|
||||
{ workspaceID: "wrk_a", time: { updated: 600 } },
|
||||
{ workspaceID: "wrk_missing", time: { updated: 500 } },
|
||||
{ workspaceID: "wrk_c", time: { updated: 400 } },
|
||||
{ workspaceID: "wrk_d", time: { updated: 300 } },
|
||||
{ workspaceID: "wrk_e", time: { updated: 200 } },
|
||||
],
|
||||
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
|
||||
status: (workspaceID) => status[workspaceID as keyof typeof status],
|
||||
})
|
||||
|
||||
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
|
||||
})
|
||||
|
||||
test("omits the active workspace before limiting recent workspaces", () => {
|
||||
const workspaces = [
|
||||
{ id: "wrk_a", name: "alpha" },
|
||||
{ id: "wrk_b", name: "beta" },
|
||||
{ id: "wrk_c", name: "gamma" },
|
||||
{ id: "wrk_d", name: "delta" },
|
||||
]
|
||||
|
||||
const { recent, hasMore } = recentConnectedWorkspaces({
|
||||
sessions: [
|
||||
{ workspaceID: "wrk_a", time: { updated: 400 } },
|
||||
{ workspaceID: "wrk_b", time: { updated: 300 } },
|
||||
{ workspaceID: "wrk_c", time: { updated: 200 } },
|
||||
{ workspaceID: "wrk_d", time: { updated: 100 } },
|
||||
],
|
||||
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
|
||||
status: () => "connected",
|
||||
limit: 3,
|
||||
omitWorkspaceID: "wrk_a",
|
||||
})
|
||||
|
||||
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"])
|
||||
expect(hasMore).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider"
|
||||
|
||||
describe("providerOptions", () => {
|
||||
test("includes a synthetic Other option for custom providers", () => {
|
||||
expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({
|
||||
title: "Other",
|
||||
description: "Custom provider",
|
||||
category: "Providers",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not use Other as the generic provider category", () => {
|
||||
expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers")
|
||||
})
|
||||
|
||||
test("does not collide with a configured provider named other", () => {
|
||||
const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value)
|
||||
expect(new Set(values).size).toBe(values.length)
|
||||
})
|
||||
|
||||
test("normalizes and validates custom provider ids", () => {
|
||||
expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider")
|
||||
expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider")
|
||||
expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider")
|
||||
expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined()
|
||||
expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
|
||||
import { ToastProvider } from "../../../../src/cli/cmd/tui/ui/toast" // kilocode_change
|
||||
import { Instance } from "../../../../src/project/instance" // kilocode_change
|
||||
import { WithInstance } from "../../../../src/project/with-instance" // kilocode_change
|
||||
import { disposeAllInstances, tmpdir } from "../../../fixture/fixture"
|
||||
|
||||
const worktree = "/tmp/opencode"
|
||||
@@ -138,7 +138,7 @@ describe("tui sync", () => {
|
||||
await using tmp = await tmpdir()
|
||||
Global.Path.state = tmp.path
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
const { app, kv, sync, session } = await Instance.provide({ directory: tmp.path, fn: mount }) // kilocode_change
|
||||
const { app, kv, sync, session } = await WithInstance.provide({ directory: tmp.path, fn: mount }) // kilocode_change
|
||||
|
||||
try {
|
||||
expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
// Regression for PR #25522: when an effectCmd handler does
|
||||
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
|
||||
// the inner runPromise creates a fresh fiber after `await` whose Effect context
|
||||
// has lost the outer InstanceRef. Services that read `InstanceState.context`
|
||||
// then fall back to `Instance.current` ALS, which must be installed at the JS
|
||||
// callback boundary (Node ALS persists across awaits, Effect's fiber context
|
||||
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
|
||||
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
|
||||
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
|
||||
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
|
||||
// fail it. This grep guards the actual production callsite.
|
||||
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
|
||||
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
|
||||
expect(source).toContain("Instance.restore(ctx")
|
||||
})
|
||||
|
||||
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
|
||||
await using dir = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.promise(async () => {
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const current = await Effect.runPromise(
|
||||
Effect.sync(() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
expect(current?.directory).toBe(dir.path)
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,37 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { parseGitHubRemote } from "../../src/cli/cmd/github"
|
||||
|
||||
// kilocode_change start: rebrand fixtures off upstream repo path
|
||||
test("parses https URL with .git suffix", () => {
|
||||
expect(parseGitHubRemote("https://github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
|
||||
expect(parseGitHubRemote("https://github.com/Kilo-Org/kilocode.git")).toEqual({
|
||||
owner: "Kilo-Org",
|
||||
repo: "kilocode",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses https URL without .git suffix", () => {
|
||||
expect(parseGitHubRemote("https://github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
|
||||
expect(parseGitHubRemote("https://github.com/Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
|
||||
})
|
||||
|
||||
test("parses git@ URL with .git suffix", () => {
|
||||
expect(parseGitHubRemote("git@github.com:sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
|
||||
expect(parseGitHubRemote("git@github.com:Kilo-Org/kilocode.git")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
|
||||
})
|
||||
|
||||
test("parses git@ URL without .git suffix", () => {
|
||||
expect(parseGitHubRemote("git@github.com:sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
|
||||
expect(parseGitHubRemote("git@github.com:Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
|
||||
})
|
||||
|
||||
test("parses ssh:// URL with .git suffix", () => {
|
||||
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
|
||||
expect(parseGitHubRemote("ssh://git@github.com/Kilo-Org/kilocode.git")).toEqual({
|
||||
owner: "Kilo-Org",
|
||||
repo: "kilocode",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses ssh:// URL without .git suffix", () => {
|
||||
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
|
||||
expect(parseGitHubRemote("ssh://git@github.com/Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("parses http URL", () => {
|
||||
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
|
||||
|
||||
@@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) {
|
||||
} as unknown as typeof WebSocket
|
||||
}
|
||||
|
||||
function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") {
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "selection_changed",
|
||||
params: {
|
||||
text,
|
||||
filePath,
|
||||
selection: {
|
||||
start: { line: 1, character: 1 },
|
||||
end: { line: 1, character: 4 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function expectedSelection(filePath: string, text = "foo") {
|
||||
return {
|
||||
filePath,
|
||||
source: "websocket" as const,
|
||||
ranges: [
|
||||
{
|
||||
text,
|
||||
selection: {
|
||||
start: { line: 1, character: 1 },
|
||||
end: { line: 1, character: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
test("useEditorContext reconnect switches editor server by session directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const startupDirectory = path.join(tmp.path, "startup")
|
||||
@@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a
|
||||
await nextTick()
|
||||
|
||||
expect(firstSocket.closed).toBeFalse()
|
||||
sendSelection(firstSocket, path.join(startupDirectory, "file.ts"))
|
||||
|
||||
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||
expect(mounted.editor.labelState()).toBe("pending")
|
||||
|
||||
mounted.editor.reconnect(sessionDirectory)
|
||||
await nextTick()
|
||||
|
||||
expect(firstSocket.closed).toBeTrue()
|
||||
expect(secondSocket.closed).toBeFalse()
|
||||
expect(mounted.editor.selection()).toBeUndefined()
|
||||
expect(mounted.editor.labelState()).toBe("none")
|
||||
|
||||
mounted.dispose()
|
||||
})
|
||||
@@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => {
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
test("useEditorContext resets selection when reconnecting", async () => {
|
||||
test("useEditorContext clears selection when reconnecting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const startupDirectory = path.join(tmp.path, "startup")
|
||||
const ideDirectory = path.join(tmp.path, ".claude", "ide")
|
||||
@@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "selection_changed",
|
||||
params: {
|
||||
text: "foo",
|
||||
filePath: path.join(startupDirectory, "file.ts"),
|
||||
selection: {
|
||||
start: { line: 1, character: 1 },
|
||||
end: { line: 1, character: 4 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
sendSelection(socket, path.join(startupDirectory, "file.ts"))
|
||||
|
||||
expect(mounted.editor.connected()).toBeTrue()
|
||||
expect(mounted.editor.server()).toEqual({
|
||||
protocolVersion: "2025-11-25",
|
||||
serverInfo: { name: "test", version: "0.0.0" },
|
||||
})
|
||||
expect(mounted.editor.selection()).toEqual({
|
||||
filePath: path.join(startupDirectory, "file.ts"),
|
||||
source: "websocket",
|
||||
ranges: [
|
||||
{
|
||||
text: "foo",
|
||||
selection: {
|
||||
start: { line: 1, character: 1 },
|
||||
end: { line: 1, character: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||
expect(mounted.editor.labelState()).toBe("pending")
|
||||
mounted.editor.markSelectionSent()
|
||||
expect(mounted.editor.labelState()).toBe("sent")
|
||||
|
||||
mounted.editor.reconnect(startupDirectory)
|
||||
|
||||
expect(socket.closed).toBeFalse()
|
||||
expect(mounted.editor.connected()).toBeTrue()
|
||||
expect(mounted.editor.selection()).toBeUndefined()
|
||||
expect(mounted.editor.labelState()).toBe("none")
|
||||
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
test("useEditorContext preserves selection for the next reconnect when requested", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const startupDirectory = path.join(tmp.path, "startup")
|
||||
const ideDirectory = path.join(tmp.path, ".claude", "ide")
|
||||
await mkdir(startupDirectory, { recursive: true })
|
||||
await mkdir(ideDirectory, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(ideDirectory, "3001.lock"),
|
||||
JSON.stringify({
|
||||
transport: "ws",
|
||||
workspaceFolders: [startupDirectory],
|
||||
}),
|
||||
)
|
||||
|
||||
process.env.CLAUDE_CODE_SSE_PORT = undefined
|
||||
process.env.KILO_EDITOR_SSE_PORT = undefined
|
||||
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
|
||||
spyOn(os, "homedir").mockImplementation(() => tmp.path)
|
||||
const socket = new FakeWebSocket("ws://127.0.0.1:3001")
|
||||
|
||||
const mounted = mountEditorContext(createWebSocketImpl(socket))
|
||||
await nextTick()
|
||||
|
||||
sendSelection(socket, path.join(startupDirectory, "file.ts"))
|
||||
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||
|
||||
mounted.editor.markSelectionSent()
|
||||
mounted.editor.preserveSelectionFromNewSession()
|
||||
mounted.editor.reconnect(startupDirectory)
|
||||
|
||||
expect(socket.closed).toBeFalse()
|
||||
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||
expect(mounted.editor.labelState()).toBe("sent")
|
||||
|
||||
mounted.editor.reconnect(startupDirectory)
|
||||
|
||||
expect(mounted.editor.selection()).toBeUndefined()
|
||||
expect(mounted.editor.labelState()).toBe("none")
|
||||
|
||||
mounted.dispose()
|
||||
})
|
||||
|
||||
@@ -179,7 +179,8 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
test(
|
||||
// kilocode_change - skipped flaky test on Windows #9496
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"times out hanging plugin cleanup on dispose",
|
||||
async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string })
|
||||
|
||||
function vcs(branch: string): Event {
|
||||
return {
|
||||
id: `evt_vcs_${branch}`,
|
||||
type: "vcs.branch.updated",
|
||||
properties: {
|
||||
branch,
|
||||
@@ -34,6 +35,7 @@ function vcs(branch: string): Event {
|
||||
|
||||
function update(version: string): Event {
|
||||
return {
|
||||
id: `evt_update_${version}`,
|
||||
type: "installation.update-available",
|
||||
properties: {
|
||||
version,
|
||||
|
||||
@@ -7,13 +7,15 @@ import { ConfigParse } from "../../src/config/parse"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -41,6 +43,12 @@ const emptyAuth = Layer.mock(Auth.Service)({
|
||||
|
||||
const testFlock = EffectFlock.defaultLayer
|
||||
|
||||
const noopNpm = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
add: () => Effect.die("not implemented"),
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
@@ -48,7 +56,7 @@ const layer = Config.layer.pipe(
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
const it = testEffect(layer)
|
||||
@@ -57,11 +65,23 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe
|
||||
const save = (config: Config.Info) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const saveGlobal = (config: Config.Info) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = (wait = false) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
||||
Effect.map((result) => result.info),
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
),
|
||||
)
|
||||
const clear = async (wait = false) => {
|
||||
await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
if (wait) await InstanceRuntime.disposeAllInstances()
|
||||
}
|
||||
const listDirs = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
// kilocode_change start
|
||||
const warnings = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
// kilocode_change end
|
||||
const ready = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
|
||||
@@ -98,7 +118,7 @@ async function check(map: (dir: string) => string) {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
snapshot: false,
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: map(tmp.path),
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
@@ -108,7 +128,7 @@ async function check(map: (dir: string) => string) {
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await disposeAllInstances()
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear()
|
||||
}
|
||||
@@ -116,7 +136,7 @@ async function check(map: (dir: string) => string) {
|
||||
|
||||
test("loads config with defaults when no files exist", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -135,7 +155,7 @@ test("loads JSON config file", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -154,7 +174,7 @@ test("loads shell config field", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -173,7 +193,7 @@ test("updates config and preserves empty shell sentinel", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await save({ shell: "" })
|
||||
@@ -252,7 +272,7 @@ test("loads formatter boolean config", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -270,7 +290,7 @@ test("loads lsp boolean config", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -307,7 +327,7 @@ test("ignores legacy tui keys in opencode config", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -332,7 +352,7 @@ test("loads JSONC config file", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -360,7 +380,7 @@ test("jsonc overrides json in the same directory", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -370,6 +390,7 @@ test("jsonc overrides json in the same directory", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
test("prefers .kilo directory config over legacy .kilocode", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -390,14 +411,15 @@ test("prefers .kilo directory config over legacy .kilocode", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const config = await load()
|
||||
expect(config.model).toBe("new/model")
|
||||
},
|
||||
})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("handles environment variable substitution", async () => {
|
||||
const originalEnv = process.env["TEST_VAR"]
|
||||
@@ -412,7 +434,7 @@ test("handles environment variable substitution", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -444,7 +466,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -510,6 +532,7 @@ test("resolves env templates in account config with account token", async () =>
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(fakeAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -520,7 +543,7 @@ test("resolves env templates in account config with account token", async () =>
|
||||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise)
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
if (originalControlToken !== undefined) {
|
||||
process.env["KILO_CONSOLE_TOKEN"] = originalControlToken
|
||||
@@ -540,7 +563,7 @@ test("handles file inclusion substitution", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -559,7 +582,7 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -568,6 +591,7 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
test("validates config schema and reports warning on invalid fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -577,33 +601,36 @@ test("validates config schema and reports warning on invalid fields", async () =
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change - invalid schema surfaces as warnings, not a throw
|
||||
// invalid schema surfaces as warnings, not a throw
|
||||
await load()
|
||||
const warnings = await Config.warnings()
|
||||
expect(warnings.length).toBeGreaterThan(0)
|
||||
const issues = await warnings()
|
||||
expect(issues.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start
|
||||
test("reports warning for invalid JSON", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "kilo.json"), "{ invalid json }")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// kilocode_change - invalid JSON surfaces as a warning, not a throw
|
||||
// invalid JSON surfaces as a warning, not a throw
|
||||
await load()
|
||||
const warnings = await Config.warnings()
|
||||
expect(warnings.length).toBeGreaterThan(0)
|
||||
const issues = await warnings()
|
||||
expect(issues.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("handles agent configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -620,7 +647,7 @@ test("handles agent configuration", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -651,7 +678,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -681,7 +708,7 @@ test("handles command configuration", async () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -706,7 +733,7 @@ test("migrates autoshare to share field", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -733,7 +760,7 @@ test("migrates mode field to agent field", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -765,7 +792,7 @@ Test agent prompt`,
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -798,7 +825,7 @@ Ordered permissions`,
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -836,7 +863,7 @@ Nested agent prompt`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -885,7 +912,7 @@ Nested command template`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -930,7 +957,7 @@ Nested command template`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -948,6 +975,7 @@ Nested command template`,
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
test("prefers .kilo commands over legacy .kilocode commands", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -968,10 +996,10 @@ Hello from new command`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const config = await load()
|
||||
|
||||
expect(config.command?.["hello"]).toEqual({
|
||||
description: "New command",
|
||||
@@ -980,10 +1008,11 @@ Hello from new command`,
|
||||
},
|
||||
})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("gets config directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const dirs = await listDirs()
|
||||
@@ -1013,7 +1042,7 @@ test("does not try to install dependencies in read-only KILO_CONFIG_DIR", async
|
||||
process.env.KILO_CONFIG_DIR = tmp.extra
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await load()
|
||||
@@ -1037,11 +1066,6 @@ test("installs dependencies in writable KILO_CONFIG_DIR", async () => {
|
||||
const prev = process.env.KILO_CONFIG_DIR
|
||||
process.env.KILO_CONFIG_DIR = tmp.extra
|
||||
|
||||
const noopNpm = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
add: () => Effect.die("not implemented"),
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const testLayer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
@@ -1053,7 +1077,7 @@ test("installs dependencies in writable KILO_CONFIG_DIR", async () => {
|
||||
)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
|
||||
@@ -1112,7 +1136,7 @@ test("resolves scoped npm plugins in config", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1150,7 +1174,7 @@ test("merges plugin arrays from global and local configs", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1186,7 +1210,7 @@ Helper subagent prompt`,
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1225,7 +1249,7 @@ test("merges instructions arrays from global and local configs", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1264,7 +1288,7 @@ test("deduplicates duplicate instructions from global and local configs", async
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1309,7 +1333,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1358,7 +1382,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
@@ -1399,7 +1423,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1430,7 +1454,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1460,7 +1484,7 @@ test("migrates legacy write tool to edit permission", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1492,7 +1516,7 @@ test("managed settings override user settings", async () => {
|
||||
share: "disabled",
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1520,7 +1544,7 @@ test("managed settings override project settings", async () => {
|
||||
disabled_providers: ["openai"],
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1540,7 +1564,7 @@ test("missing managed settings file is not an error", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1567,7 +1591,7 @@ test("migrates legacy edit tool to edit permission", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1596,7 +1620,7 @@ test("migrates legacy patch tool to edit permission", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1628,7 +1652,7 @@ test("migrates mixed legacy tools config", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1663,7 +1687,7 @@ test("merges legacy tools with existing permission config", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1716,7 +1740,7 @@ test("permission config preserves user key order", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1767,6 +1791,73 @@ test("Effect config parser preserves permission order while rejecting unknown to
|
||||
|
||||
// MCP config merging tests
|
||||
|
||||
// kilocode_change start - regression for `env` alias on local MCP entries
|
||||
test("local mcp accepts `env` as an alias for `environment`", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "kilo.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
mcp: {
|
||||
context7: {
|
||||
type: "local",
|
||||
command: ["npx", "-y", "@upstash/context7-mcp"],
|
||||
env: { CONTEXT7_API_KEY: "test-key" },
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.mcp?.context7).toEqual({
|
||||
type: "local",
|
||||
command: ["npx", "-y", "@upstash/context7-mcp"],
|
||||
environment: { CONTEXT7_API_KEY: "test-key" },
|
||||
enabled: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("local mcp prefers `environment` over `env` when both are present", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "kilo.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
mcp: {
|
||||
context7: {
|
||||
type: "local",
|
||||
command: ["npx", "-y", "@upstash/context7-mcp"],
|
||||
environment: { CONTEXT7_API_KEY: "from-environment" },
|
||||
env: { CONTEXT7_API_KEY: "from-env" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.mcp?.context7).toEqual({
|
||||
type: "local",
|
||||
command: ["npx", "-y", "@upstash/context7-mcp"],
|
||||
environment: { CONTEXT7_API_KEY: "from-environment" },
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("project config can override MCP server enabled status", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -1807,7 +1898,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||
// kilocode_change end
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1865,7 +1956,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1916,7 +2007,7 @@ test("local .kilo config can override MCP from project config", async () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -1960,7 +2051,7 @@ test("project config overrides remote well-known config", async () => {
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -2018,7 +2109,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -2037,6 +2128,83 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("wellknown remote_config supports templated env vars in headers", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalToken = process.env.TEST_TOKEN
|
||||
let wellknownFetchedUrl: string | undefined
|
||||
let remoteFetchedUrl: string | undefined
|
||||
let remoteHeaders: HeadersInit | undefined
|
||||
globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
wellknownFetchedUrl = urlStr
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
remote_config: {
|
||||
url: "https://config.example.com/opencode.json",
|
||||
headers: {
|
||||
Authorization: "Bearer {env:TEST_TOKEN}",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (urlStr.includes("config.example.com")) {
|
||||
remoteFetchedUrl = urlStr
|
||||
remoteHeaders = init?.headers
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
}
|
||||
return originalFetch(url, init)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json")
|
||||
expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" })
|
||||
expect(config.mcp?.confluence?.enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
if (originalToken === undefined) delete process.env.TEST_TOKEN
|
||||
else process.env.TEST_TOKEN = originalToken
|
||||
}
|
||||
})
|
||||
|
||||
describe("resolvePluginSpec", () => {
|
||||
test("keeps package specs unchanged", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -2173,7 +2341,7 @@ describe("deduplicatePluginOrigins", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -2205,7 +2373,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -2237,7 +2405,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
|
||||
await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const directories = await listDirs()
|
||||
@@ -2261,7 +2429,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Should still get default config (from global or defaults)
|
||||
@@ -2303,7 +2471,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// The relative instruction should be skipped without error
|
||||
@@ -2363,7 +2531,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => {
|
||||
process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
|
||||
process.env["KILO_CONFIG_DIR"] = configDirTmp.path
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -2398,7 +2566,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => {
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -2432,7 +2600,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
|
||||
const clear = async (wait = false) => {
|
||||
await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
|
||||
if (wait) await InstanceRuntime.disposeAllInstances()
|
||||
}
|
||||
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -89,7 +92,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await load()
|
||||
@@ -630,3 +633,43 @@ test("merges plugin_enabled flags across config layers", async () => {
|
||||
"local.plugin": true,
|
||||
})
|
||||
})
|
||||
|
||||
test("silently skips malformed tui.json — load failures degrade to {}", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",')
|
||||
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
// Project tui.json is malformed → silently skipped (logs a warning)
|
||||
// .opencode/tui.json (lower precedence in this path) still loads
|
||||
expect(config.theme).toBe("fallback")
|
||||
})
|
||||
|
||||
test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// tui.json exists as a DIRECTORY rather than a file → readFileString fails
|
||||
// with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes
|
||||
// that through catchCause → log + skip, so a fallback layer should still load.
|
||||
await fs.mkdir(path.join(dir, "tui.json"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
// Did NOT crash; .opencode/tui.json (lower precedence) still loads.
|
||||
expect(config.theme).toBe("fallback")
|
||||
})
|
||||
|
||||
test("missing tui.json — silently treated as empty (ENOENT path)", async () => {
|
||||
await using tmp = await tmpdir({})
|
||||
|
||||
// No tui.json anywhere. Should not throw.
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config).toBeDefined()
|
||||
// No theme set anywhere.
|
||||
expect(config.theme).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "node:fs/promises"
|
||||
import Http from "node:http"
|
||||
import path from "node:path"
|
||||
@@ -6,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
@@ -14,12 +15,12 @@ import { Database } from "@/storage/db"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { ProjectTable } from "@/project/project.sql"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { SessionID, MessageID, PartID } from "@/session/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
|
||||
import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -29,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
|
||||
import * as WorkspaceOld from "../../src/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const testServerLayer = Layer.mergeAll(
|
||||
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
|
||||
WorkspaceOld.defaultLayer,
|
||||
WorkspaceOld.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
),
|
||||
SessionNs.defaultLayer,
|
||||
)
|
||||
const it = testEffect(testServerLayer)
|
||||
@@ -101,22 +107,36 @@ afterEach(async () => {
|
||||
|
||||
async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
return Instance.provide({
|
||||
return WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => fn(tmp.path),
|
||||
})
|
||||
}
|
||||
|
||||
async function initGitRepo(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await $`git init`.cwd(dir).quiet()
|
||||
await $`git config core.autocrlf false`.cwd(dir).quiet() // kilocode_change - align test repos with Git service patch behavior
|
||||
await $`git config core.fsmonitor false`.cwd(dir).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(dir).quiet()
|
||||
await $`git config user.email "test@opencode.test"`.cwd(dir).quiet()
|
||||
await $`git config user.name "Test"`.cwd(dir).quiet()
|
||||
await fs.writeFile(path.join(dir, "tracked.txt"), "base\n")
|
||||
await $`git add tracked.txt`.cwd(dir).quiet()
|
||||
await $`git commit -m "base"`.cwd(dir).quiet()
|
||||
}
|
||||
|
||||
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
|
||||
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
||||
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
|
||||
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
|
||||
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
|
||||
const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
|
||||
const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
|
||||
const winSkip = process.platform === "win32" ? test.skip : test // kilocode_change - git patch application is covered on Linux CI
|
||||
const isWorkspaceSyncing = (id: WorkspaceID) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
|
||||
const startWorkspaceSyncing = (projectID: ProjectID) => {
|
||||
@@ -316,48 +336,24 @@ function sessionSequence(sessionID: SessionID) {
|
||||
)?.seq
|
||||
}
|
||||
|
||||
function eventRows(sessionID: SessionID) {
|
||||
function sessionSequenceOwner(sessionID: SessionID) {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
.select({ ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get(),
|
||||
)?.ownerID
|
||||
}
|
||||
|
||||
function sessionUpdatedType() {
|
||||
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
|
||||
}
|
||||
|
||||
function replaceSessionEvents(sessionID: SessionID, count: number) {
|
||||
Database.use((db) => {
|
||||
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
|
||||
if (count === 0) return
|
||||
|
||||
db.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: sessionID, seq: count - 1 })
|
||||
.run()
|
||||
db.insert(EventTable)
|
||||
.values(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `evt_${unique(`manual-${i}`)}`,
|
||||
aggregate_id: sessionID,
|
||||
seq: i,
|
||||
type: sessionUpdatedType(),
|
||||
data: { sessionID, info: { title: `manual ${i}` } },
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
})
|
||||
}
|
||||
|
||||
describe("workspace-old schemas and exports", () => {
|
||||
test("keeps the historical event type names", () => {
|
||||
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
|
||||
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
|
||||
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
|
||||
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
|
||||
})
|
||||
|
||||
@@ -374,17 +370,6 @@ describe("workspace-old schemas and exports", () => {
|
||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
|
||||
})
|
||||
|
||||
test("validates session restore input", () => {
|
||||
const input = {
|
||||
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
|
||||
sessionID: SessionID.descending("ses_schema_restore"),
|
||||
}
|
||||
|
||||
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
|
||||
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
|
||||
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspace-old CRUD", () => {
|
||||
@@ -650,6 +635,177 @@ describe("workspace-old CRUD", () => {
|
||||
expect(await getWorkspace(info.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const previousType = unique("warp-prev-local")
|
||||
const targetType = unique("warp-target-local")
|
||||
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||
const target = workspaceInfo(Instance.project.id, targetType)
|
||||
insertWorkspace(previous)
|
||||
insertWorkspace(target)
|
||||
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
|
||||
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
|
||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||
attachSessionToWorkspace(session.id, previous.id)
|
||||
|
||||
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
|
||||
|
||||
expect(
|
||||
Database.use((db) =>
|
||||
db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get(),
|
||||
)?.workspaceID,
|
||||
).toBe(target.id)
|
||||
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
||||
})
|
||||
})
|
||||
|
||||
winSkip("sessionWarp applies source workspace patch to local target workspace", async () => { // kilocode_change
|
||||
await withInstance(async (dir) => {
|
||||
const previousType = unique("warp-patch-prev-local")
|
||||
const targetType = unique("warp-patch-target-local")
|
||||
const previousDir = path.join(dir, "warp-patch-prev-local")
|
||||
const targetDir = path.join(dir, "warp-patch-target-local")
|
||||
await initGitRepo(previousDir)
|
||||
await initGitRepo(targetDir)
|
||||
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
|
||||
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
|
||||
await $`git add new.txt`.cwd(previousDir).quiet() // kilocode_change - avoid unrelated untracked patch path
|
||||
|
||||
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||
const target = workspaceInfo(Instance.project.id, targetType)
|
||||
insertWorkspace(previous)
|
||||
insertWorkspace(target)
|
||||
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
|
||||
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
|
||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||
attachSessionToWorkspace(session.id, previous.id)
|
||||
|
||||
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
|
||||
|
||||
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
|
||||
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
|
||||
})
|
||||
})
|
||||
|
||||
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const previousType = unique("warp-detach-local")
|
||||
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||
insertWorkspace(previous)
|
||||
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
|
||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||
attachSessionToWorkspace(session.id, previous.id)
|
||||
|
||||
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
|
||||
|
||||
expect(
|
||||
Database.use((db) =>
|
||||
db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get(),
|
||||
)?.workspaceID,
|
||||
).toBeNull()
|
||||
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
|
||||
})
|
||||
})
|
||||
|
||||
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
|
||||
const calls: FetchCall[] = []
|
||||
let historySessionID: SessionID | undefined
|
||||
let historyNextSeq = 0
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const bodyText = yield* req.text
|
||||
const call = {
|
||||
url: new URL(req.url, "http://localhost"),
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
bodyText,
|
||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
||||
}
|
||||
calls.push(call)
|
||||
if (call.url.pathname === "/warp-source/sync/history") {
|
||||
return yield* HttpServerResponse.json([
|
||||
{
|
||||
id: `evt_${unique("warp-source-history")}`,
|
||||
aggregate_id: historySessionID!,
|
||||
seq: historyNextSeq,
|
||||
type: sessionUpdatedType(),
|
||||
data: { sessionID: historySessionID!, info: { title: "from source history" } },
|
||||
},
|
||||
])
|
||||
}
|
||||
if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch")
|
||||
if (call.url.pathname === "/warp-target/sync/replay")
|
||||
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||
if (call.url.pathname === "/warp-target/sync/steal")
|
||||
return yield* HttpServerResponse.json({ sessionID: "ok" })
|
||||
if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true })
|
||||
return HttpServerResponse.text("unexpected", { status: 500 })
|
||||
}),
|
||||
)
|
||||
const url = yield* serverUrl()
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const previousType = unique("warp-remote-source")
|
||||
const targetType = unique("warp-remote-target")
|
||||
const previous = workspaceInfo(Instance.project.id, previousType)
|
||||
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
|
||||
insertWorkspace(previous)
|
||||
insertWorkspace(target)
|
||||
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
|
||||
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
|
||||
const session = yield* sessionSvc.create({})
|
||||
attachSessionToWorkspace(session.id, previous.id)
|
||||
historySessionID = session.id
|
||||
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
|
||||
|
||||
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
|
||||
"POST /warp-source/sync/history",
|
||||
"GET /warp-source/vcs/diff/raw",
|
||||
"POST /warp-target/vcs/apply",
|
||||
"POST /warp-target/sync/replay",
|
||||
"POST /warp-target/sync/steal",
|
||||
])
|
||||
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
|
||||
expect(calls[2].json).toEqual({ patch: "remote patch" })
|
||||
expect(calls[3].json).toMatchObject({
|
||||
directory: "remote-target-dir",
|
||||
events: [
|
||||
{
|
||||
aggregateID: session.id,
|
||||
seq: 0,
|
||||
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
|
||||
},
|
||||
{
|
||||
aggregateID: session.id,
|
||||
seq: historyNextSeq,
|
||||
type: sessionUpdatedType(),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(calls[4].json).toEqual({ sessionID: session.id })
|
||||
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
|
||||
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspace-old sync state", () => {
|
||||
@@ -957,7 +1113,7 @@ describe("workspace-old sync state", () => {
|
||||
|
||||
yield* eventuallyEffect(
|
||||
Effect.gen(function* () {
|
||||
expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
|
||||
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history")
|
||||
}),
|
||||
)
|
||||
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
|
||||
@@ -1105,7 +1261,7 @@ describe("workspace-old sync state", () => {
|
||||
|
||||
yield* eventuallyEffect(
|
||||
Effect.gen(function* () {
|
||||
expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
|
||||
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse")
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
@@ -1214,313 +1370,3 @@ describe("workspace-old waitForSync", () => {
|
||||
})
|
||||
}, 7000)
|
||||
})
|
||||
|
||||
describe("workspace-old sessionRestore", () => {
|
||||
test("throws when the workspace is missing", async () => {
|
||||
await withInstance(async () => {
|
||||
await expect(
|
||||
restoreWorkspaceSession({
|
||||
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
|
||||
sessionID: SessionID.descending("ses_restore_missing_workspace"),
|
||||
}),
|
||||
).rejects.toThrow("Workspace not found: wrk_restore_missing")
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when switching a missing session fails", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const type = unique("restore-missing-session")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
|
||||
|
||||
await expect(
|
||||
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
|
||||
).rejects.toThrow("NotFoundError")
|
||||
await removeWorkspace(info.id)
|
||||
})
|
||||
})
|
||||
|
||||
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
|
||||
const replay: FetchCall[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const bodyText = yield* req.text
|
||||
const call = {
|
||||
url: new URL(req.url, "http://localhost"),
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
bodyText,
|
||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
||||
}
|
||||
if (call.url.pathname === "/restore/sync/replay") {
|
||||
replay.push(call)
|
||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
||||
}
|
||||
return HttpServerResponse.text("unexpected", { status: 500 })
|
||||
}),
|
||||
)
|
||||
const url = yield* serverUrl()
|
||||
yield* provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const captured = captureGlobalEvents()
|
||||
try {
|
||||
const type = unique("restore-remote")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(
|
||||
Instance.project.id,
|
||||
type,
|
||||
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
|
||||
directory: dir,
|
||||
headers: { authorization: "Bearer restore" },
|
||||
}).adapter,
|
||||
)
|
||||
const session = yield* sessionSvc.create({ title: "restore remote" })
|
||||
replaceSessionEvents(session.id, 24)
|
||||
|
||||
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
|
||||
|
||||
expect(result).toEqual({ total: 3 })
|
||||
expect(replay).toHaveLength(3)
|
||||
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
|
||||
"/restore/sync/replay",
|
||||
"/restore/sync/replay",
|
||||
"/restore/sync/replay",
|
||||
])
|
||||
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
|
||||
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
|
||||
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
|
||||
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
|
||||
expect(
|
||||
replay.flatMap((call) =>
|
||||
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
|
||||
),
|
||||
).toEqual(Array.from({ length: 25 }, (_, i) => i))
|
||||
expect(
|
||||
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
|
||||
).toMatchObject({
|
||||
seq: 24,
|
||||
type: sessionUpdatedType(),
|
||||
data: { sessionID: session.id, info: { workspaceID: info.id } },
|
||||
})
|
||||
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
|
||||
expect(
|
||||
captured.events
|
||||
.filter(
|
||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
||||
)
|
||||
.map((event) => event.payload.properties.step),
|
||||
).toEqual([0, 1, 2, 3])
|
||||
yield* workspace.remove(info.id)
|
||||
} finally {
|
||||
captured.dispose()
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
|
||||
const replay: FetchCall[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const bodyText = yield* req.text
|
||||
replay.push({
|
||||
url: new URL(req.url, "http://localhost"),
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
bodyText,
|
||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
||||
})
|
||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
||||
}),
|
||||
)
|
||||
const url = yield* serverUrl()
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const type = unique("restore-null-dir")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: null })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
|
||||
const session = yield* sessionSvc.create({ title: "null dir" })
|
||||
replaceSessionEvents(session.id, 0)
|
||||
|
||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
||||
total: 1,
|
||||
})
|
||||
expect((replay[0].json as { directory: string }).directory).toBe("")
|
||||
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
|
||||
yield* workspace.remove(info.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
|
||||
const replay: FetchCall[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const bodyText = yield* req.text
|
||||
replay.push({
|
||||
url: new URL(req.url, "http://localhost"),
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
bodyText,
|
||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
||||
})
|
||||
return HttpServerResponse.text("replay failed", { status: 503 })
|
||||
}),
|
||||
)
|
||||
const url = yield* serverUrl()
|
||||
yield* provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const captured = captureGlobalEvents()
|
||||
try {
|
||||
const type = unique("restore-remote-fail")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
|
||||
const session = yield* sessionSvc.create({ title: "restore fail" })
|
||||
replaceSessionEvents(session.id, 11)
|
||||
|
||||
const error = yield* Effect.flip(
|
||||
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
|
||||
)
|
||||
expect((error as Error).message).toContain(
|
||||
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
|
||||
)
|
||||
|
||||
expect(replay).toHaveLength(1)
|
||||
expect(
|
||||
captured.events
|
||||
.filter(
|
||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
||||
)
|
||||
.map((event) => event.payload.properties.step),
|
||||
).toEqual([0])
|
||||
yield* workspace.remove(info.id)
|
||||
} finally {
|
||||
captured.dispose()
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.live("local restore replays batches and emits progress", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const captured = captureGlobalEvents()
|
||||
try {
|
||||
const type = unique("restore-local")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
|
||||
const session = yield* sessionSvc.create({ title: "restore local" })
|
||||
replaceSessionEvents(session.id, 20)
|
||||
|
||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
||||
total: 3,
|
||||
})
|
||||
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
|
||||
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
|
||||
expect(
|
||||
captured.events
|
||||
.filter(
|
||||
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
|
||||
)
|
||||
.map((event) => event.payload.properties.step),
|
||||
).toEqual([0, 1, 2, 3])
|
||||
yield* workspace.remove(info.id)
|
||||
} finally {
|
||||
captured.dispose()
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session restore includes real message and part events in sequence order", () => {
|
||||
const replay: FetchCall[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const bodyText = yield* req.text
|
||||
replay.push({
|
||||
url: new URL(req.url, "http://localhost"),
|
||||
method: req.method,
|
||||
headers: new Headers(req.headers),
|
||||
bodyText,
|
||||
json: bodyText ? JSON.parse(bodyText) : undefined,
|
||||
})
|
||||
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
|
||||
}),
|
||||
)
|
||||
const url = yield* serverUrl()
|
||||
yield* provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const sessionSvc = yield* SessionNs.Service
|
||||
const type = unique("restore-real-events")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
|
||||
const session = yield* sessionSvc.create({ title: "real events" })
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const msg = yield* sessionSvc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* sessionSvc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: msg.id,
|
||||
type: "text",
|
||||
text: `message ${i}`,
|
||||
})
|
||||
}
|
||||
const before = eventRows(session.id)
|
||||
|
||||
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
|
||||
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
|
||||
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
|
||||
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
|
||||
yield* workspace.remove(info.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { $ } from "bun"
|
||||
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
@@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () =>
|
||||
)
|
||||
|
||||
const a = yield* access(state, dir)
|
||||
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
|
||||
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
|
||||
const b = yield* access(state, dir)
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
@@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () =>
|
||||
|
||||
const [, b] = yield* Effect.all(
|
||||
[
|
||||
Effect.promise(() => InstanceStore.reloadInstance({ directory: one })),
|
||||
Effect.promise(() => reloadTestInstance({ directory: one })),
|
||||
Test.use((svc) => svc.get()).pipe(provideInstance(two)),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||
@@ -30,7 +31,7 @@ describe("file fsmonitor", () => {
|
||||
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await status()
|
||||
@@ -55,7 +56,7 @@ describe("file fsmonitor", () => {
|
||||
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await read("tracked.txt")
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -28,7 +29,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "Hello World", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.txt")
|
||||
@@ -41,7 +42,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("reads with Filesystem.exists() check", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Non-existent file should return empty content
|
||||
@@ -57,7 +58,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.txt")
|
||||
@@ -71,7 +72,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "empty.txt")
|
||||
await fs.writeFile(filepath, "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("empty.txt")
|
||||
@@ -86,7 +87,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "multiline.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("multiline.txt")
|
||||
@@ -103,7 +104,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
await fs.writeFile(filepath, binaryContent)
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("image.png")
|
||||
@@ -120,7 +121,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "binary.so")
|
||||
await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("binary.so")
|
||||
@@ -137,7 +138,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.json")
|
||||
await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
expect(await Filesystem.mimeType(filepath)).toContain("application/json")
|
||||
@@ -161,7 +162,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, `test.${ext}`)
|
||||
await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
expect(await Filesystem.mimeType(filepath)).toContain(mime)
|
||||
@@ -175,7 +176,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("reads .gitignore via Filesystem.exists() and readText()", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||
@@ -193,7 +194,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("reads .ignore file similarly", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ignorePath = path.join(tmp.path, ".ignore")
|
||||
@@ -208,7 +209,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("handles missing .gitignore gracefully", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||
@@ -226,7 +227,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("reads untracked files via Filesystem.readText()", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const untrackedPath = path.join(tmp.path, "untracked.txt")
|
||||
@@ -247,7 +248,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "readonly.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistentPath = path.join(tmp.path, "does-not-exist.txt")
|
||||
@@ -264,7 +265,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("handles errors in Filesystem.readArrayBuffer()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistentPath = path.join(tmp.path, "does-not-exist.bin")
|
||||
@@ -279,7 +280,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const _filepath = path.join(tmp.path, "broken.png")
|
||||
// Don't create the file
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// read() handles missing images gracefully
|
||||
@@ -297,7 +298,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.ts")
|
||||
await fs.writeFile(filepath, "export const value = 1", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.ts")
|
||||
@@ -312,7 +313,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.mts")
|
||||
await fs.writeFile(filepath, "export const value = 1", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.mts")
|
||||
@@ -327,7 +328,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.sh")
|
||||
await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.sh")
|
||||
@@ -342,7 +343,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "Dockerfile")
|
||||
await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("Dockerfile")
|
||||
@@ -357,7 +358,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "simple text", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.txt")
|
||||
@@ -372,7 +373,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(tmp.path, "test.jpg")
|
||||
await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.jpg")
|
||||
@@ -387,7 +388,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
@@ -398,7 +399,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
@@ -416,7 +417,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
@@ -433,7 +434,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
@@ -454,7 +455,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.rm(filepath)
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
@@ -477,7 +478,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.rm(path.join(tmp.path, "remove.txt"))
|
||||
await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
@@ -491,7 +492,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("returns empty for non-git project", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
@@ -503,7 +504,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("returns empty for clean repo", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
@@ -526,7 +527,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
for (let i = 0; i < 512; i++) modified[i] = i % 256
|
||||
await fs.writeFile(filepath, modified)
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
@@ -547,7 +548,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
@@ -571,7 +572,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
@@ -596,7 +597,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
@@ -615,7 +616,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8")
|
||||
await fs.mkdir(path.join(tmp.path, "build"))
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
@@ -635,7 +636,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list("sub")
|
||||
@@ -650,7 +651,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(list("../outside")).rejects.toThrow("Access denied")
|
||||
@@ -662,7 +663,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
@@ -693,7 +694,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("empty query returns files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -707,7 +708,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("search works before explicit init", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await search({ query: "main", type: "file" })
|
||||
@@ -719,7 +720,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("empty query returns dirs sorted with hidden last", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -739,7 +740,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("fuzzy matches file names", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -753,7 +754,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("type filter returns only files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -770,7 +771,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("type filter returns only directories", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -787,7 +788,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("respects limit", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -801,7 +802,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("query starting with dot prefers hidden files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -816,7 +817,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
test("search refreshes after init when files change", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -840,7 +841,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified content\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("file.txt")
|
||||
@@ -864,7 +865,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(filepath, "after\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("staged.txt")
|
||||
@@ -881,7 +882,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("clean.txt")
|
||||
@@ -902,7 +903,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
|
||||
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -913,7 +914,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -929,7 +930,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
@@ -943,7 +944,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
|
||||
await fs.rm(path.join(tmp.path, "before.ts"))
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { containsPath } from "../../src/project/instance-context"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -55,7 +56,7 @@ describe("File.read path traversal protection", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
@@ -66,7 +67,7 @@ describe("File.read path traversal protection", () => {
|
||||
test("rejects deeply nested traversal", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
|
||||
@@ -83,7 +84,7 @@ describe("File.read path traversal protection", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("valid.txt")
|
||||
@@ -97,7 +98,7 @@ describe("File.list path traversal protection", () => {
|
||||
test("rejects ../ traversal attempting to list /etc", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
@@ -112,7 +113,7 @@ describe("File.list path traversal protection", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await list("subdir")
|
||||
@@ -126,7 +127,7 @@ describe("containsPath", () => {
|
||||
test("returns true for path inside directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
|
||||
@@ -140,7 +141,7 @@ describe("containsPath", () => {
|
||||
const subdir = path.join(tmp.path, "packages", "lib")
|
||||
await fs.mkdir(subdir, { recursive: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: subdir,
|
||||
fn: () => {
|
||||
// .opencode at worktree root, but we're running from packages/lib
|
||||
@@ -156,7 +157,7 @@ describe("containsPath", () => {
|
||||
test("returns false for path outside both directory and worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||
@@ -168,7 +169,7 @@ describe("containsPath", () => {
|
||||
test("returns false for path with .. escaping worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
|
||||
@@ -179,7 +180,7 @@ describe("containsPath", () => {
|
||||
test("handles directory === worktree (running from repo root)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(Instance.directory).toBe(Instance.worktree)
|
||||
@@ -192,7 +193,7 @@ describe("containsPath", () => {
|
||||
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
|
||||
await using tmp = await tmpdir() // no git: true
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Config } from "@/config/config"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Git } from "../../src/git"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
@@ -28,7 +29,7 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
/** Run `body` with a live FileWatcher service. */
|
||||
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
return Instance.provide({
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
|
||||
@@ -193,7 +194,7 @@ describeWatcher("FileWatcher", () => {
|
||||
await withWatcher(tmp.path, Effect.void)
|
||||
|
||||
// Now write a file — no watcher should be listening
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Separate file because every export in `agent-plugin.ts` must be a function.
|
||||
export const PLUGIN_AGENT = {
|
||||
name: "plugin_added",
|
||||
description: "Added by a plugin via the config hook",
|
||||
mode: "subagent",
|
||||
} as const
|
||||
@@ -0,0 +1,12 @@
|
||||
// Every export in this file must be a plugin function — `getLegacyPlugins`
|
||||
// (src/plugin/index.ts) throws on anything else. Test constants live in
|
||||
// `agent-plugin.constants.ts`.
|
||||
export default async () => ({
|
||||
config: async (cfg: { agent?: Record<string, unknown> }) => {
|
||||
cfg.agent = cfg.agent ?? {}
|
||||
cfg.agent["plugin_added"] = {
|
||||
description: "Added by a plugin via the config hook",
|
||||
mode: "subagent",
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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,21 +1,47 @@
|
||||
import { $ } from "bun"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import * as fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Context } from "effect"
|
||||
import { Effect, Context, Layer, ManagedRuntime } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import type { Config } from "@/config/config"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import { remove as cleanup } from "../kilocode/cleanup" // kilocode_change
|
||||
|
||||
// Re-export for test ergonomics. The implementation lives next to the runtime
|
||||
// it consumes; see `InstanceStore.disposeAllInstances` for the rationale.
|
||||
export { disposeAllInstances } from "../../src/project/instance-store"
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const testInstanceRuntime = ManagedRuntime.make(
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)),
|
||||
)
|
||||
|
||||
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
|
||||
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
|
||||
|
||||
export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
|
||||
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory }))
|
||||
try {
|
||||
if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
return await Instance.restore(ctx, () => input.fn())
|
||||
} finally {
|
||||
await runTestInstanceStore((store) => store.dispose(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadTestInstance(input: { directory: string }) {
|
||||
return runTestInstanceStore((store) => store.reload(input))
|
||||
}
|
||||
|
||||
export async function disposeAllInstances() {
|
||||
await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
|
||||
}
|
||||
|
||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||
function sanitizePath(p: string): string {
|
||||
@@ -125,12 +151,12 @@ export const provideInstance =
|
||||
(directory: string) =>
|
||||
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
Effect.contextWith((services: Context.Context<R>) =>
|
||||
Effect.promise<A>(async () =>
|
||||
Instance.provide({
|
||||
directory,
|
||||
fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
|
||||
}),
|
||||
),
|
||||
Effect.promise<A>(async () => {
|
||||
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
|
||||
return Instance.restore(ctx, () =>
|
||||
Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export function provideTmpdirInstance<A, E, R>(
|
||||
@@ -144,10 +170,9 @@ export function provideTmpdirInstance<A, E, R>(
|
||||
yield* Effect.addFinalizer(() =>
|
||||
provided
|
||||
? Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: path,
|
||||
fn: () => InstanceStore.disposeInstance(Instance.current),
|
||||
}),
|
||||
runTestInstanceStore((store) =>
|
||||
store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))),
|
||||
),
|
||||
).pipe(Effect.ignore)
|
||||
: Effect.void,
|
||||
)
|
||||
@@ -157,6 +182,21 @@ export function provideTmpdirInstance<A, E, R>(
|
||||
})
|
||||
}
|
||||
|
||||
export class TestInstance extends Context.Service<TestInstance, { readonly directory: string }>()("@test/Instance") {}
|
||||
|
||||
export const withTmpdirInstance =
|
||||
(options?: { git?: boolean; config?: Partial<Config.Info> }) =>
|
||||
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped(options)
|
||||
return yield* InstanceStore.Service.use((store) =>
|
||||
store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))),
|
||||
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export function provideTmpdirServer<A, E, R>(
|
||||
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
|
||||
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
|
||||
|
||||
@@ -285,6 +285,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
count: opts.state?.session?.count ?? (() => 0),
|
||||
diff: opts.state?.session?.diff ?? (() => []),
|
||||
todo: opts.state?.session?.todo ?? (() => []),
|
||||
processes: opts.state?.session?.processes ?? (() => []), // kilocode_change
|
||||
messages: opts.state?.session?.messages ?? (() => []),
|
||||
status: opts.state?.session?.status ?? (() => undefined),
|
||||
permission: opts.state?.session?.permission ?? (() => []),
|
||||
|
||||
@@ -114,6 +114,53 @@ describe("Git", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("patch() returns capped native patch output", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [patch, all, capped] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))),
|
||||
])
|
||||
|
||||
expect(patch.truncated).toBe(false)
|
||||
expect(patch.text).toContain("diff --git")
|
||||
expect(patch.text).toContain("-before")
|
||||
expect(patch.text).toContain("+after")
|
||||
expect(all.truncated).toBe(false)
|
||||
expect(all.text).toContain("diff --git")
|
||||
expect(all.text).toContain("other.txt")
|
||||
expect(all.text).toContain("+new")
|
||||
expect(capped.truncated).toBe(true)
|
||||
expect(capped.text).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
test("patchUntracked() and statUntracked() handle added files", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [patch, stat] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))),
|
||||
])
|
||||
|
||||
expect(patch.truncated).toBe(false)
|
||||
expect(patch.text).toContain("diff --git")
|
||||
expect(patch.text).toContain("+one")
|
||||
expect(patch.text).toContain("+two")
|
||||
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
|
||||
})
|
||||
})
|
||||
|
||||
test("show() returns empty text for binary blobs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
// kilocode_change - new file
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
const it = testEffect(Agent.defaultLayer)
|
||||
|
||||
test("code agent allows global config directory reads by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const code = await Agent.get("code")
|
||||
expect(code).toBeDefined()
|
||||
expect(Permission.evaluate("external_directory", `${Global.Path.config}/*`, code!.permission).action).toBe(
|
||||
"allow",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("code agent allows global config directory reads by default", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const code = yield* agent.get("code")
|
||||
expect(code).toBeDefined()
|
||||
expect(Permission.evaluate("external_directory", `${Global.Path.config}/*`, code!.permission).action).toBe("allow")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
|
||||
@@ -25,7 +25,7 @@ test("ask agent honors user MCP allow over generated ask rule", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = await load(tmp.path, (svc) => svc.get("ask"))
|
||||
@@ -44,7 +44,7 @@ test("plan agent honors user bash allow over read-only deny default", async () =
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const plan = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
@@ -63,7 +63,7 @@ test("plan agent still hard-denies non-plan edits after user edit allow", async
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const plan = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
// kilocode_change - new file
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Permission } from "../../src/permission"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
const it = testEffect(Agent.defaultLayer)
|
||||
|
||||
function action(name: string, ruleset: Permission.Ruleset) {
|
||||
return Permission.evaluate("skill", name, ruleset).action
|
||||
}
|
||||
|
||||
test("skill tool available for non-system native agents and denied for system agents", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const allow = ["code", "plan", "debug", "orchestrator", "ask", "general", "explore"]
|
||||
for (const name of allow) {
|
||||
const agent = await Agent.get(name)
|
||||
expect(agent).toBeDefined()
|
||||
expect(action("using-superpowers", agent!.permission)).toBe("allow")
|
||||
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(false)
|
||||
}
|
||||
it.instance("skill tool available for non-system native agents and denied for system agents", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Agent.Service
|
||||
const allow = ["code", "plan", "debug", "orchestrator", "ask", "general", "explore"]
|
||||
for (const name of allow) {
|
||||
const agent = yield* svc.get(name)
|
||||
expect(agent).toBeDefined()
|
||||
expect(action("using-superpowers", agent!.permission)).toBe("allow")
|
||||
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(false)
|
||||
}
|
||||
|
||||
const deny = ["compaction", "title", "summary"]
|
||||
for (const name of deny) {
|
||||
const agent = await Agent.get(name)
|
||||
expect(agent).toBeDefined()
|
||||
expect(action("using-superpowers", agent!.permission)).toBe("deny")
|
||||
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
const deny = ["compaction", "title", "summary"]
|
||||
for (const name of deny) {
|
||||
const agent = yield* svc.get(name)
|
||||
expect(agent).toBeDefined()
|
||||
expect(action("using-superpowers", agent!.permission)).toBe("deny")
|
||||
expect(Permission.disabled(["skill"], agent!.permission).has("skill")).toBe(true)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Result, Schema } from "effect"
|
||||
import { Params } from "@/kilocode/tool/background-process"
|
||||
import { toJsonSchema } from "@/util/effect-zod"
|
||||
|
||||
const accepts = (input: unknown) => Result.isSuccess(Schema.decodeUnknownResult(Params)(input))
|
||||
|
||||
describe("BackgroundProcessTool", () => {
|
||||
test("emits a root object JSON schema", () => {
|
||||
const json = toJsonSchema(Params) as { type?: unknown; anyOf?: unknown; properties?: Record<string, unknown> }
|
||||
|
||||
expect(json.type).toBe("object")
|
||||
expect(json.anyOf).toBeUndefined()
|
||||
expect(json.properties?.action).toEqual(
|
||||
expect.objectContaining({ enum: ["start", "list", "status", "logs", "stop", "restart"] }),
|
||||
)
|
||||
})
|
||||
|
||||
test("validates action-specific required fields", () => {
|
||||
expect(accepts({ action: "list" })).toBe(true)
|
||||
expect(accepts({ action: "start", command: "bun run dev", ready: { pattern: "ready" } })).toBe(true)
|
||||
expect(accepts({ action: "start" })).toBe(false)
|
||||
expect(accepts({ action: "stop", id: "bgp01" })).toBe(true)
|
||||
expect(accepts({ action: "stop" })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@/bus"
|
||||
import { BackgroundProcess } from "@/kilocode/background-process"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
function quote(input: string) {
|
||||
const value = input.replaceAll("\\", "/")
|
||||
if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
async function script(dir: string, name: string, source: string) {
|
||||
const file = path.join(dir, name)
|
||||
await Bun.write(file, source)
|
||||
const bin = quote(process.execPath)
|
||||
const arg = quote(file)
|
||||
if (Shell.ps(Shell.acceptable())) return `& ${bin} ${arg}`
|
||||
return `${bin} ${arg}`
|
||||
}
|
||||
|
||||
function update(sessionID: SessionID) {
|
||||
const state: { off?: () => void; timer?: ReturnType<typeof setTimeout> } = {}
|
||||
const promise = new Promise<BackgroundProcess.Info>((resolve, reject) => {
|
||||
state.timer = setTimeout(() => {
|
||||
state.off?.()
|
||||
reject(new Error("timed out waiting for process update"))
|
||||
}, 5_000)
|
||||
state.off = Bus.subscribe(BackgroundProcess.Event.Updated, (event) => {
|
||||
const info = event.properties.info
|
||||
if (info.sessionID !== sessionID) return
|
||||
if (!info.output.includes("tick")) return
|
||||
state.off?.()
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
resolve(info)
|
||||
})
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
dispose() {
|
||||
state.off?.()
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("BackgroundProcess", () => {
|
||||
it.instance("starts, reports readiness, and stops a process", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = SessionID.descending()
|
||||
const command = yield* Effect.promise(() =>
|
||||
script(
|
||||
test.directory,
|
||||
"ready.mjs",
|
||||
`console.log("ready")
|
||||
setInterval(() => {}, 1_000)
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
const info = yield* Effect.promise(() =>
|
||||
BackgroundProcess.start({
|
||||
sessionID,
|
||||
command,
|
||||
cwd: test.directory,
|
||||
description: "test server",
|
||||
ready: { pattern: "ready", timeout: 5_000 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(info.status).toBe("ready")
|
||||
expect(info.output).toContain("ready")
|
||||
|
||||
const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
|
||||
expect(list.map((item) => item.id)).toContain(info.id)
|
||||
|
||||
const stopped = yield* Effect.promise(() => BackgroundProcess.stop(info.id))
|
||||
expect(stopped?.status).toBe("stopped")
|
||||
if (process.platform !== "win32") {
|
||||
expect(stopped?.exitCode).toBeUndefined()
|
||||
expect(stopped?.signal).toBe("SIGTERM")
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
|
||||
const next = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
|
||||
expect(next).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("publishes output updates from process callbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = SessionID.descending()
|
||||
const command = yield* Effect.promise(() =>
|
||||
script(
|
||||
test.directory,
|
||||
"tick.mjs",
|
||||
`console.log("ready")
|
||||
setTimeout(() => console.log("tick"), 200)
|
||||
setInterval(() => {}, 1_000)
|
||||
`,
|
||||
),
|
||||
)
|
||||
const wait = update(sessionID)
|
||||
const info = yield* Effect.promise(() =>
|
||||
BackgroundProcess.start({
|
||||
sessionID,
|
||||
command,
|
||||
cwd: test.directory,
|
||||
ready: { pattern: "ready", timeout: 5_000 },
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const event = yield* Effect.promise(() => wait.promise)
|
||||
expect(event.id).toBe(info.id)
|
||||
expect(event.output).toContain("tick")
|
||||
} finally {
|
||||
wait.dispose()
|
||||
yield* Effect.promise(() => BackgroundProcess.stop(info.id))
|
||||
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects invalid readiness patterns before launching", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = SessionID.descending()
|
||||
|
||||
const err = yield* Effect.promise(async () => {
|
||||
try {
|
||||
await BackgroundProcess.start({
|
||||
sessionID,
|
||||
command: "printf 'ready\n'",
|
||||
cwd: test.directory,
|
||||
ready: { pattern: "[", timeout: 1_000 },
|
||||
})
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
})
|
||||
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect((err as Error).message).toContain("Invalid ready pattern")
|
||||
|
||||
const list = yield* Effect.promise(() => BackgroundProcess.list({ sessionID }))
|
||||
expect(list).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
// regression test for bash permission metadata.command
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ShellTool } from "../../src/tool/shell"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
@@ -49,10 +49,10 @@ const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "
|
||||
describe("bash permission metadata.command", () => {
|
||||
test("permission prompt shows raw command without tool name prefix", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
|
||||
const bash = await runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init())))
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const command = "echo hello"
|
||||
await Effect.runPromise(bash.execute({ command, description: "Echo hello" }, capture(requests)))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
|
||||
const script = join(import.meta.dir, "..", "..", "bin", "kilo")
|
||||
|
||||
describe("bin/kilo tree-sitter resources", () => {
|
||||
async function setup(root: string, nested: boolean) {
|
||||
const dir = nested
|
||||
? join(root, "node_modules", "@kilocode", "cli-darwin-arm64", "bin")
|
||||
: join(root, "node_modules", "@kilocode", "cli", "bin")
|
||||
const wasm = join(dir, "tree-sitter")
|
||||
const bin = join(dir, nested ? "kilo" : ".kilo")
|
||||
const log = join(root, nested ? "nested-env.txt" : "cached-env.txt")
|
||||
|
||||
await mkdir(wasm, { recursive: true })
|
||||
await writeFile(join(wasm, "tree-sitter.wasm"), "wasm")
|
||||
await writeFile(bin, "binary")
|
||||
|
||||
return { bin, log, wasm, wrapper: join(dir, "kilo") }
|
||||
}
|
||||
|
||||
async function run(root: string, bin: string | undefined, log: string, wrapper?: string) {
|
||||
const capture = `
|
||||
const kiloFs = require("fs")
|
||||
const kiloChild = require("child_process")
|
||||
const log = process.argv[1]
|
||||
const wrapper = process.argv[2]
|
||||
const realpathSync = kiloFs.realpathSync
|
||||
kiloFs.realpathSync = (file) => wrapper && file === __filename ? wrapper : realpathSync(file)
|
||||
kiloChild.spawnSync = () => {
|
||||
kiloFs.writeFileSync(log, process.env.KILO_TREE_SITTER_WASM_DIR || "")
|
||||
return { status: 0 }
|
||||
}
|
||||
`
|
||||
const source = (await Bun.file(script).text()).replace(/^#!.*\n/, "")
|
||||
return Bun.spawnSync(["node", "--input-type=commonjs", "--eval", capture + source, log, wrapper ?? ""], {
|
||||
cwd: root,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
...(bin ? { KILO_BIN_PATH: bin } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("exports co-located tree-sitter WASM dir for optional package binary", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kilo-bin-tree-sitter-"))
|
||||
try {
|
||||
const item = await setup(root, true)
|
||||
const proc = await run(root, item.bin, item.log)
|
||||
|
||||
expect(proc.exitCode).toBe(0)
|
||||
expect(await Bun.file(item.log).text()).toBe(item.wasm)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("exports co-located tree-sitter WASM dir for cached postinstall binary", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kilo-bin-tree-sitter-"))
|
||||
try {
|
||||
const item = await setup(root, false)
|
||||
const proc = await run(root, undefined, item.log, item.wrapper)
|
||||
|
||||
expect(proc.exitCode).toBe(0)
|
||||
expect(await Bun.file(item.log).text()).toBe(item.wasm)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,20 @@
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import path from "path"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { BUILTIN_SKILLS } from "../../src/kilocode/skills/builtin"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
test("built-in skills are present in empty project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
it.instance(
|
||||
"built-in skills are present in empty project",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skills = yield* skill.all()
|
||||
for (const builtin of BUILTIN_SKILLS) {
|
||||
const found = skills.find((s) => s.name === builtin.name)
|
||||
expect(found).toBeDefined()
|
||||
@@ -23,33 +22,34 @@ test("built-in skills are present in empty project", async () => {
|
||||
expect(found!.description).toBe(builtin.description)
|
||||
expect(found!.content.length).toBeGreaterThan(0)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("built-in skill has correct metadata", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
it.instance(
|
||||
"built-in skill has correct metadata",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const item = yield* skill.get("kilo-config")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.name).toBe("kilo-config")
|
||||
expect(item!.location).toBe(Skill.BUILTIN_LOCATION)
|
||||
expect(item!.content).toContain("kilo")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skill = await Skill.get("kilo-config")
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill!.name).toBe("kilo-config")
|
||||
expect(skill!.location).toBe(Skill.BUILTIN_LOCATION)
|
||||
expect(skill!.content).toContain("kilo")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("user skill overrides built-in with same name", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".kilo", "skill", "kilo-config")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"user skill overrides built-in with same name",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
const dir = path.join(instance.directory, ".kilo", "skill", "kilo-config")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, "SKILL.md"),
|
||||
`---
|
||||
name: kilo-config
|
||||
description: User override of kilo-config.
|
||||
---
|
||||
@@ -58,18 +58,15 @@ description: User override of kilo-config.
|
||||
|
||||
User-provided content.
|
||||
`,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skill = await Skill.get("kilo-config")
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill!.description).toBe("User override of kilo-config.")
|
||||
expect(skill!.location).not.toBe(Skill.BUILTIN_LOCATION)
|
||||
expect(skill!.location).toContain(path.join("skill", "kilo-config", "SKILL.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
const skill = yield* Skill.Service
|
||||
const item = yield* skill.get("kilo-config")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBe("User override of kilo-config.")
|
||||
expect(item!.location).not.toBe(Skill.BUILTIN_LOCATION)
|
||||
expect(item!.location).toContain(path.join("skill", "kilo-config", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { Provider } from "../../../src/provider/provider"
|
||||
import { formatTable, formatMarkdown, handle, isTextModel } from "../../../src/kilocode/cli/cmd/roll-call"
|
||||
|
||||
const base = {
|
||||
input: { text: false, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: false, audio: false, image: false, video: false, pdf: false },
|
||||
}
|
||||
|
||||
function caps(opts: { input?: Partial<typeof base.input>; output?: Partial<typeof base.output> }) {
|
||||
return {
|
||||
capabilities: {
|
||||
...base,
|
||||
input: { ...base.input, ...opts.input },
|
||||
output: { ...base.output, ...opts.output },
|
||||
},
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
describe("formatTable", () => {
|
||||
test("formats simple table correctly", () => {
|
||||
const rows = [
|
||||
["kilo/test-model", "YES", "Hello!", "1000ms"],
|
||||
["kilo/another-model", "NO", "(Error)", "500ms"],
|
||||
]
|
||||
const result = formatTable(rows, 120)
|
||||
|
||||
expect(result.header).toContain("Model")
|
||||
expect(result.header).toContain("Access")
|
||||
expect(result.header).toContain("Snippet")
|
||||
expect(result.header).toContain("Latency")
|
||||
expect(result.separator).toMatch(/^-+$/)
|
||||
expect(result.rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("truncates long snippets", () => {
|
||||
const rows = [["model", "YES", "A".repeat(200), "100ms"]]
|
||||
const result = formatTable(rows, 80)
|
||||
const start = result.rows[0].indexOf("AAA")
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test("strips ANSI codes from cells", () => {
|
||||
const rows = [["\x1b[31mmodel\x1b[0m", "YES", "text", "100ms"]]
|
||||
const result = formatTable(rows, 120)
|
||||
|
||||
expect(result.rows[0]).not.toContain("\x1b[")
|
||||
expect(result.rows[0]).toContain("model")
|
||||
})
|
||||
|
||||
test("handles empty rows", () => {
|
||||
const result = formatTable([], 120)
|
||||
|
||||
expect(result.rows).toHaveLength(0)
|
||||
expect(result.header).toContain("Model")
|
||||
})
|
||||
|
||||
test("handles special characters in cells", () => {
|
||||
const rows = [
|
||||
["model\nwith\nnewlines", "YES", "text\ttab", "100ms"],
|
||||
["model\r\nwindows", "YES", "text", "100ms"],
|
||||
]
|
||||
const result = formatTable(rows, 120)
|
||||
|
||||
expect(result.rows[0]).not.toContain("\n")
|
||||
expect(result.rows[0]).not.toContain("\t")
|
||||
expect(result.rows[1]).not.toContain("\r")
|
||||
})
|
||||
|
||||
test("adjusts column widths for terminal", () => {
|
||||
const rows = [["very-long-model-name-here", "YES", "short", "100ms"]]
|
||||
const wide = formatTable(rows, 200)
|
||||
const narrow = formatTable(rows, 60)
|
||||
|
||||
expect(wide.header.indexOf("Snippet")).toBeGreaterThanOrEqual(0)
|
||||
expect(narrow.header.indexOf("Snippet")).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTextModel", () => {
|
||||
test("accepts text-in text-out model", () => {
|
||||
expect(isTextModel(caps({ input: { text: true }, output: { text: true } }))).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts multimodal model with text capability", () => {
|
||||
expect(isTextModel(caps({ input: { text: true, image: true }, output: { text: true } }))).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects audio-in text-out model", () => {
|
||||
expect(isTextModel(caps({ input: { audio: true }, output: { text: true } }))).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects text-in image-out model", () => {
|
||||
expect(isTextModel(caps({ input: { text: true }, output: { image: true } }))).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects embedding model", () => {
|
||||
expect(isTextModel(caps({ input: { text: true } }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMarkdown", () => {
|
||||
test("produces valid markdown table", () => {
|
||||
const rows = [
|
||||
["openai/gpt-4o", "YES", "Hello!", "500ms"],
|
||||
["openai/gpt-3.5", "NO", "(timeout)", "25000ms"],
|
||||
]
|
||||
const md = formatMarkdown(rows)
|
||||
const lines = md.split("\n")
|
||||
|
||||
expect(lines[0]).toMatch(/^\|.*Model.*\|.*Access.*\|.*Snippet.*\|.*Latency.*\|$/)
|
||||
expect(lines[1]).toMatch(/^\| -+ \| -+ \| -+ \| -+ \|$/)
|
||||
expect(lines).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("handles empty rows", () => {
|
||||
const md = formatMarkdown([])
|
||||
const lines = md.split("\n")
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("escapes pipe characters in cells", () => {
|
||||
const rows = [["model", "YES", "hello | world", "100ms"]]
|
||||
const md = formatMarkdown(rows)
|
||||
|
||||
expect(md).toContain("hello \\| world")
|
||||
expect(md.split("\n")[2].match(/(?<!\\)\|/g)?.length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handle", () => {
|
||||
test("does not print progress before markdown output", async () => {
|
||||
const logs: string[] = []
|
||||
const print = console.log
|
||||
const code = process.exitCode
|
||||
|
||||
console.log = (msg?: unknown) => {
|
||||
logs.push(String(msg))
|
||||
}
|
||||
|
||||
try {
|
||||
await handle({
|
||||
prompt: "Hello",
|
||||
timeout: 1,
|
||||
filter: "test",
|
||||
parallel: 1,
|
||||
output: "md",
|
||||
verbose: true,
|
||||
quiet: false,
|
||||
list: async () => ({}),
|
||||
})
|
||||
} finally {
|
||||
console.log = print
|
||||
process.exitCode = code
|
||||
}
|
||||
|
||||
expect(logs).toEqual([formatMarkdown([])])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
|
||||
const root = path.join(__dirname, "..", "..")
|
||||
|
||||
const files = [
|
||||
"src/cli/cmd/tui/feature-plugins/home/tips-view.tsx",
|
||||
"src/cli/cmd/run.ts",
|
||||
"src/config/config.ts",
|
||||
"src/server/routes/instance/httpapi/public.ts",
|
||||
"src/mcp/index.ts",
|
||||
]
|
||||
|
||||
const command = /opencode\s+(--[a-z-]+|run|serve|auth|upgrade|agent|github|mcp)\b/g
|
||||
|
||||
describe("Kilo command branding", () => {
|
||||
test("user-facing command help uses the `kilo` binary name", async () => {
|
||||
const results = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
file,
|
||||
matches: [...(await Bun.file(path.join(root, file)).text()).matchAll(command)].map((match) => match[0]),
|
||||
})),
|
||||
)
|
||||
|
||||
expect(results.filter((result) => result.matches.length > 0)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, mock, beforeEach } from "bun:test"
|
||||
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test"
|
||||
import type { GitContext } from "@/kilocode/commit-message/types"
|
||||
|
||||
// Mock dependencies before importing the module under test.
|
||||
@@ -8,7 +8,6 @@ import type { GitContext } from "@/kilocode/commit-message/types"
|
||||
|
||||
const realLog = await import("@opencode-ai/core/util/log")
|
||||
const realProvider = await import("@/provider/provider")
|
||||
const realLLM = await import("@/session/llm")
|
||||
const realAgent = await import("@/agent/agent")
|
||||
const realGitContext = await import("@/kilocode/commit-message/git-context")
|
||||
|
||||
@@ -50,19 +49,6 @@ mock.module("@/provider/provider", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/session/llm", () => ({
|
||||
...realLLM,
|
||||
LLM: {
|
||||
...realLLM.LLM,
|
||||
stream: async () => ({
|
||||
textStream: (async function* () {
|
||||
yield mockStreamText
|
||||
})(),
|
||||
text: Promise.resolve(mockStreamText),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/agent/agent", () => ({
|
||||
...realAgent,
|
||||
Agent: {},
|
||||
@@ -78,10 +64,13 @@ mock.module("@opencode-ai/core/util/log", () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
import { generateCommitMessage } from "../../../src/kilocode/commit-message/generate"
|
||||
import { CommitMessageRuntime, generateCommitMessage } from "../../../src/kilocode/commit-message/generate"
|
||||
|
||||
const stream = spyOn(CommitMessageRuntime, "generate").mockImplementation(async () => mockStreamText)
|
||||
|
||||
describe("commit-message.generate", () => {
|
||||
beforeEach(() => {
|
||||
stream.mockImplementation(async () => mockStreamText)
|
||||
mockStreamText = "feat(src): add hello world logging"
|
||||
mockGitContext = { ...defaultGitContext }
|
||||
captured = { path: "" }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Config } from "../../src/config/config"
|
||||
import { KiloCompactionPayloadRecovery } from "../../src/kilocode/session/compaction-payload-recovery"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
@@ -125,7 +125,6 @@ function llm() {
|
||||
const stream = typeof item === "function" ? item(input) : item
|
||||
return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
|
||||
},
|
||||
raw: () => Effect.die("raw not implemented in test LLM"),
|
||||
}),
|
||||
),
|
||||
}
|
||||
@@ -306,7 +305,7 @@ describe("KiloCompactionPayloadRecovery", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -58,7 +58,7 @@ test(".gitignore in .kilo config dir includes pnpm and yarn lockfile patterns",
|
||||
const kilo = path.join(dir, ".kilo")
|
||||
await fs.mkdir(kilo, { recursive: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||
const warnings = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings()))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await Config.invalidate()
|
||||
await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
|
||||
})
|
||||
|
||||
describe("config resilience", () => {
|
||||
@@ -31,10 +35,10 @@ Valid agent prompt`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const cfg = await Config.get()
|
||||
const cfg = await load()
|
||||
|
||||
expect(cfg.agent?.["skip"]).toBeUndefined()
|
||||
expect(cfg.agent?.["keep"]).toMatchObject({
|
||||
@@ -59,11 +63,11 @@ Broken agent prompt`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Config.get()
|
||||
const warns = await Config.warnings()
|
||||
await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("mode"))).toBe(true)
|
||||
},
|
||||
@@ -90,10 +94,10 @@ Valid command template`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const cfg = await Config.get()
|
||||
const cfg = await load()
|
||||
|
||||
expect(cfg.command?.["skip"]).toBeUndefined()
|
||||
expect(cfg.command?.["keep"]).toEqual({
|
||||
@@ -117,11 +121,11 @@ Broken command template`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Config.get()
|
||||
const warns = await Config.warnings()
|
||||
await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("subtask"))).toBe(true)
|
||||
},
|
||||
@@ -141,11 +145,11 @@ Broken agent`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Config.get()
|
||||
const warns = await Config.warnings()
|
||||
await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true)
|
||||
},
|
||||
@@ -165,11 +169,11 @@ Broken command`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Config.get()
|
||||
const warns = await Config.warnings()
|
||||
await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true)
|
||||
},
|
||||
@@ -183,11 +187,11 @@ Broken command`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const cfg = await Config.get()
|
||||
const warns = await Config.warnings()
|
||||
const cfg = await load()
|
||||
const warns = await warnings()
|
||||
|
||||
// Config loading should not crash
|
||||
expect(cfg).toBeDefined()
|
||||
@@ -204,11 +208,11 @@ Broken command`,
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const cfg = await Config.get()
|
||||
const warns = await Config.warnings()
|
||||
const cfg = await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(cfg).toBeDefined()
|
||||
expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("invalid"))).toBe(true)
|
||||
@@ -221,11 +225,11 @@ Broken command`,
|
||||
config: { model: "test/model" },
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Config.get()
|
||||
const warns = await Config.warnings()
|
||||
await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(warns).toEqual([])
|
||||
},
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { ConfigValidation } from "../../src/kilocode/config-validation"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -11,15 +12,17 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const check = (filepath: string) => ConfigValidation.check(filepath)
|
||||
|
||||
describe("ConfigValidation.check", () => {
|
||||
test("returns empty string for non-config files", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "src", "index.ts")
|
||||
await Filesystem.write(filepath, "export const x = 1")
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toBe("")
|
||||
})
|
||||
@@ -29,9 +32,9 @@ describe("ConfigValidation.check", () => {
|
||||
const filepath = path.join(tmp.path, "kilo.json")
|
||||
await Filesystem.write(filepath, JSON.stringify({ model: "anthropic/claude-sonnet-4-20250514" }))
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("validated successfully")
|
||||
@@ -42,9 +45,9 @@ describe("ConfigValidation.check", () => {
|
||||
const filepath = path.join(tmp.path, "kilo.json")
|
||||
await Filesystem.write(filepath, '{ "model": "test/model" "extra": true }')
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("ERROR")
|
||||
@@ -57,9 +60,9 @@ describe("ConfigValidation.check", () => {
|
||||
// Config.Info uses .strict() so unknown fields produce errors
|
||||
await Filesystem.write(filepath, JSON.stringify({ notAField: true }))
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("WARNING")
|
||||
@@ -77,9 +80,9 @@ description: A test command
|
||||
Do something useful`,
|
||||
)
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("validated successfully")
|
||||
@@ -98,9 +101,9 @@ subtask: "not-a-boolean"
|
||||
Do something`,
|
||||
)
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("WARNING")
|
||||
@@ -119,9 +122,9 @@ description: A helper agent
|
||||
You are a helpful agent.`,
|
||||
)
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("validated successfully")
|
||||
@@ -132,9 +135,9 @@ You are a helpful agent.`,
|
||||
const filepath = path.join(tmp.path, "AGENTS.md")
|
||||
await Filesystem.write(filepath, "# Project agents")
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toBe("")
|
||||
})
|
||||
@@ -144,9 +147,9 @@ You are a helpful agent.`,
|
||||
const filepath = path.join(tmp.path, ".kilo", "plans", "plan.md")
|
||||
await Filesystem.write(filepath, "# Plan")
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
fn: () => check(filepath),
|
||||
})
|
||||
expect(result).toBe("")
|
||||
})
|
||||
@@ -169,12 +172,12 @@ Broken agent`,
|
||||
const filepath = path.join(tmp.path, "kilo.json")
|
||||
await Filesystem.write(filepath, JSON.stringify({ model: "anthropic/claude-sonnet-4-20250514" }))
|
||||
|
||||
const result = await Instance.provide({
|
||||
const result = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Force config load to populate warnings
|
||||
await Config.get()
|
||||
return ConfigValidation.check(filepath)
|
||||
await AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||
return check(filepath)
|
||||
},
|
||||
})
|
||||
expect(result).toContain("Pre-existing config issues")
|
||||
|
||||
@@ -14,7 +14,8 @@ import { Config } from "../../../src/config/config"
|
||||
import { ConfigMarkdown } from "../../../src/config/markdown"
|
||||
import { Env } from "../../../src/env"
|
||||
import { KiloIndexing } from "../../../src/kilocode/indexing"
|
||||
import { Instance } from "../../../src/project/instance"
|
||||
import { KilocodeConfig } from "../../../src/kilocode/config/config"
|
||||
import { WithInstance } from "../../../src/project/with-instance"
|
||||
import { Filesystem } from "../../../src/util/filesystem"
|
||||
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
|
||||
|
||||
@@ -44,8 +45,8 @@ const layer = Config.layer.pipe(
|
||||
)
|
||||
|
||||
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = (wait = false) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
|
||||
async function writeConfig(dir: string, config: object, name = "kilo.json") {
|
||||
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
||||
@@ -67,8 +68,8 @@ const cfg: Partial<Config.Info> = {
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.KILO_MD_TEST
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
await clear(true)
|
||||
})
|
||||
|
||||
describe("markdown substitutions", () => {
|
||||
@@ -98,7 +99,8 @@ describe("kilocode indexing config", () => {
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
await clear(true)
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
@@ -109,7 +111,7 @@ describe("kilocode indexing config", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
@@ -123,7 +125,8 @@ describe("kilocode indexing config", () => {
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear(true)
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -133,7 +136,8 @@ describe("kilocode indexing config", () => {
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
await clear(true)
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
@@ -143,7 +147,7 @@ describe("kilocode indexing config", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const global = await Effect.runPromise(
|
||||
@@ -156,7 +160,8 @@ describe("kilocode indexing config", () => {
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear(true)
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -164,4 +169,23 @@ describe("kilocode indexing config", () => {
|
||||
const input = KiloIndexing.input({ enabled: false }, { enabled: true })
|
||||
expect(input.enabled).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts delete sentinels for indexing model overrides", () => {
|
||||
const patch = Config.Info.zod.parse({ indexing: { model: null, dimension: null } })
|
||||
const merged = KilocodeConfig.mergeConfig(
|
||||
{
|
||||
indexing: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-large",
|
||||
dimension: 3072,
|
||||
},
|
||||
},
|
||||
patch,
|
||||
)
|
||||
const input = KiloIndexing.input(patch.indexing)
|
||||
|
||||
expect(merged.indexing).toEqual({ provider: "openai" })
|
||||
expect(input.modelId).toBeUndefined()
|
||||
expect(input.modelDimension).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,15 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
|
||||
import { Account } from "../../../src/account/account"
|
||||
import { Auth } from "../../../src/auth"
|
||||
import { Config } from "../../../src/config/config"
|
||||
import type { ConfigPlugin } from "../../../src/config/plugin"
|
||||
import { KilocodeDefaultPlugins } from "../../../src/kilocode/config/default-plugins"
|
||||
import { INDEXING_PLUGIN } from "../../../src/kilocode/indexing-feature"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Env } from "../../../src/env"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Filesystem } from "../../../src/util/filesystem"
|
||||
import { Instance } from "../../../src/project/instance"
|
||||
import { WithInstance } from "../../../src/project/with-instance"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
|
||||
|
||||
@@ -41,13 +44,35 @@ const layer = Config.layer.pipe(
|
||||
)
|
||||
|
||||
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = (wait = false) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
|
||||
describe("kilocode default indexing plugin", () => {
|
||||
afterEach(async () => {
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
await clear(true)
|
||||
})
|
||||
|
||||
test("injects indexing without registering an external plugin origin", () => {
|
||||
const config: { plugin?: ConfigPlugin.Spec[]; plugin_origins?: ConfigPlugin.Origin[] } = {}
|
||||
|
||||
KilocodeDefaultPlugins.apply(config, { disabled: false })
|
||||
|
||||
expect(hasIndexingPlugin(config.plugin ?? [])).toBe(true)
|
||||
expect(config.plugin_origins).toBeUndefined()
|
||||
})
|
||||
|
||||
test("removes a persisted indexing marker from external plugin origins", () => {
|
||||
const external: ConfigPlugin.Origin = { spec: "global-plugin", source: "global", scope: "global" }
|
||||
const config = {
|
||||
plugin: [INDEXING_PLUGIN, external.spec],
|
||||
plugin_origins: [{ spec: INDEXING_PLUGIN, source: "global", scope: "global" as const }, external],
|
||||
}
|
||||
|
||||
KilocodeDefaultPlugins.apply(config, { disabled: true })
|
||||
|
||||
expect(config.plugin).toEqual([INDEXING_PLUGIN, external.spec])
|
||||
expect(config.plugin_origins).toEqual([external])
|
||||
})
|
||||
|
||||
test("does not hard-enable indexing plugin when default plugins are disabled", async () => {
|
||||
@@ -67,7 +92,7 @@ describe("kilocode default indexing plugin", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Config } from "../../../src/config/config"
|
||||
|
||||
describe("Config.Info experimental speech-to-text model", () => {
|
||||
test("parses the selected speech-to-text model", () => {
|
||||
const parsed = Config.Info.zod.parse({
|
||||
experimental: {
|
||||
speech_to_text_model: "openai/gpt-4o-mini-transcribe",
|
||||
},
|
||||
})
|
||||
|
||||
expect(parsed.experimental?.speech_to_text_model).toBe("openai/gpt-4o-mini-transcribe")
|
||||
})
|
||||
|
||||
test("keeps existing experimental defaults", () => {
|
||||
const parsed = Config.Info.zod.parse({ experimental: { speech_to_text_model: "google/chirp-3" } })
|
||||
expect(parsed.experimental?.openTelemetry).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -94,9 +94,14 @@ describe("daemon manager", () => {
|
||||
expect(started.state?.port).toBeGreaterThanOrEqual(Daemon.PortRange.start)
|
||||
expect(started.state?.port).toBeLessThanOrEqual(Daemon.PortRange.end)
|
||||
|
||||
const blocked = await fetch(`${started.state!.url}/global/health`)
|
||||
const blocked = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`)
|
||||
expect(blocked.status).toBe(401)
|
||||
|
||||
const config = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`, {
|
||||
headers: { authorization: `Basic ${started.state!.token}` },
|
||||
})
|
||||
expect(config.status).toBe(200)
|
||||
|
||||
const health = await fetch(`${started.state!.url}/global/health`, {
|
||||
headers: { authorization: `Basic ${started.state!.token}` },
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { afterAll, afterEach, describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { LSP } from "../../src/lsp/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -69,7 +69,7 @@ describe("edit tool permission filediff metadata", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "new.txt")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
@@ -103,7 +103,7 @@ describe("edit tool permission filediff metadata", () => {
|
||||
const filepath = path.join(tmp.path, "existing.txt")
|
||||
await Bun.write(filepath, "line one\nline two\nline three\n")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
@@ -137,7 +137,7 @@ describe("edit tool permission filediff metadata", () => {
|
||||
const filepath = path.join(tmp.path, "diff-check.txt")
|
||||
await Bun.write(filepath, "alpha\nbeta\ngamma\n")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
@@ -168,7 +168,7 @@ describe("edit tool permission filediff metadata", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "result-new.txt")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
@@ -196,7 +196,7 @@ describe("edit tool permission filediff metadata", () => {
|
||||
const filepath = path.join(tmp.path, "result-edit.txt")
|
||||
await Bun.write(filepath, "before\n")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { clean } from "../../src/kilocode/enhance-prompt"
|
||||
import { clean, INSTRUCTION } from "../../src/kilocode/enhance-prompt"
|
||||
|
||||
describe("enhance-prompt", () => {
|
||||
describe("instruction", () => {
|
||||
it("treats question-shaped drafts as prompts to rewrite", () => {
|
||||
expect(INSTRUCTION).toContain("never as a request to answer")
|
||||
expect(INSTRUCTION).toContain("rewrite it into a clearer question or request without answering it")
|
||||
})
|
||||
|
||||
it("improves instruction-shaped drafts instead of following them", () => {
|
||||
expect(INSTRUCTION).toContain("improve those instructions instead of following them")
|
||||
})
|
||||
})
|
||||
|
||||
describe("clean", () => {
|
||||
it("trims whitespace", () => {
|
||||
expect(clean(" hello world ")).toBe("hello world")
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
@@ -43,13 +44,13 @@ describe("kilocode external directory boundaries", () => {
|
||||
const file = path.join(outer.path, "outside.txt")
|
||||
const { items, ctx } = asks()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: repo.path,
|
||||
fn: async () => {
|
||||
try {
|
||||
await assertExternalDirectory(ctx, file)
|
||||
} finally {
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -67,13 +68,13 @@ describe("kilocode external directory boundaries", () => {
|
||||
const file = path.join(outer.path, "outside-root.txt")
|
||||
const { items, ctx } = asks()
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
try {
|
||||
await assertExternalDirectory(ctx, file)
|
||||
} finally {
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { registerDisposer } from "../../src/effect/instance-registry"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const experimental = Flag.KILO_EXPERIMENTAL_HTTPAPI
|
||||
const root = Global.Path.config
|
||||
|
||||
function app(value: boolean) {
|
||||
Flag.KILO_EXPERIMENTAL_HTTPAPI = value
|
||||
return value ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
async function update(target: ReturnType<typeof app>, provider: "kilo" | "openrouter") {
|
||||
return target.request("/global/config", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ indexing: { provider } }),
|
||||
})
|
||||
}
|
||||
|
||||
async function provider(target: ReturnType<typeof app>, directory: string) {
|
||||
const response = await target.request("/config", { headers: { "x-kilo-directory": directory } })
|
||||
return (await response.json()).indexing?.provider as string | undefined
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
|
||||
;(Global.Path as { config: string }).config = root
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("global config refresh", () => {
|
||||
for (const value of [false, true]) {
|
||||
test(`${value ? "httpapi" : "legacy"} update reloads existing instance before responding`, async () => {
|
||||
await using config = await tmpdir()
|
||||
await using workspace = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
;(Global.Path as { config: string }).config = config.path
|
||||
await disposeAllInstances()
|
||||
const target = app(value)
|
||||
|
||||
expect((await update(target, "openrouter")).status).toBe(200)
|
||||
expect(await provider(target, workspace.path)).toBe("openrouter")
|
||||
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const unregister = registerDisposer(async (directory) => {
|
||||
if (directory !== workspace.path) return
|
||||
started.resolve()
|
||||
await release.promise
|
||||
})
|
||||
try {
|
||||
const pending = update(target, "kilo")
|
||||
await started.promise
|
||||
const early = await Promise.race([pending.then(() => true), Bun.sleep(10).then(() => false)])
|
||||
expect(early).toBe(false)
|
||||
release.resolve()
|
||||
expect((await pending).status).toBe(200)
|
||||
expect(await provider(target, workspace.path)).toBe("kilo")
|
||||
} finally {
|
||||
release.resolve()
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
|
||||
test(`${value ? "httpapi" : "legacy"} update ignores disposal notification failures`, async () => {
|
||||
await using config = await tmpdir()
|
||||
;(Global.Path as { config: string }).config = config.path
|
||||
await disposeAllInstances()
|
||||
const target = app(value)
|
||||
const listener = () => {
|
||||
throw new Error("listener failed")
|
||||
}
|
||||
GlobalBus.on("event", listener)
|
||||
try {
|
||||
expect((await update(target, "kilo")).status).toBe(200)
|
||||
} finally {
|
||||
GlobalBus.off("event", listener)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
|
||||
import { normalizeIndexingStatus } from "@kilocode/kilo-indexing/status"
|
||||
import type { Config } from "../../src/config/config"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { getBootstrapRunEffect } from "../../src/effect/app-runtime"
|
||||
import { KiloIndexing } from "../../src/kilocode/indexing"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { IndexingWorker } from "../../src/kilocode/indexing-worker-client"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
@@ -42,6 +43,17 @@ const off: Partial<Config.Info> = {
|
||||
},
|
||||
},
|
||||
}
|
||||
const inactive: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: false,
|
||||
provider: "ollama",
|
||||
vectorStore: "qdrant",
|
||||
},
|
||||
}
|
||||
const kilo: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
@@ -65,10 +77,42 @@ const implicitOpenAi: Partial<Config.Info> = {
|
||||
},
|
||||
},
|
||||
}
|
||||
const staleKilo: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: true,
|
||||
provider: "kilo",
|
||||
model: "custom/model",
|
||||
dimension: 2048,
|
||||
vectorStore: "qdrant",
|
||||
},
|
||||
}
|
||||
const configDir = process.env["KILO_CONFIG_DIR"]
|
||||
const disabled = process.env["KILO_DISABLE_CODEBASE_INDEXING"]
|
||||
const error = new Error("test indexing initialization failed")
|
||||
|
||||
function inline(directory: string, root: string, hooks: IndexingWorker.Hooks): IndexingWorker.Driver {
|
||||
const manager = new CodeIndexManager(directory, root)
|
||||
const progress = manager.onProgressUpdate.on(() => hooks.status(normalizeIndexingStatus(manager)))
|
||||
const telemetry = manager.onTelemetry.on(hooks.telemetry)
|
||||
|
||||
return {
|
||||
async init(input) {
|
||||
await manager.initialize(input)
|
||||
return normalizeIndexingStatus(manager)
|
||||
},
|
||||
search: (query, directoryPrefix) => manager.searchIndex(query, directoryPrefix),
|
||||
async dispose() {
|
||||
progress.dispose()
|
||||
telemetry.dispose()
|
||||
manager.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function wait(read: () => Promise<KiloIndexing.Status>, state: KiloIndexing.Status["state"]) {
|
||||
for (const _ of Array.from({ length: 100 })) {
|
||||
const status = await read()
|
||||
@@ -86,7 +130,12 @@ async function called(init: ReturnType<typeof spyOn<CodeIndexManager, "initializ
|
||||
throw new Error("indexing initialization did not start")
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
IndexingWorker.override(inline)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
IndexingWorker.override()
|
||||
if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"]
|
||||
else process.env["KILO_CONFIG_DIR"] = configDir
|
||||
if (disabled === undefined) delete process.env["KILO_DISABLE_CODEBASE_INDEXING"]
|
||||
@@ -183,9 +232,8 @@ describe("indexing startup degradation", () => {
|
||||
GlobalBus.on("event", on)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
await called(init)
|
||||
expect((await KiloIndexing.current()).state).toBe("In Progress")
|
||||
@@ -204,16 +252,16 @@ describe("indexing startup degradation", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps degraded indexing queryable but unavailable", async () => {
|
||||
test("keeps degraded indexing queryable but releases its failed engine", async () => {
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize").mockRejectedValue(error)
|
||||
const dispose = spyOn(CodeIndexManager.prototype, "dispose")
|
||||
|
||||
await using tmp = await tmpdir({ git: true, config: cfg })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
const status = await wait(() => KiloIndexing.current(), "Error")
|
||||
|
||||
@@ -222,9 +270,11 @@ describe("indexing startup degradation", () => {
|
||||
expect(await KiloIndexing.available()).toBe(false)
|
||||
expect(KiloIndexing.ready()).toBe(false)
|
||||
expect(await KiloIndexing.search("boot failure")).toEqual([])
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
dispose.mockRestore()
|
||||
init.mockRestore()
|
||||
}
|
||||
})
|
||||
@@ -236,9 +286,8 @@ describe("indexing startup degradation", () => {
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize").mockImplementation(() => gate.promise)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
await called(init)
|
||||
|
||||
@@ -259,9 +308,8 @@ describe("indexing startup degradation", () => {
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize")
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
const status = await KiloIndexing.current()
|
||||
|
||||
@@ -277,6 +325,33 @@ describe("indexing startup degradation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not allocate an engine when indexing configuration is disabled", async () => {
|
||||
const created: string[] = []
|
||||
IndexingWorker.override((directory, root, hooks) => {
|
||||
created.push(directory)
|
||||
return inline(directory, root, hooks)
|
||||
})
|
||||
|
||||
await using tmp = await tmpdir({ git: true, config: inactive })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const status = await wait(() => KiloIndexing.current(), "Disabled")
|
||||
|
||||
expect(status).toMatchObject({
|
||||
state: "Disabled",
|
||||
message: "Indexing disabled.",
|
||||
})
|
||||
expect(await KiloIndexing.available()).toBe(false)
|
||||
expect(KiloIndexing.ready()).toBe(false)
|
||||
expect(await KiloIndexing.search("disabled")).toEqual([])
|
||||
expect(created).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("enriches Kilo provider config from env auth", async () => {
|
||||
global.fetch = (() =>
|
||||
Promise.resolve(
|
||||
@@ -300,9 +375,8 @@ describe("indexing startup degradation", () => {
|
||||
process.env.KILO_ORG_ID = "org_123"
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
await called(init)
|
||||
expect(init.mock.calls[0]?.[0]).toMatchObject({
|
||||
@@ -324,6 +398,125 @@ describe("indexing startup degradation", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("falls back from unsupported stored Kilo models to the hosted default", async () => {
|
||||
global.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
defaultModel: "mistralai/mistral-embed-2312",
|
||||
models: [
|
||||
{ id: "mistralai/mistral-embed-2312", name: "Mistral Embed 2312", dimension: 1024, scoreThreshold: 0.35 },
|
||||
],
|
||||
aliases: {},
|
||||
}),
|
||||
),
|
||||
)) as unknown as typeof global.fetch
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
|
||||
const key = process.env.KILO_API_KEY
|
||||
|
||||
await using tmp = await tmpdir({ git: true, config: staleKilo })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
process.env.KILO_API_KEY = "kilo-token"
|
||||
|
||||
try {
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await called(init)
|
||||
expect(init.mock.calls[0]?.[0]).toMatchObject({
|
||||
embedderProvider: "kilo",
|
||||
modelId: "mistralai/mistral-embed-2312",
|
||||
modelDimension: 1024,
|
||||
searchMinScore: 0.35,
|
||||
})
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (key === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = key
|
||||
init.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps configured dimensions for supported Kilo models", async () => {
|
||||
global.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
defaultModel: "mistralai/mistral-embed-2312",
|
||||
models: [
|
||||
{ id: "mistralai/mistral-embed-2312", name: "Mistral Embed 2312", dimension: 1024, scoreThreshold: 0.35 },
|
||||
{
|
||||
id: "openai/text-embedding-3-small",
|
||||
name: "OpenAI Text Embedding 3 Small",
|
||||
dimension: 1536,
|
||||
scoreThreshold: 0.4,
|
||||
},
|
||||
],
|
||||
aliases: {},
|
||||
}),
|
||||
),
|
||||
)) as unknown as typeof global.fetch
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
|
||||
const key = process.env.KILO_API_KEY
|
||||
const config: Partial<Config.Info> = {
|
||||
...staleKilo,
|
||||
indexing: {
|
||||
...staleKilo.indexing,
|
||||
model: "openai/text-embedding-3-small",
|
||||
dimension: 256,
|
||||
},
|
||||
}
|
||||
|
||||
await using tmp = await tmpdir({ git: true, config })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
process.env.KILO_API_KEY = "kilo-token"
|
||||
|
||||
try {
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await called(init)
|
||||
expect(init.mock.calls[0]?.[0]).toMatchObject({
|
||||
embedderProvider: "kilo",
|
||||
modelId: "openai/text-embedding-3-small",
|
||||
modelDimension: 256,
|
||||
})
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (key === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = key
|
||||
init.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not execute stored Kilo models when the hosted catalog is unavailable", async () => {
|
||||
global.fetch = (() => Promise.resolve(new Response(undefined, { status: 500 }))) as unknown as typeof global.fetch
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
|
||||
const key = process.env.KILO_API_KEY
|
||||
|
||||
await using tmp = await tmpdir({ git: true, config: staleKilo })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
process.env.KILO_API_KEY = "kilo-token"
|
||||
|
||||
try {
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await called(init)
|
||||
expect(init.mock.calls[0]?.[0]).toMatchObject({ embedderProvider: "kilo" })
|
||||
expect(init.mock.calls[0]?.[0].modelId).toBeUndefined()
|
||||
expect(init.mock.calls[0]?.[0].modelDimension).toBeUndefined()
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (key === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = key
|
||||
init.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not default to Kilo when an existing provider config is present", async () => {
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
|
||||
const key = process.env.KILO_API_KEY
|
||||
@@ -333,9 +526,8 @@ describe("indexing startup degradation", () => {
|
||||
process.env.KILO_API_KEY = "kilo-token"
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
await called(init)
|
||||
expect(init.mock.calls[0]?.[0]).toMatchObject({
|
||||
@@ -358,9 +550,8 @@ describe("indexing startup degradation", () => {
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize")
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
const status = await KiloIndexing.current()
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { IndexingWorker } from "../../src/kilocode/indexing-worker-client"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
test("runs indexing engine requests in its worker", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const failures: unknown[] = []
|
||||
const engine = IndexingWorker.create(tmp.path, tmp.path, {
|
||||
status() {},
|
||||
telemetry() {},
|
||||
failure(err) {
|
||||
failures.push(err)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const status = await engine.init({ enabled: false, embedderProvider: "openai" })
|
||||
expect(status.state).toBe("Disabled")
|
||||
} finally {
|
||||
await engine.dispose()
|
||||
}
|
||||
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import type { Config } from "../../src/config/config"
|
||||
import { getBootstrapRunEffect } from "../../src/effect/app-runtime"
|
||||
import { KiloIndexing } from "../../src/kilocode/indexing"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const cfg: Partial<Config.Info> = {
|
||||
@@ -36,9 +35,8 @@ describe("indexing worktree disable", () => {
|
||||
const dir = `${tmp.path}/.kilo/worktrees/feature`
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: dir,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
const status = await KiloIndexing.current()
|
||||
|
||||
@@ -57,9 +55,8 @@ describe("indexing worktree disable", () => {
|
||||
const dir = `${tmp.path}/.kilocode/worktrees/feature`
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: dir,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
const status = await KiloIndexing.current()
|
||||
|
||||
|
||||
@@ -6,29 +6,15 @@ import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Config } from "@/config/config"
|
||||
import { emptyConsoleState } from "@/config/console-state"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
const configLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
get: () => Effect.succeed({}),
|
||||
getGlobal: () => Effect.succeed({}),
|
||||
getConsoleState: () => Effect.succeed(emptyConsoleState),
|
||||
update: () => Effect.void,
|
||||
updateGlobal: (config) => Effect.succeed(config),
|
||||
invalidate: () => Effect.void,
|
||||
directories: () => Effect.succeed([]),
|
||||
waitForDependencies: () => Effect.void,
|
||||
warnings: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const configLayer = TestConfig.layer()
|
||||
|
||||
const instructionLayer = (global: Partial<Global.Interface>) =>
|
||||
Instruction.layer.pipe(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from "bun:test"
|
||||
import { describe, it, expect, afterEach, beforeEach } from "bun:test"
|
||||
import { buildKiloHeaders, getFeatureHeader, getEditorNameHeader } from "@kilocode/kilo-gateway"
|
||||
import { HEADER_FEATURE, ENV_FEATURE, ENV_VERSION, DEFAULT_EDITOR_NAME } from "@kilocode/kilo-gateway"
|
||||
import { HEADER_FEATURE, ENV_FEATURE, ENV_EDITOR_NAME, ENV_VERSION, DEFAULT_EDITOR_NAME } from "@kilocode/kilo-gateway"
|
||||
|
||||
describe("getFeatureHeader", () => {
|
||||
const original = process.env[ENV_FEATURE]
|
||||
@@ -31,6 +31,11 @@ describe("getFeatureHeader", () => {
|
||||
|
||||
describe("getEditorNameHeader", () => {
|
||||
const originalVersion = process.env[ENV_VERSION]
|
||||
const originalEditor = process.env[ENV_EDITOR_NAME]
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[ENV_EDITOR_NAME]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVersion === undefined) {
|
||||
@@ -38,6 +43,12 @@ describe("getEditorNameHeader", () => {
|
||||
} else {
|
||||
process.env[ENV_VERSION] = originalVersion
|
||||
}
|
||||
|
||||
if (originalEditor === undefined) {
|
||||
delete process.env[ENV_EDITOR_NAME]
|
||||
} else {
|
||||
process.env[ENV_EDITOR_NAME] = originalEditor
|
||||
}
|
||||
})
|
||||
|
||||
it("returns default editor name without version when KILOCODE_VERSION is not set", () => {
|
||||
|
||||
@@ -1,169 +1,139 @@
|
||||
// kilocode_change - new file
|
||||
//
|
||||
// Tests that the kilo custom loader keeps paid models visible without authentication.
|
||||
// Mocks fetchKiloModels from @kilocode/kilo-gateway to avoid real network
|
||||
// calls (which fail on Windows CI).
|
||||
// Tests that unauthenticated Kilo models are assembled with paid models and autoloaded anonymously.
|
||||
|
||||
import { test, expect, mock } from "bun:test"
|
||||
import path from "path"
|
||||
import { unlink } from "fs/promises"
|
||||
|
||||
// Bun's mock.module() is process-wide and permanent — it replaces the module
|
||||
// for ALL test files in the same runner process. To avoid breaking other tests
|
||||
// that import @kilocode/kilo-gateway, we spread the real exports and only
|
||||
// override fetchKiloModels with a stub that returns both free and paid models.
|
||||
const real = await import("@kilocode/kilo-gateway")
|
||||
|
||||
mock.module("@kilocode/kilo-gateway", () => ({
|
||||
...real,
|
||||
fetchKiloModels: async () => ({
|
||||
models: {
|
||||
"free-model": {
|
||||
id: "free-model",
|
||||
name: "Free Model",
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
"paid-model": {
|
||||
id: "paid-model",
|
||||
name: "Paid Model",
|
||||
cost: { input: 1.0, output: 2.0 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { ModelCache } from "../../src/provider/model-cache"
|
||||
import { expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { kiloCustomLoaders } from "../../src/kilocode/provider/provider"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { ModelCache } from "../../src/provider/model-cache"
|
||||
import { ModelsDev } from "../../src/provider/models"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
|
||||
const item = providers[ProviderID.kilo]
|
||||
expect(item).toBeDefined()
|
||||
return Object.values(item.models).filter((model) => model.cost.input > 0).length
|
||||
const input = {
|
||||
id: "kilo",
|
||||
env: ["KILO_API_KEY"],
|
||||
models: {
|
||||
"free-model": {
|
||||
id: "free-model",
|
||||
name: "Free Model",
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
"paid-model": {
|
||||
id: "paid-model",
|
||||
name: "Paid Model",
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
test("kilo loader keeps paid models without auth and when config apiKey is present", async () => {
|
||||
// Reset state that may be stale from other test files sharing this process.
|
||||
// Auth.set from other tests persists in the shared auth.json,
|
||||
// and ModelCache keeps fetched models in a TTL map.
|
||||
// ModelsDev.Data was removed in v1.14.33 — instance-store disposal handles cache invalidation.
|
||||
await Auth.remove("kilo")
|
||||
ModelCache.clear("kilo")
|
||||
const seed: Record<string, ModelsDev.Provider> = {
|
||||
apertis: {
|
||||
id: "apertis",
|
||||
name: "Apertis",
|
||||
env: ["APERTIS_API_KEY"],
|
||||
models: {},
|
||||
},
|
||||
}
|
||||
|
||||
await using base = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "kilo.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
const none = await Instance.provide({
|
||||
directory: base.path,
|
||||
fn: async () => paid(await Provider.list()),
|
||||
})
|
||||
const files = Layer.effect(
|
||||
AppFileSystem.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
return AppFileSystem.Service.of({
|
||||
...fs,
|
||||
readJson: () => Effect.succeed(seed),
|
||||
stat: () => fs.stat(import.meta.path),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
await using keyed = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "kilo.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
provider: {
|
||||
kilo: {
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
},
|
||||
function load(data?: { auth?: object; config?: object; env?: Record<string, string | undefined> }) {
|
||||
return kiloCustomLoaders({
|
||||
auth: () => Effect.succeed(data?.auth),
|
||||
config: () => Effect.succeed(data?.config ?? {}),
|
||||
env: () => Effect.succeed(data?.env ?? {}),
|
||||
get: () => Effect.succeed(undefined),
|
||||
}).kilo(input)
|
||||
}
|
||||
|
||||
function layer() {
|
||||
const cfg = TestConfig.layer()
|
||||
const models = Layer.succeed(
|
||||
ModelCache.KiloModelsService,
|
||||
ModelCache.KiloModelsService.of({
|
||||
fetch: () =>
|
||||
Effect.succeed({
|
||||
models: {
|
||||
"paid-model": {
|
||||
id: "paid-model",
|
||||
name: "Paid Model",
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
const cache = Layer.fresh(ModelCache.layer).pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(cfg),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(models),
|
||||
)
|
||||
return Layer.fresh(ModelsDev.layer).pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(files),
|
||||
Layer.provide(cfg),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(cache),
|
||||
)
|
||||
}
|
||||
|
||||
const count = await Instance.provide({
|
||||
directory: keyed.path,
|
||||
fn: async () => paid(await Provider.list()),
|
||||
})
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
expect(none).toBeGreaterThan(0)
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
it.live("assembles paid Kilo models without auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(Effect.provide(layer()))
|
||||
const kilo = Provider.fromModelsDevProvider(providers.kilo)
|
||||
|
||||
test("kilo loader keeps paid models without auth and when auth exists", async () => {
|
||||
await Auth.remove("kilo")
|
||||
ModelCache.clear("kilo")
|
||||
|
||||
await using base = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "kilo.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const none = await Instance.provide({
|
||||
directory: base.path,
|
||||
fn: async () => paid(await Provider.list()),
|
||||
})
|
||||
|
||||
await using keyed = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "kilo.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
let prev: string | undefined
|
||||
|
||||
try {
|
||||
prev = await Filesystem.readText(authPath)
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await Filesystem.write(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
kilo: {
|
||||
type: "api",
|
||||
key: "test-key",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const count = await Instance.provide({
|
||||
directory: keyed.path,
|
||||
fn: async () => paid(await Provider.list()),
|
||||
expect(kilo.models["paid-model"]).toMatchObject({
|
||||
id: "paid-model",
|
||||
providerID: "kilo",
|
||||
cost: { input: 1, output: 2 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(none).toBeGreaterThan(0)
|
||||
expect(count).toBeGreaterThan(0)
|
||||
} finally {
|
||||
if (prev !== undefined) {
|
||||
await Filesystem.write(authPath, prev)
|
||||
}
|
||||
if (prev === undefined) {
|
||||
try {
|
||||
await unlink(authPath)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
})
|
||||
it.effect("enables a paid catalog anonymously without auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* load()
|
||||
expect(result.autoload).toBe(true)
|
||||
expect(result.options).toEqual({ apiKey: "anonymous" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables a paid catalog when config apiKey is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* load({ config: { provider: { kilo: { options: { apiKey: "test-key" } } } } })
|
||||
expect(result.autoload).toBe(true)
|
||||
expect(result.options).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables a paid catalog when auth exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* load({ auth: { type: "api", key: "test-key" } })
|
||||
expect(result.autoload).toBe(true)
|
||||
expect(result.options).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
// kilocode_change - new file
|
||||
// Integration: when fetchKiloModels returns a 401 error result, ModelCache
|
||||
// surfaces the failure and caches empty models (allowing re-auth via /connect).
|
||||
// The real 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts.
|
||||
// When the injected Kilo models source returns a 401 error result, ModelCache surfaces
|
||||
// the failure and caches empty models (allowing re-auth via /connect).
|
||||
// The real fetchKiloModels 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts.
|
||||
|
||||
import { test, expect, mock } from "bun:test"
|
||||
import path from "path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
// Simulate a 401 typed error result from the gateway
|
||||
mock.module("@kilocode/kilo-gateway", () => ({
|
||||
fetchKiloModels: async () => ({
|
||||
models: {},
|
||||
error: { kind: "unauthorized", status: 401 },
|
||||
}),
|
||||
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
|
||||
}))
|
||||
|
||||
mock.module("opencode-copilot-auth", () => ({ default: () => ({}) }))
|
||||
mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) }))
|
||||
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) }))
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { ModelCache } from "../../src/provider/model-cache"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" })
|
||||
|
||||
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "kilo.json"), CONFIG)
|
||||
},
|
||||
})
|
||||
return Instance.provide({ directory: tmp.path, fn })
|
||||
}
|
||||
|
||||
test("401 from gateway sets provider as failed in ModelCache", async () => {
|
||||
ModelCache.clear("kilo")
|
||||
await withInstance(() => ModelCache.fetch("kilo"))
|
||||
expect(ModelCache.failedProviders()).toContain("kilo")
|
||||
expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
test("401 from gateway caches empty models (not undefined)", async () => {
|
||||
ModelCache.clear("kilo")
|
||||
await withInstance(() => ModelCache.fetch("kilo"))
|
||||
const cached = ModelCache.get("kilo")
|
||||
expect(cached).toBeDefined()
|
||||
expect(Object.keys(cached!)).toHaveLength(0)
|
||||
})
|
||||
const models = Layer.succeed(
|
||||
ModelCache.KiloModelsService,
|
||||
ModelCache.KiloModelsService.of({
|
||||
fetch: () => Effect.succeed({ models: {}, error: { kind: "unauthorized", status: 401 } }),
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = Layer.fresh(ModelCache.layer).pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(TestConfig.layer()),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(models),
|
||||
)
|
||||
|
||||
const it = testEffect(layer)
|
||||
|
||||
it.live("401 from Kilo models sets provider as failed in ModelCache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelCache.Service
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* cache.failedProviders()).toContain("kilo")
|
||||
expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("401 from Kilo models caches empty models (not undefined)", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelCache.Service
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* cache.get("kilo")).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// kilocode_change - new file
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Bus } from "../../src/bus"
|
||||
import type { Config } from "../../src/config/config"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
|
||||
function layer(overrides: Partial<Config.Interface> = {}) {
|
||||
return Layer.merge(
|
||||
KiloSessions.layer.pipe(Layer.provideMerge(Bus.layer), Layer.provide(TestConfig.layer(overrides))),
|
||||
Auth.defaultLayer,
|
||||
)
|
||||
}
|
||||
|
||||
it.instance("initializes once per instance through Config.Service", () => {
|
||||
let reads = 0
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
yield* sessions.init()
|
||||
yield* sessions.init()
|
||||
expect(reads).toBe(1)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer({
|
||||
getGlobal: () =>
|
||||
Effect.sync(() => {
|
||||
reads += 1
|
||||
return {}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("does not duplicate created-session subscribers when init is repeated", () => {
|
||||
const calls: string[] = []
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
|
||||
if (url.endsWith("/api/session")) {
|
||||
calls.push(url)
|
||||
return Response.json({ id: "remote-1", ingestPath: "/api/ingest/session-1" })
|
||||
}
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
const id = SessionID.descending("session-created")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const bus = yield* Bus.Service
|
||||
const sessions = yield* KiloSessions.Service
|
||||
yield* auth.set("kilo", { type: "api", key: "test-token" })
|
||||
yield* sessions.init()
|
||||
yield* sessions.init()
|
||||
yield* Effect.sleep(50)
|
||||
yield* bus.publish(Session.Event.Created, {
|
||||
sessionID: id,
|
||||
info: {
|
||||
id,
|
||||
slug: "test",
|
||||
projectID: ProjectID.make("project-test"),
|
||||
directory: "/tmp/test",
|
||||
title: "test",
|
||||
version: "test",
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
},
|
||||
})
|
||||
yield* Effect.sleep(50)
|
||||
expect(calls).toHaveLength(1)
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { localReviewCommand, localReviewUncommittedCommand, parseReviewCommand } from "../../src/kilocode/review/command"
|
||||
|
||||
describe("review command parsing", () => {
|
||||
test("parses review slash commands", () => {
|
||||
expect(parseReviewCommand("/review")).toBe("review")
|
||||
expect(parseReviewCommand("/local-review -- focus tests")).toBe("local-review")
|
||||
expect(parseReviewCommand("/local-review-uncommitted focus tests")).toBe("local-review-uncommitted")
|
||||
expect(parseReviewCommand("/test")).toBeUndefined()
|
||||
expect(parseReviewCommand("local-review")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("local-review command", () => {
|
||||
const cmd = localReviewCommand()
|
||||
|
||||
test("exposes a static string template", () => {
|
||||
expect(cmd.name).toBe("local-review")
|
||||
expect(typeof cmd.template).toBe("string")
|
||||
})
|
||||
|
||||
test("template includes $ARGUMENTS for raw user input", () => {
|
||||
expect(cmd.template).toContain("$ARGUMENTS")
|
||||
})
|
||||
|
||||
test("hints expose $ARGUMENTS as the only placeholder", () => {
|
||||
expect(cmd.hints).toEqual(["$ARGUMENTS"])
|
||||
})
|
||||
|
||||
test("template documents free-form argument handling", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("Empty input")
|
||||
expect(text).toContain("literal free-form text")
|
||||
expect(text).toContain("Clearly requested base")
|
||||
expect(text).toContain("Base plus guidance")
|
||||
expect(text).toContain("Everything else")
|
||||
expect(text).toContain("ambiguous input as review instructions")
|
||||
expect(text).not.toContain("<base> -- <instructions>")
|
||||
expect(text).not.toContain("-- <instructions>")
|
||||
})
|
||||
|
||||
test("template documents the default base priority", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("origin/main")
|
||||
expect(text).toContain("origin/master")
|
||||
expect(text).toContain("origin/dev")
|
||||
expect(text).toContain("origin/develop")
|
||||
expect(text).toContain("local `main`")
|
||||
expect(text).toContain("local `master`")
|
||||
expect(text).toContain("local `dev`")
|
||||
expect(text).toContain("local `develop`")
|
||||
expect(text).toContain("fall back to `main`")
|
||||
expect(text).toContain("Review.getBaseBranch()")
|
||||
})
|
||||
|
||||
test("template instructs the model to validate the base before reviewing", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("git merge-base HEAD <base>")
|
||||
expect(text).toMatch(/no common history|not found/i)
|
||||
})
|
||||
|
||||
test("template avoids dereferencing untracked symlinks", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("verify it is not a symlink")
|
||||
expect(text).toContain("do not follow the link")
|
||||
})
|
||||
|
||||
test("template tells the model not to edit files", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("DO NOT modify any files")
|
||||
})
|
||||
|
||||
test("template applies the review-pr high-signal review focus", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("Review only these things")
|
||||
expect(text).toContain("deploy safety")
|
||||
expect(text).toContain("duplicated code or duplicated logic")
|
||||
expect(text).toContain("dead code caused by the reviewed changes")
|
||||
expect(text).toContain("Do not review these things")
|
||||
expect(text).toContain("code style")
|
||||
expect(text).toContain("generic refactors with no bug or product risk")
|
||||
})
|
||||
|
||||
test("template applies the review-pr parallel review tracks", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("spawn six sub-agents in parallel")
|
||||
expect(text).toContain("security")
|
||||
expect(text).toContain("performance")
|
||||
expect(text).toContain("business logic")
|
||||
expect(text).toContain("NO_FINDINGS")
|
||||
})
|
||||
})
|
||||
|
||||
describe("local-review-uncommitted command", () => {
|
||||
const cmd = localReviewUncommittedCommand()
|
||||
|
||||
test("exposes a static string template", () => {
|
||||
expect(cmd.name).toBe("local-review-uncommitted")
|
||||
expect(typeof cmd.template).toBe("string")
|
||||
})
|
||||
|
||||
test("template includes $ARGUMENTS for raw user input", () => {
|
||||
expect(cmd.template).toContain("$ARGUMENTS")
|
||||
})
|
||||
|
||||
test("hints expose $ARGUMENTS as the only placeholder", () => {
|
||||
expect(cmd.hints).toEqual(["$ARGUMENTS"])
|
||||
})
|
||||
|
||||
test("template includes $ARGUMENTS in a user input section", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("## User Input\n\n$ARGUMENTS")
|
||||
})
|
||||
|
||||
test("template documents free-form user guidance", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("literal free-form review guidance")
|
||||
expect(text).toContain("never changes the diff scope")
|
||||
expect(text).toContain("no base branch selection")
|
||||
expect(text).toContain("MUST NOT override the diff scope")
|
||||
})
|
||||
|
||||
test("template documents the uncommitted scope and key git commands", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toMatch(/git\b[^\n]*\bdiff HEAD/)
|
||||
expect(text).toMatch(/git\b[^\n]*\bdiff --cached/)
|
||||
expect(text).toContain("git ls-files --others --exclude-standard")
|
||||
})
|
||||
|
||||
test("template avoids dereferencing untracked symlinks", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("verify it is not a symlink")
|
||||
expect(text).toContain("do not follow the link")
|
||||
})
|
||||
|
||||
test("template tells the model not to edit files", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("DO NOT modify any files")
|
||||
})
|
||||
|
||||
test("template applies the review-pr high-signal review focus", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("Review only these things")
|
||||
expect(text).toContain("deploy safety")
|
||||
expect(text).toContain("duplicated code or duplicated logic")
|
||||
expect(text).toContain("dead code caused by the reviewed changes")
|
||||
expect(text).toContain("Do not review these things")
|
||||
expect(text).toContain("code style")
|
||||
expect(text).toContain("generic refactors with no bug or product risk")
|
||||
})
|
||||
|
||||
test("template applies the review-pr parallel review tracks", () => {
|
||||
const text = cmd.template as string
|
||||
expect(text).toContain("spawn six sub-agents in parallel")
|
||||
expect(text).toContain("security")
|
||||
expect(text).toContain("performance")
|
||||
expect(text).toContain("business logic")
|
||||
expect(text).toContain("NO_FINDINGS")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { plain, session, supports, tui } from "../../src/kilocode/cli/logo"
|
||||
|
||||
describe("kilocode logo", () => {
|
||||
test("allows remote terminals", () => {
|
||||
expect(supports({ SSH_TTY: "/dev/pts/0" }, "linux")).toBe(true)
|
||||
expect(supports({ SSH_CLIENT: "127.0.0.1 12345 22" }, "linux")).toBe(true)
|
||||
expect(supports({ SSH_CONNECTION: "127.0.0.1 12345 127.0.0.1 22" }, "linux")).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back on old Windows terminals", () => {
|
||||
expect(supports({}, "win32")).toBe(false)
|
||||
expect(supports({ ANSICON: "1" }, "win32")).toBe(false)
|
||||
expect(supports({ ConEmuPID: "123" }, "win32")).toBe(false)
|
||||
})
|
||||
|
||||
test("allows modern Windows terminals", () => {
|
||||
expect(supports({ WT_SESSION: "session" }, "win32")).toBe(true)
|
||||
expect(supports({ TERM_PROGRAM: "vscode" }, "win32")).toBe(true)
|
||||
expect(supports({ WEZTERM_PANE: "1" }, "win32")).toBe(true)
|
||||
expect(supports({ TERM_PROGRAM: "WezTerm" }, "win32")).toBe(true)
|
||||
})
|
||||
|
||||
test("allows an override", () => {
|
||||
expect(supports({ KILO_UNICODE_LOGO: "1", SSH_TTY: "/dev/pts/0" }, "linux")).toBe(true)
|
||||
expect(supports({ KILO_UNICODE_LOGO: "0" }, "linux")).toBe(false)
|
||||
})
|
||||
|
||||
test("uses modern and fallback logo variants", () => {
|
||||
expect(tui({ KILO_UNICODE_LOGO: "1" }, "linux").join("\n")).toContain("🬺🬏")
|
||||
expect(tui({}, "win32").join("\n")).not.toContain("🬺🬏")
|
||||
expect(plain({}, "win32").join("\n")).not.toContain("🬁🬬")
|
||||
})
|
||||
|
||||
test("formats child session exit logo", () => {
|
||||
const out = session("Title", "ses_test", "<dim>", "<reset>", {}, "win32")
|
||||
expect(out).toContain("<dim>Title<reset>")
|
||||
expect(out).not.toContain("🬺🬏")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
// kilocode_change - new file
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { ModelCache } from "../../src/provider/model-cache"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type Hit = { readonly url: string }
|
||||
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
function layer(
|
||||
hits: Ref.Ref<Hit[]>,
|
||||
cfg = TestConfig.layer(),
|
||||
access = auth,
|
||||
gates?: { readonly started: Deferred.Deferred<void>; readonly wait: Deferred.Deferred<void> },
|
||||
) {
|
||||
const http = HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(hits, (list) => [...list, { url: request.url }])
|
||||
const count = (yield* Ref.get(hits)).length
|
||||
if (gates && count === 1) {
|
||||
yield* Deferred.succeed(gates.started, undefined)
|
||||
yield* Deferred.await(gates.wait)
|
||||
}
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({ data: [{ id: `apertis-${count}`, owned_by: "apertis" }] }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return Layer.fresh(ModelCache.layer).pipe(
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
|
||||
Layer.provide(cfg),
|
||||
Layer.provide(access),
|
||||
Layer.provide(ModelCache.kiloModelsLayer),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("fetches Apertis models through the injected HttpClient", () =>
|
||||
Effect.gen(function* () {
|
||||
const hits = yield* Ref.make<Hit[]>([])
|
||||
const models = yield* ModelCache.Service.use((cache) =>
|
||||
cache.fetch("apertis", { apiKey: "test-key", baseURL: "https://apertis.test/v1" }),
|
||||
).pipe(Effect.provide(layer(hits)))
|
||||
|
||||
expect(Object.keys(models)).toEqual(["apertis-1"])
|
||||
expect((yield* Ref.get(hits)).map((hit) => hit.url)).toEqual(["https://apertis.test/v1/models"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reuses cached values and refresh invalidates the provider cell", () =>
|
||||
Effect.gen(function* () {
|
||||
const hits = yield* Ref.make<Hit[]>([])
|
||||
const run = ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* cache.fetch("apertis", { apiKey: "test-key" })
|
||||
const cached = yield* cache.fetch("apertis", { apiKey: "test-key" })
|
||||
const refreshed = yield* cache.refresh("apertis", { apiKey: "test-key" })
|
||||
return { first, cached, refreshed }
|
||||
}),
|
||||
).pipe(Effect.provide(layer(hits)))
|
||||
const out = yield* run
|
||||
|
||||
expect(Object.keys(out.first)).toEqual(["apertis-1"])
|
||||
expect(Object.keys(out.cached)).toEqual(["apertis-1"])
|
||||
expect(Object.keys(out.refreshed)).toEqual(["apertis-2"])
|
||||
expect((yield* Ref.get(hits)).length).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps concurrent request options isolated", () =>
|
||||
Effect.gen(function* () {
|
||||
const hits = yield* Ref.make<Hit[]>([])
|
||||
const started = yield* Deferred.make<void>()
|
||||
const wait = yield* Deferred.make<void>()
|
||||
const out = yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* cache
|
||||
.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
const second = yield* cache
|
||||
.fetch("apertis", { apiKey: "second", baseURL: "https://second.test/v1" })
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* Deferred.succeed(wait, undefined)
|
||||
const firstModels = yield* Fiber.join(first)
|
||||
const secondModels = yield* Fiber.join(second)
|
||||
return { first: firstModels, second: secondModels, current: yield* cache.get("apertis") }
|
||||
}),
|
||||
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
|
||||
|
||||
expect(Object.keys(out.first)).toEqual(["apertis-1"])
|
||||
expect(Object.keys(out.second)).toEqual(["apertis-2"])
|
||||
expect(out.current).toEqual(out.second)
|
||||
expect((yield* Ref.get(hits)).map((hit) => hit.url)).toEqual([
|
||||
"https://first.test/v1/models",
|
||||
"https://second.test/v1/models",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not let an older fetch override a newer refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const hits = yield* Ref.make<Hit[]>([])
|
||||
const started = yield* Deferred.make<void>()
|
||||
const wait = yield* Deferred.make<void>()
|
||||
const models = yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
const stale = yield* cache
|
||||
.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
const fresh = yield* cache.refresh("apertis", { apiKey: "second", baseURL: "https://second.test/v1" })
|
||||
yield* Deferred.succeed(wait, undefined)
|
||||
yield* Fiber.join(stale)
|
||||
return { fresh, current: yield* cache.get("apertis") }
|
||||
}),
|
||||
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
|
||||
|
||||
expect(models.current).toEqual(models.fresh)
|
||||
expect(Object.keys(models.current ?? {})).toEqual(["apertis-2"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not restore a fetch that was cleared while pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const hits = yield* Ref.make<Hit[]>([])
|
||||
const started = yield* Deferred.make<void>()
|
||||
const wait = yield* Deferred.make<void>()
|
||||
const current = yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* cache
|
||||
.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* cache.clear("apertis")
|
||||
yield* Deferred.succeed(wait, undefined)
|
||||
yield* Fiber.join(pending)
|
||||
return yield* cache.get("apertis")
|
||||
}),
|
||||
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
|
||||
|
||||
expect(current).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("exposes the most recently refreshed provider value", () =>
|
||||
Effect.gen(function* () {
|
||||
const hits = yield* Ref.make<Hit[]>([])
|
||||
const models = yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
yield* cache.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
|
||||
const refreshed = yield* cache.refresh("apertis", { apiKey: "second", baseURL: "https://second.test/v1" })
|
||||
const current = yield* cache.get("apertis")
|
||||
return { refreshed, current }
|
||||
}),
|
||||
).pipe(Effect.provide(layer(hits)))
|
||||
|
||||
expect(models.current).toEqual(models.refreshed)
|
||||
expect(Object.keys(models.current ?? {})).toEqual(["apertis-2"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not resolve auth or config for unsupported providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const hits = yield* Ref.make<Hit[]>([])
|
||||
const configs = yield* Ref.make(0)
|
||||
const auths = yield* Ref.make(0)
|
||||
const cfg = TestConfig.layer({
|
||||
get: () => Ref.update(configs, (count) => count + 1).pipe(Effect.as({})),
|
||||
})
|
||||
const access = Layer.mock(Auth.Service)({
|
||||
get: () => Ref.update(auths, (count) => count + 1).pipe(Effect.as(undefined)),
|
||||
})
|
||||
const models = yield* ModelCache.Service.use((cache) => cache.fetch("openai")).pipe(
|
||||
Effect.provide(layer(hits, cfg, access)),
|
||||
)
|
||||
|
||||
expect(models).toEqual({})
|
||||
expect(yield* Ref.get(configs)).toBe(0)
|
||||
expect(yield* Ref.get(auths)).toBe(0)
|
||||
expect(yield* Ref.get(hits)).toEqual([])
|
||||
}),
|
||||
)
|
||||
@@ -2,163 +2,111 @@
|
||||
// When a user logs in via OAuth and selects an enterprise organization, the model fetch
|
||||
// should use the organization-specific endpoint, not the personal endpoint.
|
||||
|
||||
import { test, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
// Capture the options passed to fetchKiloModels
|
||||
let captured: any = undefined
|
||||
|
||||
mock.module("@kilocode/kilo-gateway", () => ({
|
||||
fetchKiloModels: async (options: any) => {
|
||||
captured = options
|
||||
return {
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
cost: { input: 0.001, output: 0.002 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
|
||||
}))
|
||||
|
||||
// Mock default plugins to prevent actual installations during tests
|
||||
const mockPlugin = () => ({})
|
||||
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
|
||||
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
|
||||
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { ModelCache } from "../../src/provider/model-cache"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
test("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
type Options = Parameters<ModelCache.KiloModels["fetch"]>[0]
|
||||
|
||||
function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefined>) {
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: (id) => Effect.succeed(id === "kilo" ? info : undefined),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
// Simulate an OAuth login where user selected an enterprise organization
|
||||
await Auth.set("kilo", {
|
||||
type: "oauth",
|
||||
access: "test-oauth-token",
|
||||
refresh: "test-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
accountId: "org-enterprise-123",
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
fn: async () => {
|
||||
// Reset captured and cache
|
||||
captured = undefined
|
||||
ModelCache.clear("kilo")
|
||||
const models = Layer.succeed(
|
||||
ModelCache.KiloModelsService,
|
||||
ModelCache.KiloModelsService.of({
|
||||
fetch: (options) =>
|
||||
Ref.set(captured, options).pipe(
|
||||
Effect.as({
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
cost: { input: 0.001, output: 0.002 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return Layer.fresh(ModelCache.layer).pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(TestConfig.layer()),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(models),
|
||||
)
|
||||
}
|
||||
|
||||
// Trigger model fetch through the cache
|
||||
await ModelCache.fetch("kilo")
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
// The fetchKiloModels call should have received the organization ID
|
||||
expect(captured).toBeDefined()
|
||||
expect(captured.kilocodeToken).toBe("test-oauth-token")
|
||||
expect(captured.kilocodeOrganizationId).toBe("org-enterprise-123")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.live("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", () =>
|
||||
Effect.gen(function* () {
|
||||
const captured = yield* Ref.make<Options | undefined>(undefined)
|
||||
const info = new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: "test-oauth-token",
|
||||
refresh: "test-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
accountId: "org-enterprise-123",
|
||||
})
|
||||
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured)))
|
||||
expect(yield* Ref.get(captured)).toMatchObject({
|
||||
kilocodeToken: "test-oauth-token",
|
||||
kilocodeOrganizationId: "org-enterprise-123",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("model fetch without OAuth accountId does not set kilocodeOrganizationId", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
// Simulate an OAuth login for a personal account (no accountId)
|
||||
await Auth.set("kilo", {
|
||||
type: "oauth",
|
||||
access: "test-personal-token",
|
||||
refresh: "test-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
fn: async () => {
|
||||
captured = undefined
|
||||
ModelCache.clear("kilo")
|
||||
it.live("model fetch without OAuth accountId does not set kilocodeOrganizationId", () =>
|
||||
Effect.gen(function* () {
|
||||
const captured = yield* Ref.make<Options | undefined>(undefined)
|
||||
const info = new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: "test-personal-token",
|
||||
refresh: "test-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
})
|
||||
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured)))
|
||||
expect(yield* Ref.get(captured)).toMatchObject({ kilocodeToken: "test-personal-token" })
|
||||
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
await ModelCache.fetch("kilo")
|
||||
it.live("ModelCache.clear removes cached entry so next fetch hits the network", () =>
|
||||
Effect.gen(function* () {
|
||||
const captured = yield* Ref.make<Options | undefined>(undefined)
|
||||
const info = new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: "token-clear-test",
|
||||
refresh: "refresh-clear",
|
||||
expires: Date.now() + 3600000,
|
||||
accountId: "org-clear",
|
||||
})
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* Ref.get(captured)).toBeDefined()
|
||||
|
||||
expect(captured).toBeDefined()
|
||||
expect(captured.kilocodeToken).toBe("test-personal-token")
|
||||
expect(captured.kilocodeOrganizationId).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* Ref.set(captured, undefined)
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* Ref.get(captured)).toBeUndefined()
|
||||
expect(yield* cache.get("kilo")).toBeDefined()
|
||||
|
||||
test("ModelCache.clear removes cached entry so next fetch hits the network", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
await Auth.set("kilo", {
|
||||
type: "oauth",
|
||||
access: "token-clear-test",
|
||||
refresh: "refresh-clear",
|
||||
expires: Date.now() + 3600000,
|
||||
accountId: "org-clear",
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
fn: async () => {
|
||||
// Populate cache
|
||||
captured = undefined
|
||||
ModelCache.clear("kilo")
|
||||
await ModelCache.fetch("kilo")
|
||||
expect(captured).toBeDefined()
|
||||
yield* cache.clear("kilo")
|
||||
expect(yield* cache.get("kilo")).toBeUndefined()
|
||||
|
||||
// Verify cache is populated — second fetch should NOT call fetchKiloModels
|
||||
captured = undefined
|
||||
await ModelCache.fetch("kilo")
|
||||
expect(captured).toBeUndefined()
|
||||
expect(ModelCache.get("kilo")).toBeDefined()
|
||||
|
||||
// Clear the cache
|
||||
ModelCache.clear("kilo")
|
||||
|
||||
// get() should return undefined after clear
|
||||
expect(ModelCache.get("kilo")).toBeUndefined()
|
||||
|
||||
// Next fetch should call fetchKiloModels again
|
||||
captured = undefined
|
||||
await ModelCache.fetch("kilo")
|
||||
expect(captured).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* Ref.get(captured)).toBeDefined()
|
||||
}),
|
||||
).pipe(Effect.provide(layer(info, captured)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Env } from "../../src/env"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function withNvidiaKey<A, E, R>(self: Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const env = yield* Env.Service
|
||||
yield* env.set("NVIDIA_API_KEY", "test-api-key")
|
||||
yield* Effect.addFinalizer(() => env.remove("NVIDIA_API_KEY"))
|
||||
return yield* self
|
||||
})
|
||||
}
|
||||
|
||||
it.live("nvidia provider includes KiloCode billing origin header", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
withNvidiaKey(
|
||||
Provider.Service.use((provider) =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* provider.list()
|
||||
const headers = providers[ProviderID.make("nvidia")].options.headers
|
||||
|
||||
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
|
||||
expect(headers["X-Title"]).toBe("Kilo Code")
|
||||
expect(headers["X-BILLING-INVOKE-ORIGIN"]).toBe("KiloCode")
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("nvidia billing origin header can be overridden from config", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
provider: {
|
||||
nvidia: {
|
||||
options: {
|
||||
headers: {
|
||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* withNvidiaKey(
|
||||
Provider.Service.use((provider) =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* provider.list()
|
||||
const headers = providers[ProviderID.make("nvidia")].options.headers
|
||||
|
||||
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
|
||||
expect(headers["X-Title"]).toBe("Kilo Code")
|
||||
expect(headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
|
||||
const root = path.join(__dirname, "..", "..")
|
||||
|
||||
describe("Kilo OAuth branding", () => {
|
||||
test("Codex OAuth browser flow uses Kilo branding", async () => {
|
||||
const src = await Bun.file(path.join(root, "src", "plugin", "codex.ts")).text()
|
||||
|
||||
expect(src).toContain('originator: "kilo"')
|
||||
expect(src).toContain("return to Kilo")
|
||||
expect(src).not.toContain('originator: "opencode"')
|
||||
expect(src).not.toContain("return to OpenCode")
|
||||
})
|
||||
|
||||
test("MCP OAuth callback page uses Kilo branding", async () => {
|
||||
const src = await Bun.file(path.join(root, "src", "mcp", "oauth-callback.ts")).text()
|
||||
|
||||
expect(src).toContain("return to Kilo")
|
||||
expect(src).not.toContain("return to OpenCode")
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { Bus } from "../../../src/bus"
|
||||
import * as Config from "../../../src/config/config"
|
||||
import { InstanceRuntime } from "../../../src/project/instance-runtime"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Permission } from "../../../src/permission"
|
||||
@@ -13,7 +14,11 @@ import { provideTmpdirInstance } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const bus = Bus.layer
|
||||
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
|
||||
const env = Layer.mergeAll(
|
||||
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
|
||||
bus,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
)
|
||||
const it = testEffect(env)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -21,7 +26,10 @@ afterAll(async () => {
|
||||
for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) {
|
||||
await fs.rm(path.join(dir, file), { force: true }).catch(() => {})
|
||||
}
|
||||
await Config.invalidate(true)
|
||||
await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)),
|
||||
)
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
})
|
||||
|
||||
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
|
||||
|
||||
@@ -8,11 +8,11 @@ import { Agent } from "../../../src/agent/agent"
|
||||
import { Config } from "../../../src/config/config"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PermissionID } from "../../../src/permission/schema"
|
||||
import { Instance } from "../../../src/project/instance"
|
||||
import { WithInstance } from "../../../src/project/with-instance"
|
||||
import { MessageID, SessionID } from "../../../src/session/schema"
|
||||
import { Shell } from "../../../src/shell/shell"
|
||||
import { Truncate } from "../../../src/tool/truncate"
|
||||
import { BashTool } from "../../../src/tool/bash"
|
||||
import { ShellTool } from "../../../src/tool/shell"
|
||||
import { Plugin } from "../../../src/plugin"
|
||||
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
|
||||
import { ConfigProtection } from "../../../src/kilocode/permission/config-paths"
|
||||
@@ -51,7 +51,7 @@ const ps =
|
||||
|
||||
Shell.acceptable.reset()
|
||||
|
||||
const init = () => runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
|
||||
const init = () => runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init())))
|
||||
const quote = (text: string) => `"${text.replaceAll('"', '\\"')}"`
|
||||
const glob = (file: string) =>
|
||||
process.platform === "win32" ? AppFileSystem.normalizePathPattern(file) : file.replaceAll("\\", "/")
|
||||
@@ -131,7 +131,7 @@ afterEach(async () => {
|
||||
describe("external_directory allow config protection", () => {
|
||||
test("allows file-tool external_directory requests for global config paths", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await immediate(
|
||||
@@ -151,7 +151,7 @@ describe("external_directory allow config protection", () => {
|
||||
|
||||
test("allows read-only bash external_directory requests for global config paths", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await immediate(
|
||||
@@ -183,7 +183,7 @@ describe("external_directory allow config protection", () => {
|
||||
|
||||
test("keeps unknown bash external_directory requests for global config paths protected", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = Permission.ask({
|
||||
@@ -214,7 +214,7 @@ describe("bash external_directory access metadata", () => {
|
||||
test("emits read access metadata for cat external files", async () => {
|
||||
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await init()
|
||||
@@ -242,7 +242,7 @@ describe("bash external_directory access metadata", () => {
|
||||
withShell(item, async () => {
|
||||
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await init()
|
||||
@@ -269,7 +269,7 @@ describe("bash external_directory access metadata", () => {
|
||||
test("does not emit read access metadata for mutating external file commands", async () => {
|
||||
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await init()
|
||||
@@ -300,7 +300,7 @@ describe("bash external_directory access metadata", () => {
|
||||
test("does not emit read access metadata for mixed read and write external commands", async () => {
|
||||
await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") })
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await init()
|
||||
|
||||
@@ -7,13 +7,19 @@ import { Permission } from "../../../src/permission"
|
||||
import { PermissionID } from "../../../src/permission/schema"
|
||||
import { SessionID } from "../../../src/session/schema"
|
||||
import * as Config from "../../../src/config/config"
|
||||
import { InstanceRuntime } from "../../../src/project/instance-runtime"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const bus = Bus.layer
|
||||
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
|
||||
const env = Layer.mergeAll(
|
||||
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
|
||||
Config.defaultLayer,
|
||||
bus,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
)
|
||||
const it = testEffect(env)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -21,7 +27,10 @@ afterAll(async () => {
|
||||
for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) {
|
||||
await fs.rm(path.join(dir, file), { force: true }).catch(() => {})
|
||||
}
|
||||
await Config.invalidate(true)
|
||||
await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)),
|
||||
)
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
})
|
||||
|
||||
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
|
||||
@@ -733,7 +742,8 @@ describe("saveAlwaysRules", () => {
|
||||
yield* reply({ requestID: PermissionID.make("permission_saved_always"), reply: "always" })
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const config = yield* Config.Service
|
||||
const cfg = yield* config.get()
|
||||
expect(cfg.permission?.bash).toMatchObject({ "kilo-permission-8353 test": "allow" })
|
||||
expect(cfg.permission?.bash).not.toMatchObject({ "kilo-permission-8353 *": "allow" })
|
||||
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PermissionID } from "../../../src/permission/schema"
|
||||
import { Instance } from "../../../src/project/instance"
|
||||
import { WithInstance } from "../../../src/project/with-instance"
|
||||
import { Session } from "../../../src/session/session"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["KILO_EXPERIMENTAL_HTTPAPI"]
|
||||
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
|
||||
})
|
||||
|
||||
async function app() {
|
||||
async function app(experimental = false) {
|
||||
const { Server } = await import("../../../src/server/server")
|
||||
return Server.Default().app
|
||||
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
describe("POST /permission/:requestID/reply", () => {
|
||||
test("returns 404 when requestID is not pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await app()
|
||||
@@ -40,7 +44,7 @@ describe("POST /permission/:requestID/reply", () => {
|
||||
test("returns 200 for an accepted reply", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await app()
|
||||
@@ -78,7 +82,7 @@ describe("POST /permission/:requestID/reply", () => {
|
||||
test("returns 404 when replying to an already-answered request (double-reply)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await app()
|
||||
@@ -119,13 +123,12 @@ describe("POST /permission/:requestID/reply", () => {
|
||||
})
|
||||
|
||||
test("returns 404 for unknown replies when experimental HttpApi is enabled", async () => {
|
||||
process.env["KILO_EXPERIMENTAL_HTTPAPI"] = "1"
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await app()
|
||||
const server = await app(true)
|
||||
|
||||
const response = await server.request("/permission/permission_missing/reply", {
|
||||
method: "POST",
|
||||
@@ -143,7 +146,7 @@ describe("POST /permission/:requestID/always-rules", () => {
|
||||
test("returns 404 when requestID is not pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await app()
|
||||
@@ -164,7 +167,7 @@ describe("POST /permission/:requestID/always-rules", () => {
|
||||
test("returns 200 for an accepted save", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await app()
|
||||
|
||||
@@ -7,13 +7,18 @@ import { Permission } from "../../../src/permission"
|
||||
import { PermissionID } from "../../../src/permission/schema"
|
||||
import { SessionID } from "../../../src/session/schema"
|
||||
import * as Config from "../../../src/config/config"
|
||||
import { InstanceRuntime } from "../../../src/project/instance-runtime"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const bus = Bus.layer
|
||||
const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
|
||||
const env = Layer.mergeAll(
|
||||
Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)),
|
||||
bus,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
)
|
||||
const it = testEffect(env)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -21,7 +26,10 @@ afterAll(async () => {
|
||||
for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) {
|
||||
await fs.rm(path.join(dir, file), { force: true }).catch(() => {})
|
||||
}
|
||||
await Config.invalidate(true)
|
||||
await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)),
|
||||
)
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
})
|
||||
|
||||
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Identifier } from "../../src/id/id"
|
||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { PlanFollowup } from "../../src/kilocode/plan-followup"
|
||||
import { Question } from "../../src/question"
|
||||
import { Session } from "../../src/session/session"
|
||||
@@ -22,7 +23,7 @@ const model = {
|
||||
|
||||
async function withInstance(fn: () => Promise<void>) {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({ directory: tmp.path, fn })
|
||||
await WithInstance.provide({ directory: tmp.path, fn })
|
||||
}
|
||||
|
||||
async function seed(input: {
|
||||
@@ -144,6 +145,49 @@ describe("plan_exit detection", () => {
|
||||
await expect(pending).resolves.toBe("break")
|
||||
}))
|
||||
|
||||
test("JetBrains client enables plan follow-up with custom answer", () =>
|
||||
withInstance(async () => {
|
||||
const prev = process.env.KILO_CLIENT
|
||||
try {
|
||||
process.env.KILO_CLIENT = "jetbrains"
|
||||
const seeded = await seed({
|
||||
text: "Here is the plan",
|
||||
tools: [
|
||||
{
|
||||
tool: "plan_exit",
|
||||
input: {},
|
||||
output: "Plan is ready. Ending planning turn.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(true)
|
||||
|
||||
const pending = PlanFollowup.ask({
|
||||
sessionID: seeded.sessionID,
|
||||
messages: seeded.messages,
|
||||
abort: AbortSignal.any([]),
|
||||
})
|
||||
|
||||
const question = await waitQuestion(seeded.sessionID)
|
||||
expect(question).toBeDefined()
|
||||
if (!question) return
|
||||
expect(question.questions[0].question).toBe("Ready to implement?")
|
||||
expect(question.questions[0].header).toBe("Implement")
|
||||
expect(question.questions[0].custom).toBe(true)
|
||||
expect(question.questions[0].options.map((item) => item.label)).toEqual([
|
||||
PlanFollowup.ANSWER_NEW_SESSION,
|
||||
PlanFollowup.ANSWER_CONTINUE,
|
||||
])
|
||||
expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe("code")
|
||||
await Question.reject(question.id)
|
||||
await expect(pending).resolves.toBe("break")
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.KILO_CLIENT
|
||||
else process.env.KILO_CLIENT = prev
|
||||
}
|
||||
}))
|
||||
|
||||
test("PlanFollowup.ask triggers and continue works with plan_exit", () =>
|
||||
withInstance(async () => {
|
||||
const seeded = await seed({
|
||||
|
||||
@@ -7,10 +7,10 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { formatTodos, generateHandover, PlanFollowup, PlanFollowupRuntime } from "../../src/kilocode/plan-followup"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { Question } from "../../src/question"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
@@ -71,7 +71,7 @@ const savedKey = `${saved.providerID}/${saved.modelID}`
|
||||
async function withInstance(fn: () => Promise<void>) {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.rm(statePath, { force: true }).catch(() => {})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await fs.rm(statePath, { force: true }).catch(() => {})
|
||||
@@ -234,17 +234,15 @@ function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) {
|
||||
(opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any,
|
||||
)
|
||||
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
|
||||
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||
text: Promise.resolve(text),
|
||||
} as any)
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue(text)
|
||||
return {
|
||||
agentSpy,
|
||||
modelSpy,
|
||||
llmSpy,
|
||||
handoverSpy,
|
||||
[Symbol.dispose]() {
|
||||
agentSpy.mockRestore()
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -567,16 +565,14 @@ describe("plan follow-up", () => {
|
||||
return fakeModel
|
||||
},
|
||||
)
|
||||
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||
text: Promise.resolve(
|
||||
"## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer",
|
||||
),
|
||||
} as any)
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue(
|
||||
"## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer",
|
||||
)
|
||||
using _mocks = {
|
||||
llmSpy,
|
||||
handoverSpy,
|
||||
[Symbol.dispose]() {
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
},
|
||||
}
|
||||
using _loop = {
|
||||
@@ -625,7 +621,7 @@ describe("plan follow-up", () => {
|
||||
expect(added).toHaveLength(1)
|
||||
expect(created).toHaveLength(1)
|
||||
expect(loop).toHaveBeenCalledTimes(1)
|
||||
expect(_mocks.llmSpy).toHaveBeenCalledTimes(1)
|
||||
expect(_mocks.handoverSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const newSessionID = created[0]
|
||||
const next = added[0]
|
||||
@@ -664,9 +660,7 @@ describe("plan follow-up", () => {
|
||||
await using other = await tmpdir({ git: true })
|
||||
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async () => undefined as any)
|
||||
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
|
||||
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||
text: Promise.resolve(""),
|
||||
} as any)
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("")
|
||||
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
|
||||
info: {
|
||||
id: MessageID.make("msg_test"),
|
||||
@@ -694,19 +688,19 @@ describe("plan follow-up", () => {
|
||||
[Symbol.dispose]() {
|
||||
get.mockRestore()
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
loop.mockRestore()
|
||||
},
|
||||
}
|
||||
|
||||
const dir = other.path
|
||||
|
||||
const seeded = await Instance.provide({
|
||||
const seeded = await WithInstance.provide({
|
||||
directory: dir,
|
||||
fn: async () => seed({ text: "1. Add API\n2. Add tests" }),
|
||||
})
|
||||
|
||||
const before = await Instance.provide({
|
||||
const before = await WithInstance.provide({
|
||||
directory: dir,
|
||||
fn: async () => sessions(),
|
||||
})
|
||||
@@ -726,7 +720,7 @@ describe("plan follow-up", () => {
|
||||
})
|
||||
|
||||
await expect(pending).resolves.toBe("break")
|
||||
const after = await Instance.provide({
|
||||
const after = await WithInstance.provide({
|
||||
directory: dir,
|
||||
fn: async () => sessions(),
|
||||
})
|
||||
@@ -740,7 +734,7 @@ describe("plan follow-up", () => {
|
||||
expect(next?.parentID).toBeUndefined()
|
||||
|
||||
if (next) {
|
||||
const planPath = await Instance.provide({
|
||||
const planPath = await WithInstance.provide({
|
||||
directory: dir,
|
||||
fn: async () => Session.plan(await Session.get(seeded.sessionID), Instance.current),
|
||||
})
|
||||
@@ -983,12 +977,12 @@ describe("plan follow-up", () => {
|
||||
const deferred = Promise.withResolvers<string>()
|
||||
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
|
||||
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
|
||||
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||
text: deferred.promise.then((t) => {
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() =>
|
||||
deferred.promise.then((text) => {
|
||||
handoverResolvedAt = performance.now()
|
||||
return t
|
||||
return text
|
||||
}),
|
||||
} as any)
|
||||
)
|
||||
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
|
||||
info: {
|
||||
id: MessageID.make("msg_test"),
|
||||
@@ -1016,7 +1010,7 @@ describe("plan follow-up", () => {
|
||||
[Symbol.dispose]() {
|
||||
agentSpy.mockRestore()
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
loop.mockRestore()
|
||||
unsub()
|
||||
},
|
||||
@@ -1070,9 +1064,7 @@ describe("plan follow-up", () => {
|
||||
const deferred = Promise.withResolvers<string>()
|
||||
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
|
||||
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
|
||||
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||
text: deferred.promise,
|
||||
} as any)
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise)
|
||||
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
|
||||
info: {
|
||||
id: MessageID.make("msg_test"),
|
||||
@@ -1094,7 +1086,7 @@ describe("plan follow-up", () => {
|
||||
[Symbol.dispose]() {
|
||||
agentSpy.mockRestore()
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
loop.mockRestore()
|
||||
unsub()
|
||||
},
|
||||
@@ -1169,9 +1161,7 @@ describe("plan follow-up", () => {
|
||||
const deferred = Promise.withResolvers<string>()
|
||||
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
|
||||
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
|
||||
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||
text: deferred.promise,
|
||||
} as any)
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise)
|
||||
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
|
||||
info: {
|
||||
id: MessageID.make("msg_test"),
|
||||
@@ -1193,7 +1183,7 @@ describe("plan follow-up", () => {
|
||||
[Symbol.dispose]() {
|
||||
agentSpy.mockRestore()
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
loop.mockRestore()
|
||||
created()
|
||||
status()
|
||||
@@ -1315,16 +1305,16 @@ describe("plan follow-up", () => {
|
||||
expect(result).toBe("- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task")
|
||||
})
|
||||
|
||||
test("generateHandover - returns empty string on LLM.stream failure", () =>
|
||||
test("generateHandover - returns empty string on LLM stream failure", () =>
|
||||
withInstance(async () => {
|
||||
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent)
|
||||
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
|
||||
const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable"))
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("provider unavailable"))
|
||||
using _ = {
|
||||
[Symbol.dispose]() {
|
||||
agentSpy.mockRestore()
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
},
|
||||
}
|
||||
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||
@@ -1332,22 +1322,16 @@ describe("plan follow-up", () => {
|
||||
expect(result).toBe("")
|
||||
}))
|
||||
|
||||
test("generateHandover - returns empty string on stream.text rejection", () =>
|
||||
test("generateHandover - returns empty string on text stream rejection", () =>
|
||||
withInstance(async () => {
|
||||
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent)
|
||||
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
|
||||
const textPromise = new Promise<string>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("stream aborted")), 0)
|
||||
})
|
||||
textPromise.catch(() => {})
|
||||
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||
text: textPromise,
|
||||
} as any)
|
||||
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("stream aborted"))
|
||||
using _ = {
|
||||
[Symbol.dispose]() {
|
||||
agentSpy.mockRestore()
|
||||
modelSpy.mockRestore()
|
||||
llmSpy.mockRestore()
|
||||
handoverSpy.mockRestore()
|
||||
},
|
||||
}
|
||||
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||
@@ -1362,7 +1346,7 @@ describe("plan follow-up", () => {
|
||||
const result = await generateHandover({ messages: seeded.messages, model })
|
||||
expect(result).toBe("## Discoveries\n\nFallback works")
|
||||
expect(mocks.agentSpy).toHaveBeenCalledWith("compaction")
|
||||
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.handoverSpy).toHaveBeenCalledTimes(1)
|
||||
}))
|
||||
|
||||
test("generateHandover - returns LLM output on success", () =>
|
||||
@@ -1371,6 +1355,6 @@ describe("plan follow-up", () => {
|
||||
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||
const result = await generateHandover({ messages: seeded.messages, model })
|
||||
expect(result).toBe("## Discoveries\n\nKey finding here")
|
||||
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.handoverSpy).toHaveBeenCalledTimes(1)
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Auth } from "../../src/auth"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { Env } from "../../src/env"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -56,7 +56,7 @@ async function writeConfig(dir: string, config: unknown) {
|
||||
|
||||
test("project config update creates .kilo/kilo.json and reloads it", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await save({ model: "updated/model" } as any)
|
||||
@@ -72,7 +72,7 @@ test("project config update creates .kilo/kilo.json and reloads it", async () =>
|
||||
|
||||
test("project config update skips empty delete-only writes when no config exists", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await save({ provider: { missing: null } } as any)
|
||||
@@ -86,7 +86,7 @@ test("project config update prefers existing root kilo.json", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await writeConfig(tmp.path, { username: "alice" })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await save({ model: "updated/model" } as any)
|
||||
@@ -105,7 +105,7 @@ test("project config update patches ancestor .kilo/kilo.json from nested directo
|
||||
await fs.mkdir(path.join(tmp.path, ".kilo"), { recursive: true })
|
||||
await writeConfig(path.join(tmp.path, ".kilo"), { username: "alice" })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: child,
|
||||
fn: async () => {
|
||||
await save({ model: "updated/model" } as any)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect, describe } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { getKiloProjectId } from "../../src/kilocode/project-id"
|
||||
|
||||
describe("project-id", () => {
|
||||
@@ -16,7 +16,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -33,7 +33,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -65,7 +65,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -82,7 +82,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -112,7 +112,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -136,7 +136,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -158,7 +158,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -182,7 +182,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -208,7 +208,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -234,7 +234,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -258,7 +258,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -284,7 +284,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -297,7 +297,7 @@ describe("project-id", () => {
|
||||
test("returns undefined when no config and no git origin", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -308,7 +308,7 @@ describe("project-id", () => {
|
||||
test("returns undefined for non-git directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -327,7 +327,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -354,7 +354,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -373,12 +373,12 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id1 = await Instance.provide({
|
||||
const id1 = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
|
||||
const id2 = await Instance.provide({
|
||||
const id2 = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -397,7 +397,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -420,7 +420,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
@@ -444,7 +444,7 @@ describe("project-id", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const id = await Instance.provide({
|
||||
const id = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => getKiloProjectId(),
|
||||
})
|
||||
|
||||
@@ -4,98 +4,152 @@
|
||||
// 2. ModelCache.getFailure() returns the typed error for a failed provider.
|
||||
// 3. Clear removes failure state.
|
||||
|
||||
import { test, expect, mock } from "bun:test"
|
||||
import path from "path"
|
||||
import { beforeEach, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
// Stub fetchKiloModels to return controlled typed results.
|
||||
let stubbedResult: { models: Record<string, any>; error?: { kind: string; status?: number } } = { models: {} }
|
||||
|
||||
mock.module("@kilocode/kilo-gateway", () => ({
|
||||
fetchKiloModels: async () => stubbedResult,
|
||||
KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter",
|
||||
}))
|
||||
|
||||
mock.module("opencode-copilot-auth", () => ({ default: () => ({}) }))
|
||||
mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) }))
|
||||
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) }))
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { ModelCache } from "../../src/provider/model-cache"
|
||||
import type { Provider } from "../../src/provider/models"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" })
|
||||
type Failure = { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
|
||||
type Result = { models: Provider["models"]; error?: Failure }
|
||||
|
||||
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "kilo.json"), CONFIG)
|
||||
},
|
||||
})
|
||||
return Instance.provide({ directory: tmp.path, fn })
|
||||
let result: Result = { models: {} }
|
||||
let error: Error | undefined
|
||||
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
function layer() {
|
||||
const models = Layer.succeed(
|
||||
ModelCache.KiloModelsService,
|
||||
ModelCache.KiloModelsService.of({
|
||||
fetch: () => (error ? Effect.fail(error) : Effect.succeed(result)),
|
||||
}),
|
||||
)
|
||||
return Layer.fresh(ModelCache.layer).pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(TestConfig.layer()),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(models),
|
||||
)
|
||||
}
|
||||
|
||||
test("failedProviders returns empty array when no fetch has occurred", () => {
|
||||
ModelCache.clear("kilo")
|
||||
expect(ModelCache.failedProviders()).not.toContain("kilo")
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
beforeEach(() => {
|
||||
result = { models: {} }
|
||||
error = undefined
|
||||
})
|
||||
|
||||
test("getFailure returns undefined when fetch succeeds", async () => {
|
||||
stubbedResult = {
|
||||
models: {
|
||||
"test/model": {
|
||||
id: "test/model",
|
||||
name: "Test",
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
it.live("failedProviders returns empty array when no fetch has occurred", () =>
|
||||
ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* cache.failedProviders()).not.toContain("kilo")
|
||||
}),
|
||||
).pipe(Effect.provide(layer())),
|
||||
)
|
||||
|
||||
it.live("getFailure returns undefined when fetch succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
result = {
|
||||
models: {
|
||||
"test/model": {
|
||||
id: "test/model",
|
||||
name: "Test",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
release_date: "",
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ModelCache.clear("kilo")
|
||||
await withInstance(() => ModelCache.fetch("kilo"))
|
||||
expect(ModelCache.getFailure("kilo")).toBeUndefined()
|
||||
expect(ModelCache.failedProviders()).not.toContain("kilo")
|
||||
})
|
||||
}
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* cache.getFailure("kilo")).toBeUndefined()
|
||||
expect(yield* cache.failedProviders()).not.toContain("kilo")
|
||||
}),
|
||||
).pipe(Effect.provide(layer()))
|
||||
}),
|
||||
)
|
||||
|
||||
test("failedProviders includes provider after auth error", async () => {
|
||||
stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } }
|
||||
ModelCache.clear("kilo")
|
||||
await withInstance(() => ModelCache.fetch("kilo"))
|
||||
expect(ModelCache.failedProviders()).toContain("kilo")
|
||||
expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
|
||||
})
|
||||
it.live("failedProviders includes provider after auth error", () =>
|
||||
Effect.gen(function* () {
|
||||
result = { models: {}, error: { kind: "unauthorized", status: 401 } }
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* cache.failedProviders()).toContain("kilo")
|
||||
expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 })
|
||||
}),
|
||||
).pipe(Effect.provide(layer()))
|
||||
}),
|
||||
)
|
||||
|
||||
test("clear removes failure state", async () => {
|
||||
stubbedResult = { models: {}, error: { kind: "network" } }
|
||||
ModelCache.clear("kilo")
|
||||
await withInstance(() => ModelCache.fetch("kilo"))
|
||||
expect(ModelCache.failedProviders()).toContain("kilo")
|
||||
it.live("gateway rejection remains recoverable through the Effect error channel", () =>
|
||||
Effect.gen(function* () {
|
||||
error = new Error("gateway failed")
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* cache.fetch("kilo").pipe(Effect.catch(() => Effect.succeed({})))
|
||||
expect(models).toEqual({})
|
||||
}),
|
||||
).pipe(Effect.provide(layer()))
|
||||
}),
|
||||
)
|
||||
|
||||
ModelCache.clear("kilo")
|
||||
expect(ModelCache.failedProviders()).not.toContain("kilo")
|
||||
expect(ModelCache.getFailure("kilo")).toBeUndefined()
|
||||
})
|
||||
it.live("clear removes failure state", () =>
|
||||
Effect.gen(function* () {
|
||||
result = { models: {}, error: { kind: "network" } }
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* cache.failedProviders()).toContain("kilo")
|
||||
yield* cache.clear("kilo")
|
||||
expect(yield* cache.failedProviders()).not.toContain("kilo")
|
||||
expect(yield* cache.getFailure("kilo")).toBeUndefined()
|
||||
}),
|
||||
).pipe(Effect.provide(layer()))
|
||||
}),
|
||||
)
|
||||
|
||||
test("failure state is cleared when subsequent fetch succeeds", async () => {
|
||||
stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } }
|
||||
ModelCache.clear("kilo")
|
||||
await withInstance(() => ModelCache.fetch("kilo"))
|
||||
expect(ModelCache.failedProviders()).toContain("kilo")
|
||||
|
||||
stubbedResult = {
|
||||
models: {
|
||||
"test/model": {
|
||||
id: "test/model",
|
||||
name: "Test",
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
}
|
||||
ModelCache.clear("kilo")
|
||||
await withInstance(() => ModelCache.fetch("kilo"))
|
||||
expect(ModelCache.failedProviders()).not.toContain("kilo")
|
||||
expect(ModelCache.getFailure("kilo")).toBeUndefined()
|
||||
})
|
||||
it.live("failure state is cleared when subsequent refresh succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
result = { models: {}, error: { kind: "unauthorized", status: 401 } }
|
||||
yield* ModelCache.Service.use((cache) =>
|
||||
Effect.gen(function* () {
|
||||
yield* cache.fetch("kilo")
|
||||
expect(yield* cache.failedProviders()).toContain("kilo")
|
||||
result = {
|
||||
models: {
|
||||
"test/model": {
|
||||
id: "test/model",
|
||||
name: "Test",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
release_date: "",
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* cache.refresh("kilo")
|
||||
expect(yield* cache.failedProviders()).not.toContain("kilo")
|
||||
expect(yield* cache.getFailure("kilo")).toBeUndefined()
|
||||
}),
|
||||
).pipe(Effect.provide(layer()))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Question } from "../../src/question"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -9,7 +9,7 @@ import { tmpdir } from "../fixture/fixture"
|
||||
describe("Question.dismissAll", () => {
|
||||
test("rejects pending asks for the target session and clears them", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sesA = SessionID.make("ses_a")
|
||||
@@ -99,7 +99,7 @@ describe("Question.dismissAll", () => {
|
||||
|
||||
test("is a no-op when no questions exist", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Question.dismissAll("ses_missing")
|
||||
@@ -114,7 +114,7 @@ describe("Question.dismissAll", () => {
|
||||
// the user manually dismisses it. Verify the pre-emptive hasFollowup check
|
||||
// rejects with RejectedError before any pending entry is registered.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("ses_auto_ask")
|
||||
|
||||
@@ -39,6 +39,7 @@ function asked(id: number): Event {
|
||||
sessionID: "ses_test",
|
||||
id: `req_${id}`,
|
||||
message: "Connection refused",
|
||||
restored: false,
|
||||
time: { created: 0 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { SemanticSearchTool } from "../../src/kilocode/tool/semantic-search"
|
||||
import { KiloIndexing } from "../../src/kilocode/indexing"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
@@ -37,9 +37,8 @@ describe("tool.semantic_search", () => {
|
||||
test("describes code snippet results", async () => {
|
||||
const tool = await initTool()
|
||||
|
||||
expect(tool.description).toContain("Find code snippets most relevant")
|
||||
expect(tool.description).toContain("Returns matching content with file paths, line ranges, and relevance scores")
|
||||
expect(tool.description).not.toContain("Find files most relevant")
|
||||
expect(tool.description).toContain("Find code snippets by semantic meaning")
|
||||
expect(tool.description).toContain("Search for an exact symbol")
|
||||
})
|
||||
|
||||
test("throws when query is empty", async () => {
|
||||
@@ -49,7 +48,7 @@ describe("tool.semantic_search", () => {
|
||||
|
||||
test("asks permission and forwards normalized relative path to indexing search", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
@@ -90,7 +89,7 @@ describe("tool.semantic_search", () => {
|
||||
|
||||
test("searches entire workspace when path is omitted", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
|
||||
@@ -111,7 +110,7 @@ describe("tool.semantic_search", () => {
|
||||
|
||||
test("formats and normalizes search results", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const search = spyOn(KiloIndexing, "search").mockResolvedValue([
|
||||
@@ -167,7 +166,7 @@ describe("tool.semantic_search", () => {
|
||||
|
||||
test("rejects paths outside the workspace", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process"
|
||||
import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway"
|
||||
import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { PublicApi } from "../../../src/server/routes/instance/httpapi/public"
|
||||
import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Server } from "../../../src/server/server"
|
||||
|
||||
const methods = ["get", "post", "put", "delete", "patch"] as const
|
||||
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
|
||||
|
||||
function effectOpenApi() {
|
||||
return (effectSpec ??= OpenApi.fromApi(PublicApi))
|
||||
}
|
||||
|
||||
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
|
||||
return Object.entries(spec.paths)
|
||||
.flatMap(([path, item]) =>
|
||||
methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
|
||||
)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function stableSchema(input: unknown): string {
|
||||
return JSON.stringify(sortSchema(input))
|
||||
}
|
||||
|
||||
function sortSchema(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(sortSchema)
|
||||
if (!input || typeof input !== "object") return input
|
||||
return Object.fromEntries(
|
||||
Object.entries(input)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, sortSchema(value)]),
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return !!input && typeof input === "object" && !Array.isArray(input)
|
||||
}
|
||||
|
||||
type Operation = {
|
||||
responses?: unknown
|
||||
}
|
||||
|
||||
function providerSchema(input: unknown) {
|
||||
if (!input || typeof input !== "object" || !("components" in input)) return undefined
|
||||
const components = input.components
|
||||
if (!components || typeof components !== "object" || !("schemas" in components)) return undefined
|
||||
const schemas = components.schemas
|
||||
if (!schemas || typeof schemas !== "object" || !("Config" in schemas)) return undefined
|
||||
const config = schemas.Config
|
||||
if (!config || typeof config !== "object" || !("properties" in config)) return undefined
|
||||
const props = config.properties
|
||||
if (!props || typeof props !== "object" || !("provider" in props)) return undefined
|
||||
const provider = props.provider
|
||||
if (!provider || typeof provider !== "object" || !("additionalProperties" in provider)) return undefined
|
||||
return provider.additionalProperties
|
||||
}
|
||||
|
||||
function indexingNullableFields(input: unknown) {
|
||||
if (!isRecord(input) || !isRecord(input.components) || !isRecord(input.components.schemas)) return []
|
||||
const indexing = input.components.schemas.IndexingConfig
|
||||
if (!isRecord(indexing) || !isRecord(indexing.properties)) return []
|
||||
const properties = indexing.properties
|
||||
return ["model", "dimension"].filter((key) => JSON.stringify(properties[key]).includes('"null"'))
|
||||
}
|
||||
|
||||
function responseSchema(input: {
|
||||
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||
path: string
|
||||
method: (typeof methods)[number]
|
||||
status: string
|
||||
contentType: string
|
||||
}) {
|
||||
const responses = input.spec.paths[input.path]?.[input.method]?.responses
|
||||
if (!responses || typeof responses !== "object" || !(input.status in responses)) return undefined
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
|
||||
const response = (responses as Record<string, unknown>)[input.status]
|
||||
if (!response || typeof response !== "object" || !("content" in response)) return undefined
|
||||
const content = (response as { content?: unknown }).content
|
||||
if (!content || typeof content !== "object" || !(input.contentType in content)) return undefined
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response content lookup.
|
||||
const body = (content as Record<string, unknown>)[input.contentType]
|
||||
if (!body || typeof body !== "object" || !("schema" in body)) return undefined
|
||||
return body.schema
|
||||
}
|
||||
|
||||
describe("Kilo HttpApi bridge", () => {
|
||||
test("mirrors Kilo overlay routes in Hono and Effect specs", async () => {
|
||||
const hono = new Set(openApiRouteKeys(await Server.openapiHono()))
|
||||
const effect = new Set(openApiRouteKeys(effectOpenApi()))
|
||||
const kilo = [
|
||||
`GET ${BackgroundProcessPaths.list}`,
|
||||
"GET /background-process/{processID}",
|
||||
"GET /background-process/{processID}/logs",
|
||||
"POST /background-process/{processID}/stop",
|
||||
"POST /background-process/{processID}/restart",
|
||||
"POST /background-process/session/{sessionID}/stop",
|
||||
"POST /permission/allow-everything",
|
||||
"POST /enhance-prompt",
|
||||
"POST /commit-message",
|
||||
`GET ${ExperimentalPaths.worktreeDiff}`,
|
||||
`GET ${ExperimentalPaths.worktreeDiffFile}`,
|
||||
`GET ${ExperimentalPaths.worktreeDiffSummary}`,
|
||||
"GET /network",
|
||||
"POST /network/{requestID}/reply",
|
||||
"POST /network/{requestID}/reject",
|
||||
`GET ${KiloGatewayPaths.modes}`,
|
||||
`POST ${KiloGatewayPaths.fim}`,
|
||||
`POST ${KiloGatewayPaths.audioTranscriptions}`,
|
||||
"POST /remote/enable",
|
||||
"POST /remote/disable",
|
||||
"GET /remote/status",
|
||||
`POST ${SessionPaths.viewed}`,
|
||||
"POST /telemetry/capture",
|
||||
"POST /telemetry/setEnabled",
|
||||
"GET /suggestion",
|
||||
"POST /suggestion/{requestID}/accept",
|
||||
"POST /suggestion/{requestID}/dismiss",
|
||||
"POST /kilocode/heap/snapshot",
|
||||
"POST /kilocode/skill/remove",
|
||||
"POST /kilocode/agent/remove",
|
||||
"POST /kilocode/session-import/project",
|
||||
"POST /kilocode/session-import/session",
|
||||
"POST /kilocode/session-import/message",
|
||||
"POST /kilocode/session-import/part",
|
||||
]
|
||||
|
||||
expect(kilo.filter((route) => !hono.has(route))).toEqual([])
|
||||
expect(kilo.filter((route) => !effect.has(route))).toEqual([])
|
||||
expect(hono.has("POST /background-process")).toBe(false)
|
||||
expect(effect.has("POST /background-process")).toBe(false)
|
||||
expect(effect.has("GET /indexing/status")).toBe(true)
|
||||
})
|
||||
|
||||
test("documents cloud session import separately from id lookup", () => {
|
||||
const effect = effectOpenApi()
|
||||
|
||||
expect(effect.paths["/kilo/cloud/session/import"]?.post).toBeDefined()
|
||||
expect(effect.paths["/kilo/cloud/session/{id}"]?.get).toBeDefined()
|
||||
expect(KiloGatewayPaths.cloudSessionImport).not.toBe(KiloGatewayPaths.cloudSession)
|
||||
})
|
||||
|
||||
test("matches nullable provider delete sentinels", async () => {
|
||||
const hono = await Server.openapiHono()
|
||||
const effect = effectOpenApi()
|
||||
|
||||
expect(stableSchema(providerSchema(effect))).toBe(stableSchema(providerSchema(hono)))
|
||||
})
|
||||
|
||||
test("keeps nullable indexing model reset sentinels in both APIs", async () => {
|
||||
const hono = await Server.openapiHono()
|
||||
const effect = effectOpenApi()
|
||||
|
||||
expect(indexingNullableFields(effect)).toEqual(["model", "dimension"])
|
||||
expect(indexingNullableFields(hono)).toEqual(["model", "dimension"])
|
||||
})
|
||||
|
||||
test("matches Kilo FIM SSE response schema", async () => {
|
||||
const hono = await Server.openapiHono()
|
||||
const effect = effectOpenApi()
|
||||
const input = {
|
||||
path: KiloGatewayPaths.fim,
|
||||
method: "post" as const,
|
||||
status: "200",
|
||||
contentType: "text/event-stream",
|
||||
}
|
||||
|
||||
expect(stableSchema(responseSchema({ spec: effect, ...input }))).toBe(
|
||||
stableSchema(responseSchema({ spec: hono, ...input })),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway"
|
||||
import { PublicApi } from "../../../src/server/routes/instance/httpapi/public"
|
||||
|
||||
type Schema = {
|
||||
anyOf?: Schema[]
|
||||
properties?: Record<string, Schema>
|
||||
type?: string
|
||||
}
|
||||
|
||||
type Body = {
|
||||
content?: Record<string, { schema?: Schema }>
|
||||
}
|
||||
|
||||
describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
test("keeps personal organization resets nullable", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
const body = spec.paths[KiloGatewayPaths.organization]?.post?.requestBody as Body | undefined
|
||||
const schema = body?.content?.["application/json"]?.schema
|
||||
const props = schema?.properties
|
||||
expect(props?.organizationId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PermissionID } from "../../../src/permission/schema"
|
||||
import { Instance } from "../../../src/project/instance"
|
||||
import { WithInstance } from "../../../src/project/with-instance"
|
||||
import { Server } from "../../../src/server/server"
|
||||
import { Session } from "../../../src/session/session"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
@@ -11,7 +11,7 @@ describe("permission.allowEverything endpoint", () => {
|
||||
test("disables global allow-all and removes wildcard from config", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const app = Server.Default().app
|
||||
@@ -58,7 +58,7 @@ describe("permission.allowEverything endpoint", () => {
|
||||
test("disables session-scoped allow-all without touching global config", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const app = Server.Default().app
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Env } from "../../src/env"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Git } from "../../src/git"
|
||||
import { KiloSession } from "../../src/kilocode/session"
|
||||
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
|
||||
import { LSP } from "../../src/lsp/lsp"
|
||||
@@ -146,6 +147,7 @@ function makeHttp() {
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { KiloCompactionChunks } from "../../src/kilocode/session/compaction-chunks"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import * as SessionProcessorModule from "../../src/session/processor"
|
||||
import type { SessionProcessor } from "../../src/session/processor"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { Session as SessionNs } from "../../src/session/session"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { ProviderTest } from "../fake/provider"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const providerID = ProviderID.make("test")
|
||||
const modelID = ModelID.make("test-model")
|
||||
const ref = { providerID, modelID }
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
messages(input: Parameters<SessionNs.Interface["messages"]>[0]) {
|
||||
return run(SessionNs.Service.use((svc) => svc.messages(input)))
|
||||
},
|
||||
updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
},
|
||||
updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
},
|
||||
}
|
||||
|
||||
const summary = Layer.succeed(
|
||||
SessionSummary.Service,
|
||||
SessionSummary.Service.of({
|
||||
summarize: () => Effect.void,
|
||||
diff: () => Effect.succeed([]),
|
||||
computeDiff: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
async function user(sessionID: SessionID, text: string) {
|
||||
const msg = await svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return msg
|
||||
}
|
||||
|
||||
async function assistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: root, root },
|
||||
cost: 0,
|
||||
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID,
|
||||
providerID,
|
||||
parentID,
|
||||
time: { created: Date.now() },
|
||||
finish: "stop",
|
||||
}
|
||||
await svc.updateMessage(msg)
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return msg
|
||||
}
|
||||
|
||||
function llm() {
|
||||
const queue: Array<
|
||||
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
|
||||
> = []
|
||||
|
||||
return {
|
||||
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
|
||||
queue.push(stream)
|
||||
},
|
||||
layer: Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: (input) => {
|
||||
const item = queue.shift() ?? Stream.empty
|
||||
const stream = typeof item === "function" ? item(input) : item
|
||||
return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function reply(text: string, capture?: (input: LLM.StreamInput) => void) {
|
||||
return (input: LLM.StreamInput) => {
|
||||
capture?.(input)
|
||||
return Stream.make(
|
||||
{ type: "start" } as LLM.Event,
|
||||
{ type: "text-start", id: "txt-0" } as LLM.Event,
|
||||
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
|
||||
{ type: "text-end", id: "txt-0" } as LLM.Event,
|
||||
{
|
||||
type: "finish-step",
|
||||
finishReason: "stop",
|
||||
rawFinishReason: "stop",
|
||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
||||
providerMetadata: undefined,
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||
},
|
||||
} as LLM.Event,
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
rawFinishReason: "stop",
|
||||
totalUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||
},
|
||||
} as LLM.Event,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function overflow() {
|
||||
return Stream.make(
|
||||
{ type: "start" } as LLM.Event,
|
||||
{
|
||||
type: "finish-step",
|
||||
finishReason: "stop",
|
||||
rawFinishReason: "stop",
|
||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
||||
providerMetadata: undefined,
|
||||
usage: {
|
||||
inputTokens: 20_000,
|
||||
outputTokens: 1,
|
||||
totalTokens: 20_001,
|
||||
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||
},
|
||||
} as LLM.Event,
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
rawFinishReason: "stop",
|
||||
totalUsage: {
|
||||
inputTokens: 20_000,
|
||||
outputTokens: 1,
|
||||
totalTokens: 20_001,
|
||||
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||
},
|
||||
} as LLM.Event,
|
||||
)
|
||||
}
|
||||
|
||||
function runtime(layer: Layer.Layer<LLM.Service>, context = 7_000) {
|
||||
const bus = Bus.layer
|
||||
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
||||
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
|
||||
const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } })
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
||||
Layer.provide(ProviderTest.fake({ model }).layer),
|
||||
Layer.provide(SessionNs.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(layer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(status),
|
||||
Layer.provide(bus),
|
||||
Layer.provide(
|
||||
Layer.mock(Config.Service)({
|
||||
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeRuntime() {
|
||||
const calls: string[] = []
|
||||
const outputs: number[] = []
|
||||
const bus = Bus.layer
|
||||
const processor = Layer.effect(
|
||||
SessionProcessorModule.SessionProcessor.Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionNs.Service
|
||||
return SessionProcessorModule.SessionProcessor.Service.of({
|
||||
create: Effect.fn("TestSessionProcessor.create")((input) =>
|
||||
Effect.succeed({
|
||||
get message() {
|
||||
return input.assistantMessage
|
||||
},
|
||||
updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
|
||||
completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
|
||||
process: Effect.fn("TestSessionProcessor.process")((stream: LLM.StreamInput) =>
|
||||
Effect.gen(function* () {
|
||||
outputs.push(input.model.limit.output)
|
||||
calls.push(JSON.stringify(stream.messages))
|
||||
const text = stream.messages.some((msg) =>
|
||||
JSON.stringify(msg).includes("Create a new anchored summary"),
|
||||
)
|
||||
? "final summary"
|
||||
: calls.length === 1
|
||||
? "chunk one"
|
||||
: "chunk two"
|
||||
yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
input.assistantMessage.finish = "stop"
|
||||
return "continue" as const
|
||||
}),
|
||||
),
|
||||
} satisfies SessionProcessor.Handle),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 1_000 } })
|
||||
return {
|
||||
calls,
|
||||
outputs,
|
||||
rt: ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus).pipe(
|
||||
Layer.provide(ProviderTest.fake({ model }).layer),
|
||||
Layer.provide(SessionNs.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(bus),
|
||||
Layer.provide(
|
||||
Layer.mock(Config.Service)({
|
||||
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function liveRuntime(layer: Layer.Layer<LLM.Service>, context = 10_000) {
|
||||
const bus = Bus.layer
|
||||
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
||||
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
|
||||
const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } })
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
||||
Layer.provide(ProviderTest.fake({ model }).layer),
|
||||
Layer.provide(SessionNs.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(layer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(status),
|
||||
Layer.provide(bus),
|
||||
Layer.provide(
|
||||
Layer.mock(Config.Service)({
|
||||
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("KiloCompactionChunks", () => {
|
||||
test("splits oversized history into chronological chunks", async () => {
|
||||
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 7_000, output: 1_000 } })
|
||||
const sessionID = SessionID.make("ses_chunks_split")
|
||||
const messages: MessageV2.WithParts[] = Array.from({ length: 4 }, (_, index) => ({
|
||||
info: {
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
messageID: MessageID.ascending(),
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: `${index}: ${"x".repeat(8_000)}`,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chunks = await Effect.runPromise(KiloCompactionChunks.split({ messages, model, size: 2_000 }))
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.flatMap((chunk) => chunk.messages.map((msg) => msg.info.id))).toEqual(
|
||||
messages.map((msg) => msg.info.id),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to chunk workers after the first compaction overflows", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const first = await user(session.id, "first " + "a".repeat(10_000))
|
||||
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
|
||||
const second = await user(session.id, "second " + "c".repeat(10_000))
|
||||
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
|
||||
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
|
||||
|
||||
const { rt, calls } = fakeRuntime()
|
||||
try {
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const parent = msgs.at(-1)?.info.id
|
||||
expect(parent).toBeTruthy()
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
parentID: parent!,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
auto: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const all = await svc.messages({ sessionID: session.id })
|
||||
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
|
||||
const parts = summaries
|
||||
.flatMap((msg) => msg.parts)
|
||||
.filter((part): part is MessageV2.TextPart => part.type === "text")
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(calls.at(-1)).toContain("Create a new anchored summary")
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(parts.map((part) => part.text)).toEqual(["final summary"])
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("uses chunk fallback before sending oversized normal compaction", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const first = await user(session.id, "first " + "a".repeat(10_000))
|
||||
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
|
||||
const second = await user(session.id, "second " + "c".repeat(10_000))
|
||||
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
|
||||
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
|
||||
|
||||
const { rt, calls } = fakeRuntime()
|
||||
try {
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const parent = msgs.at(-1)?.info.id
|
||||
expect(parent).toBeTruthy()
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
parentID: parent!,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
auto: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(calls[0]).toContain("Summarize conversation chunk")
|
||||
expect(calls[0]).not.toContain("Create a new anchored summary")
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("uses a worker even when fallback selection produces one oversized chunk", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const first = await user(session.id, "first " + "a".repeat(20_000))
|
||||
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000))
|
||||
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
|
||||
|
||||
const { rt, calls } = fakeRuntime()
|
||||
try {
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const parent = msgs.at(-1)?.info.id
|
||||
expect(parent).toBeTruthy()
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
parentID: parent!,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
auto: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const all = await svc.messages({ sessionID: session.id })
|
||||
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
|
||||
const parts = summaries
|
||||
.flatMap((msg) => msg.parts)
|
||||
.filter((part): part is MessageV2.TextPart => part.type === "text")
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
expect(calls[0]).toContain("Summarize conversation chunk")
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(parts.map((part) => part.text)).toEqual(["final summary"])
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes oversized fallback chunks before summarizing", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const first = await user(session.id, "single huge request " + "a".repeat(80_000))
|
||||
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
|
||||
|
||||
const { rt, calls } = fakeRuntime()
|
||||
try {
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const parent = msgs.at(-1)?.info.id
|
||||
expect(parent).toBeTruthy()
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
parentID: parent!,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
auto: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(calls[0]).toContain("compacted transcript")
|
||||
expect(calls[0]).toContain("Text truncated for compaction")
|
||||
expect(calls[0]).toContain("Summarize conversation chunk")
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("caps worker output budget below oversized model output limit", async () => {
|
||||
const { rt, calls, outputs } = fakeRuntime()
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const first = await user(session.id, "first " + "a".repeat(1_000))
|
||||
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(1_000))
|
||||
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
|
||||
|
||||
try {
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const parent = msgs.at(-1)?.info.id
|
||||
expect(parent).toBeTruthy()
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
parentID: parent!,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
auto: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
expect(outputs.every((value) => value <= 2_048)).toBe(true)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("compacts oversized replay turns after overflow compaction", async () => {
|
||||
const stub = llm()
|
||||
const calls: string[] = []
|
||||
stub.push(reply("history summary"))
|
||||
stub.push(reply("replay summary", (input) => calls.push(JSON.stringify(input.messages))))
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const old = await user(session.id, "old context")
|
||||
await assistant(session.id, old.id, tmp.path, "old reply")
|
||||
const large = await user(session.id, "large replay " + "x".repeat(40_000))
|
||||
await SessionCompaction.create({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const rt = liveRuntime(stub.layer)
|
||||
try {
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const parent = msgs.at(-1)?.info.id
|
||||
expect(parent).toBeTruthy()
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
parentID: parent!,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const all = await svc.messages({ sessionID: session.id })
|
||||
const replay = all.findLast((msg) => msg.info.role === "user" && msg.info.id !== large.id)
|
||||
const part = replay?.parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toContain("Summarize conversation chunk 1 of 1")
|
||||
expect(part?.text).toContain("compacted representation")
|
||||
expect(part?.text).toContain("replay summary")
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
@@ -72,7 +72,7 @@ describe("Session.fork child session remapping", () => {
|
||||
"forked session gets its own copy of child sessions",
|
||||
async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const parent = await Session.create({ title: "parent" })
|
||||
@@ -138,7 +138,7 @@ describe("Session.fork child session remapping", () => {
|
||||
"nested child sessions are also remapped",
|
||||
async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// grandchild -> child -> parent
|
||||
@@ -225,7 +225,7 @@ describe("Session.fork child session remapping", () => {
|
||||
"non-task tool parts are not affected",
|
||||
async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const parent = await Session.create({ title: "parent" })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { ProjectTable } from "../../src/project/project.sql"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
@@ -18,7 +18,7 @@ afterEach(async () => {
|
||||
describe("Kilo Session.list", () => {
|
||||
test("includes directory matches from legacy project ids", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "legacy-session" })
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Config } from "@/config/config"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import type { MessageV2 } from "@/session/message-v2"
|
||||
import { isOverflow } from "@/session/overflow"
|
||||
|
||||
function cfg(compaction?: Config.Info["compaction"]) {
|
||||
return Config.Info.zod.parse({ compaction })
|
||||
}
|
||||
|
||||
function model(opts: { context: number; output: number; input?: number }): Provider.Model {
|
||||
return {
|
||||
id: "test-model",
|
||||
providerID: "test",
|
||||
name: "Test",
|
||||
limit: {
|
||||
context: opts.context,
|
||||
input: opts.input,
|
||||
output: opts.output,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
capabilities: {
|
||||
toolcall: true,
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
input: { text: true, image: false, audio: false, video: false },
|
||||
output: { text: true, image: false, audio: false, video: false },
|
||||
},
|
||||
api: { npm: "@ai-sdk/anthropic" },
|
||||
options: {},
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
function tokens(count: number): MessageV2.Assistant["tokens"] {
|
||||
return { input: count, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
describe("Kilo auto-compaction threshold", () => {
|
||||
test("triggers at the configured context percentage", () => {
|
||||
const conf = cfg({ threshold_percent: 75 })
|
||||
const mdl = model({ context: 200_000, output: 32_000 })
|
||||
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(149_999) })).toBe(false)
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps the reserved safety trigger when it is lower", () => {
|
||||
const conf = cfg({ threshold_percent: 95 })
|
||||
const mdl = model({ context: 200_000, output: 32_000 })
|
||||
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(167_999) })).toBe(false)
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(168_000) })).toBe(true)
|
||||
})
|
||||
|
||||
test("uses a model input limit when present", () => {
|
||||
const conf = cfg({ threshold_percent: 75 })
|
||||
const mdl = model({ context: 400_000, input: 200_000, output: 32_000 })
|
||||
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(149_999) })).toBe(false)
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores a cleared threshold", () => {
|
||||
const conf = cfg({ threshold_percent: null })
|
||||
const mdl = model({ context: 200_000, output: 32_000 })
|
||||
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(false)
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(168_000) })).toBe(true)
|
||||
})
|
||||
|
||||
test("still respects disabled auto-compaction", () => {
|
||||
const conf = cfg({ auto: false, threshold_percent: 75 })
|
||||
const mdl = model({ context: 200_000, output: 32_000 })
|
||||
|
||||
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -84,7 +84,6 @@ const llm = Layer.unwrap(
|
||||
const item = queue.shift() ?? Stream.empty
|
||||
return item
|
||||
},
|
||||
raw: () => Effect.die("raw not implemented in TestLLM"),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(TestLLM, TestLLM.of({ reply })),
|
||||
|
||||
@@ -83,7 +83,6 @@ const llm = Layer.unwrap(
|
||||
const item = queue.shift() ?? Stream.empty
|
||||
return item
|
||||
},
|
||||
raw: () => Effect.die("raw not implemented in TestLLM"),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(TestLLM, TestLLM.of({ push })),
|
||||
|
||||
@@ -96,7 +96,6 @@ const llm = Layer.unwrap(
|
||||
const item = queue.shift() ?? Stream.fail(new Error("unexpected extra llm call"))
|
||||
return item
|
||||
},
|
||||
raw: () => Effect.die("raw not implemented in TestLLM"),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(TestLLM, TestLLM.of({ push, calls: Effect.sync(() => calls) })),
|
||||
|
||||
@@ -81,3 +81,45 @@ describe("KiloSessionProcessor.extractReviewTelemetry", () => {
|
||||
expect(KiloSessionProcessor.extractReviewTelemetry(parts as unknown as MessageV2.Part[])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloSessionProcessor.suggestionReviewTelemetry", () => {
|
||||
test("returns suggest-sourced telemetry for accepted review commands", () => {
|
||||
expect(
|
||||
KiloSessionProcessor.suggestionReviewTelemetry({
|
||||
accepted: { prompt: "/local-review-uncommitted --focus telemetry" },
|
||||
}),
|
||||
).toEqual({ ...expected("local-review-uncommitted"), tool: "suggest" })
|
||||
})
|
||||
|
||||
test("returns undefined for accepted non-review commands", () => {
|
||||
expect(KiloSessionProcessor.suggestionReviewTelemetry({ accepted: { prompt: "/test" } })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined when accepted prompt is not a slash command", () => {
|
||||
expect(KiloSessionProcessor.suggestionReviewTelemetry({ accepted: { prompt: "Run tests" } })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined when accepted metadata is missing", () => {
|
||||
expect(KiloSessionProcessor.suggestionReviewTelemetry({ dismissed: true })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloSessionProcessor.extractSuggestionReviewTelemetry", () => {
|
||||
test("recovers review telemetry from completed suggest tool metadata", () => {
|
||||
const parts = [
|
||||
{
|
||||
type: "tool",
|
||||
tool: "suggest",
|
||||
state: {
|
||||
status: "completed",
|
||||
metadata: { accepted: { prompt: "/local-review" } },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(KiloSessionProcessor.extractSuggestionReviewTelemetry(parts as unknown as MessageV2.Part[])).toEqual({
|
||||
...expected("local-review"),
|
||||
tool: "suggest",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Env } from "../../src/env"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Git } from "../../src/git"
|
||||
import { LSP } from "../../src/lsp/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
@@ -139,6 +140,7 @@ function makeHttp() {
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
|
||||
import { Suggestion } from "../../src/kilocode/suggestion"
|
||||
import { Question } from "../../src/question"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
@@ -246,7 +246,7 @@ describe("session prompt queue", () => {
|
||||
// scope() hides that marker, runLoop never processes the compaction task and
|
||||
// instead retries the same oversized request until compaction is exhausted.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Queued compaction regression" })
|
||||
@@ -412,7 +412,7 @@ describe("session prompt queue", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Queued prompt regression" })
|
||||
@@ -529,7 +529,7 @@ describe("session prompt queue", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Queued cancel regression" })
|
||||
@@ -588,7 +588,7 @@ describe("session prompt queue", () => {
|
||||
const dismissed = Promise.withResolvers<void>()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Suggestion unblock regression" })
|
||||
@@ -633,7 +633,7 @@ describe("session prompt queue", () => {
|
||||
const rejected = Promise.withResolvers<void>()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Question unblock regression" })
|
||||
@@ -687,7 +687,7 @@ describe("session prompt queue", () => {
|
||||
// hasFollowup=true and reject synchronously, before any pending entry or
|
||||
// Shown event is published.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("ses_auto_suggestion")
|
||||
@@ -748,7 +748,7 @@ describe("session prompt queue", () => {
|
||||
|
||||
test("auto-dismisses a question shown after a queued prompt", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("ses_auto_question")
|
||||
|
||||
@@ -5,30 +5,16 @@ import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Config } from "../../../src/config/config"
|
||||
import { emptyConsoleState } from "../../../src/config/console-state"
|
||||
import { Instruction } from "../../../src/session/instruction"
|
||||
import { MessageID } from "../../../src/session/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { provideTmpdirInstance } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { TestConfig } from "../../fixture/config"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
const configLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
get: () => Effect.succeed({}),
|
||||
getGlobal: () => Effect.succeed({}),
|
||||
getConsoleState: () => Effect.succeed(emptyConsoleState),
|
||||
update: () => Effect.void,
|
||||
updateGlobal: (config) => Effect.succeed(config),
|
||||
invalidate: () => Effect.void,
|
||||
directories: () => Effect.succeed([]),
|
||||
waitForDependencies: () => Effect.void,
|
||||
warnings: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const configLayer = TestConfig.layer()
|
||||
|
||||
const layer = (dir: string) =>
|
||||
Instruction.layer.pipe(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { KiloLLM } from "@/kilocode/session/llm"
|
||||
import type { LLM } from "@/session/llm"
|
||||
|
||||
describe("kilocode.session.llm.text", () => {
|
||||
test("joins text delta events", async () => {
|
||||
const out = await Effect.runPromise(
|
||||
KiloLLM.text(
|
||||
Stream.make(
|
||||
{ type: "text-delta", id: "text", text: "hello ", delta: "hello " } as LLM.Event,
|
||||
{ type: "text-delta", id: "text", text: "world", delta: "world" } as LLM.Event,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(out).toBe("hello world")
|
||||
})
|
||||
|
||||
test("fails on error events after partial text", async () => {
|
||||
const err = new Error("provider unavailable")
|
||||
const text = KiloLLM.text(
|
||||
Stream.make(
|
||||
{ type: "text-delta", id: "text", text: "partial", delta: "partial" } as LLM.Event,
|
||||
{ type: "error", error: err } as LLM.Event,
|
||||
),
|
||||
)
|
||||
|
||||
await expect(Effect.runPromise(text)).rejects.toThrow("provider unavailable")
|
||||
})
|
||||
|
||||
test("fails on abort events", async () => {
|
||||
const text = KiloLLM.text(Stream.make({ type: "abort" } as LLM.Event))
|
||||
|
||||
await expect(Effect.runPromise(text)).rejects.toMatchObject({ name: "AbortError" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { AppRuntime } from "../../../src/effect/app-runtime"
|
||||
import { Bus } from "../../../src/bus"
|
||||
import { KiloSession } from "../../../src/kilocode/session"
|
||||
import { WithInstance } from "../../../src/project/with-instance"
|
||||
import { MessageV2 } from "../../../src/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "../../../src/session/schema"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../../..")
|
||||
void Log.init({ print: false })
|
||||
|
||||
function create(input?: SessionNs.CreateInput) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
}
|
||||
|
||||
function remove(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
}
|
||||
|
||||
function updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
}
|
||||
|
||||
function updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
}
|
||||
|
||||
describe("session platform attribution", () => {
|
||||
test("child sessions inherit the root platform override", async () => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const root = await create({ platform: "agent-manager" })
|
||||
const child = await create({ parentID: root.id, title: "child" })
|
||||
const attr = KiloSession.attribution(child.id)
|
||||
|
||||
expect(KiloSession.getPlatformOverride(root.id)).toBe("agent-manager")
|
||||
expect(KiloSession.getPlatformOverride(child.id)).toBe("agent-manager")
|
||||
expect(KiloSession.resolvePlatform(child.id)).toBe("agent-manager")
|
||||
expect(attr.rootID).toBe(root.id)
|
||||
expect(attr.feature).toBe("agent-manager")
|
||||
|
||||
await remove(root.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("step-finish token propagation via Bus event", () => {
|
||||
test(
|
||||
"non-zero tokens propagate through PartUpdated event",
|
||||
async () => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const info = await create({})
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
await updateMessage({
|
||||
id: messageID,
|
||||
sessionID: info.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "user",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
|
||||
let received: MessageV2.Part | undefined
|
||||
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
|
||||
received = event.properties.part as MessageV2.Part
|
||||
})
|
||||
|
||||
const tokens = {
|
||||
total: 1500,
|
||||
input: 500,
|
||||
output: 800,
|
||||
reasoning: 200,
|
||||
cache: { read: 100, write: 50 },
|
||||
}
|
||||
|
||||
const part = {
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID: info.id,
|
||||
type: "step-finish" as const,
|
||||
reason: "stop",
|
||||
cost: 0.005,
|
||||
tokens,
|
||||
}
|
||||
|
||||
await updatePart(part)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toBeDefined()
|
||||
expect(received!.type).toBe("step-finish")
|
||||
const finish = received as MessageV2.StepFinishPart
|
||||
expect(finish.tokens.input).toBe(500)
|
||||
expect(finish.tokens.output).toBe(800)
|
||||
expect(finish.tokens.reasoning).toBe(200)
|
||||
expect(finish.tokens.total).toBe(1500)
|
||||
expect(finish.tokens.cache.read).toBe(100)
|
||||
expect(finish.tokens.cache.write).toBe(50)
|
||||
expect(finish.cost).toBe(0.005)
|
||||
expect(received).not.toBe(part)
|
||||
|
||||
unsub()
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { Bus } from "../../../src/bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { WithInstance } from "../../../src/project/with-instance"
|
||||
import { AppRuntime } from "../../../src/effect/app-runtime"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import type { SessionID } from "../../../src/session/schema"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../../..")
|
||||
void Log.init({ print: false })
|
||||
|
||||
function create(input?: SessionNs.CreateInput) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
}
|
||||
|
||||
function get(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
|
||||
function remove(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
}
|
||||
|
||||
describe("session.created event", () => {
|
||||
test("should emit session.created event when session is created", async () => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
let eventReceived = false
|
||||
let receivedInfo: SessionNs.Info | undefined
|
||||
|
||||
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
|
||||
eventReceived = true
|
||||
receivedInfo = event.properties.info as SessionNs.Info
|
||||
})
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
unsub()
|
||||
|
||||
expect(eventReceived).toBe(true)
|
||||
expect(receivedInfo).toBeDefined()
|
||||
expect(receivedInfo?.id).toBe(info.id)
|
||||
expect(receivedInfo?.projectID).toBe(info.projectID)
|
||||
expect(receivedInfo?.directory).toBe(info.directory)
|
||||
expect(receivedInfo?.path).toBe(info.path)
|
||||
expect(receivedInfo?.title).toBe(info.title)
|
||||
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("session.created event should be emitted before session.updated", async () => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const events: string[] = []
|
||||
|
||||
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
|
||||
events.push("created")
|
||||
})
|
||||
|
||||
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
|
||||
events.push("updated")
|
||||
})
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
unsubCreated()
|
||||
unsubUpdated()
|
||||
|
||||
expect(events).toContain("created")
|
||||
expect(events).toContain("updated")
|
||||
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
|
||||
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Session", () => {
|
||||
test("remove works without an instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const info = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => create({ title: "remove-without-instance" }),
|
||||
})
|
||||
|
||||
await expect(async () => {
|
||||
await remove(info.id)
|
||||
}).not.toThrow()
|
||||
|
||||
let missing = false
|
||||
await get(info.id).catch(() => {
|
||||
missing = true
|
||||
})
|
||||
|
||||
expect(missing).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -22,7 +22,7 @@ async function bootstrap() {
|
||||
|
||||
test("diffFull returns cached result for same hash pair", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
@@ -45,7 +45,7 @@ test("diffFull returns cached result for same hash pair", async () => {
|
||||
|
||||
test("diffFull returns empty array when from === to", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const hash = await Snapshot.track()
|
||||
@@ -59,7 +59,7 @@ test("diffFull returns empty array when from === to", async () => {
|
||||
|
||||
test("diffFull concurrent calls for same pair share one result", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
import { test, expect, afterEach, mock } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
@@ -45,7 +45,7 @@ test("pathological diffFull workload finishes quickly and does not block abort",
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user