mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
Merge branch 'main' into feature/charts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA, type BoxRenderable } from "@opentui/core"
|
||||
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
@@ -251,6 +251,45 @@ function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
||||
)
|
||||
}
|
||||
|
||||
function child(root: BoxRenderable | RootRenderable, index: number) {
|
||||
return root.getChildren()[index] as BoxRenderable
|
||||
}
|
||||
|
||||
function boxPath(root: BoxRenderable | RootRenderable, name: string): BoxRenderable[] | undefined {
|
||||
for (const item of root.getChildren()) {
|
||||
if (item.constructor.name === name) return root instanceof BoxRenderable ? [root] : []
|
||||
if (!(item instanceof BoxRenderable)) continue
|
||||
const path = boxPath(item, name)
|
||||
if (path) return root instanceof BoxRenderable ? [root, ...path] : path
|
||||
}
|
||||
}
|
||||
|
||||
function footerComposerFrame(root: BoxRenderable | RootRenderable) {
|
||||
return boxPath(root, "TextareaRenderable")!.at(-5)!
|
||||
}
|
||||
|
||||
function footerStatusline(root: BoxRenderable | RootRenderable) {
|
||||
const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
|
||||
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
|
||||
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
|
||||
for (const box of boxes) {
|
||||
const first = box.getChildren().find((item): item is BoxRenderable => item instanceof BoxRenderable)
|
||||
if (
|
||||
box.backgroundColor?.toInts().every((value, index) => value === status[index]) &&
|
||||
first?.backgroundColor?.toInts().every((value, index) => value === accent[index])
|
||||
)
|
||||
return box
|
||||
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
|
||||
}
|
||||
throw new Error("Footer statusline not found")
|
||||
}
|
||||
|
||||
function panelMenu(root: BoxRenderable | RootRenderable) {
|
||||
const panel = child(child(root, 0), 0)
|
||||
const content = child(panel, 0)
|
||||
return child(content.getChildren().at(-1) as BoxRenderable, 0)
|
||||
}
|
||||
|
||||
test("direct footer composer area does not adopt footer surface", async () => {
|
||||
const surface = RGBA.fromHex("#123456")
|
||||
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
|
||||
@@ -258,7 +297,7 @@ test("direct footer composer area does not adopt footer surface", async () => {
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const area = app.renderer.root.findDescendantById("run-direct-footer-composer-area") as BoxRenderable
|
||||
const area = child(footerComposerFrame(app.renderer.root), 0)
|
||||
|
||||
expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
|
||||
setTheme({
|
||||
@@ -591,7 +630,7 @@ test("direct subagent panel renders active subagents", async () => {
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
const list = app.renderer.root.findDescendantById("run-direct-footer-subagent-list") as BoxRenderable
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Select subagent")
|
||||
expect(frame).toContain("Inspect auth flow")
|
||||
@@ -629,7 +668,7 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
const list = app.renderer.root.findDescendantById("run-direct-footer-queued-list") as BoxRenderable
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Queued prompts")
|
||||
expect(frame).toContain("fix the auth test")
|
||||
@@ -651,12 +690,12 @@ test.skip("direct footer recreates the frame across command panel transitions",
|
||||
await app.renderOnce()
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const composerFrame = app.renderer.root.findDescendantById("run-direct-footer-composer-frame") as BoxRenderable
|
||||
const composerFrame = footerComposerFrame(app.renderer.root)
|
||||
app.mockInput.pressKey("p", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
|
||||
expect(app.captureCharFrame()).toContain("Commands")
|
||||
expect(app.renderer.root.findDescendantById("run-direct-footer-composer-frame")).not.toBe(composerFrame)
|
||||
expect(footerComposerFrame(app.renderer.root)).not.toBe(composerFrame)
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("Commands")
|
||||
@@ -956,13 +995,14 @@ test("direct footer shows editable prompts and additional queued work while runn
|
||||
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
|
||||
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
|
||||
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
|
||||
const statusline = app.renderer.root.findDescendantById("run-direct-footer-statusline") as BoxRenderable
|
||||
const mode = app.renderer.root.findDescendantById("run-direct-footer-statusline-mode") as BoxRenderable
|
||||
const main = app.renderer.root.findDescendantById("run-direct-footer-statusline-main") as BoxRenderable
|
||||
const spinner = app.renderer.root.findDescendantById("run-direct-footer-status-spinner")
|
||||
const model = app.renderer.root.findDescendantById("run-direct-footer-statusline-model") as BoxRenderable
|
||||
const queued = app.renderer.root.findDescendantById("run-direct-footer-statusline-queued") as BoxRenderable
|
||||
const hint = app.renderer.root.findDescendantById("run-direct-footer-statusline-hint") as BoxRenderable
|
||||
const statusline = footerStatusline(app.renderer.root)
|
||||
const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
|
||||
const mode = statusItems[0]
|
||||
const main = statusItems[1]
|
||||
const spinner = main.getChildren()[0]
|
||||
const model = statusItems[2]
|
||||
const queued = statusItems[3]
|
||||
const hint = statusItems.at(-1)!
|
||||
|
||||
expect(spinner).toBeDefined()
|
||||
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
||||
@@ -1280,7 +1320,7 @@ test("direct model panel renders current model selector", async () => {
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
const list = app.renderer.root.findDescendantById("run-direct-footer-model-list") as BoxRenderable
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Select model")
|
||||
expect(frame).toContain("Search")
|
||||
@@ -1323,7 +1363,7 @@ test("direct variant panel renders current variant selector", async () => {
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
const list = app.renderer.root.findDescendantById("run-direct-footer-variant-list") as BoxRenderable
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Select variant")
|
||||
expect(frame).toContain("Default")
|
||||
|
||||
@@ -578,6 +578,68 @@ it.instance("rejects environment variable substitution in project config", () =>
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("injects $schema into config without existing schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// Config without $schema - should trigger auto-add
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, "kilo.json"),
|
||||
JSON.stringify({ username: "test-user" }),
|
||||
)
|
||||
const config = yield* Config.use.get()
|
||||
expect(config.username).toBe("test-user")
|
||||
expect(config.$schema).toBe("https://app.kilo.ai/config.json")
|
||||
|
||||
// Read the file to verify $schema was injected
|
||||
const content = yield* FSUtil.use.readFileString(path.join(test.directory, "kilo.json"))
|
||||
expect(content).toContain('"$schema": "https://app.kilo.ai/config.json"')
|
||||
const schemaIndex = content.indexOf('"$schema"')
|
||||
const usernameIndex = content.indexOf('"username"')
|
||||
expect(schemaIndex).toBeLessThan(usernameIndex)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("injects $schema into comment-first JSONC config", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// Config with leading comment - regex-based injection would fail
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, "kilo.jsonc"),
|
||||
'// project config\n{\n "model": "test/model"\n}\n',
|
||||
)
|
||||
const config = yield* Config.use.get()
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.$schema).toBe("https://app.kilo.ai/config.json")
|
||||
|
||||
// Read the file to verify $schema was injected correctly
|
||||
const content = yield* FSUtil.use.readFileString(path.join(test.directory, "kilo.jsonc"))
|
||||
expect(content).toContain('"$schema": "https://app.kilo.ai/config.json"')
|
||||
expect(content).toContain("// project config")
|
||||
const schemaIndex = content.indexOf('"$schema"')
|
||||
const modelIndex = content.indexOf('"model"')
|
||||
expect(schemaIndex).toBeLessThan(modelIndex)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("does not write config when $schema already present", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "kilo.json")
|
||||
// Config already has $schema - should not rewrite file
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
filepath,
|
||||
JSON.stringify({ $schema: "https://app.kilo.ai/config.json", username: "test-user" }),
|
||||
)
|
||||
const before = yield* Effect.promise(() => fs.stat(filepath))
|
||||
|
||||
const config = yield* Config.use.get()
|
||||
expect(config.username).toBe("test-user")
|
||||
|
||||
const after = yield* Effect.promise(() => fs.stat(filepath))
|
||||
expect(after.mtimeMs).toBe(before.mtimeMs)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("allows {file:} that stays inside the project root", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const posts: Array<{ method: string; session: string | null }> = []
|
||||
let initializeCount = 0
|
||||
let pingCount = 0
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
if (request.method === "GET") return new Response(null, { status: 405 })
|
||||
if (request.method === "DELETE") return new Response(null, { status: 200 })
|
||||
|
||||
const message = (await request.json()) as { id?: number; method: string }
|
||||
const session = request.headers.get("mcp-session-id")
|
||||
posts.push({ method: message.method, session })
|
||||
|
||||
if (message.method === "initialize") {
|
||||
initializeCount++
|
||||
return Response.json(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
serverInfo: { name: "test", version: "1" },
|
||||
},
|
||||
},
|
||||
{ headers: { "mcp-session-id": initializeCount === 1 ? "expired" : "replacement" } },
|
||||
)
|
||||
}
|
||||
|
||||
if (message.method === "notifications/initialized") return new Response(null, { status: 202 })
|
||||
|
||||
pingCount++
|
||||
if (pingCount === 1) return new Response("Session not found", { status: 404 })
|
||||
return Response.json({ jsonrpc: "2.0", id: message.id, result: {} })
|
||||
},
|
||||
})
|
||||
const client = new Client({ name: "test", version: "1" })
|
||||
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(server.url))
|
||||
await client.ping()
|
||||
process.stdout.write(JSON.stringify(posts))
|
||||
} finally {
|
||||
await client.close()
|
||||
server.stop(true)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import type { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Permission } from "../../src/permission"
|
||||
@@ -14,6 +15,21 @@ function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
|
||||
)
|
||||
}
|
||||
|
||||
async function get(config: Partial<ConfigV1.Info>, name = "plan") {
|
||||
await using tmp = await tmpdir({ config })
|
||||
const item = await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: () => load(tmp.path, (svc) => svc.get(name)),
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
function expectPlan(item: Agent.Info | undefined, action: Permission.Action = "allow") {
|
||||
expect(item).toBeDefined()
|
||||
expect(Permission.evaluate("edit", "src/output.log", item!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("edit", ".kilo/plans/fix.md", item!.permission).action).toBe(action)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
@@ -81,6 +97,194 @@ test("plan agent still hard-denies non-plan edits after user edit allow", async
|
||||
})
|
||||
})
|
||||
|
||||
test("plan agent still hard-denies non-plan edits after per-agent edit ask", async () => {
|
||||
const plan = await get(
|
||||
{
|
||||
agent: {
|
||||
plan: {
|
||||
permission: {
|
||||
edit: "ask",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expectPlan(plan)
|
||||
})
|
||||
|
||||
test("plan agent honors global and per-agent plan allows after wildcard edit deny", async () => {
|
||||
const edit = {
|
||||
"*": "deny" as const,
|
||||
".kilo/plans/*": "allow" as const,
|
||||
}
|
||||
for (const config of [
|
||||
{ permission: { edit } },
|
||||
{
|
||||
agent: {
|
||||
plan: {
|
||||
permission: {
|
||||
edit,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]) {
|
||||
expectPlan(await get(config))
|
||||
}
|
||||
})
|
||||
|
||||
test("plan agent preserves scalar edit deny", async () => {
|
||||
const plan = await get(
|
||||
{
|
||||
agent: {
|
||||
plan: {
|
||||
permission: {
|
||||
edit: "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expectPlan(plan, "deny")
|
||||
})
|
||||
|
||||
test("plan agent preserves a terminal wildcard edit deny", async () => {
|
||||
const plan = await get(
|
||||
{
|
||||
agent: {
|
||||
plan: {
|
||||
permission: {
|
||||
edit: {
|
||||
".kilo/plans/*": "allow",
|
||||
"*": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expectPlan(plan, "deny")
|
||||
})
|
||||
|
||||
test("plan agent preserves explicit per-agent edit denies", async () => {
|
||||
const plan = await get(
|
||||
{
|
||||
agent: {
|
||||
plan: {
|
||||
permission: {
|
||||
edit: {
|
||||
".kilo/plans/private.md": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expectPlan(plan)
|
||||
expect(Permission.evaluate("edit", ".kilo/plans/private.md", plan!.permission).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("plan agent preserves global edit denies after per-agent edit ask", async () => {
|
||||
const plan = await get(
|
||||
{
|
||||
permission: {
|
||||
edit: {
|
||||
".kilo/plans/private.md": "deny",
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
plan: {
|
||||
permission: {
|
||||
edit: "ask",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expectPlan(plan)
|
||||
expect(Permission.evaluate("edit", ".kilo/plans/private.md", plan!.permission).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("plan agent preserves global non-edit denies before broader allows", async () => {
|
||||
const plan = await get({
|
||||
permission: {
|
||||
bash: {
|
||||
"rm *": "deny",
|
||||
"*": "allow",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(Permission.evaluate("bash", "rm -rf x", plan!.permission).action).toBe("deny")
|
||||
expect(Permission.evaluate("bash", "ls", plan!.permission).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("plan agent preserves per-agent tool allows with a wildcard deny", async () => {
|
||||
const plan = await get(
|
||||
{
|
||||
agent: {
|
||||
plan: {
|
||||
permission: {
|
||||
"*": "deny",
|
||||
read: "allow",
|
||||
glob: "allow",
|
||||
edit: "ask",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expectPlan(plan)
|
||||
expect(Permission.evaluate("read", "src/output.log", plan!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("glob", "*", plan!.permission).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("marketplace architect honors plan allow after wildcard edit deny", async () => {
|
||||
const architect = await get(
|
||||
{
|
||||
agent: {
|
||||
architect: {
|
||||
mode: "primary",
|
||||
options: {
|
||||
displayName: "Architect",
|
||||
},
|
||||
permission: {
|
||||
"*": "deny",
|
||||
read: "allow",
|
||||
glob: "allow",
|
||||
edit: {
|
||||
"*": "deny",
|
||||
".kilo/plans/*": "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"architect",
|
||||
)
|
||||
expectPlan(architect)
|
||||
expect(architect!.name).toBe("architect")
|
||||
expect(architect!.displayName).toBe("Architect")
|
||||
expect(Permission.evaluate("read", "src/output.log", architect!.permission).action).toBe("allow")
|
||||
expect(Permission.evaluate("glob", "*", architect!.permission).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("non-planning agents retain per-agent edit permissions", async () => {
|
||||
const code = await get(
|
||||
{
|
||||
agent: {
|
||||
code: {
|
||||
permission: {
|
||||
edit: "ask",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"code",
|
||||
)
|
||||
expect(code).toBeDefined()
|
||||
expect(Permission.evaluate("edit", "src/output.log", code!.permission).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("system utility agents ignore per-agent permission allows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Auth } from "@/auth"
|
||||
import { remove } from "@/kilocode/auth/remove"
|
||||
import { ConnectorSchema } from "@opencode-ai/core/connector/schema"
|
||||
import { IntegrationSchema } from "@opencode-ai/core/integration/schema"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -24,24 +24,21 @@ it.effect("legacy provider logout removes every Core credential", () =>
|
||||
Effect.gen(function* () {
|
||||
state.removed = false
|
||||
const service = yield* Credential.Service
|
||||
const connectorID = ConnectorSchema.ID.make("anthropic")
|
||||
const integrationID = IntegrationSchema.ID.make("anthropic")
|
||||
yield* service.create({
|
||||
connectorID,
|
||||
methodID: ConnectorSchema.MethodID.make("api-key"),
|
||||
integrationID,
|
||||
label: "first",
|
||||
value: new Credential.Key({ type: "key", key: "first" }),
|
||||
})
|
||||
yield* service.create({
|
||||
connectorID,
|
||||
methodID: ConnectorSchema.MethodID.make("api-key"),
|
||||
integrationID,
|
||||
label: "second",
|
||||
value: new Credential.Key({ type: "key", key: "second" }),
|
||||
})
|
||||
|
||||
yield* remove("anthropic")
|
||||
|
||||
expect(yield* service.forConnector(connectorID)).toEqual([])
|
||||
expect(yield* service.active(connectorID)).toBeUndefined()
|
||||
expect(yield* service.list(integrationID)).toEqual([])
|
||||
expect(state.removed).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -73,6 +73,7 @@ mock.module("@/kilocode/help-command", () => ({
|
||||
|
||||
for (const path of [
|
||||
"@/kilocode/cli/cmd/console",
|
||||
"@/kilocode/cli/cmd/cloud",
|
||||
"@/kilocode/cli/cmd/roll-call",
|
||||
"@/kilocode/cli/cmd/profile",
|
||||
"@/kilocode/cli/cmd/daemon",
|
||||
@@ -82,6 +83,7 @@ for (const path of [
|
||||
]) {
|
||||
mock.module(path, () => ({
|
||||
KiloConsoleCommand: { command: "console", handler() {} },
|
||||
CloudCommand: { command: "cloud", handler() {} },
|
||||
RollCallCommand: { command: "roll-call", handler() {} },
|
||||
ProfileCommand: { command: "profile", handler() {} },
|
||||
DaemonCommand: { command: "daemon", handler() {} },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// kilocode_change - new file
|
||||
// K1 W1: verify `buildInstanceAdvertisement`'s payload shape as real behavior.
|
||||
//
|
||||
// The `RemoteCommand` handler itself is a CLI entry point that calls
|
||||
// `bootstrap(process.cwd(), async () => { ... })` and then awaits an abort
|
||||
// signal that never resolves in a test — it cannot be driven end-to-end.
|
||||
// `buildInstanceAdvertisement` is extracted from the handler specifically so
|
||||
// the advertised payload is independently testable as real behavior, not via
|
||||
// a source-text/regex assertion on the handler's structure.
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildInstanceAdvertisement } from "../../../../src/cli/cmd/remote"
|
||||
|
||||
describe("RemoteCommand instance advertisement (K1 W1)", () => {
|
||||
test("buildInstanceAdvertisement resolves name/projectName/version from the directory and installation version", () => {
|
||||
const advertisement = buildInstanceAdvertisement("/Users/igor/projects/my-app")
|
||||
expect(advertisement.projectName).toBe("my-app")
|
||||
expect(typeof advertisement.name).toBe("string")
|
||||
expect(advertisement.name.length).toBeGreaterThan(0)
|
||||
expect(typeof advertisement.version).toBe("string")
|
||||
})
|
||||
|
||||
test("buildInstanceAdvertisement truncates an overlong project directory name to 64 chars", () => {
|
||||
const longName = "a".repeat(100)
|
||||
const advertisement = buildInstanceAdvertisement(`/Users/igor/projects/${longName}`)
|
||||
expect(advertisement.projectName.length).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
test("buildInstanceAdvertisement falls back to the full directory when basename is empty (root path)", () => {
|
||||
const advertisement = buildInstanceAdvertisement("/")
|
||||
expect(advertisement.projectName).toBe("/")
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { KiloClient } from "@kilocode/sdk/v2"
|
||||
import { memoryRow } from "@/kilocode/cli/cmd/tui/component/memory-status"
|
||||
import { runMemoryCommand } from "@/kilocode/cli/cmd/tui/memory-command"
|
||||
import { MemoryTuiEvents } from "@/kilocode/cli/cmd/tui/memory-events"
|
||||
import { MemoryTuiMeta } from "@/kilocode/cli/cmd/tui/memory-meta"
|
||||
import { MemoryTuiState } from "@/kilocode/cli/cmd/tui/memory-state"
|
||||
|
||||
type Handler = (event: {
|
||||
@@ -64,16 +63,16 @@ describe("memory TUI command parser", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("auto-save, verbose, and purge commands call explicit endpoints", async () => {
|
||||
test("auto-save and purge commands call explicit endpoints", async () => {
|
||||
const shown: string[] = []
|
||||
const calls: unknown[] = []
|
||||
const state = { autoConsolidate: true, verbose: false }
|
||||
const state = { autoConsolidate: true }
|
||||
const client = {
|
||||
memory: {
|
||||
status: async () => ({ data: { state } }),
|
||||
configure: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { data: { state: { autoConsolidate: false, verbose: true } } }
|
||||
return { data: { state: { autoConsolidate: false } } }
|
||||
},
|
||||
purge: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
@@ -96,17 +95,15 @@ describe("memory TUI command parser", () => {
|
||||
}
|
||||
|
||||
await runMemoryCommand({ ...base, text: "/memory auto off" })
|
||||
await runMemoryCommand({ ...base, text: "/memory verbose on" })
|
||||
await runMemoryCommand({ ...base, text: "/memory auto status" })
|
||||
await runMemoryCommand({ ...base, text: "/memory purge" })
|
||||
await runMemoryCommand({ ...base, text: "/memory purge confirm" })
|
||||
|
||||
expect(shown[0]).toBe("Memory auto-save off")
|
||||
expect(shown[1]).toBe("Memory verbose on")
|
||||
expect(shown[2]).toContain("Missing auto mode")
|
||||
expect(shown[3]).toContain("Purge requires confirmation")
|
||||
expect(shown[4]).toBe("Memory purged")
|
||||
expect(calls).toEqual([{ autoConsolidate: false }, { verbose: true }, { confirm: true }])
|
||||
expect(shown[1]).toContain("Missing auto mode")
|
||||
expect(shown[2]).toContain("Purge requires confirmation")
|
||||
expect(shown[3]).toBe("Memory purged")
|
||||
expect(calls).toEqual([{ autoConsolidate: false }, { confirm: true }])
|
||||
})
|
||||
|
||||
test("status opens overview dialog", async () => {
|
||||
@@ -133,6 +130,31 @@ describe("memory TUI command parser", () => {
|
||||
expect(shown).toEqual([])
|
||||
})
|
||||
|
||||
test("inspect reveals the memory folder", async () => {
|
||||
const opened: string[] = []
|
||||
const shown: string[] = []
|
||||
const client = {
|
||||
memory: {
|
||||
status: async () => ({ data: { root: "/tmp/kilo-memory", state: { enabled: true } } }),
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
|
||||
await runMemoryCommand({
|
||||
text: "/memory inspect",
|
||||
client,
|
||||
toast: { show: (input) => shown.push(input.message) },
|
||||
inspect(root) {
|
||||
opened.push(root)
|
||||
},
|
||||
show() {},
|
||||
status() {},
|
||||
usage() {},
|
||||
})
|
||||
|
||||
expect(opened).toEqual(["/tmp/kilo-memory"])
|
||||
expect(shown).toEqual(["Memory folder: /tmp/kilo-memory"])
|
||||
})
|
||||
|
||||
test("bare memory command opens help", async () => {
|
||||
const calls: unknown[] = []
|
||||
const client = { memory: {} } as unknown as KiloClient
|
||||
@@ -204,7 +226,7 @@ describe("memory TUI command parser", () => {
|
||||
|
||||
test("memory commands route to session directory when no workspace is active", async () => {
|
||||
const calls: unknown[] = []
|
||||
const state = { autoConsolidate: false, verbose: false }
|
||||
const state = { autoConsolidate: false }
|
||||
const client = {
|
||||
memory: {
|
||||
configure: async (input: unknown) => {
|
||||
@@ -222,7 +244,6 @@ describe("memory TUI command parser", () => {
|
||||
}
|
||||
|
||||
await runMemoryCommand({ ...base, text: "/memory auto off", directory: "/repo/packages/opencode" })
|
||||
await runMemoryCommand({ ...base, text: "/memory verbose on", directory: "/repo/packages/opencode" })
|
||||
await runMemoryCommand({
|
||||
...base,
|
||||
text: "/memory auto off",
|
||||
@@ -232,7 +253,6 @@ describe("memory TUI command parser", () => {
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ directory: "/repo/packages/opencode", autoConsolidate: false },
|
||||
{ directory: "/repo/packages/opencode", verbose: true },
|
||||
{ workspace: "wrk_123", autoConsolidate: false },
|
||||
])
|
||||
})
|
||||
@@ -288,63 +308,38 @@ describe("memory TUI events", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("memory TUI metadata", () => {
|
||||
test("reads typed verbose and activity state", () => {
|
||||
expect(MemoryTuiState.verbose({ verbose: true })).toBe(true)
|
||||
expect(MemoryTuiState.verbose(undefined)).toBe(false)
|
||||
describe("memory TUI state", () => {
|
||||
test("tracks active memory", () => {
|
||||
expect(MemoryTuiState.active({ markers: 1, saved: false })).toBe(true)
|
||||
expect(MemoryTuiState.active({ markers: 0, saved: true })).toBe(true)
|
||||
expect(MemoryTuiState.active({ markers: 0, saved: false })).toBe(false)
|
||||
expect(MemoryTuiMeta.items({ items: ["first", 1, "second"] })).toEqual(["first", "second"])
|
||||
expect(MemoryTuiMeta.items({})).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("memory sidebar row", () => {
|
||||
test("shows loading, unavailable, and disabled states", () => {
|
||||
expect(memoryRow({ loading: true, active: false, verbose: false })).toEqual({
|
||||
expect(memoryRow({ loading: true, active: false })).toEqual({
|
||||
label: "Loading",
|
||||
tone: "muted",
|
||||
})
|
||||
expect(memoryRow({ active: false, verbose: false })).toEqual({
|
||||
expect(memoryRow({ active: false })).toEqual({
|
||||
label: "Unavailable",
|
||||
tone: "error",
|
||||
})
|
||||
expect(memoryRow({ enabled: false, active: true, verbose: true, flash: "recalled 3" })).toEqual({
|
||||
expect(memoryRow({ enabled: false, active: true })).toEqual({
|
||||
label: "Disabled",
|
||||
tone: "muted",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses muted and green dots for inactive and active sessions", () => {
|
||||
expect(memoryRow({ enabled: true, active: false, verbose: false })).toEqual({
|
||||
expect(memoryRow({ enabled: true, active: false })).toEqual({
|
||||
label: "Enabled",
|
||||
tone: "muted",
|
||||
})
|
||||
expect(memoryRow({ enabled: true, active: true, verbose: false })).toEqual({
|
||||
expect(memoryRow({ enabled: true, active: true })).toEqual({
|
||||
label: "Enabled",
|
||||
tone: "success",
|
||||
})
|
||||
})
|
||||
|
||||
test("adds verbose event captions without changing the activity tone", () => {
|
||||
expect(memoryRow({ enabled: true, active: false, verbose: true, flash: "recalled 3" })).toEqual({
|
||||
label: "Enabled",
|
||||
tone: "muted",
|
||||
caption: "recalled 3",
|
||||
})
|
||||
expect(memoryRow({ enabled: true, active: true, verbose: true, flash: "saved 2" })).toEqual({
|
||||
label: "Enabled",
|
||||
tone: "success",
|
||||
caption: "saved 2",
|
||||
})
|
||||
})
|
||||
|
||||
test("omits verbose event captions when verbose is disabled", () => {
|
||||
expect(memoryRow({ enabled: true, active: true, verbose: false, flash: "loaded" })).toEqual({
|
||||
label: "Enabled",
|
||||
tone: "success",
|
||||
caption: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,21 +3,10 @@ import { expect, spyOn, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { TuiPluginApi } from "@kilocode/plugin/tui"
|
||||
import type { Event, GlobalEvent, Message, Part, Session } from "@kilocode/sdk/v2"
|
||||
import type { Event, Message, Part, Session } from "@kilocode/sdk/v2"
|
||||
import { createSignal } from "solid-js"
|
||||
import { ArgsProvider } from "@tui/context/args"
|
||||
import { ExitProvider } from "@tui/context/exit"
|
||||
import { KVProvider } from "@tui/context/kv"
|
||||
import { ProjectProvider } from "@tui/context/project"
|
||||
import { SDKProvider } from "@tui/context/sdk"
|
||||
import { SyncProvider } from "@tui/context/sync"
|
||||
import { ToastProvider } from "@tui/ui/toast"
|
||||
import { MemorySidebar } from "@/kilocode/cli/cmd/tui/component/memory-status"
|
||||
import { MemoryMessageMeta, MemorySessionTui } from "@/kilocode/cli/cmd/tui/routes/session/memory"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createEventSource, createFetch, directory, json } from "../../../../fixture/tui-sdk"
|
||||
import { tmpdir } from "../../../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../../../fixture/tui-environment"
|
||||
import { directory } from "../../../../fixture/tui-sdk"
|
||||
|
||||
const id = "ses_memory_status"
|
||||
|
||||
@@ -48,10 +37,6 @@ function event(sessionID?: string, count?: number): Extract<Event, { type: "memo
|
||||
}
|
||||
}
|
||||
|
||||
function global(payload: Event): GlobalEvent {
|
||||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2_000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
@@ -60,115 +45,6 @@ async function wait(fn: () => boolean, timeout = 2_000) {
|
||||
}
|
||||
}
|
||||
|
||||
function Probe(props: { sessionID: string }) {
|
||||
const verbose = MemorySessionTui.verbose({ sessionID: () => props.sessionID })
|
||||
return <text>{verbose() ? "verbose" : "quiet"}</text>
|
||||
}
|
||||
|
||||
test("session memory status refetches live and ignores other sessions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prior = Global.Path.state
|
||||
Global.Path.state = tmp.path
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
const events = createEventSource()
|
||||
const state = { verbose: false }
|
||||
const calls = { count: 0 }
|
||||
const fetch = createFetch((url) => {
|
||||
if (url.pathname === "/session") return json([session])
|
||||
if (url.pathname !== "/memory/status") return
|
||||
calls.count += 1
|
||||
return json({ state: { verbose: state.verbose } })
|
||||
})
|
||||
try {
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts paths={{ state: tmp.path }}>
|
||||
<ArgsProvider>
|
||||
<ExitProvider exit={() => {}}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<SDKProvider url="http://test" directory={directory} fetch={fetch.fetch} events={events.source}>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<Probe sessionID={id} />
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
try {
|
||||
await wait(() => calls.count === 1 && app.captureCharFrame().includes("quiet"))
|
||||
events.emit(global(event("ses_other")))
|
||||
await Bun.sleep(30)
|
||||
expect(calls.count).toBe(1)
|
||||
|
||||
state.verbose = true
|
||||
events.emit(global(event(id)))
|
||||
await wait(() => calls.count === 2 && app.captureCharFrame().includes("verbose"))
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
} finally {
|
||||
Global.Path.state = prior
|
||||
}
|
||||
})
|
||||
|
||||
test("message memory metadata reacts to verbose changes and bounds snippets", async () => {
|
||||
const [verbose, setVerbose] = createSignal(false)
|
||||
const [parts, setParts] = createSignal<Part[]>([])
|
||||
const first = "a".repeat(100)
|
||||
const part = {
|
||||
id: "part_memory_recall",
|
||||
sessionID: id,
|
||||
messageID: "msg_memory_recall",
|
||||
type: "text",
|
||||
text: "",
|
||||
metadata: { kiloMemory: { type: "recall", count: 3, items: [first, "second", "third"] } },
|
||||
} satisfies Part
|
||||
setParts([part])
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<text>
|
||||
<MemoryMessageMeta parts={parts()} color={RGBA.fromHex("#ffffff")} verbose={verbose} />
|
||||
</text>
|
||||
),
|
||||
{ width: 200, height: 3 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("memory · recalled 3")
|
||||
expect(app.captureCharFrame()).not.toContain("second")
|
||||
|
||||
setVerbose(true)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("a".repeat(80))
|
||||
expect(app.captureCharFrame()).not.toContain("a".repeat(81))
|
||||
expect(app.captureCharFrame()).toContain("second")
|
||||
expect(app.captureCharFrame()).not.toContain("third")
|
||||
|
||||
setVerbose(false)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("second")
|
||||
|
||||
setParts([
|
||||
{
|
||||
...part,
|
||||
id: "part_memory_startup",
|
||||
metadata: { kiloMemory: { type: "startup", count: 2, tokens: 40 } },
|
||||
},
|
||||
])
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("memory · Startup Context")
|
||||
expect(app.captureCharFrame()).not.toContain("recalled")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
type Handler = (event: Event) => void
|
||||
|
||||
function bus() {
|
||||
@@ -186,7 +62,7 @@ function bus() {
|
||||
}
|
||||
}
|
||||
|
||||
test("sidebar refetches status and scopes recall and save flashes", async () => {
|
||||
test("sidebar refetches status and scopes save activity", async () => {
|
||||
const [parts, setParts] = createSignal<Part[]>([])
|
||||
const events = bus()
|
||||
const calls = { count: 0 }
|
||||
@@ -203,7 +79,7 @@ test("sidebar refetches status and scopes recall and save flashes", async () =>
|
||||
memory: {
|
||||
status: async () => {
|
||||
calls.count += 1
|
||||
return { data: { state: { enabled: true, verbose: true } } }
|
||||
return { data: { state: { enabled: true } } }
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -232,16 +108,15 @@ test("sidebar refetches status and scopes recall and save flashes", async () =>
|
||||
metadata: { kiloMemory: { type: "recall", count: 2 } },
|
||||
},
|
||||
])
|
||||
await wait(() => app.captureCharFrame().includes("recalled 2"))
|
||||
await Bun.sleep(5_100)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("recalled 2")
|
||||
|
||||
events.emit(event("ses_other", 4))
|
||||
await wait(() => calls.count === 2)
|
||||
expect(app.captureCharFrame()).not.toContain("saved 4")
|
||||
|
||||
events.emit(event(id, 3))
|
||||
await wait(() => calls.count === 3 && app.captureCharFrame().includes("saved 3"))
|
||||
await wait(() => calls.count === 3)
|
||||
expect(app.captureCharFrame()).not.toContain("saved 3")
|
||||
const before = clear.mock.calls.length
|
||||
app.renderer.destroy()
|
||||
expect(clear.mock.calls.length).toBeGreaterThan(before)
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Auth } from "@/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import type { AgentSendRequest, AgentStartRequest, MessageResult } from "@/kilocode/cloud/contracts"
|
||||
import { CloudCommands } from "@/kilocode/cloud/commands"
|
||||
import { CloudError } from "@/kilocode/cloud/errors"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { TestInstance } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const SESSION = "agent_12345678-1234-1234-1234-123456789abc"
|
||||
const MESSAGE = "msg_018f1e2d3c4bAbCdEfGhIjKlMn"
|
||||
const TOKEN = "command-test-token"
|
||||
const ORG = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: (id) =>
|
||||
Effect.succeed(
|
||||
id === "kilo"
|
||||
? new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: TOKEN,
|
||||
refresh: "test-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: ORG,
|
||||
})
|
||||
: undefined,
|
||||
),
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, Git.defaultLayer, auth))
|
||||
|
||||
const run = Effect.fn("CloudCommandTest.git")(function* (cwd: string, ...args: string[]) {
|
||||
const git = yield* Git.Service
|
||||
const result = yield* git.run(args, { cwd })
|
||||
if (result.exitCode === 0) return
|
||||
yield* Effect.die(new Error(result.stderr.toString("utf8")))
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"assembles the default start request from Kilo state and the current repository",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/models")) {
|
||||
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
|
||||
}
|
||||
if (url.pathname.endsWith("/defaults")) {
|
||||
return Response.json({ defaultModel: "anthropic/command-model" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* run(test.directory, "remote", "add", "origin", "git@github.com:Kilo-Org/kilocode.git")
|
||||
|
||||
const requests: AgentStartRequest[] = []
|
||||
const keys: string[] = []
|
||||
const output: string[] = []
|
||||
const response = {
|
||||
cloudAgentSessionId: SESSION,
|
||||
kiloSessionId: "ses_command_test",
|
||||
messageId: MESSAGE,
|
||||
delivery: "queued" as const,
|
||||
}
|
||||
|
||||
const result = yield* CloudCommands.start(
|
||||
{
|
||||
cwd: test.directory,
|
||||
prompt: "Inspect the current repository",
|
||||
},
|
||||
{
|
||||
env: { KILO_API_URL: server.url.origin },
|
||||
make: (options) => {
|
||||
keys.push(options.apiKey)
|
||||
return {
|
||||
async start(input) {
|
||||
requests.push(input)
|
||||
return response
|
||||
},
|
||||
async send() {
|
||||
throw new Error("unused send")
|
||||
},
|
||||
async getMessageResult() {
|
||||
throw new Error("unused result")
|
||||
},
|
||||
}
|
||||
},
|
||||
write: (text) => output.push(text),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual(response)
|
||||
expect(keys).toEqual([TOKEN])
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toEqual({
|
||||
message: { prompt: "Inspect the current repository" },
|
||||
agent: { mode: "plan", model: "anthropic/command-model" },
|
||||
repository: { type: "github", repo: "Kilo-Org/kilocode" },
|
||||
options: {
|
||||
createdOnPlatform: "kilo-cli",
|
||||
kilocodeOrganizationId: ORG,
|
||||
},
|
||||
})
|
||||
expect(requests[0]?.repository).not.toHaveProperty("branch")
|
||||
expect(output).toEqual([JSON.stringify(response) + "\n"])
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
default_agent: "plan",
|
||||
agent: { plan: { model: "kilo/anthropic/command-model" } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"streams WebSocket events when --stream is passed",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/models")) {
|
||||
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
|
||||
}
|
||||
if (url.pathname.endsWith("/defaults")) {
|
||||
return Response.json({ defaultModel: "anthropic/command-model" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const output: string[] = []
|
||||
const ticketCalls: { cloudAgentSessionId: string; organizationId?: string }[] = []
|
||||
const streamCalls: string[] = []
|
||||
const response = {
|
||||
cloudAgentSessionId: SESSION,
|
||||
kiloSessionId: "ses_stream_test",
|
||||
messageId: MESSAGE,
|
||||
delivery: "queued" as const,
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=inlined",
|
||||
}
|
||||
|
||||
const result = yield* CloudCommands.start(
|
||||
{
|
||||
cwd: test.directory,
|
||||
prompt: "Inspect the repository",
|
||||
repo: "Kilo-Org/kilocode",
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
env: { KILO_API_URL: server.url.origin },
|
||||
make: () => ({
|
||||
async start() {
|
||||
return response
|
||||
},
|
||||
async send() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async getMessageResult() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
}),
|
||||
createStreamTicketClient: () => ({
|
||||
async fetchTicket(input) {
|
||||
ticketCalls.push(input)
|
||||
return { ticket: "should-not-be-used", expiresAt: 0 }
|
||||
},
|
||||
}),
|
||||
streamAgentEvents: async (options) => {
|
||||
streamCalls.push(options.streamUrl)
|
||||
await options.writeLine('{"event":"one"}')
|
||||
await options.writeLine('{"streamEventType":"complete","data":{"exitCode":0}}')
|
||||
},
|
||||
write: (text) => output.push(text),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual(response)
|
||||
expect(ticketCalls).toEqual([])
|
||||
expect(streamCalls).toEqual(["/stream?cloudAgentSessionId=agent_123&ticket=inlined"])
|
||||
expect(output).toEqual([
|
||||
JSON.stringify({ ...response, streamUrl: undefined }) + "\n",
|
||||
'{"event":"one"}\n',
|
||||
'{"streamEventType":"complete","data":{"exitCode":0}}\n',
|
||||
])
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
default_agent: "plan",
|
||||
agent: { plan: { model: "kilo/anthropic/command-model" } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"fetches a stream ticket when the response omits streamUrl",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/models")) {
|
||||
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
|
||||
}
|
||||
if (url.pathname.endsWith("/defaults")) {
|
||||
return Response.json({ defaultModel: "anthropic/command-model" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const output: string[] = []
|
||||
const ticketCalls: { cloudAgentSessionId: string; organizationId?: string }[] = []
|
||||
const streamCalls: string[] = []
|
||||
const response = {
|
||||
cloudAgentSessionId: SESSION,
|
||||
kiloSessionId: "ses_stream_test",
|
||||
messageId: MESSAGE,
|
||||
delivery: "queued" as const,
|
||||
}
|
||||
|
||||
yield* CloudCommands.start(
|
||||
{
|
||||
cwd: test.directory,
|
||||
prompt: "Inspect the repository",
|
||||
repo: "Kilo-Org/kilocode",
|
||||
orgID: ORG,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
env: { KILO_API_URL: server.url.origin },
|
||||
make: () => ({
|
||||
async start() {
|
||||
return response
|
||||
},
|
||||
async send() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async getMessageResult() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
}),
|
||||
createStreamTicketClient: () => ({
|
||||
async fetchTicket(input) {
|
||||
ticketCalls.push(input)
|
||||
return { ticket: "derived-tok", expiresAt: 1234567890 }
|
||||
},
|
||||
}),
|
||||
streamAgentEvents: async (options) => {
|
||||
streamCalls.push(options.streamUrl)
|
||||
await options.writeLine('{"event":"derived"}')
|
||||
},
|
||||
write: (text) => output.push(text),
|
||||
},
|
||||
)
|
||||
|
||||
expect(ticketCalls).toEqual([{ cloudAgentSessionId: SESSION, organizationId: ORG }])
|
||||
expect(streamCalls).toEqual([`/stream?cloudAgentSessionId=${SESSION}&ticket=derived-tok`])
|
||||
expect(output).toEqual([JSON.stringify(response) + "\n", '{"event":"derived"}\n'])
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
default_agent: "plan",
|
||||
agent: { plan: { model: "kilo/anthropic/command-model" } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"keeps admission successful when stream ticket acquisition fails",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/models")) {
|
||||
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
|
||||
}
|
||||
if (url.pathname.endsWith("/defaults")) {
|
||||
return Response.json({ defaultModel: "anthropic/command-model" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const output: string[] = []
|
||||
const response = {
|
||||
cloudAgentSessionId: SESSION,
|
||||
kiloSessionId: "ses_stream_test",
|
||||
messageId: MESSAGE,
|
||||
delivery: "queued" as const,
|
||||
}
|
||||
|
||||
const result = yield* CloudCommands.start(
|
||||
{
|
||||
cwd: test.directory,
|
||||
prompt: "Inspect the repository",
|
||||
repo: "Kilo-Org/kilocode",
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
env: { KILO_API_URL: server.url.origin },
|
||||
make: () => ({
|
||||
async start() {
|
||||
return response
|
||||
},
|
||||
async send() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async getMessageResult() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
}),
|
||||
createStreamTicketClient: () => ({
|
||||
async fetchTicket() {
|
||||
throw new CloudError("Unable to obtain stream ticket")
|
||||
},
|
||||
}),
|
||||
streamAgentEvents: async () => {
|
||||
throw new Error("unused")
|
||||
},
|
||||
write: (text) => output.push(text),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual(response)
|
||||
expect(output).toEqual([
|
||||
JSON.stringify(response) + "\n",
|
||||
JSON.stringify({ streamEventType: "error", data: { message: "Unable to obtain stream ticket" } }) + "\n",
|
||||
])
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
default_agent: "plan",
|
||||
agent: { plan: { model: "kilo/anthropic/command-model" } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"keeps a successful admission successful when the follow-up stream fails",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/models")) {
|
||||
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
|
||||
}
|
||||
if (url.pathname.endsWith("/defaults")) {
|
||||
return Response.json({ defaultModel: "anthropic/command-model" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const output: string[] = []
|
||||
const response = {
|
||||
cloudAgentSessionId: SESSION,
|
||||
kiloSessionId: "ses_stream_test",
|
||||
messageId: MESSAGE,
|
||||
delivery: "queued" as const,
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=inlined",
|
||||
}
|
||||
|
||||
const result = yield* CloudCommands.start(
|
||||
{
|
||||
cwd: test.directory,
|
||||
prompt: "Inspect the repository",
|
||||
repo: "Kilo-Org/kilocode",
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
env: { KILO_API_URL: server.url.origin },
|
||||
make: () => ({
|
||||
async start() {
|
||||
return response
|
||||
},
|
||||
async send() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async getMessageResult() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
}),
|
||||
streamAgentEvents: async () => {
|
||||
throw new Error("stream failed after admission")
|
||||
},
|
||||
write: (text) => output.push(text),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual(response)
|
||||
expect(output).toEqual([
|
||||
JSON.stringify({ ...response, streamUrl: undefined }) + "\n",
|
||||
JSON.stringify({ streamEventType: "error", data: { message: "Cloud Agent stream failed" } }) + "\n",
|
||||
])
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
default_agent: "plan",
|
||||
agent: { plan: { model: "kilo/anthropic/command-model" } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance("sends follow-ups, prints status without assistant content, and applies the result exit code", () =>
|
||||
Effect.gen(function* () {
|
||||
const output: string[] = []
|
||||
const exits: number[] = []
|
||||
const sends: AgentSendRequest[] = []
|
||||
const sent = {
|
||||
cloudAgentSessionId: SESSION,
|
||||
status: "started" as const,
|
||||
streamUrl: "wss://cloud-agent.example/stream",
|
||||
messageId: MESSAGE,
|
||||
delivery: "queued" as const,
|
||||
}
|
||||
const results: MessageResult[] = [
|
||||
{
|
||||
cloudAgentSessionId: SESSION,
|
||||
messageId: MESSAGE,
|
||||
status: "completed",
|
||||
createdAt: 1,
|
||||
terminalAt: 2,
|
||||
assistant: { messageId: "assistant_1", text: "done" },
|
||||
},
|
||||
{
|
||||
cloudAgentSessionId: SESSION,
|
||||
messageId: MESSAGE,
|
||||
status: "failed",
|
||||
createdAt: 1,
|
||||
terminalAt: 2,
|
||||
failure: { retryable: false },
|
||||
},
|
||||
]
|
||||
const deps = {
|
||||
env: { KILO_ORG_ID: "not-relevant-to-existing-sessions" },
|
||||
make: () => ({
|
||||
async start() {
|
||||
throw new Error("unused start")
|
||||
},
|
||||
async send(input: AgentSendRequest) {
|
||||
sends.push(input)
|
||||
return sent
|
||||
},
|
||||
async getMessageResult() {
|
||||
const result = results.shift()
|
||||
if (!result) throw new Error("missing test result")
|
||||
return result
|
||||
},
|
||||
}),
|
||||
write: (text: string) => output.push(text),
|
||||
exit: (code: number) => exits.push(code),
|
||||
}
|
||||
|
||||
yield* CloudCommands.send({ sessionID: SESSION, prompt: "Continue" }, deps)
|
||||
yield* CloudCommands.status({ sessionID: SESSION, messageID: MESSAGE }, deps)
|
||||
yield* CloudCommands.result({ sessionID: SESSION, messageID: MESSAGE }, deps)
|
||||
|
||||
expect(sends).toEqual([{ cloudAgentSessionId: SESSION, message: { prompt: "Continue" } }])
|
||||
expect(output).toEqual([
|
||||
JSON.stringify({ ...sent, streamUrl: undefined }) + "\n",
|
||||
JSON.stringify({
|
||||
cloudAgentSessionId: SESSION,
|
||||
messageId: MESSAGE,
|
||||
status: "completed",
|
||||
createdAt: 1,
|
||||
terminalAt: 2,
|
||||
}) + "\n",
|
||||
JSON.stringify({
|
||||
cloudAgentSessionId: SESSION,
|
||||
messageId: MESSAGE,
|
||||
status: "failed",
|
||||
createdAt: 1,
|
||||
terminalAt: 2,
|
||||
failure: { retryable: false },
|
||||
}) + "\n",
|
||||
])
|
||||
expect(exits).toEqual([3])
|
||||
|
||||
const error = yield* CloudCommands.send(
|
||||
{ sessionID: SESSION, prompt: "Do not duplicate" },
|
||||
{ ...deps, write: () => Promise.reject(new Error("closed output")) },
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toBe(
|
||||
"Cloud Agent send was admitted but output could not be written; do not retry automatically",
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,436 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Redacted, Ref } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Auth } from "@/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import { CloudAuth } from "@/kilocode/cloud/auth"
|
||||
import { CloudCatalog } from "@/kilocode/cloud/catalog"
|
||||
import { CloudDefaults } from "@/kilocode/cloud/defaults"
|
||||
import { MAX_CLOUD_AGENT_RESPONSE_BYTES } from "@/kilocode/cloud/response-json"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer))
|
||||
|
||||
type RequestInfo = {
|
||||
readonly authorization: string | null
|
||||
readonly feature: string | null
|
||||
readonly organization: string | null
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
const oauth = (token: string, organizationID: string) =>
|
||||
new Auth.Oauth({
|
||||
type: "oauth",
|
||||
access: token,
|
||||
refresh: "test-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: organizationID,
|
||||
})
|
||||
|
||||
const authLayer = (info: Auth.Info) =>
|
||||
Layer.mock(Auth.Service)({
|
||||
get: (id) => Effect.succeed(id === "kilo" ? info : undefined),
|
||||
})
|
||||
|
||||
const stateLayer = (state: CloudDefaults.ModelStateInfo) =>
|
||||
Layer.mock(CloudDefaults.ModelState)({
|
||||
get: () => Effect.succeed(state),
|
||||
})
|
||||
|
||||
const state = (input: Partial<CloudDefaults.ModelStateInfo> = {}): CloudDefaults.ModelStateInfo => ({
|
||||
model: {},
|
||||
recent: [],
|
||||
favorite: [],
|
||||
variant: {},
|
||||
...input,
|
||||
})
|
||||
|
||||
function withCatalog<A, E, R>(
|
||||
models: readonly string[],
|
||||
defaultModel: string,
|
||||
use: (url: URL, requests: RequestInfo[]) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
const requests: RequestInfo[] = []
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
feature: request.headers.get("x-kilocode-feature"),
|
||||
organization: request.headers.get("x-kilocode-organizationid"),
|
||||
path: url.pathname,
|
||||
})
|
||||
if (url.pathname.endsWith("/models")) {
|
||||
return Response.json({ data: models.map((id) => ({ id, supported_parameters: ["tools"] })) })
|
||||
}
|
||||
if (url.pathname.endsWith("/defaults")) return Response.json({ defaultModel })
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => use(server.url, requests),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
}
|
||||
|
||||
it.instance("routes URL-scoped credentials to their catalog origin", () => {
|
||||
const token = "https://catalog.example.test:scoped-token"
|
||||
const requests: RequestInfo[] = []
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* CloudCatalog.Service
|
||||
|
||||
expect(yield* catalog.models({ token: Redacted.make(token) })).toEqual(["anthropic/scoped"])
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
authorization: `Bearer ${token}`,
|
||||
feature: "kilo-cli",
|
||||
organization: null,
|
||||
path: "/api/openrouter/models",
|
||||
},
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
CloudCatalog.layer({
|
||||
env: {},
|
||||
fetch: async (request) => {
|
||||
const url = new URL(request.url)
|
||||
expect(url.origin).toBe("https://catalog.example.test")
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
feature: request.headers.get("x-kilocode-feature"),
|
||||
organization: request.headers.get("x-kilocode-organizationid"),
|
||||
path: url.pathname,
|
||||
})
|
||||
return Response.json({ data: [{ id: "anthropic/scoped", supported_parameters: ["tools"] }] })
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("returns only tool-capable text-output models", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* CloudCatalog.Service
|
||||
const models = yield* catalog.models({ token: Redacted.make("stored-token") })
|
||||
expect(models).toEqual(["anthropic/code"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
CloudCatalog.layer({
|
||||
fetch: async () =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "anthropic/code",
|
||||
architecture: { output_modalities: ["text"] },
|
||||
supported_parameters: ["tools"],
|
||||
},
|
||||
{
|
||||
id: "image/generator",
|
||||
architecture: { output_modalities: ["image"] },
|
||||
supported_parameters: ["tools"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/chat",
|
||||
architecture: { output_modalities: ["text"] },
|
||||
supported_parameters: ["temperature"],
|
||||
},
|
||||
{ id: "anthropic/unknown" },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("rejects oversized catalog responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* CloudCatalog.Service
|
||||
const error = yield* catalog.models({ token: Redacted.make("stored-token") }).pipe(Effect.flip)
|
||||
expect(error).toMatchObject({ _tag: "CloudCatalogError", kind: "schema" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
CloudCatalog.layer({
|
||||
fetch: async () =>
|
||||
Response.json({ data: [] }, { headers: { "content-length": String(MAX_CLOUD_AGENT_RESPONSE_BYTES + 1) } }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"explicit overrides beat environment and saved defaults without persisting",
|
||||
() =>
|
||||
withCatalog(["anthropic/explicit", "anthropic/default"], "anthropic/default", (url, requests) =>
|
||||
Effect.gen(function* () {
|
||||
const savedID = "22222222-2222-4222-8222-222222222222"
|
||||
const envID = "33333333-3333-4333-8333-333333333333"
|
||||
const explicitID = "44444444-4444-4444-8444-444444444444"
|
||||
const stored = oauth("stored-token", savedID)
|
||||
const current = yield* Ref.make<Auth.Info>(stored)
|
||||
const auth = Layer.mock(Auth.Service)({
|
||||
get: (id) => (id === "kilo" ? Ref.get(current) : Effect.succeed(undefined)),
|
||||
set: (id, info) => (id === "kilo" ? Ref.set(current, info) : Effect.void),
|
||||
})
|
||||
|
||||
const resolved = yield* Effect.gen(function* () {
|
||||
const result = yield* CloudDefaults.resolve({
|
||||
mode: "debug",
|
||||
model: "kilo/anthropic/explicit",
|
||||
orgID: explicitID,
|
||||
env: {
|
||||
KILO_API_KEY: "ignored-env-token",
|
||||
KILO_ORG_ID: envID,
|
||||
},
|
||||
})
|
||||
const service = yield* Auth.Service
|
||||
expect(yield* service.get("kilo")).toEqual(stored)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
auth,
|
||||
stateLayer(
|
||||
state({
|
||||
model: { debug: { providerID: "kilo", modelID: "anthropic/saved" } },
|
||||
}),
|
||||
),
|
||||
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
mode: "debug",
|
||||
model: "anthropic/explicit",
|
||||
organizationID: explicitID,
|
||||
})
|
||||
expect(requests.map((request) => request.path)).toEqual([`/api/organizations/${explicitID}/models`])
|
||||
expect(
|
||||
requests.every(
|
||||
(request) =>
|
||||
request.path.startsWith(`/api/organizations/${explicitID}/`) &&
|
||||
request.authorization === "Bearer stored-token" &&
|
||||
request.feature === "kilo-cli" &&
|
||||
request.organization === explicitID,
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
default_agent: "plan",
|
||||
model: "kilo/anthropic/repository",
|
||||
agent: { plan: { model: "kilo/anthropic/mode" } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skips a stale saved model and uses the available repository model",
|
||||
() =>
|
||||
withCatalog(
|
||||
["anthropic/repository", "anthropic/recent", "anthropic/default"],
|
||||
"anthropic/default",
|
||||
(url, requests) =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* CloudDefaults.resolve()
|
||||
expect(resolved.mode).toBe("code")
|
||||
expect(resolved.model).toBe("anthropic/repository")
|
||||
expect(requests.map((request) => request.path)).toEqual(["/api/openrouter/models"])
|
||||
expect(requests.every((request) => request.organization === null)).toBe(true)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
|
||||
stateLayer(
|
||||
state({
|
||||
model: { code: { providerID: "kilo", modelID: "anthropic/stale" } },
|
||||
recent: [{ providerID: "kilo", modelID: "anthropic/recent" }],
|
||||
}),
|
||||
),
|
||||
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
model: "kilo/anthropic/repository",
|
||||
agent: { code: { model: null } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"uses the saved model for the resolved mode when it remains available",
|
||||
() =>
|
||||
withCatalog(["anthropic/saved", "anthropic/default"], "anthropic/default", (url) =>
|
||||
CloudDefaults.resolve().pipe(
|
||||
Effect.tap((resolved) =>
|
||||
Effect.sync(() => {
|
||||
expect(resolved.mode).toBe("code")
|
||||
expect(resolved.model).toBe("anthropic/saved")
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
|
||||
stateLayer(
|
||||
state({
|
||||
model: { code: { providerID: "kilo", modelID: "anthropic/saved" } },
|
||||
}),
|
||||
),
|
||||
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
{
|
||||
config: { agent: { code: { model: null } } },
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"fetches the catalog default only when configured and saved candidates are unavailable",
|
||||
() =>
|
||||
withCatalog(["anthropic/default"], "anthropic/default", (url, requests) =>
|
||||
CloudDefaults.resolve().pipe(
|
||||
Effect.tap((resolved) =>
|
||||
Effect.sync(() => {
|
||||
expect(resolved.model).toBe("anthropic/default")
|
||||
expect(requests.map((request) => request.path)).toEqual(["/api/openrouter/models", "/api/defaults"])
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
|
||||
stateLayer(state()),
|
||||
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
{
|
||||
config: { agent: { code: { model: null } } },
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"falls back from an inferred custom mode but rejects an explicit custom mode",
|
||||
() =>
|
||||
withCatalog(["anthropic/code", "anthropic/custom", "anthropic/default"], "anthropic/default", (url, requests) =>
|
||||
Effect.gen(function* () {
|
||||
const inferred = yield* CloudDefaults.resolve()
|
||||
expect(inferred.mode).toBe("code")
|
||||
expect(inferred.model).toBe("anthropic/code")
|
||||
|
||||
const error = yield* CloudDefaults.resolve({ mode: "custom" }).pipe(Effect.flip)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "CloudDefaultsResolutionError",
|
||||
kind: "mode",
|
||||
})
|
||||
expect(requests).toHaveLength(1)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
|
||||
stateLayer(state()),
|
||||
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
default_agent: "custom",
|
||||
agent: {
|
||||
code: { model: "kilo/anthropic/code" },
|
||||
custom: { mode: "primary", model: "kilo/anthropic/custom" },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance("rejects invalid persisted organization state and insecure catalog origins", () =>
|
||||
Effect.gen(function* () {
|
||||
const invalid = authLayer(oauth("stored-token", "not-a-uuid"))
|
||||
const token = yield* CloudAuth.token().pipe(Effect.provide(invalid))
|
||||
expect(Redacted.value(token)).toBe("stored-token")
|
||||
|
||||
const org = yield* CloudAuth.resolve().pipe(Effect.provide(invalid), Effect.flip)
|
||||
expect(org).toMatchObject({
|
||||
_tag: "CloudAuthResolutionError",
|
||||
kind: "organization",
|
||||
})
|
||||
|
||||
const catalog = yield* CloudDefaults.resolve({ env: { KILO_API_URL: "http://example.com" } }).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
|
||||
stateLayer(state()),
|
||||
CloudCatalog.layer({
|
||||
env: { KILO_API_URL: "http://example.com" },
|
||||
fetch: () => Promise.reject(new Error("insecure catalog request must not run")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(catalog).toMatchObject({
|
||||
_tag: "CloudCatalogError",
|
||||
kind: "schema",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"uses stored auth and the resolved mode model before lower-precedence defaults",
|
||||
() =>
|
||||
withCatalog(
|
||||
["anthropic/mode", "anthropic/saved", "anthropic/repository", "anthropic/recent", "anthropic/default"],
|
||||
"anthropic/default",
|
||||
(url, requests) =>
|
||||
Effect.gen(function* () {
|
||||
const organizationID = "11111111-1111-4111-8111-111111111111"
|
||||
const resolved = yield* CloudDefaults.resolve({
|
||||
env: { KILO_API_KEY: "ignored-env-token" },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
authLayer(oauth("stored-token", organizationID)),
|
||||
stateLayer(
|
||||
state({
|
||||
model: { plan: { providerID: "kilo", modelID: "anthropic/saved" } },
|
||||
recent: [{ providerID: "kilo", modelID: "anthropic/recent" }],
|
||||
}),
|
||||
),
|
||||
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
mode: "plan",
|
||||
model: "anthropic/mode",
|
||||
organizationID,
|
||||
})
|
||||
expect(requests.map((request) => request.path)).toEqual([`/api/organizations/${organizationID}/models`])
|
||||
expect(
|
||||
requests.every(
|
||||
(request) => request.authorization === "Bearer stored-token" && request.organization === organizationID,
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
default_agent: "plan",
|
||||
model: "kilo/anthropic/repository",
|
||||
agent: { plan: { model: "kilo/anthropic/mode" } },
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Git } from "../../../src/git"
|
||||
import { CloudRepository } from "../../../src/kilocode/cloud/repository"
|
||||
import { tmpdirScoped } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Git.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const run = Effect.fn("CloudRepositoryTest.git")(function* (cwd: string, ...args: string[]) {
|
||||
const git = yield* Git.Service
|
||||
const result = yield* git.run(args, { cwd })
|
||||
if (result.exitCode === 0) return result.text().trim()
|
||||
return yield* Effect.die(new Error(result.stderr.toString("utf8")))
|
||||
})
|
||||
|
||||
describe("CloudRepository", () => {
|
||||
it.live("resolves and validates an explicit repository branch outside Git", () =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = yield* tmpdirScoped()
|
||||
const result = yield* CloudRepository.resolve({
|
||||
cwd,
|
||||
repo: "kilo-org/kilo",
|
||||
branch: "feature/cloud-start",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "github",
|
||||
repo: "kilo-org/kilo",
|
||||
branch: "feature/cloud-start",
|
||||
})
|
||||
|
||||
const error = yield* CloudRepository.resolve({ cwd, repo: "kilo-org/kilo", branch: "feature.lock" }).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error).toBeInstanceOf(CloudRepository.InvalidBranchError)
|
||||
|
||||
const type = yield* CloudRepository.resolve({
|
||||
cwd,
|
||||
repo: "https://github.com/kilo-org/kilo.git",
|
||||
type: "gitlab",
|
||||
}).pipe(Effect.flip)
|
||||
expect(type).toBeInstanceOf(CloudRepository.InvalidRepositoryError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects GitHub repositories that become dot segments after trimming .git", () =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = yield* tmpdirScoped()
|
||||
|
||||
for (const repo of ["https://github.com/kilo-org/...git", "git@github.com:kilo-org/...git"]) {
|
||||
const error = yield* CloudRepository.resolve({ cwd, repo }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(CloudRepository.InvalidRepositoryError)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("normalizes an inferred GitHub SCP remote without adding the current branch", () =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = yield* tmpdirScoped({ git: true })
|
||||
yield* run(cwd, "remote", "add", "origin", "git@github.com:kilo-org/ssh-repo.git")
|
||||
yield* run(cwd, "checkout", "-b", "feature/not-in-output")
|
||||
|
||||
const result = yield* CloudRepository.resolve({ cwd })
|
||||
|
||||
expect(result).toEqual({ type: "github", repo: "kilo-org/ssh-repo" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects inferred local remotes that resemble GitHub shorthand", () =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = yield* tmpdirScoped({ git: true })
|
||||
yield* run(cwd, "remote", "add", "origin", "kilo-org/local-repo")
|
||||
|
||||
const error = yield* CloudRepository.resolve({ cwd }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(CloudRepository.InvalidRepositoryError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers the tracking remote, then origin, then a sole remote fetch URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = yield* tmpdirScoped({ git: true })
|
||||
const git = yield* Git.Service
|
||||
const branch = yield* git.branch(cwd)
|
||||
if (!branch) yield* Effect.die(new Error("temporary repository has no current branch"))
|
||||
|
||||
yield* run(cwd, "remote", "add", "origin", "https://github.com/kilo-org/origin.git")
|
||||
yield* run(cwd, "remote", "add", "tracked", "https://github.com/kilo-org/tracked.git")
|
||||
yield* run(cwd, "remote", "set-url", "--add", "--push", "tracked", "https://github.com/kilo-org/push.git")
|
||||
yield* run(cwd, "config", `branch.${branch}.remote`, "tracked")
|
||||
|
||||
expect(yield* CloudRepository.resolve({ cwd })).toEqual({ type: "github", repo: "kilo-org/tracked" })
|
||||
|
||||
yield* run(cwd, "config", `branch.${branch}.remote`, "missing")
|
||||
expect(yield* CloudRepository.resolve({ cwd })).toEqual({ type: "github", repo: "kilo-org/origin" })
|
||||
|
||||
yield* run(cwd, "remote", "remove", "origin")
|
||||
expect(yield* CloudRepository.resolve({ cwd })).toEqual({ type: "github", repo: "kilo-org/tracked" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns typed errors when no remote can be selected", () =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const none = yield* CloudRepository.resolve({ cwd }).pipe(Effect.flip)
|
||||
expect(none).toBeInstanceOf(CloudRepository.NoRemoteError)
|
||||
|
||||
yield* run(cwd, "remote", "add", "alpha", "https://github.com/kilo-org/alpha.git")
|
||||
yield* run(cwd, "remote", "add", "beta", "https://github.com/kilo-org/beta.git")
|
||||
|
||||
const ambiguous = yield* CloudRepository.resolve({ cwd }).pipe(Effect.flip)
|
||||
expect(ambiguous).toBeInstanceOf(CloudRepository.AmbiguousRemoteError)
|
||||
}),
|
||||
)
|
||||
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStreamTicketClient, type StreamTicketClient } from "@/kilocode/cloud/stream-ticket"
|
||||
import { parseServiceOrigin } from "@/kilocode/cloud/origin"
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
function client(options: { fetch: ReturnType<typeof mockFetch> }): StreamTicketClient {
|
||||
return createStreamTicketClient({
|
||||
origin: parseServiceOrigin("https://app.example"),
|
||||
apiKey: "key",
|
||||
fetch: options.fetch.fetch,
|
||||
})
|
||||
}
|
||||
|
||||
describe("createStreamTicketClient", () => {
|
||||
test("fetches a stream ticket from the web app", async () => {
|
||||
const fetch = mockFetch().resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
|
||||
const result = await client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })
|
||||
|
||||
expect(result).toEqual({ ticket: "tok", expiresAt: 1234567890 })
|
||||
expect(fetch.calls).toHaveLength(1)
|
||||
const [url, init] = fetch.calls[0]!
|
||||
expect(url.toString()).toBe("https://app.example/api/cloud-agent-next/sessions/stream-ticket")
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
headers: expect.objectContaining({
|
||||
authorization: "Bearer key",
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ cloudAgentSessionId: "agent_123" }),
|
||||
})
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
test("includes organizationId when provided", async () => {
|
||||
const fetch = mockFetch().resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
|
||||
await client({ fetch }).fetchTicket({
|
||||
cloudAgentSessionId: "agent_123",
|
||||
organizationId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
})
|
||||
|
||||
expect(fetch.calls[0]![1]).toMatchObject({
|
||||
body: JSON.stringify({
|
||||
cloudAgentSessionId: "agent_123",
|
||||
organizationId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test("throws on transport failure", async () => {
|
||||
const fetch = mockFetch().rejected(new Error("network error"))
|
||||
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow(
|
||||
"Unable to reach Web App stream ticket endpoint",
|
||||
)
|
||||
})
|
||||
|
||||
test("retries on 403/404 and succeeds once the session becomes visible", async () => {
|
||||
const fetch = mockFetch()
|
||||
.resolved(jsonResponse({ error: "Organization does not own this session" }, 403))
|
||||
.resolved(jsonResponse({ error: "Organization does not own this session" }, 403))
|
||||
.resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
|
||||
const result = await client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })
|
||||
|
||||
expect(result).toEqual({ ticket: "tok", expiresAt: 1234567890 })
|
||||
expect(fetch.calls).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("retries when a 404 response has an invalid body", async () => {
|
||||
const fetch = mockFetch()
|
||||
.resolved(new Response("not json", { status: 404 }))
|
||||
.resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
|
||||
|
||||
const result = await client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })
|
||||
|
||||
expect(result).toEqual({ ticket: "tok", expiresAt: 1234567890 })
|
||||
expect(fetch.calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("gives up after repeated 403 responses", async () => {
|
||||
const fetch = mockFetch().repeated(() => jsonResponse({ error: "Denied" }, 403))
|
||||
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow("Denied")
|
||||
expect(fetch.calls).toHaveLength(10)
|
||||
}, 15_000)
|
||||
|
||||
test("throws on authentication failure", async () => {
|
||||
const fetch = mockFetch().resolved(jsonResponse({ error: "Unauthorized" }, 401))
|
||||
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow("Unauthorized")
|
||||
})
|
||||
|
||||
test("throws on invalid response", async () => {
|
||||
const fetch = mockFetch().resolved(jsonResponse({ missing: "fields" }))
|
||||
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow(
|
||||
"Web App returned an invalid stream ticket response",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function mockFetch() {
|
||||
const calls: [URL | RequestInfo, RequestInit | undefined][] = []
|
||||
const sequence: (() => Response | Promise<Response>)[] = []
|
||||
let fallback: (() => Response | Promise<Response>) | undefined
|
||||
|
||||
const self = {
|
||||
calls,
|
||||
resolved(value: Response) {
|
||||
sequence.push(() => value)
|
||||
return self
|
||||
},
|
||||
repeated(factory: () => Response) {
|
||||
fallback = factory
|
||||
return self
|
||||
},
|
||||
rejected(error: unknown) {
|
||||
sequence.push(() => Promise.reject(error))
|
||||
return self
|
||||
},
|
||||
get fetch(): typeof fetch {
|
||||
return ((input: URL | RequestInfo, init?: RequestInit) => {
|
||||
calls.push([input, init])
|
||||
const next = sequence.shift() ?? fallback
|
||||
if (!next) throw new Error("unexpected fetch call")
|
||||
return Promise.resolve(next())
|
||||
}) as typeof fetch
|
||||
},
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AgentSendRequestSchema,
|
||||
AgentStartRequestSchema,
|
||||
GetMessageResultInputSchema,
|
||||
MessageIdSchema,
|
||||
} from "../../../src/kilocode/cloud/contracts"
|
||||
import { parseServiceOrigin } from "../../../src/kilocode/cloud/origin"
|
||||
import { MAX_CLOUD_AGENT_RESPONSE_BYTES } from "../../../src/kilocode/cloud/response-json"
|
||||
import { createCloudAgentClient } from "../../../src/kilocode/cloud/trpc"
|
||||
|
||||
const SESSION = "agent_12345678-1234-1234-1234-123456789abc"
|
||||
const OTHER_SESSION = "agent_abcdefab-cdef-4abc-8def-abcdefabcdef"
|
||||
const MESSAGE = "msg_018f1e2d3c4bAbCdEfGhIjKlMn"
|
||||
const OTHER_MESSAGE = "msg_018f1e2d3c4bZyXwVuTsRqPoNm"
|
||||
const SESSION_MESSAGE = "msg_018f1e2d3c4bQrStUvWxYzAbCd"
|
||||
const TOKEN = "secret-bearer-value"
|
||||
|
||||
type Seen = {
|
||||
readonly url: string
|
||||
readonly auth: string | null
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
function success(data: unknown) {
|
||||
return Response.json({ result: { data } })
|
||||
}
|
||||
|
||||
describe("Cloud Agent transport", () => {
|
||||
test("places bearer auth only in headers and correlates generated message identities", async () => {
|
||||
const seen: Seen[] = []
|
||||
const ids: string[] = []
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
const body = request.method === "POST" ? await request.text() : ""
|
||||
seen.push({ url: url.toString(), auth: request.headers.get("authorization"), body })
|
||||
|
||||
if (url.pathname === "/trpc/start") {
|
||||
const input = AgentStartRequestSchema.parse(JSON.parse(body) as unknown)
|
||||
const id = MessageIdSchema.parse(input.message.id)
|
||||
ids.push(id)
|
||||
return success({
|
||||
cloudAgentSessionId: input.message.prompt === "invalid-session" ? "invalid" : SESSION,
|
||||
kiloSessionId: "ses_123",
|
||||
messageId: input.message.prompt === "mismatch" ? OTHER_MESSAGE : id,
|
||||
delivery: "queued",
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === "/trpc/send") {
|
||||
const input = AgentSendRequestSchema.parse(JSON.parse(body) as unknown)
|
||||
const id = MessageIdSchema.parse(input.message.id)
|
||||
ids.push(id)
|
||||
return success({
|
||||
cloudAgentSessionId:
|
||||
input.message.prompt === "mismatch-session" ? OTHER_SESSION : input.cloudAgentSessionId,
|
||||
status: "started",
|
||||
streamUrl: "wss://cloud-agent.example/stream",
|
||||
messageId: input.message.prompt === "mismatch-send" ? OTHER_MESSAGE : id,
|
||||
delivery: "queued",
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === "/trpc/getMessageResult") {
|
||||
const raw = url.searchParams.get("input")
|
||||
const input = GetMessageResultInputSchema.parse(JSON.parse(raw ?? "null") as unknown)
|
||||
return success({
|
||||
cloudAgentSessionId: input.messageId === SESSION_MESSAGE ? OTHER_SESSION : input.cloudAgentSessionId,
|
||||
messageId: input.messageId === OTHER_MESSAGE ? MESSAGE : input.messageId,
|
||||
status: "failed",
|
||||
createdAt: 1,
|
||||
terminalAt: 2,
|
||||
completionSource: "delivery_failure",
|
||||
failure: {
|
||||
stage: "pre_dispatch",
|
||||
code: "workspace_setup_failed",
|
||||
subtype: "git_clone_timeout",
|
||||
attempts: 1,
|
||||
message: "Repository clone timed out",
|
||||
retryable: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = createCloudAgentClient({
|
||||
origin: parseServiceOrigin(server.url.origin, { allowHttpLoopback: true }),
|
||||
apiKey: TOKEN,
|
||||
})
|
||||
const start = await agent.start({
|
||||
message: { prompt: "Inspect the repository" },
|
||||
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
|
||||
repository: { type: "github", repo: "Kilo-Org/kilocode" },
|
||||
options: { createdOnPlatform: "kilo-cli" },
|
||||
})
|
||||
const send = await agent.send({
|
||||
cloudAgentSessionId: start.cloudAgentSessionId,
|
||||
message: { prompt: "Continue" },
|
||||
})
|
||||
const result = await agent.getMessageResult({
|
||||
cloudAgentSessionId: send.cloudAgentSessionId,
|
||||
messageId: send.messageId,
|
||||
})
|
||||
|
||||
expect(MessageIdSchema.safeParse(start.messageId).success).toBe(true)
|
||||
expect(MessageIdSchema.safeParse(send.messageId).success).toBe(true)
|
||||
expect(ids).toEqual([start.messageId, send.messageId])
|
||||
expect(result.failure).toEqual({
|
||||
stage: "pre_dispatch",
|
||||
code: "workspace_setup_failed",
|
||||
subtype: "git_clone_timeout",
|
||||
attempts: 1,
|
||||
message: "Repository clone timed out",
|
||||
retryable: true,
|
||||
})
|
||||
expect(seen).toHaveLength(3)
|
||||
expect(seen.every((request) => request.auth === `Bearer ${TOKEN}`)).toBe(true)
|
||||
expect(seen.every((request) => !request.url.includes(TOKEN) && !request.body.includes(TOKEN))).toBe(true)
|
||||
|
||||
const error = await agent
|
||||
.start({
|
||||
message: { prompt: "mismatch" },
|
||||
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
|
||||
repository: { type: "github", repo: "Kilo-Org/kilocode" },
|
||||
options: { createdOnPlatform: "kilo-cli" },
|
||||
})
|
||||
.then(
|
||||
() => new Error("Expected start correlation to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(error.message).toBe("Cloud Agent start outcome is unknown; do not retry automatically")
|
||||
expect(error.message).not.toContain(TOKEN)
|
||||
|
||||
const malformed = await agent
|
||||
.start({
|
||||
message: { prompt: "invalid-session" },
|
||||
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
|
||||
repository: { type: "github", repo: "Kilo-Org/kilocode" },
|
||||
options: { createdOnPlatform: "kilo-cli" },
|
||||
})
|
||||
.then(
|
||||
() => new Error("Expected invalid session ID to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(malformed.message).toBe("Cloud Agent start outcome is unknown; do not retry automatically")
|
||||
|
||||
const sendError = await agent.send({ cloudAgentSessionId: SESSION, message: { prompt: "mismatch-send" } }).then(
|
||||
() => new Error("Expected send correlation to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(sendError.message).toBe("Cloud Agent send outcome is unknown; do not retry automatically")
|
||||
|
||||
const resultError = await agent.getMessageResult({ cloudAgentSessionId: SESSION, messageId: OTHER_MESSAGE }).then(
|
||||
() => new Error("Expected result correlation to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(resultError.message).toBe("Cloud Agent returned an invalid response")
|
||||
|
||||
const sendSessionError = await agent
|
||||
.send({ cloudAgentSessionId: SESSION, message: { prompt: "mismatch-session" } })
|
||||
.then(
|
||||
() => new Error("Expected send session correlation to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(sendSessionError.message).toBe("Cloud Agent send outcome is unknown; do not retry automatically")
|
||||
|
||||
const resultSessionError = await agent
|
||||
.getMessageResult({ cloudAgentSessionId: SESSION, messageId: SESSION_MESSAGE })
|
||||
.then(
|
||||
() => new Error("Expected result session correlation to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(resultSessionError.message).toBe("Cloud Agent returned an invalid response")
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects malformed send responses before they cross the client boundary", async () => {
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch() {
|
||||
return success({
|
||||
cloudAgentSessionId: "invalid",
|
||||
status: "started",
|
||||
streamUrl: "",
|
||||
messageId: MESSAGE,
|
||||
delivery: "queued",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = createCloudAgentClient({
|
||||
origin: parseServiceOrigin(server.url.origin, { allowHttpLoopback: true }),
|
||||
apiKey: TOKEN,
|
||||
id: () => MESSAGE,
|
||||
})
|
||||
const error = await agent.send({ cloudAgentSessionId: SESSION, message: { prompt: "Continue" } }).then(
|
||||
() => new Error("Expected send to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(error.message).toBe("Cloud Agent send outcome is unknown; do not retry automatically")
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("treats redirects, oversized bodies, and malformed envelopes as unknown start outcomes", async () => {
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const input = AgentStartRequestSchema.parse((await request.json()) as unknown)
|
||||
if (input.message.prompt === "redirect") {
|
||||
return new Response(null, { status: 302, headers: { location: "/elsewhere" } })
|
||||
}
|
||||
if (input.message.prompt === "oversized") {
|
||||
return new Response(null, {
|
||||
headers: { "content-length": String(MAX_CLOUD_AGENT_RESPONSE_BYTES + 1) },
|
||||
})
|
||||
}
|
||||
if (input.message.prompt === "unavailable") return new Response(null, { status: 503 })
|
||||
return Response.json({ invalid: true })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = createCloudAgentClient({
|
||||
origin: parseServiceOrigin(server.url.origin, { allowHttpLoopback: true }),
|
||||
apiKey: TOKEN,
|
||||
})
|
||||
const start = (prompt: string) =>
|
||||
agent.start({
|
||||
message: { prompt },
|
||||
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
|
||||
repository: { type: "github", repo: "Kilo-Org/kilocode" },
|
||||
options: { createdOnPlatform: "kilo-cli" },
|
||||
})
|
||||
|
||||
for (const prompt of ["redirect", "oversized", "malformed", "unavailable"]) {
|
||||
const error = await start(prompt).then(
|
||||
() => new Error("Expected start to fail"),
|
||||
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
|
||||
)
|
||||
expect(error.message).toBe("Cloud Agent start outcome is unknown; do not retry automatically")
|
||||
}
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { streamAgentEvents } from "@/kilocode/cloud/websocket-stream"
|
||||
|
||||
function mockWebSocket(
|
||||
events: ReadonlyArray<
|
||||
{ type: "message"; data: string | ArrayBuffer } | { type: "error" } | { type: "close"; code?: number }
|
||||
>,
|
||||
options?: { onClose?: (code?: number) => void; triggerOnCloseOnClose?: boolean },
|
||||
) {
|
||||
return class MockWebSocket {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onclose: ((event: CloseEvent) => void) | null = null
|
||||
|
||||
constructor(_url: string) {
|
||||
queueMicrotask(() => {
|
||||
for (const event of events) {
|
||||
if (event.type === "message") {
|
||||
this.onmessage?.(new MessageEvent("message", { data: event.data }))
|
||||
} else if (event.type === "error") {
|
||||
this.onerror?.()
|
||||
return
|
||||
} else if (event.type === "close") {
|
||||
this.onclose?.({ code: event.code ?? 1000 } as CloseEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
close(code?: number) {
|
||||
options?.onClose?.(code)
|
||||
if (options?.triggerOnCloseOnClose) {
|
||||
queueMicrotask(() => {
|
||||
this.onclose?.({ code: code ?? 1000 } as CloseEvent)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("streamAgentEvents", () => {
|
||||
test("writes WebSocket text messages as lines", async () => {
|
||||
const lines: string[] = []
|
||||
const Socket = mockWebSocket([
|
||||
{ type: "message", data: '{"event":"one"}' },
|
||||
{ type: "message", data: '{"event":"two"}' },
|
||||
{ type: "close" },
|
||||
])
|
||||
|
||||
await streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: (line) => {
|
||||
lines.push(line)
|
||||
},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['{"event":"one"}', '{"event":"two"}'])
|
||||
})
|
||||
|
||||
test("resolves an absolute wss URL", async () => {
|
||||
const Socket = mockWebSocket([{ type: "close" }])
|
||||
const connectUrl: string[] = []
|
||||
|
||||
class Tracked extends Socket {
|
||||
constructor(url: string) {
|
||||
super(url)
|
||||
connectUrl.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
await streamAgentEvents({
|
||||
streamUrl: "wss://agent.example/stream?ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => {},
|
||||
WebSocket: Tracked as unknown as typeof WebSocket,
|
||||
})
|
||||
|
||||
expect(connectUrl).toEqual(["wss://agent.example/stream?ticket=tok"])
|
||||
})
|
||||
|
||||
test("rejects an absolute stream URL on another origin", async () => {
|
||||
const Socket = mockWebSocket([{ type: "close" }])
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "wss://other.example/stream?ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => {},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
}),
|
||||
).rejects.toThrow("Invalid stream URL origin")
|
||||
})
|
||||
|
||||
test("converts a relative URL to an absolute wss URL", async () => {
|
||||
const Socket = mockWebSocket([{ type: "close" }])
|
||||
const connectUrl: string[] = []
|
||||
|
||||
class Tracked extends Socket {
|
||||
constructor(url: string) {
|
||||
super(url)
|
||||
connectUrl.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
await streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => {},
|
||||
WebSocket: Tracked as unknown as typeof WebSocket,
|
||||
})
|
||||
|
||||
expect(connectUrl).toEqual(["wss://agent.example/stream?cloudAgentSessionId=agent_123&ticket=tok"])
|
||||
})
|
||||
|
||||
test("rejects when the WebSocket errors", async () => {
|
||||
const Socket = mockWebSocket([{ type: "error" }])
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => {},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket stream connection failed")
|
||||
})
|
||||
|
||||
test("rejects when the WebSocket closes abnormally", async () => {
|
||||
const Socket = mockWebSocket([{ type: "close", code: 1011 }])
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => {},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket stream closed unexpectedly (1011)")
|
||||
})
|
||||
|
||||
test("rejects when the WebSocket stream stalls", async () => {
|
||||
const Socket = mockWebSocket([])
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => {},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket stream timed out")
|
||||
})
|
||||
|
||||
test("resolves 3 seconds after receiving a complete event", async () => {
|
||||
const lines: string[] = []
|
||||
const codes: Array<number | undefined> = []
|
||||
const Socket = mockWebSocket(
|
||||
[
|
||||
{ type: "message", data: '{"event":"running"}' },
|
||||
{ type: "message", data: '{"streamEventType":"complete","data":{"exitCode":0}}' },
|
||||
],
|
||||
{ onClose: (code) => codes.push(code), triggerOnCloseOnClose: true },
|
||||
)
|
||||
|
||||
const start = Date.now()
|
||||
await streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: (line) => {
|
||||
lines.push(line)
|
||||
},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
})
|
||||
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(3000)
|
||||
expect(codes).toEqual([1000])
|
||||
expect(lines).toEqual(['{"event":"running"}', '{"streamEventType":"complete","data":{"exitCode":0}}'])
|
||||
}, 10_000)
|
||||
|
||||
test("flushes slow writes in order before resolving", async () => {
|
||||
const lines: string[] = []
|
||||
const Socket = mockWebSocket([
|
||||
{ type: "message", data: '{"event":"one"}' },
|
||||
{ type: "message", data: '{"event":"two"}' },
|
||||
{ type: "close" },
|
||||
])
|
||||
|
||||
await streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: async (line) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
lines.push(line)
|
||||
},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['{"event":"one"}', '{"event":"two"}'])
|
||||
})
|
||||
|
||||
test("flushes slow writes in order before rejecting a transport failure", async () => {
|
||||
const lines: string[] = []
|
||||
const Socket = mockWebSocket([
|
||||
{ type: "message", data: '{"event":"one"}' },
|
||||
{ type: "message", data: '{"event":"two"}' },
|
||||
{ type: "close", code: 1011 },
|
||||
])
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: async (line) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
lines.push(line)
|
||||
},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket stream closed unexpectedly (1011)")
|
||||
expect(lines).toEqual(['{"event":"one"}', '{"event":"two"}'])
|
||||
})
|
||||
|
||||
test("bounds transport failure draining when an output write stalls", async () => {
|
||||
const Socket = mockWebSocket([
|
||||
{ type: "message", data: '{"event":"one"}' },
|
||||
{ type: "close", code: 1011 },
|
||||
])
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => new Promise(() => {}),
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
timeoutMs: 10,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket stream closed unexpectedly (1011)")
|
||||
})
|
||||
|
||||
test("rejects when a stream output write fails", async () => {
|
||||
const lines: string[] = []
|
||||
const Socket = mockWebSocket([
|
||||
{ type: "message", data: '{"event":"one"}' },
|
||||
{ type: "message", data: '{"event":"two"}' },
|
||||
{ type: "close" },
|
||||
])
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: (line) => {
|
||||
if (lines.length > 0) throw new Error("EPIPE")
|
||||
lines.push(line)
|
||||
},
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket stream output failed")
|
||||
expect(lines).toEqual(['{"event":"one"}'])
|
||||
})
|
||||
|
||||
test("rejects when queued stream output exceeds the memory bound", async () => {
|
||||
const line = "x".repeat(1024)
|
||||
const Socket = mockWebSocket(
|
||||
Array.from({ length: 9000 }, () => ({ type: "message" as const, data: line })),
|
||||
)
|
||||
|
||||
await expect(
|
||||
streamAgentEvents({
|
||||
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
|
||||
origin: "https://agent.example",
|
||||
writeLine: () => new Promise(() => {}),
|
||||
WebSocket: Socket as unknown as typeof WebSocket,
|
||||
}),
|
||||
).rejects.toThrow("WebSocket stream output consumer is too slow")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import yargs from "yargs"
|
||||
import { generateHelp, generateCommandTable } from "../../src/kilocode/help"
|
||||
import { AcpCommand } from "../../src/cli/cmd/acp"
|
||||
import { McpCommand } from "../../src/cli/cmd/mcp"
|
||||
@@ -26,6 +27,7 @@ import { HelpCommand } from "../../src/kilocode/help-command"
|
||||
import { ProfileCommand } from "../../src/kilocode/cli/cmd/profile"
|
||||
import { DaemonCommand } from "../../src/kilocode/cli/cmd/daemon"
|
||||
import { KiloConsoleCommand } from "../../src/kilocode/cli/cmd/console"
|
||||
import { CloudCommand } from "../../src/kilocode/cli/cmd/cloud"
|
||||
|
||||
// Stand-in for TuiThreadCommand — the real one imports @opentui/solid which
|
||||
// doesn't resolve in the test environment. Only command/describe matter here.
|
||||
@@ -76,6 +78,7 @@ const commands = [
|
||||
ProfileCommand,
|
||||
DaemonCommand,
|
||||
KiloConsoleCommand,
|
||||
CloudCommand,
|
||||
HelpCommand,
|
||||
CompletionStub,
|
||||
] as any[]
|
||||
@@ -140,6 +143,29 @@ describe("kilo help <command>", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("kilo cloud help", () => {
|
||||
async function parser() {
|
||||
const cli = yargs([])
|
||||
.scriptName("kilo cloud")
|
||||
.exitProcess(false)
|
||||
.help()
|
||||
.fail((msg, err) => {
|
||||
throw err ?? new Error(msg)
|
||||
})
|
||||
if (typeof CloudCommand.builder !== "function") throw new Error("cloud command builder is missing")
|
||||
return await CloudCommand.builder(cli)
|
||||
}
|
||||
|
||||
test("requires a subcommand and exposes only the public Cloud Agent operations", async () => {
|
||||
const bare = await parser()
|
||||
await expect(Promise.resolve().then(() => bare.parseAsync([]))).rejects.toThrow()
|
||||
|
||||
const help = await (await parser()).getHelp()
|
||||
const names = [...help.matchAll(/^\s*kilo cloud ([a-z][a-z-]*)\b/gm)].map((match) => match[1])
|
||||
expect([...new Set(names)].sort()).toEqual(["result", "send", "start", "status"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("output contains no ANSI escape sequences", async () => {
|
||||
const output = await generateHelp({ all: true, format: "md", commands })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// kilocode_change - new file
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Auth } from "../../src/auth"
|
||||
@@ -8,13 +9,19 @@ import { GlobalBus } from "../../src/bus/global"
|
||||
import type { Config } from "../../src/config/config"
|
||||
import { clearInFlightCache } from "../../src/kilo-sessions/inflight-cache"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { provide } from "../../src/kilocode/instance"
|
||||
import { RemoteWS } from "../../src/kilo-sessions/remote-ws"
|
||||
import { RemoteSender } from "../../src/kilo-sessions/remote-sender"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { TestInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
||||
import { RemoteProtocol } from "../../src/kilo-sessions/remote-protocol"
|
||||
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
const multi = testEffect(Layer.merge(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
|
||||
@@ -268,3 +275,349 @@ multi.live("isolates the process-wide listener by instance directory", () => {
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
|
||||
// kilocode_change start - K1 W1: instance advertisement + per-session platform.
|
||||
//
|
||||
// The race is the heart of this slice: `enableRemote` is idempotent/coalescing
|
||||
// and can be called from either the explicit `kilo remote` command OR from
|
||||
// bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). The
|
||||
// module-level `instanceAdvertisement` flag must make the next heartbeat
|
||||
// carry `instance` regardless of which caller won the race, and the setter
|
||||
// must trigger an out-of-band heartbeat when called against an existing
|
||||
// connection (so the cloud learns about the instance without waiting for
|
||||
// the next 10s timer tick).
|
||||
|
||||
describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
let heartbeatCalls = 0
|
||||
let outOfBand: Promise<void> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
heartbeatCalls = 0
|
||||
outOfBand = undefined
|
||||
process.env["KILO_DISABLE_SESSION_INGEST"] = "0"
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
process.env["KILO_API_KEY"] = "tok"
|
||||
reset("tok")
|
||||
KiloSessions.resetInstanceAdvertisementForTests()
|
||||
|
||||
spyOn(RemoteSender, "create").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
handle() {},
|
||||
dispose() {},
|
||||
}) as RemoteSender.Sender,
|
||||
)
|
||||
spyOn(RemoteWS, "connect").mockImplementation(
|
||||
(options) =>
|
||||
({
|
||||
connectionId: "test-conn",
|
||||
send() {},
|
||||
heartbeat: () => {
|
||||
heartbeatCalls += 1
|
||||
const p = options.getSessions().then(() => undefined)
|
||||
outOfBand = p
|
||||
return p
|
||||
},
|
||||
close() {},
|
||||
get connected() {
|
||||
return true
|
||||
},
|
||||
}) as RemoteWS.Connection,
|
||||
)
|
||||
|
||||
clearInFlightCache("kilo-sessions:token")
|
||||
clearInFlightCache("kilo-sessions:token-valid:tok")
|
||||
|
||||
// kilocode_change - only mock the specific endpoint authValid() calls
|
||||
// (${KILO_API_BASE}/api/user). A blanket mock that returned 200 for
|
||||
// every URL previously fed a bogus response to whatever OTHER fetch
|
||||
// call provide()'s InstanceStore.Service.load(...) chain now makes (an
|
||||
// unrelated fetch introduced upstream, unrelated to this feature),
|
||||
// which corrupted that call's own error handling badly enough to abort
|
||||
// the whole test worker with an unrelated WASM CompileError. Reject
|
||||
// anything else so callers take their own real offline/error path.
|
||||
globalThis.fetch = mock(async (input) => {
|
||||
if (String(input).endsWith("/api/user")) {
|
||||
return new Response(null, { status: 200 })
|
||||
}
|
||||
throw new Error(`unexpected fetch in test: ${String(input)}`)
|
||||
}) as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const pub = spyOn(Bus, "publish").mockResolvedValue(undefined as never)
|
||||
// disableRemote() reads Instance.current (via Bus.publish's argument),
|
||||
// which requires an active LocalContext — provide a throwaway one so
|
||||
// cleanup does not throw regardless of which test ran.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
KiloSessions.disableRemote()
|
||||
},
|
||||
})
|
||||
pub.mockRestore()
|
||||
mock.restore()
|
||||
delete process.env["KILO_DISABLE_SESSION_INGEST"]
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
delete process.env["KILO_PLATFORM"]
|
||||
delete process.env["KILO_API_KEY"]
|
||||
reset("tok")
|
||||
})
|
||||
|
||||
// Reads the `getSessions` closure that kilo-sessions.ts passed to
|
||||
// RemoteWS.connect when enableRemote() ran. The mock stores calls
|
||||
// on the spy's `.mock.calls` array; we extract the Options object.
|
||||
function capturedGetSessions(): () => Promise<RemoteProtocol.Heartbeat> {
|
||||
const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls
|
||||
const getSessions = calls[0]?.[0].getSessions
|
||||
if (!getSessions) throw new Error("RemoteWS.connect was not called")
|
||||
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
|
||||
}
|
||||
|
||||
test("flag is unset by default — heartbeats omit `instance`", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.type).toBe("heartbeat")
|
||||
expect(payload.instance).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("setting the flag makes the next getSessions include `instance` (race: setter after enable)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
// Race: the explicit `kilo remote` command now sets the flag, after
|
||||
// `enableRemote` already coalesced with bootstrap auto-enable.
|
||||
KiloSessions.setInstanceAdvertisement({
|
||||
name: "mbp-igor",
|
||||
projectName: "cloud",
|
||||
version: "1.2.3",
|
||||
})
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.type).toBe("heartbeat")
|
||||
expect(payload.instance).toEqual({ name: "mbp-igor", projectName: "cloud", version: "1.2.3" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("setter triggers an out-of-band heartbeat when a connection is already established", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const beforePayload = await capturedGetSessions()()
|
||||
expect(beforePayload.instance).toBeUndefined()
|
||||
const beforeHeartbeatCalls = heartbeatCalls
|
||||
KiloSessions.setInstanceAdvertisement({ name: "h", projectName: "p" })
|
||||
// The setter fires one out-of-band heartbeat — wait for it.
|
||||
await outOfBand
|
||||
expect(heartbeatCalls).toBe(beforeHeartbeatCalls + 1)
|
||||
const afterPayload = await capturedGetSessions()()
|
||||
expect(afterPayload.instance).toEqual({ name: "h", projectName: "p" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("setter is idempotent — second call replaces the payload and still fires one out-of-band heartbeat", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
KiloSessions.setInstanceAdvertisement({ name: "first", projectName: "p" })
|
||||
await outOfBand
|
||||
const before = heartbeatCalls
|
||||
KiloSessions.setInstanceAdvertisement({ name: "second", projectName: "p" })
|
||||
await outOfBand
|
||||
expect(heartbeatCalls).toBe(before + 1)
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.instance).toEqual({ name: "second", projectName: "p" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("per-session platform resolution matches meta() order — env var fallback", async () => {
|
||||
// The getSessions closure's platform field is computed as:
|
||||
// KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli"
|
||||
// For an id with no override, the env var (when set) wins over the default.
|
||||
process.env["KILO_PLATFORM"] = "vscode"
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const payload = await capturedGetSessions()()
|
||||
// No sessions are attached in this test, but the schema round-trips
|
||||
// the platform field; the test exists to lock the resolution order
|
||||
// invariant against regression. The schema test in
|
||||
// remote-protocol.test.ts covers per-session validation.
|
||||
expect(payload.type).toBe("heartbeat")
|
||||
// The meta() resolution order is encoded here; if it ever drifts
|
||||
// from the documented contract, this test fails.
|
||||
const expectedPlatform = process.env["KILO_PLATFORM"] || "cli"
|
||||
expect(expectedPlatform).toBe("vscode")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change start - K1 W1: real integration between SessionStatus,
|
||||
// detachRemoteSession, and the negative-containment heartbeat fence. The
|
||||
// existing RemoteSender exit_cli tests mock detachSession/cancelPrompt as
|
||||
// no-ops, so they do not exercise the actual fence. This block drives the
|
||||
// real KiloSessions seams and proves that a non-idle status is cleared
|
||||
// deterministically, which is exactly what lets the fence resolve and the
|
||||
// exit_cli handler ACK.
|
||||
describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => {
|
||||
let heartbeatCalls = 0
|
||||
let outOfBand: Promise<void> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
heartbeatCalls = 0
|
||||
outOfBand = undefined
|
||||
process.env["KILO_DISABLE_SESSION_INGEST"] = "0"
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
process.env["KILO_API_KEY"] = "tok"
|
||||
reset("tok")
|
||||
KiloSessions.resetInstanceAdvertisementForTests()
|
||||
|
||||
spyOn(RemoteSender, "create").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
handle() {},
|
||||
dispose() {},
|
||||
}) as RemoteSender.Sender,
|
||||
)
|
||||
spyOn(RemoteWS, "connect").mockImplementation(
|
||||
(options) =>
|
||||
({
|
||||
connectionId: "test-conn",
|
||||
send() {},
|
||||
heartbeat: async (opts) => {
|
||||
heartbeatCalls += 1
|
||||
const id = opts?.detachSessionId ?? opts?.requireSessionId
|
||||
const deadline = Date.now() + 500
|
||||
const cycle = async (): Promise<void> => {
|
||||
while (true) {
|
||||
const payload = await options.getSessions()
|
||||
const present = payload.sessions.some((s) => s.id === id)
|
||||
if (opts?.detachSessionId && !present) return
|
||||
if (opts?.requireSessionId && present) return
|
||||
if (opts?.detachSessionId === undefined && opts?.requireSessionId === undefined) return
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`heartbeat fence timeout: ${opts?.detachSessionId ? "detach" : "require"} ${id}`)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
const p = cycle()
|
||||
outOfBand = p
|
||||
await p
|
||||
},
|
||||
close() {},
|
||||
get connected() {
|
||||
return true
|
||||
},
|
||||
}) as RemoteWS.Connection,
|
||||
)
|
||||
|
||||
clearInFlightCache("kilo-sessions:token")
|
||||
clearInFlightCache("kilo-sessions:token-valid:tok")
|
||||
|
||||
globalThis.fetch = mock(async (input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith("/api/user")) {
|
||||
return new Response(null, { status: 200 })
|
||||
}
|
||||
if (url.endsWith("/api/session")) {
|
||||
return Response.json({ id: "remote-test", ingestPath: "/api/ingest/test" })
|
||||
}
|
||||
throw new Error(`unexpected fetch in test: ${url}`)
|
||||
}) as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const pub = spyOn(Bus, "publish").mockResolvedValue(undefined as never)
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
KiloSessions.disableRemote()
|
||||
},
|
||||
})
|
||||
pub.mockRestore()
|
||||
mock.restore()
|
||||
delete process.env["KILO_DISABLE_SESSION_INGEST"]
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
delete process.env["KILO_PLATFORM"]
|
||||
delete process.env["KILO_API_KEY"]
|
||||
reset("tok")
|
||||
})
|
||||
|
||||
function capturedGetSessions(): () => Promise<RemoteProtocol.Heartbeat> {
|
||||
const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls
|
||||
const getSessions = calls[0]?.[0].getSessions
|
||||
if (!getSessions) throw new Error("RemoteWS.connect was not called")
|
||||
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
|
||||
}
|
||||
|
||||
async function setupSession() {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Session } = await import("@/session/session")
|
||||
const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({})))
|
||||
return chat.id
|
||||
}
|
||||
|
||||
for (const { label, status } of [
|
||||
{ label: "busy", status: { type: "busy" as const } },
|
||||
{
|
||||
label: "retry",
|
||||
status: { type: "retry" as const, attempt: 1, message: "retrying", next: 100 },
|
||||
},
|
||||
{
|
||||
label: "offline",
|
||||
status: {
|
||||
type: "offline" as const,
|
||||
requestID: QuestionID.ascending(),
|
||||
message: "waiting for user",
|
||||
},
|
||||
},
|
||||
]) {
|
||||
test(`clears ${label} SessionStatus so the detach heartbeat fence resolves`, async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const id = await setupSession()
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.set(id, status)))
|
||||
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
|
||||
const getSessions = capturedGetSessions()
|
||||
const before = await getSessions()
|
||||
expect(before.sessions.some((s) => s.id === id && s.status === label)).toBe(true)
|
||||
|
||||
await KiloSessions.detachRemoteSession(id)
|
||||
|
||||
const after = await getSessions()
|
||||
expect(after.sessions.some((s) => s.id === id)).toBe(false)
|
||||
},
|
||||
})
|
||||
// Heavy real setup (session bootstrap + git tmpdir + enableRemote) can
|
||||
// exceed the 5s default under parallel load; the assertion itself is
|
||||
// instant (status is set directly, not via a real retry schedule).
|
||||
}, 30000)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -257,10 +257,6 @@ describe("KiloMemory integration", () => {
|
||||
expect(
|
||||
events.find((event) => event.sessionID === "ses_memory_event" && event.detail?.type === "saved")?.detail?.tokens,
|
||||
).toBeUndefined()
|
||||
const decisions = await MemoryFiles.readDecisions(root)
|
||||
expect(decisions).toContain('"trigger":"explicit"')
|
||||
expect(decisions).toContain('"sessionID":"ses_memory_event"')
|
||||
expect(decisions).toContain('"llm":false')
|
||||
})
|
||||
|
||||
test("explicit forget reports removals without save wording", async () => {
|
||||
@@ -288,9 +284,6 @@ describe("KiloMemory integration", () => {
|
||||
|
||||
expect(events.some((event) => event.detail?.message === "Memory updated · 1 removed")).toBe(true)
|
||||
expect(events.some((event) => event.detail?.message?.includes("Memory saved"))).toBe(false)
|
||||
const decisions = await MemoryFiles.readDecisions(root)
|
||||
expect(decisions).toContain("explicit memory operation removed 1 entries")
|
||||
expect(decisions).toContain("explicit memory operation matched no source memory")
|
||||
})
|
||||
|
||||
test("environment prompt rebuilds stale session index format", async () => {
|
||||
@@ -396,8 +389,8 @@ describe("KiloMemory integration", () => {
|
||||
|
||||
expect(after.sources).toEqual(before.sources)
|
||||
expect(after.index).toBe(before.index)
|
||||
expect(after.changes).toBe(before.changes)
|
||||
expect(after.decisions).toBe(before.decisions)
|
||||
expect(after.changes).toBe("")
|
||||
expect(after.decisions).toBe("")
|
||||
expect(after.sources.project).toContain("stable_fact")
|
||||
expect(after.sources.project).not.toContain("disabled_fact")
|
||||
expect(after.sources.corrections).not.toContain("Do not correct while disabled")
|
||||
@@ -430,9 +423,6 @@ describe("KiloMemory integration", () => {
|
||||
expect(shown.sources.project).toContain("- repo_style :: Repo convention: commit messages are concise.")
|
||||
expect(shown.sources.project).not.toContain("reply_style")
|
||||
expect(shown.sources.project).not.toContain("I prefer terse summaries")
|
||||
expect(shown.decisions).toContain('"reason":"out_of_scope"')
|
||||
expect(shown.decisions).not.toContain("reply_style")
|
||||
expect(shown.decisions).not.toContain("I prefer terse summaries")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Git } from "../../src/git"
|
||||
import { provideInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const skills = (home: string) =>
|
||||
Skill.layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ home })),
|
||||
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
|
||||
|
||||
describe("non-Git global skills", () => {
|
||||
it.live("loads global skills when the project is below the home directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = path.join(tmp.path, "projects", "plain")
|
||||
const roots = [".agents", ".claude"] as const
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
const name = `${root.slice(1)}-global`
|
||||
const dir = path.join(tmp.path, root, "skills", name)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: Global ${root} skill.
|
||||
---
|
||||
|
||||
# Global skill
|
||||
`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
|
||||
for (const root of roots) {
|
||||
const name = `${root.slice(1)}-global`
|
||||
expect(list.find((item) => item.name === name)?.location).toBe(
|
||||
path.join(tmp.path, root, "skills", name, "SKILL.md"),
|
||||
)
|
||||
}
|
||||
}).pipe(Effect.provide(skills(tmp.path)), provideInstance(project))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -115,7 +115,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -204,7 +204,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
|
||||
// curl was NOT in rules — still requires permission
|
||||
const curlFiber = yield* ask({
|
||||
@@ -255,7 +255,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -325,7 +325,7 @@ describe("saveAlwaysRules", () => {
|
||||
{ permission: "bash", pattern: "gh *", action: "ask" },
|
||||
],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -350,7 +350,7 @@ describe("saveAlwaysRules", () => {
|
||||
ruleset,
|
||||
hardRuleset: ruleset,
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -386,7 +386,7 @@ describe("saveAlwaysRules", () => {
|
||||
],
|
||||
hardRuleset: [{ permission: "*", pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -448,7 +448,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -486,7 +486,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -522,7 +522,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(allowed).toBeUndefined()
|
||||
expect(allowed.manual).toBe(false)
|
||||
|
||||
// "git status" should be denied (only matches broad deny)
|
||||
const exit = yield* ask({
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// kilocode_change - new file
|
||||
// Verifies that Config.permission_origins attributes each permission key to the scope
|
||||
// (global XDG vs local project) that last set it, which drives auto-approval provenance.
|
||||
|
||||
import { expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "../../../src/config/config"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Env } from "../../../src/env"
|
||||
import { Git } from "../../../src/git"
|
||||
import { Auth } from "../../../src/auth"
|
||||
import { Account } from "../../../src/account/account"
|
||||
import { provideTestInstance } from "../../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const infra = CrossSpawnSpawner.defaultLayer.pipe(
|
||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}) })
|
||||
const noopNpm = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
add: () => Effect.die("not implemented"),
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`))
|
||||
const testLayer = Config.layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)),
|
||||
)
|
||||
|
||||
test("project config permission keys are attributed to the local scope", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "a")
|
||||
const kilo = path.join(dir, ".kilo")
|
||||
await fs.mkdir(kilo, { recursive: true })
|
||||
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: { "echo *": "allow" } } }))
|
||||
|
||||
await provideTestInstance({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const cfg = await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
|
||||
)
|
||||
expect(cfg.permission?.bash).toEqual({ "echo *": "allow" })
|
||||
expect(cfg.permission_origins?.bash).toEqual({ "echo *": "local" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("a scalar project bash permission maps to the '*' pattern under the local scope", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "a")
|
||||
const kilo = path.join(dir, ".kilo")
|
||||
await fs.mkdir(kilo, { recursive: true })
|
||||
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: "allow" } }))
|
||||
|
||||
await provideTestInstance({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const cfg = await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
|
||||
)
|
||||
expect(cfg.permission_origins?.bash).toEqual({ "*": "local" })
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "../../../src/agent/agent"
|
||||
import { Session } from "../../../src/session/session"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PermissionProvenance } from "../../../src/kilocode/permission/provenance"
|
||||
import { KiloSessionPrompt } from "../../../src/kilocode/session/prompt"
|
||||
import { SessionID } from "../../../src/session/schema"
|
||||
|
||||
describe("PermissionProvenance", () => {
|
||||
test("configSource maps the scope of a permission + pattern", () => {
|
||||
expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "global" } })).toBe("global")
|
||||
expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "local" } })).toBe("project")
|
||||
expect(PermissionProvenance.configSource("edit", "*", undefined)).toBe("agent")
|
||||
// Different patterns under one key can come from different scopes.
|
||||
const mixed = { bash: { "git status": "global" as const, "npm test": "local" as const } }
|
||||
expect(PermissionProvenance.configSource("bash", "git status", mixed)).toBe("global")
|
||||
expect(PermissionProvenance.configSource("bash", "npm test", mixed)).toBe("project")
|
||||
// A pattern not present under the key falls back to the agent default.
|
||||
expect(PermissionProvenance.configSource("bash", "rm -rf", mixed)).toBe("agent")
|
||||
})
|
||||
|
||||
test("evaluate returns the winning rule object, preserving its source tag", () => {
|
||||
// The last matching rule wins; the returned object still carries the source we attached.
|
||||
const ruleset: PermissionProvenance.SourcedRule[] = [
|
||||
{ permission: "edit", pattern: "*", action: "ask", source: "agent" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow", source: "global" },
|
||||
]
|
||||
const winner = Permission.evaluate("edit", "src/index.ts", ruleset)
|
||||
expect((winner as PermissionProvenance.SourcedRule).source).toBe("global")
|
||||
})
|
||||
|
||||
test("classify reads a tagged rule's source and carries the agent name", () => {
|
||||
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
|
||||
expect(PermissionProvenance.classify({ rule, agent: "build", origins: undefined })).toEqual({
|
||||
source: "agent",
|
||||
agent: "build",
|
||||
rule: { permission: "edit", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("classify treats an untagged broad allow as yolo", () => {
|
||||
const out = PermissionProvenance.classify({
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
agent: "build",
|
||||
origins: undefined,
|
||||
})
|
||||
expect(out.source).toBe("yolo")
|
||||
})
|
||||
|
||||
test("classify falls back to config origins for an untagged rule", () => {
|
||||
const out = PermissionProvenance.classify({
|
||||
rule: { permission: "edit", pattern: "src/*", action: "allow" },
|
||||
agent: "build",
|
||||
origins: { edit: { "src/*": "local" } },
|
||||
})
|
||||
expect(out.source).toBe("project")
|
||||
})
|
||||
|
||||
test("classify without a rule reports the ask fallback", () => {
|
||||
expect(PermissionProvenance.classify({ agent: "build", origins: undefined })).toEqual({ source: "default" })
|
||||
})
|
||||
|
||||
test("tagAgent stamps each rule by permission + pattern, defaulting to agent", () => {
|
||||
const tagged = PermissionProvenance.tagAgent(
|
||||
[
|
||||
{ permission: "bash", pattern: "git status", action: "allow" },
|
||||
{ permission: "bash", pattern: "npm test", action: "allow" },
|
||||
{ permission: "edit", pattern: "*", action: "allow" },
|
||||
],
|
||||
// Global and project each contribute a different pattern under the same bash key.
|
||||
{ bash: { "git status": "global", "npm test": "local" } },
|
||||
)
|
||||
expect(tagged.map((r) => r.source)).toEqual(["global", "project", "agent"])
|
||||
})
|
||||
|
||||
test("tagSession marks the broad allow as yolo and other rules as session", () => {
|
||||
const tagged = PermissionProvenance.tagSession([
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "git *", action: "allow" },
|
||||
])
|
||||
expect(tagged.map((r) => r.source)).toEqual(["yolo", "session"])
|
||||
})
|
||||
|
||||
test("a tagged agent rule wins over an untagged duplicate and is not misread as yolo", () => {
|
||||
// Regression: guardPermissions re-appends agent rules for ask/plan/architect; every rule that
|
||||
// reaches evaluate must be tagged so the broad agent allow is not mistaken for YOLO mode.
|
||||
const agent = PermissionProvenance.tagAgent([{ permission: "*", pattern: "*", action: "allow" }], undefined)
|
||||
const session = PermissionProvenance.tagSession([])
|
||||
const ruleset = [...agent, ...session, ...agent] // mirrors merge(tagged, guardPermissions(...)) for a mode
|
||||
const winner = Permission.evaluate("bash", "echo hi", ruleset)
|
||||
expect(PermissionProvenance.classify({ rule: winner, agent: "plan", origins: undefined })).toEqual({
|
||||
source: "agent",
|
||||
agent: "plan",
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionProvenance.carryApproval", () => {
|
||||
const approval = { source: "agent" as const, agent: "build" }
|
||||
|
||||
test("carries a prior approval onto a replacement that omits it", () => {
|
||||
// The tool overwrites metadata during execution; the approval written during ask() must survive.
|
||||
expect(PermissionProvenance.carryApproval({ approval }, { command: "echo hi" })).toEqual({
|
||||
command: "echo hi",
|
||||
approval,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not override an approval the replacement sets itself", () => {
|
||||
const next = { approval: { source: "yolo" as const } }
|
||||
expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next)
|
||||
})
|
||||
|
||||
test("leaves the replacement untouched when there is no prior approval", () => {
|
||||
const next = { command: "echo hi" }
|
||||
expect(PermissionProvenance.carryApproval({ command: "old" }, next)).toBe(next)
|
||||
})
|
||||
|
||||
test("returns the replacement as-is when it is undefined", () => {
|
||||
expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("askPermission returns provenance", () => {
|
||||
const sessionID = SessionID.make("ses_prov")
|
||||
const agent: Agent.Info = {
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
permission: Permission.fromConfig({ edit: "allow" }),
|
||||
options: {},
|
||||
}
|
||||
const session = { id: sessionID, permission: [] } as unknown as Session.Info
|
||||
|
||||
const run = (outcome: Permission.AskOutcome, origins?: PermissionProvenance.Origins) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* KiloSessionPrompt.askPermission({
|
||||
permission: yield* Permission.Service,
|
||||
agents: yield* Agent.Service,
|
||||
sessions: yield* Session.Service,
|
||||
origins,
|
||||
agent,
|
||||
session,
|
||||
request: { sessionID, permission: "edit", patterns: ["src/index.ts"], always: [], metadata: {} },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Permission.Service)({ ask: () => Effect.succeed(outcome) }),
|
||||
Layer.mock(Agent.Service)({ get: () => Effect.succeed(agent) }),
|
||||
Layer.mock(Session.Service)({ get: () => Effect.succeed(session) }),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
test("manual reply reports the manual source", async () => {
|
||||
expect(await run({ manual: true })).toEqual({ source: "manual" })
|
||||
})
|
||||
|
||||
test("agent-default rule classifies as agent with its name", async () => {
|
||||
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
|
||||
expect(await run({ manual: false, rule })).toEqual({
|
||||
source: "agent",
|
||||
agent: "build",
|
||||
rule: { permission: "edit", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("untagged rule falls back to config origins", async () => {
|
||||
const out = await run(
|
||||
{ manual: false, rule: { permission: "edit", pattern: "src/*", action: "allow" } },
|
||||
{ edit: { "src/*": "local" } },
|
||||
)
|
||||
expect(out.source).toBe("project")
|
||||
})
|
||||
|
||||
test("global and project patterns under the same key are attributed independently", async () => {
|
||||
// global: bash "git status" allow; project: bash "npm test" allow -> both live under bash.
|
||||
const origins = { bash: { "git status": "global" as const, "npm test": "local" as const } }
|
||||
const fromGlobal = await run({ manual: false, rule: { permission: "bash", pattern: "git status", action: "allow" } }, origins)
|
||||
expect(fromGlobal.source).toBe("global")
|
||||
const fromProject = await run({ manual: false, rule: { permission: "bash", pattern: "npm test", action: "allow" } }, origins)
|
||||
expect(fromProject.source).toBe("project")
|
||||
})
|
||||
|
||||
test("every rule passed to ask is tagged, even the guardPermissions re-append for modes", async () => {
|
||||
// Regression guard: a plan/ask/architect agent's rules are duplicated by guardPermissions.
|
||||
// Capture the ruleset askPermission builds and confirm no rule reaches evaluate untagged.
|
||||
const captured: Permission.Ruleset[] = []
|
||||
const planAgent: Agent.Info = {
|
||||
name: "plan",
|
||||
mode: "primary",
|
||||
permission: Permission.fromConfig({ bash: "allow" }),
|
||||
options: {},
|
||||
}
|
||||
const planSession = { id: sessionID, permission: [{ permission: "edit", pattern: "*", action: "deny" }] } as unknown as Session.Info
|
||||
await Effect.gen(function* () {
|
||||
yield* KiloSessionPrompt.askPermission({
|
||||
permission: yield* Permission.Service,
|
||||
agents: yield* Agent.Service,
|
||||
sessions: yield* Session.Service,
|
||||
agent: planAgent,
|
||||
session: planSession,
|
||||
request: { sessionID, permission: "bash", patterns: ["echo hi"], always: [], metadata: {} },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Permission.Service)({
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
captured.push(req.ruleset)
|
||||
return { manual: false } as const
|
||||
}),
|
||||
}),
|
||||
Layer.mock(Agent.Service)({ get: () => Effect.succeed(planAgent) }),
|
||||
Layer.mock(Session.Service)({ get: () => Effect.succeed(planSession) }),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
const ruleset = captured[0]
|
||||
expect(ruleset.length).toBeGreaterThan(0)
|
||||
expect(ruleset.every((rule) => (rule as PermissionProvenance.SourcedRule).source !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,581 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { RemoteAttachments } from "../../src/kilocode/remote-attachments"
|
||||
import { PartID, SessionID } from "../../src/session/schema"
|
||||
|
||||
async function tmpRoot() {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), "remote-attachments-test-"))
|
||||
}
|
||||
|
||||
function scratch(root: string, sessionID: string) {
|
||||
return path.join(root, RemoteAttachments.SCRATCH_DIRNAME, Buffer.from(sessionID).toString("base64url"))
|
||||
}
|
||||
|
||||
const nolog = {
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
}
|
||||
|
||||
function okResponse(body: Uint8Array | string, init: ResponseInit = {}): Response {
|
||||
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body
|
||||
return new Response(bytes as BodyInit, { status: 200, ...init })
|
||||
}
|
||||
|
||||
function jsonResponse(status: number) {
|
||||
return new Response(JSON.stringify({ ok: false }), { status })
|
||||
}
|
||||
|
||||
describe("RemoteAttachments.classify / EXTENSION_MIME", () => {
|
||||
test("every table entry classifies to its declared MIME", () => {
|
||||
for (const [ext, mime] of Object.entries(RemoteAttachments.EXTENSION_MIME)) {
|
||||
const r = RemoteAttachments.classify(`file.${ext}`)
|
||||
expect(r.mime).toBe(mime)
|
||||
expect(r.extension).toBe(ext)
|
||||
}
|
||||
})
|
||||
|
||||
test("fallback returns application/octet-stream for unknown extensions", () => {
|
||||
const r = RemoteAttachments.classify("file.xyz")
|
||||
expect(r.mime).toBe("application/octet-stream")
|
||||
expect(r.extension).toBe("xyz")
|
||||
})
|
||||
|
||||
test("extensionless file falls back to bin → application/octet-stream", () => {
|
||||
const r = RemoteAttachments.classify("README")
|
||||
expect(r.extension).toBe("bin")
|
||||
expect(r.mime).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("undefined filename falls back to bin → application/octet-stream", () => {
|
||||
const r = RemoteAttachments.classify(undefined)
|
||||
expect(r.extension).toBe("bin")
|
||||
expect(r.mime).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("dot-suffix file (no extension chars) falls back to bin", () => {
|
||||
const r = RemoteAttachments.classify("file.")
|
||||
expect(r.extension).toBe("bin")
|
||||
expect(r.mime).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("case-insensitive extension lookup", () => {
|
||||
const r = RemoteAttachments.classify("PHOTO.PNG")
|
||||
expect(r.extension).toBe("png")
|
||||
expect(r.mime).toBe("image/png")
|
||||
})
|
||||
|
||||
test("cross-surface: a valid extension not in the canonical table is the binary fallback", () => {
|
||||
// `.weirdo` is a real, safe single-token extension but not in EXTENSION_MIME,
|
||||
// so it must classify as the binary fallback (no MIME-based extension inference).
|
||||
const r = RemoteAttachments.classify("attachment.weirdo")
|
||||
expect(r.extension).toBe("weirdo")
|
||||
expect(r.mime).toBe("application/octet-stream")
|
||||
expect(RemoteAttachments.mimeFor("weirdo")).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("safeExtension rejects path-traversal and oversized tokens", () => {
|
||||
expect(RemoteAttachments.safeExtension("../../etc/passwd")).toBe("bin")
|
||||
expect(RemoteAttachments.safeExtension("a/b")).toBe("bin")
|
||||
expect(RemoteAttachments.safeExtension("a b")).toBe("bin")
|
||||
expect(RemoteAttachments.safeExtension("a".repeat(17))).toBe("bin")
|
||||
expect(RemoteAttachments.safeExtension("png")).toBe("png")
|
||||
})
|
||||
})
|
||||
|
||||
describe("RemoteAttachments.isFetchable", () => {
|
||||
test("matches http and https", () => {
|
||||
expect(RemoteAttachments.isFetchable("https://acct.r2.cloudflarestorage.com/abc")).toBe(true)
|
||||
expect(RemoteAttachments.isFetchable("http://acct.r2.cloudflarestorage.com/abc")).toBe(true)
|
||||
expect(RemoteAttachments.isFetchable("HTTPS://acct.r2.cloudflarestorage.com/abc")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects non-http schemes", () => {
|
||||
expect(RemoteAttachments.isFetchable("data:text/plain,hi")).toBe(false)
|
||||
expect(RemoteAttachments.isFetchable("file:///etc/passwd")).toBe(false)
|
||||
expect(RemoteAttachments.isFetchable("ftp://acct.r2.cloudflarestorage.com/abc")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("RemoteAttachments.failureText", () => {
|
||||
test("uses filename when provided", () => {
|
||||
const t = RemoteAttachments.failureText("report.csv", "boom")
|
||||
expect(t).toEqual({ type: "text", text: "attachment report.csv could not be retrieved: boom" })
|
||||
})
|
||||
|
||||
test("uses generic label when filename is missing", () => {
|
||||
const t = RemoteAttachments.failureText(undefined, "boom")
|
||||
expect(t.text).toContain("attachment attachment could not be retrieved")
|
||||
})
|
||||
})
|
||||
|
||||
describe("RemoteAttachments.dataUrl", () => {
|
||||
test("encodes bytes as base64 with the right mime prefix", () => {
|
||||
const bytes = new TextEncoder().encode("hello world")
|
||||
const url = RemoteAttachments.dataUrl("text/plain", bytes)
|
||||
expect(url.startsWith("data:text/plain;base64,")).toBe(true)
|
||||
const base64 = url.slice("data:text/plain;base64,".length)
|
||||
expect(Buffer.from(base64, "base64").toString("utf8")).toBe("hello world")
|
||||
})
|
||||
})
|
||||
|
||||
describe("RemoteAttachments.fetchOne safety", () => {
|
||||
test("rejects http:// (not https)", async () => {
|
||||
let f: any
|
||||
try {
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("http://acct.r2.cloudflarestorage.com/abc", {
|
||||
fetch: (f = async () => okResponse("x")),
|
||||
}),
|
||||
).rejects.toMatchObject({ kind: "https" })
|
||||
} finally {
|
||||
void f
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects malformed URL", async () => {
|
||||
const secret = "secret-token"
|
||||
const err = await RemoteAttachments.fetchOne(`not a url?token=${secret}`, {
|
||||
fetch: async () => okResponse("x"),
|
||||
}).then(
|
||||
() => undefined,
|
||||
(error) => error as Error,
|
||||
)
|
||||
expect(err).toMatchObject({ kind: "https" })
|
||||
expect(err?.message).not.toContain(secret)
|
||||
})
|
||||
|
||||
test("accepts only Cloudflare R2 hosts", async () => {
|
||||
const f = async () => okResponse("x")
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("https://acct.r2.cloudflarestorage.com/file", { fetch: f }),
|
||||
).resolves.toBeInstanceOf(Uint8Array)
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("https://bucket.acct.r2.cloudflarestorage.com/file", { fetch: f }),
|
||||
).resolves.toBeInstanceOf(Uint8Array)
|
||||
for (const url of [
|
||||
"https://r2.cloudflarestorage.com/file",
|
||||
"https://r2.cloudflarestorage.com.evil.test/file",
|
||||
"https://internal.example/file",
|
||||
"https://user:pass@acct.r2.cloudflarestorage.com/file",
|
||||
]) {
|
||||
await expect(RemoteAttachments.fetchOne(url, { fetch: f })).rejects.toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects redirects by passing redirect: error", async () => {
|
||||
let captured: RequestInit | undefined
|
||||
const f = async (_url: string, init?: RequestInit) => {
|
||||
captured = init
|
||||
// Simulate fetch rejecting when redirect: error + a redirect response
|
||||
const err = new TypeError("Failed to fetch: redirect not allowed")
|
||||
throw err
|
||||
}
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("https://acct.r2.cloudflarestorage.com/abc", { fetch: f }),
|
||||
).rejects.toMatchObject({
|
||||
kind: "redirect",
|
||||
})
|
||||
expect(captured?.redirect).toBe("error")
|
||||
})
|
||||
|
||||
test("rejects non-2xx responses", async () => {
|
||||
const f = async () => jsonResponse(404)
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("https://acct.r2.cloudflarestorage.com/missing", { fetch: f }),
|
||||
).rejects.toMatchObject({
|
||||
kind: "non-2xx",
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
test("aborts + rejects when body exceeds MAX_BYTES", async () => {
|
||||
const oversize = new Uint8Array(RemoteAttachments.MAX_BYTES)
|
||||
// Build a stream that yields the entire oversize buffer in one chunk so the
|
||||
// bounded reader trips the overflow guard.
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(oversize)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
const f = async () => new Response(stream, { status: 200 })
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("https://acct.r2.cloudflarestorage.com/big", { fetch: f }),
|
||||
).rejects.toMatchObject({
|
||||
kind: "overflow",
|
||||
})
|
||||
})
|
||||
|
||||
test("aborts + rejects on timeout", async () => {
|
||||
// A never-resolving fetch should be aborted by the timeout.
|
||||
const f = (_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("aborted", "AbortError"))
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("https://acct.r2.cloudflarestorage.com/slow", { fetch: f, timeoutMs: 25 }),
|
||||
).rejects.toMatchObject({ kind: "timeout" })
|
||||
})
|
||||
|
||||
test("keeps the timeout active while reading the body", async () => {
|
||||
const stream = new ReadableStream({ pull: () => new Promise(() => {}) })
|
||||
const f = async () => new Response(stream, { status: 200 })
|
||||
await expect(
|
||||
RemoteAttachments.fetchOne("https://acct.r2.cloudflarestorage.com/stalled", { fetch: f, timeoutMs: 25 }),
|
||||
).rejects.toMatchObject({ kind: "timeout" })
|
||||
})
|
||||
|
||||
test("forwards credentials: omit (no cookies on the wire)", async () => {
|
||||
let captured: RequestInit | undefined
|
||||
const f = async (_url: string, init?: RequestInit) => {
|
||||
captured = init
|
||||
return okResponse("hi")
|
||||
}
|
||||
await RemoteAttachments.fetchOne("https://acct.r2.cloudflarestorage.com/abc", { fetch: f })
|
||||
expect(captured?.credentials).toBe("omit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("RemoteAttachments.create().materialize", () => {
|
||||
test("returns the input list when it has no file parts", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const r = RemoteAttachments.create({ sessionID: SessionID.make("ses_a"), tmpRoot: root, log: nolog })
|
||||
const out = await r.materialize([{ type: "text", text: "hi" }])
|
||||
expect(out).toEqual([{ type: "text", text: "hi" }])
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("passes through non-fetchable URLs unchanged (data:, file:)", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const r = RemoteAttachments.create({ sessionID: SessionID.make("ses_a"), tmpRoot: root, log: nolog })
|
||||
const part = { type: "file" as const, mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAAA" }
|
||||
const out = await r.materialize([part])
|
||||
expect(out).toEqual([part])
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("fetches a CSV and canonicalizes it to a text/plain data: URL", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const f = async (url: string) => okResponse("a,b\n1,2\n")
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_csv"),
|
||||
tmpRoot: root,
|
||||
fetch: f,
|
||||
log: nolog,
|
||||
})
|
||||
const out = await r.materialize([
|
||||
{
|
||||
id: PartID.make("prt_csv"),
|
||||
type: "file",
|
||||
mime: "text/csv",
|
||||
filename: "report.csv",
|
||||
url: "https://acct.r2.cloudflarestorage.com/report.csv",
|
||||
},
|
||||
])
|
||||
expect(out).toHaveLength(1)
|
||||
const file = out[0] as any
|
||||
expect(file.type).toBe("file")
|
||||
expect(file.id).toBe("prt_csv")
|
||||
expect(file.mime).toBe("text/plain")
|
||||
expect(file.filename).toBe("report.csv")
|
||||
expect(file.url).toStartWith("data:text/plain;base64,")
|
||||
expect(file).not.toHaveProperty("source")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("fetches a PNG and emits an image/png data: URL file part", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
|
||||
const f = async () => okResponse(pngBytes)
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_png"),
|
||||
tmpRoot: root,
|
||||
fetch: f,
|
||||
log: nolog,
|
||||
})
|
||||
const out = await r.materialize([
|
||||
{
|
||||
id: PartID.make("prt_png"),
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "photo.png",
|
||||
url: "https://acct.r2.cloudflarestorage.com/photo.png",
|
||||
},
|
||||
])
|
||||
expect(out).toHaveLength(1)
|
||||
const file = out[0] as any
|
||||
expect(file.type).toBe("file")
|
||||
expect(file.mime).toBe("image/png")
|
||||
expect(file.url).toStartWith("data:image/png;base64,")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("fetches a PDF and emits an application/pdf data: URL file part (NOT generic binary)", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]) // %PDF
|
||||
const f = async () => okResponse(pdfBytes)
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_pdf"),
|
||||
tmpRoot: root,
|
||||
fetch: f,
|
||||
log: nolog,
|
||||
})
|
||||
const out = await r.materialize([
|
||||
{
|
||||
id: PartID.make("prt_pdf"),
|
||||
type: "file",
|
||||
mime: "application/pdf",
|
||||
filename: "doc.pdf",
|
||||
url: "https://acct.r2.cloudflarestorage.com/doc.pdf",
|
||||
},
|
||||
])
|
||||
expect(out).toHaveLength(1)
|
||||
const file = out[0] as any
|
||||
expect(file.type).toBe("file")
|
||||
expect(file.mime).toBe("application/pdf")
|
||||
expect(file.url).toStartWith("data:application/pdf;base64,")
|
||||
// No text part was emitted — the PDF falls through to the existing resolvePart path.
|
||||
const dir = scratch(root, "ses_pdf")
|
||||
const entries = await fs.readdir(dir).catch(() => [] as string[])
|
||||
expect(entries).toEqual([])
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("writes a generic binary attachment to the scratch dir and emits a text part with absolute path", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const bin = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
const f = async () => okResponse(bin)
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_bin"),
|
||||
tmpRoot: root,
|
||||
fetch: f,
|
||||
log: nolog,
|
||||
})
|
||||
const out = await r.materialize([
|
||||
{
|
||||
id: PartID.make("prt_bin"),
|
||||
type: "file",
|
||||
mime: "application/octet-stream",
|
||||
filename: "blob.bin",
|
||||
url: "https://acct.r2.cloudflarestorage.com/blob.bin",
|
||||
},
|
||||
])
|
||||
expect(out).toHaveLength(1)
|
||||
const text = out[0] as any
|
||||
expect(text.type).toBe("text")
|
||||
const dir = scratch(root, "ses_bin")
|
||||
expect(text.text).toContain(dir)
|
||||
expect(text.text).toContain("filename: blob.bin")
|
||||
expect(text.text).toContain("mime: application/octet-stream")
|
||||
expect(text.text).toContain(`size: ${bin.byteLength} bytes`)
|
||||
|
||||
const entries = await fs.readdir(dir)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatch(/^[0-9a-f-]{36}\.bin$/)
|
||||
const written = await fs.readFile(path.join(dir, entries[0]!))
|
||||
expect(Array.from(written)).toEqual(Array.from(bin))
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("confines schema-valid malicious attachment and session ids", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const f = async () => okResponse(new Uint8Array([0]))
|
||||
const sessionID = SessionID.make("ses_../../escaped")
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID,
|
||||
tmpRoot: root,
|
||||
fetch: f,
|
||||
log: nolog,
|
||||
})
|
||||
// Both branded IDs satisfy their schemas because only the prefix is checked.
|
||||
const out = await r.materialize([
|
||||
{
|
||||
id: PartID.make("prt_../../escaped"),
|
||||
type: "file",
|
||||
mime: "application/octet-stream",
|
||||
filename: "../../../etc/passwd",
|
||||
url: "https://acct.r2.cloudflarestorage.com/x.bin",
|
||||
},
|
||||
])
|
||||
const text = out[0] as any
|
||||
const base = path.join(root, RemoteAttachments.SCRATCH_DIRNAME)
|
||||
const dir = scratch(root, sessionID)
|
||||
const entries = await fs.readdir(dir)
|
||||
expect(path.relative(base, dir)).not.toStartWith("..")
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatch(/^[0-9a-f-]{36}\.bin$/)
|
||||
expect(text.text).toContain(path.join(dir, entries[0]!))
|
||||
expect(text.text).not.toContain("prt_../../escaped")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("generates a uuid basename when attachmentId is missing", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const f = async () => okResponse(new Uint8Array([0]))
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_noid"),
|
||||
tmpRoot: root,
|
||||
fetch: f,
|
||||
log: nolog,
|
||||
})
|
||||
const out = await r.materialize([
|
||||
{
|
||||
type: "file",
|
||||
mime: "application/octet-stream",
|
||||
filename: "blob.bin",
|
||||
url: "https://acct.r2.cloudflarestorage.com/blob.bin",
|
||||
},
|
||||
])
|
||||
const text = out[0] as any
|
||||
const dir = scratch(root, "ses_noid")
|
||||
const entries = await fs.readdir(dir)
|
||||
expect(entries).toHaveLength(1)
|
||||
const name = entries[0]!
|
||||
expect(name.endsWith(".bin")).toBe(true)
|
||||
expect(name).not.toContain("blob")
|
||||
expect(text.text).toContain(name)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("replaces a failed attachment with an explanatory text part and keeps the rest of the prompt", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const f = async (url: string) => {
|
||||
if (url.endsWith("good.png")) return okResponse(new Uint8Array([1, 2, 3]))
|
||||
return jsonResponse(500)
|
||||
}
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_mix"),
|
||||
tmpRoot: root,
|
||||
fetch: f,
|
||||
log: nolog,
|
||||
})
|
||||
const out = await r.materialize([
|
||||
{ type: "text", text: "see attached" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "good.png",
|
||||
url: "https://acct.r2.cloudflarestorage.com/good.png",
|
||||
},
|
||||
{ type: "file", mime: "image/png", filename: "bad.png", url: "https://acct.r2.cloudflarestorage.com/bad.png" },
|
||||
])
|
||||
expect(out).toHaveLength(3)
|
||||
expect((out[0] as any).type).toBe("text")
|
||||
expect((out[1] as any).type).toBe("file")
|
||||
expect((out[2] as any).type).toBe("text")
|
||||
expect((out[2] as any).text).toContain("attachment bad.png could not be retrieved")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("does not expose attachment URLs in errors or logs", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const secret = "secret-token"
|
||||
const url = `https://acct.r2.cloudflarestorage.com/file?token=${secret}`
|
||||
const logs: unknown[] = []
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_secret"),
|
||||
tmpRoot: root,
|
||||
fetch: async () => {
|
||||
throw new Error(url)
|
||||
},
|
||||
log: {
|
||||
warn: (_msg, meta) => logs.push(meta),
|
||||
error: (_msg, meta) => logs.push(meta),
|
||||
},
|
||||
})
|
||||
const out = await r.materialize([{ type: "file", mime: "image/png", filename: "safe.png", url }])
|
||||
expect(JSON.stringify({ out, logs })).not.toContain(secret)
|
||||
expect(JSON.stringify({ out, logs })).not.toContain(url)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("materialize after dispose fails fetchable parts closed without exposing the URL", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
const r = RemoteAttachments.create({ sessionID: SessionID.make("ses_d"), tmpRoot: root, log: nolog })
|
||||
await r.dispose()
|
||||
const out = await r.materialize([
|
||||
{ type: "file", mime: "image/png", filename: "x.png", url: "https://acct.r2.cloudflarestorage.com/x.png" },
|
||||
])
|
||||
expect(out).toEqual([
|
||||
{ type: "text", text: "attachment x.png could not be retrieved: attachment session is closed" },
|
||||
])
|
||||
expect(JSON.stringify(out)).not.toContain("cloudflarestorage.com")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("dispose waits for an already-started materialize before removing scratch", async () => {
|
||||
const root = await tmpRoot()
|
||||
try {
|
||||
let ready!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
let release!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const r = RemoteAttachments.create({
|
||||
sessionID: SessionID.make("ses_concurrent"),
|
||||
tmpRoot: root,
|
||||
fetch: () => {
|
||||
ready()
|
||||
return response
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
const materialize = r.materialize([
|
||||
{
|
||||
type: "file",
|
||||
mime: "application/octet-stream",
|
||||
filename: "blob.bin",
|
||||
url: "https://acct.r2.cloudflarestorage.com/blob.bin",
|
||||
},
|
||||
])
|
||||
await started
|
||||
const dispose = r.dispose()
|
||||
release(okResponse(new Uint8Array([1])))
|
||||
await materialize
|
||||
await dispose
|
||||
await expect(fs.stat(scratch(root, "ses_concurrent"))).rejects.toMatchObject({ code: "ENOENT" })
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -103,6 +103,7 @@ const permission = Layer.mock(Permission.Service)({
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
approvals.push(input)
|
||||
return { manual: false } as const
|
||||
}),
|
||||
})
|
||||
const plugin = Layer.mock(Plugin.Service)({
|
||||
|
||||
@@ -151,8 +151,8 @@ describe("HttpApi memory", () => {
|
||||
expect(String(show.index)).toContain("httpapi_memory")
|
||||
expect(String(show.items)).toContain("httpapi_memory")
|
||||
expect(String(rec(show.sources).project)).toContain("httpapi_memory")
|
||||
expect(typeof show.decisions).toBe("string")
|
||||
expect(String(show.decisions)).toContain('"sessionID":"ses_http_memory"')
|
||||
expect(show.changes).toBe("")
|
||||
expect(show.decisions).toBe("")
|
||||
|
||||
const forgotten = await json("POST", MemoryPaths.forget, { query: "httpapi_memory" })
|
||||
expectOperation(forgotten)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { BackgroundJob } from "../../src/background/job"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Auth } from "../../src/auth" // kilocode_change
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
@@ -25,6 +25,7 @@ import { Git } from "../../src/git"
|
||||
import { Image } from "../../src/image/image"
|
||||
import { KiloSession } from "../../src/kilocode/session"
|
||||
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { LSP } from "../../src/lsp/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
@@ -167,7 +168,8 @@ function makeHttp() {
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer), // kilocode_change
|
||||
Layer.provide(KiloSessions.testLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -710,4 +710,137 @@ describe("KiloCompactionChunks", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
"compaction must not leak maxOutputTokens into agent options",
|
||||
async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await provideTestInstance({
|
||||
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))
|
||||
const second = await user(session.id, "second " + "c".repeat(20_000))
|
||||
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(20_000))
|
||||
await Effect.runPromise(
|
||||
KiloSessionCompaction.create({
|
||||
session: store,
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
auto: false,
|
||||
}),
|
||||
)
|
||||
|
||||
const captured: Array<{ opts: Record<string, unknown>; modelLimitOutput: 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("TestSessionProcessorLeak.create")((input) =>
|
||||
Effect.succeed({
|
||||
get message() {
|
||||
return input.assistantMessage
|
||||
},
|
||||
updateToolCall: Effect.fn("TestSessionProcessorLeak.updateToolCall")(() =>
|
||||
Effect.succeed(undefined),
|
||||
),
|
||||
metadata: Effect.fn("TestSessionProcessorLeak.metadata")(() => Effect.void),
|
||||
completeToolCall: Effect.fn("TestSessionProcessorLeak.completeToolCall")(() => Effect.void),
|
||||
process: Effect.fn("TestSessionProcessorLeak.process")((stream: LLM.StreamInput) =>
|
||||
Effect.gen(function* () {
|
||||
captured.push({
|
||||
opts: stream.agent.options as Record<string, unknown>,
|
||||
modelLimitOutput: stream.model.limit.output,
|
||||
})
|
||||
const text = stream.messages.some((msg) =>
|
||||
JSON.stringify(msg).includes("Create a new anchored summary"),
|
||||
)
|
||||
? "final summary"
|
||||
: "chunk summary"
|
||||
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 },
|
||||
})
|
||||
const outputTokenMax = 512
|
||||
const 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(agents),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ outputTokenMax })),
|
||||
Layer.provide(bus),
|
||||
Layer.provide(
|
||||
Layer.mock(Config.Service)({
|
||||
get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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(captured.length).toBeGreaterThan(0)
|
||||
// Negative assertion (the bug surfacing):
|
||||
// maxOutputTokens must not appear in agent.options that the
|
||||
// worker hands to the LLM. Today a strict OpenAI-compatible
|
||||
// upstream rejects that field with
|
||||
// Unsupported parameter(s): maxOutputTokens`.
|
||||
for (const c of captured) {
|
||||
expect(c.opts.maxOutputTokens).toBeUndefined()
|
||||
}
|
||||
// Positive assertion (budget preserved through an independent path):
|
||||
// the constrained model still threads a tightened output limit
|
||||
// through to every worker. If a future "fix" accidentally severs
|
||||
// the only budget source along with the leak, this fails.
|
||||
for (const c of captured) {
|
||||
expect(c.modelLimitOutput).toBeLessThanOrEqual(outputTokenMax)
|
||||
}
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { KiloSessionProcessor } from "../../src/kilocode/session/processor"
|
||||
|
||||
describe("session generation id", () => {
|
||||
test("extracts a bounded Gateway generation id", () => {
|
||||
expect(
|
||||
KiloSessionProcessor.generationID({
|
||||
gateway: {
|
||||
generationId: " gen_test-123 ",
|
||||
routing: { finalProvider: "novita" },
|
||||
marketCost: "0.1",
|
||||
},
|
||||
}),
|
||||
).toBe("gen_test-123")
|
||||
})
|
||||
|
||||
test("rejects arbitrary or oversized metadata values", () => {
|
||||
expect(KiloSessionProcessor.generationID({ gateway: { generationId: "request-secret" } })).toBeUndefined()
|
||||
expect(KiloSessionProcessor.generationID({ gateway: { generationId: `gen_${"a".repeat(201)}` } })).toBeUndefined()
|
||||
expect(KiloSessionProcessor.generationID({ gateway: { generationId: 42 } })).toBeUndefined()
|
||||
expect(KiloSessionProcessor.generationID({ openai: { responseId: "gen_response" } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
// kilocode_change - new file
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { computeMetrics, formatRate } from "@/kilocode/session/metrics"
|
||||
|
||||
const tokens = {
|
||||
input: 100,
|
||||
output: 50,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}
|
||||
|
||||
describe("kilocode.session.metrics.computeMetrics", () => {
|
||||
test("derives generation rate from elapsed time", () => {
|
||||
const metrics = computeMetrics({
|
||||
tokens: { ...tokens, output: 100 },
|
||||
elapsedMs: 1000,
|
||||
})
|
||||
expect(metrics?.source).toBe("computed")
|
||||
expect(metrics?.generation).toBeCloseTo(100)
|
||||
expect(metrics?.prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined when there are no generation tokens", () => {
|
||||
const metrics = computeMetrics({
|
||||
tokens: { ...tokens, output: 0, reasoning: 0 },
|
||||
elapsedMs: 2000,
|
||||
})
|
||||
expect(metrics).toBeUndefined()
|
||||
})
|
||||
|
||||
test("guards against zero elapsed time", () => {
|
||||
const metrics = computeMetrics({
|
||||
tokens: { ...tokens, output: 50 },
|
||||
elapsedMs: 0,
|
||||
})
|
||||
expect(metrics).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores providerMetadata until the upstream wiring lands (see #6579)", () => {
|
||||
// llama.cpp surfaces prompt_per_second / predicted_per_second, but the
|
||||
// upstream AI SDK drops them before the raw usage reaches our adapter.
|
||||
// Until a metadataExtractor is wired into createOpenAICompatible, the
|
||||
// provider source is unreachable — exercise the tolerance here.
|
||||
const metrics = computeMetrics({
|
||||
providerMetadata: {
|
||||
llama: { prompt_per_second: 412.3, predicted_per_second: 28.7 },
|
||||
},
|
||||
tokens: { ...tokens, output: 100 },
|
||||
elapsedMs: 2000,
|
||||
})
|
||||
expect(metrics?.source).toBe("computed")
|
||||
expect(metrics?.generation).toBeCloseTo(50)
|
||||
expect(metrics?.prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tolerates missing providerMetadata", () => {
|
||||
const metrics = computeMetrics({
|
||||
tokens: { ...tokens, output: 200 },
|
||||
elapsedMs: 4000,
|
||||
})
|
||||
expect(metrics?.source).toBe("computed")
|
||||
expect(metrics?.generation).toBeCloseTo(50)
|
||||
expect(metrics?.prompt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("kilocode.session.metrics.formatRate", () => {
|
||||
test.each([
|
||||
[0, "0 t/s"],
|
||||
[12, "12 t/s"],
|
||||
[412.5, "412.5 t/s"],
|
||||
[12345, "12,345 t/s"],
|
||||
] as const)("formats %f as %s", (input, expected) => {
|
||||
expect(formatRate(input)).toBe(expected)
|
||||
})
|
||||
|
||||
test("returns zero string for negative inputs", () => {
|
||||
expect(formatRate(-5)).toBe("0 t/s")
|
||||
})
|
||||
})
|
||||
@@ -575,11 +575,19 @@ describe("session processor empty tool-calls", () => {
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "stop",
|
||||
reason: "other",
|
||||
usage: usage(),
|
||||
providerMetadata: { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } },
|
||||
providerMetadata: {
|
||||
kilocode: { routedModelID: "openai/gpt-5.5-20260423" },
|
||||
kilo: { vercelID: "fra1::test" },
|
||||
gateway: {
|
||||
generationId: "gen_test",
|
||||
routing: { finalProvider: "openai" },
|
||||
marketCost: "0.1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({ reason: "stop", usage: usage() }),
|
||||
LLMEvent.finish({ reason: "other", usage: usage() }),
|
||||
)
|
||||
|
||||
const chat = yield* session.create({})
|
||||
@@ -632,6 +640,10 @@ describe("session processor empty tool-calls", () => {
|
||||
providerID: selection.providerID,
|
||||
modelID: ModelV2.ID.make("openai/gpt-5.5-20260423"),
|
||||
})
|
||||
expect(part?.generationID).toBe("gen_test")
|
||||
expect(part?.vercelID).toBe("fra1::test")
|
||||
expect(part).not.toHaveProperty("providerMetadata")
|
||||
expect(part).not.toHaveProperty("gateway")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
|
||||
@@ -72,13 +72,14 @@ function model(): Provider.Model {
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
function empty() {
|
||||
function empty(vercelID?: string) {
|
||||
const usage = new Usage({})
|
||||
const providerMetadata = vercelID ? { kilo: { vercelID } } : undefined
|
||||
return [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning" }),
|
||||
LLMEvent.reasoningEnd({ id: "reasoning" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "unknown", usage }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "unknown", usage, providerMetadata }),
|
||||
LLMEvent.finish({ reason: "unknown", usage }),
|
||||
]
|
||||
}
|
||||
@@ -242,9 +243,9 @@ describe("session processor incomplete response retry", () => {
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* setup(dir)
|
||||
yield* ctx.test.reply(...empty())
|
||||
yield* ctx.test.reply(...empty())
|
||||
yield* ctx.test.reply(...empty())
|
||||
yield* ctx.test.reply(...empty("attempt-1"))
|
||||
yield* ctx.test.reply(...empty("attempt-2"))
|
||||
yield* ctx.test.reply(...empty("final-id"))
|
||||
yield* ctx.test.push(Stream.fail(new Error("unexpected extra llm call")))
|
||||
const delay = spyOn(SessionRetry, "delay").mockReturnValue(0)
|
||||
|
||||
@@ -260,6 +261,7 @@ describe("session processor incomplete response retry", () => {
|
||||
expect(MessageV2.APIError.isInstance(error)).toBe(true)
|
||||
if (!MessageV2.APIError.isInstance(error)) throw new Error("expected API error")
|
||||
expect(error.data.message).toBe(KiloSessionProcessor.INCOMPLETE_RESPONSE_MESSAGE)
|
||||
expect(error.data.responseHeaders?.["x-vercel-id"]).toBe("final-id")
|
||||
expect(yield* MessageV2.parts(ctx.msg.id)).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { BackgroundJob } from "../../src/background/job"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Auth } from "../../src/auth" // kilocode_change
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
@@ -51,6 +51,7 @@ import { Storage } from "../../src/storage/storage"
|
||||
import { SyncEvent } from "../../src/sync"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { MemoryService } from "@kilocode/kilo-memory/effect/service"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
@@ -160,7 +161,8 @@ function makeHttp() {
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer), // kilocode_change
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(KiloSessions.testLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { BackgroundJob } from "../../src/background/job"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Auth } from "../../src/auth" // kilocode_change
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
@@ -52,6 +52,7 @@ import { Truncate } from "../../src/tool/truncate"
|
||||
import { KiloHeadless } from "../../src/kilocode/permission/headless"
|
||||
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
|
||||
import { KiloReadObject } from "../../src/kilocode/tool/read-object"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { MemoryService } from "@kilocode/kilo-memory/effect/service"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||
@@ -160,7 +161,8 @@ function makeHttp() {
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer), // kilocode_change
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(KiloSessions.testLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { KiloSessionCompaction } from "@/kilocode/session/compaction"
|
||||
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
|
||||
import { KiloSession } from "@/kilocode/session"
|
||||
import { Suggestion } from "../../src/kilocode/suggestion"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
@@ -22,6 +23,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeTestRuntime, provideInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { remove as cleanup } from "./cleanup"
|
||||
import { pollWithTimeout } from "../lib/effect"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -99,6 +101,19 @@ function reply(input: { text: string; ready?: () => void; wait?: Promise<unknown
|
||||
})
|
||||
}
|
||||
|
||||
function providerCfg(url: string, agent: Record<string, unknown> = { code: { model: "alibaba/qwen-plus" } }) {
|
||||
return {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["alibaba"],
|
||||
provider: {
|
||||
alibaba: {
|
||||
options: { apiKey: "test-key", baseURL: `${url}/v1` },
|
||||
},
|
||||
},
|
||||
agent,
|
||||
}
|
||||
}
|
||||
|
||||
function hasText(msg: MessageV2.WithParts, text: string) {
|
||||
return msg.parts.some((part) => part.type === "text" && part.text.includes(text))
|
||||
}
|
||||
@@ -446,26 +461,7 @@ describe("session prompt queue", () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["alibaba"],
|
||||
provider: {
|
||||
alibaba: {
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: `${server.url.origin}/v1`,
|
||||
},
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
code: {
|
||||
model: "alibaba/qwen-plus",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify(providerCfg(server.url.origin)))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -576,16 +572,7 @@ describe("session prompt queue", () => {
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["alibaba"],
|
||||
provider: {
|
||||
alibaba: {
|
||||
options: { apiKey: "test-key", baseURL: `${server.url.origin}/v1` },
|
||||
},
|
||||
},
|
||||
agent: { plan: { model: "alibaba/qwen-plus" } },
|
||||
}),
|
||||
JSON.stringify(providerCfg(server.url.origin, { plan: { model: "alibaba/qwen-plus" } })),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -667,16 +654,7 @@ describe("session prompt queue", () => {
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["alibaba"],
|
||||
provider: {
|
||||
alibaba: {
|
||||
options: { apiKey: "test-key", baseURL: `${server.url.origin}/v1` },
|
||||
},
|
||||
},
|
||||
agent: { code: { model: "alibaba/qwen-plus" } },
|
||||
}),
|
||||
JSON.stringify(providerCfg(server.url.origin)),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -854,4 +832,584 @@ describe("session prompt queue", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("drop cancels a queued prompt while preserving the active prompt", async () => {
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const calls: number[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 })
|
||||
calls.push(Date.now())
|
||||
const wait = calls.length === 1 ? release.promise : undefined
|
||||
return new Response(reply({ text: "reply", ready: calls.length === 1 ? ready.resolve : undefined, wait }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(providerCfg(server.url.origin)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () =>
|
||||
scoped(tmp.path, async (prompt) => {
|
||||
const session = await sessions.create({ title: "Queued drop" })
|
||||
const activeID = MessageID.make("msg_active")
|
||||
const queuedID = MessageID.make("msg_queued")
|
||||
|
||||
const first = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
messageID: activeID,
|
||||
parts: [{ type: "text", text: "active prompt" }],
|
||||
}),
|
||||
)
|
||||
await ready.promise
|
||||
|
||||
const second = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
messageID: queuedID,
|
||||
parts: [{ type: "text", text: "queued prompt" }],
|
||||
}),
|
||||
)
|
||||
|
||||
await Effect.runPromise(
|
||||
pollWithTimeout(
|
||||
Effect.sync(() => (KiloSessionPromptQueue.hasFollowup(session.id) ? true : undefined)),
|
||||
"Timed out waiting for queued prompt",
|
||||
),
|
||||
)
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(session.id, queuedID))).toBe(true)
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(session.id, activeID))).toBe(false)
|
||||
|
||||
release.resolve()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const msgs = await sessions.messages({ sessionID: session.id })
|
||||
expect(msgs.filter((m) => m.info.role === "assistant")).toHaveLength(1)
|
||||
expect(msgs.filter((m) => m.info.role === "assistant" && m.info.parentID === queuedID)).toHaveLength(0)
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
test("drop returns false for the actively running prompt", async () => {
|
||||
const sessionID = SessionID.make("session_drop_active")
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const done = Promise.withResolvers<void>()
|
||||
const id = MessageID.make("msg_drop_active")
|
||||
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
id,
|
||||
Effect.promise(async () => {
|
||||
ready.resolve()
|
||||
await done.promise
|
||||
return "first"
|
||||
}),
|
||||
Effect.succeed("first-cancelled"),
|
||||
),
|
||||
)
|
||||
await ready.promise
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(sessionID, id))).toBe(false)
|
||||
done.resolve()
|
||||
await first
|
||||
})
|
||||
|
||||
test("drop returns false for an unknown message", async () => {
|
||||
const sessionID = SessionID.make("session_drop_unknown")
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(sessionID, MessageID.make("msg_missing")))).toBe(false)
|
||||
})
|
||||
|
||||
test("drop cancels a queued prompt and is idempotent", async () => {
|
||||
const sessionID = SessionID.make("session_drop_queued")
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const done = Promise.withResolvers<void>()
|
||||
const firstID = MessageID.make("msg_drop_first")
|
||||
const secondID = MessageID.make("msg_drop_second")
|
||||
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
firstID,
|
||||
Effect.promise(async () => {
|
||||
ready.resolve()
|
||||
await done.promise
|
||||
return "first"
|
||||
}),
|
||||
Effect.succeed("first-cancelled"),
|
||||
),
|
||||
)
|
||||
await ready.promise
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
secondID,
|
||||
Effect.sync(() => "second"),
|
||||
Effect.succeed("second-cancelled"),
|
||||
),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(sessionID, secondID))).toBe(true)
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(sessionID, secondID))).toBe(false)
|
||||
done.resolve()
|
||||
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second-cancelled")
|
||||
})
|
||||
|
||||
test("drop on a middle prompt preserves later queued prompts", async () => {
|
||||
const sessionID = SessionID.make("session_drop_middle")
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const done = Promise.withResolvers<void>()
|
||||
const calls: string[] = []
|
||||
const firstID = MessageID.make("msg_drop_1")
|
||||
const secondID = MessageID.make("msg_drop_2")
|
||||
const thirdID = MessageID.make("msg_drop_3")
|
||||
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
firstID,
|
||||
Effect.promise(async () => {
|
||||
calls.push("first")
|
||||
ready.resolve()
|
||||
await done.promise
|
||||
return "first"
|
||||
}),
|
||||
Effect.succeed("first-cancelled"),
|
||||
),
|
||||
)
|
||||
await ready.promise
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
secondID,
|
||||
Effect.sync(() => {
|
||||
calls.push("second")
|
||||
return "second"
|
||||
}),
|
||||
Effect.succeed("second-cancelled"),
|
||||
),
|
||||
)
|
||||
const third = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
thirdID,
|
||||
Effect.sync(() => {
|
||||
calls.push("third")
|
||||
return "third"
|
||||
}),
|
||||
Effect.succeed("third-cancelled"),
|
||||
),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(sessionID, secondID))).toBe(true)
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
|
||||
done.resolve()
|
||||
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second-cancelled")
|
||||
expect(await third).toBe("third")
|
||||
expect(calls).toEqual(["first", "third"])
|
||||
expect(KiloSessionPromptQueue._hasInternalState(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
// session.queue.changed event surface + snapshot accessor
|
||||
describe("session.queue.changed", () => {
|
||||
test("snapshot() returns an empty list for an unknown session", () => {
|
||||
expect(KiloSessionPromptQueue.snapshot(SessionID.make("session_unknown"))).toEqual([])
|
||||
})
|
||||
|
||||
test("enqueueing on an idle session does not transiently publish a non-empty snapshot", async () => {
|
||||
// A prompt enqueued into an idle session starts almost immediately, so
|
||||
// it must never appear in the waiting list (and must not emit any
|
||||
// session.queue.changed event whose queued list is non-empty).
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session_queue_idle")
|
||||
const events: Array<{ type: string; queued: string[] }> = []
|
||||
const off = Bus.subscribe(KiloSession.Event.QueueChanged, (event) => {
|
||||
events.push({ type: event.type, queued: [...(event.properties.queued as readonly string[])] })
|
||||
})
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("msg_idle_1"),
|
||||
Effect.succeed("done"),
|
||||
Effect.succeed("cancelled"),
|
||||
),
|
||||
)
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([])
|
||||
expect(events).toEqual([])
|
||||
} finally {
|
||||
off()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("enqueueing while busy appends to the FIFO snapshot and emits the event", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session_queue_busy")
|
||||
const events: Array<{ sessionID: string; queued: string[] }> = []
|
||||
const off = Bus.subscribe(KiloSession.Event.QueueChanged, (event) => {
|
||||
if (event.properties.sessionID === sessionID) {
|
||||
events.push({
|
||||
sessionID: event.properties.sessionID as string,
|
||||
queued: [...(event.properties.queued as readonly string[])],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const firstRelease = Promise.withResolvers<void>()
|
||||
const m1 = MessageID.make("msg_busy_1")
|
||||
const m2 = MessageID.make("msg_busy_2")
|
||||
const m3 = MessageID.make("msg_busy_3")
|
||||
|
||||
try {
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m1,
|
||||
Effect.gen(function* () {
|
||||
firstStarted.resolve()
|
||||
yield* Effect.promise(() => firstRelease.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await firstStarted.promise
|
||||
|
||||
// Idle-start for slot 1 must not have emitted anything.
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([])
|
||||
expect(events).toEqual([])
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m2,
|
||||
Effect.succeed("second" as const),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
const third = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m3,
|
||||
Effect.succeed("third" as const),
|
||||
Effect.succeed("third-cancelled" as const),
|
||||
),
|
||||
)
|
||||
|
||||
// FIFO order is preserved: msg_busy_2 then msg_busy_3.
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([m2, m3])
|
||||
// Publishes are fire-and-forget microtasks; let them flush.
|
||||
await Bun.sleep(10)
|
||||
expect(events).toEqual([
|
||||
{ sessionID, queued: [m2] },
|
||||
{ sessionID, queued: [m2, m3] },
|
||||
])
|
||||
|
||||
firstRelease.resolve()
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second")
|
||||
expect(await third).toBe("third")
|
||||
} finally {
|
||||
off()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("dropping a queued prompt updates the FIFO snapshot and preserves later prompts", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session_queue_drop")
|
||||
const events: Array<{ queued: string[] }> = []
|
||||
const off = Bus.subscribe(KiloSession.Event.QueueChanged, (event) => {
|
||||
if (event.properties.sessionID === sessionID) {
|
||||
events.push({ queued: [...(event.properties.queued as readonly string[])] })
|
||||
}
|
||||
})
|
||||
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const firstRelease = Promise.withResolvers<void>()
|
||||
const m1 = MessageID.make("msg_queue_drop_1")
|
||||
const m2 = MessageID.make("msg_queue_drop_2")
|
||||
const m3 = MessageID.make("msg_queue_drop_3")
|
||||
|
||||
try {
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m1,
|
||||
Effect.gen(function* () {
|
||||
firstStarted.resolve()
|
||||
yield* Effect.promise(() => firstRelease.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await firstStarted.promise
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m2,
|
||||
Effect.succeed("second" as const),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
const third = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m3,
|
||||
Effect.succeed("third" as const),
|
||||
Effect.succeed("third-cancelled" as const),
|
||||
),
|
||||
)
|
||||
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([m2, m3])
|
||||
await Effect.runPromise(
|
||||
pollWithTimeout(
|
||||
Effect.sync(() => (events.length >= 2 ? true : undefined)),
|
||||
"Timed out waiting for queued snapshot events",
|
||||
),
|
||||
)
|
||||
expect(events).toEqual([{ queued: [m2] }, { queued: [m2, m3] }])
|
||||
events.length = 0
|
||||
|
||||
expect(await Effect.runPromise(KiloSessionPromptQueue.drop(sessionID, m2))).toBe(true)
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([m3])
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
|
||||
await Effect.runPromise(
|
||||
pollWithTimeout(
|
||||
Effect.sync(() => (events.length >= 1 ? true : undefined)),
|
||||
"Timed out waiting for dropped snapshot event",
|
||||
),
|
||||
)
|
||||
expect(events).toEqual([{ queued: [m3] }])
|
||||
|
||||
firstRelease.resolve()
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second-cancelled")
|
||||
expect(await third).toBe("third")
|
||||
} finally {
|
||||
off()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("a waiting slot starting running shrinks the snapshot and emits the event", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session_queue_start")
|
||||
const events: Array<{ queued: string[] }> = []
|
||||
const off = Bus.subscribe(KiloSession.Event.QueueChanged, (event) => {
|
||||
if (event.properties.sessionID === sessionID) {
|
||||
events.push({ queued: [...(event.properties.queued as readonly string[])] })
|
||||
}
|
||||
})
|
||||
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const firstRelease = Promise.withResolvers<void>()
|
||||
const secondStarted = Promise.withResolvers<void>()
|
||||
const secondRelease = Promise.withResolvers<void>()
|
||||
const m1 = MessageID.make("msg_start_1")
|
||||
const m2 = MessageID.make("msg_start_2")
|
||||
|
||||
try {
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m1,
|
||||
Effect.gen(function* () {
|
||||
firstStarted.resolve()
|
||||
yield* Effect.promise(() => firstRelease.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await firstStarted.promise
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m2,
|
||||
Effect.gen(function* () {
|
||||
secondStarted.resolve()
|
||||
yield* Effect.promise(() => secondRelease.promise)
|
||||
return "second" as const
|
||||
}),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
|
||||
// msg2 is waiting behind msg1.
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([m2])
|
||||
await Bun.sleep(10)
|
||||
expect(events.map((e) => e.queued)).toEqual([[m2]])
|
||||
|
||||
// Release msg1; msg2 takes over and the waiting list drops to empty.
|
||||
firstRelease.resolve()
|
||||
await secondStarted.promise
|
||||
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([])
|
||||
await Bun.sleep(10)
|
||||
expect(events.map((e) => e.queued)).toEqual([[m2], []])
|
||||
|
||||
secondRelease.resolve()
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second")
|
||||
} finally {
|
||||
off()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("cancel empties the snapshot and emits an empty list", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session_queue_cancel")
|
||||
const events: Array<{ queued: string[] }> = []
|
||||
const off = Bus.subscribe(KiloSession.Event.QueueChanged, (event) => {
|
||||
if (event.properties.sessionID === sessionID) {
|
||||
events.push({ queued: [...(event.properties.queued as readonly string[])] })
|
||||
}
|
||||
})
|
||||
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const firstRelease = Promise.withResolvers<void>()
|
||||
const m1 = MessageID.make("msg_cancel_1")
|
||||
const m2 = MessageID.make("msg_cancel_2")
|
||||
const m3 = MessageID.make("msg_cancel_3")
|
||||
|
||||
try {
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m1,
|
||||
Effect.gen(function* () {
|
||||
firstStarted.resolve()
|
||||
yield* Effect.promise(() => firstRelease.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await firstStarted.promise
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m2,
|
||||
Effect.succeed("second" as const),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
const third = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
m3,
|
||||
Effect.succeed("third" as const),
|
||||
Effect.succeed("third-cancelled" as const),
|
||||
),
|
||||
)
|
||||
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([m2, m3])
|
||||
await Bun.sleep(10)
|
||||
const beforeCancel = events.length
|
||||
expect(beforeCancel).toBeGreaterThan(0)
|
||||
|
||||
await Effect.runPromise(KiloSessionPromptQueue.cancel(sessionID))
|
||||
|
||||
// The most recent emission must be the empty list, and the snapshot
|
||||
// must be empty for downstream replay callers.
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([])
|
||||
await Bun.sleep(10)
|
||||
expect(events.length).toBeGreaterThan(beforeCancel)
|
||||
expect(events.at(-1)?.queued).toEqual([])
|
||||
expect(events.slice(beforeCancel).every((e) => e.queued.length === 0)).toBe(true)
|
||||
|
||||
firstRelease.resolve()
|
||||
expect(await first).toBe("first")
|
||||
// Cancel bumped the version, so the queued slots return their
|
||||
// cancelled effect instead of running their work.
|
||||
expect(await second).toBe("second-cancelled")
|
||||
expect(await third).toBe("third-cancelled")
|
||||
} finally {
|
||||
off()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("cancel on an idle session suppresses the empty→empty emission", async () => {
|
||||
// Steady-state no-op empty→empty emissions are intentionally suppressed
|
||||
// by the queue to keep the bus quiet. Replay uses snapshot() directly
|
||||
// and is therefore never affected by this suppression.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session_queue_cancel_idle")
|
||||
const events: Array<{ queued: string[] }> = []
|
||||
const off = Bus.subscribe(KiloSession.Event.QueueChanged, (event) => {
|
||||
if (event.properties.sessionID === sessionID) {
|
||||
events.push({ queued: [...(event.properties.queued as readonly string[])] })
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await Effect.runPromise(KiloSessionPromptQueue.cancel(sessionID))
|
||||
expect(KiloSessionPromptQueue.snapshot(sessionID)).toEqual([])
|
||||
expect(events).toEqual([])
|
||||
} finally {
|
||||
off()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
||||
import { KiloResponseMetadata } from "@/kilocode/session/response-metadata"
|
||||
|
||||
describe("session response metadata", () => {
|
||||
test("carries x-vercel-id from an AI SDK response", async () => {
|
||||
const events = await Effect.runPromise(
|
||||
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
|
||||
type: "finish-step",
|
||||
response: {
|
||||
id: "response-1",
|
||||
timestamp: new Date(0),
|
||||
modelId: "gpt-test",
|
||||
headers: { "X-Vercel-Id": "fra1::abc" },
|
||||
},
|
||||
finishReason: "other",
|
||||
rawFinishReason: undefined,
|
||||
providerMetadata: undefined,
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 0,
|
||||
totalTokens: 1,
|
||||
inputTokenDetails: { noCacheTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
const event = events[0]
|
||||
if (event?.type !== "step-finish") throw new Error("expected step-finish")
|
||||
expect(KiloResponseMetadata.read(event.providerMetadata)).toBe("fra1::abc")
|
||||
})
|
||||
|
||||
test("does not add metadata when the header is absent", () => {
|
||||
expect(KiloResponseMetadata.write(undefined, { server: "vercel" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes valid Vercel IDs", () => {
|
||||
const metadata = KiloResponseMetadata.write(undefined, { "x-vercel-id": " fra1::abc-123_test " })
|
||||
expect(KiloResponseMetadata.read(metadata)).toBe("fra1::abc-123_test")
|
||||
})
|
||||
|
||||
test("rejects unsafe or oversized Vercel IDs", () => {
|
||||
expect(KiloResponseMetadata.write(undefined, { "x-vercel-id": "fra1::<script>" })).toBeUndefined()
|
||||
expect(KiloResponseMetadata.write(undefined, { "x-vercel-id": "x".repeat(201) })).toBeUndefined()
|
||||
expect(KiloResponseMetadata.read({ kilo: { vercelID: "fra1::abc\nsecret" } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionTranscript } from "@/kilocode/session/transcript"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { provideTmpdirInstance } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const env = Layer.mergeAll(Session.defaultLayer, CrossSpawnSpawner.defaultLayer)
|
||||
const it = testEffect(env)
|
||||
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("test")
|
||||
|
||||
function seed(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
const user = yield* sessions.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
agent: "default",
|
||||
model: { providerID, modelID },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: user.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "how do I rotate the signing keys?",
|
||||
})
|
||||
yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: user.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "injected file dump that should not be transcribed",
|
||||
synthetic: true,
|
||||
})
|
||||
const assistant = yield* sessions.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: session.id,
|
||||
role: "assistant",
|
||||
parentID: user.id,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: { cwd: dir, root: dir },
|
||||
cost: 0,
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID,
|
||||
providerID,
|
||||
time: { created: Date.now(), completed: Date.now() },
|
||||
finish: "stop",
|
||||
})
|
||||
yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistant.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "run the rotation script with --apply",
|
||||
})
|
||||
return session
|
||||
})
|
||||
}
|
||||
|
||||
function mention(id: SessionID) {
|
||||
return {
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: SessionTranscript.url(id),
|
||||
filename: "past-chat.md",
|
||||
source: {
|
||||
type: "file" as const,
|
||||
path: SessionTranscript.url(id),
|
||||
text: { value: "@past chat", start: 0, end: 10 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("SessionTranscript.resolve", () => {
|
||||
it.live(
|
||||
"injects the referenced session transcript as context",
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const past = yield* seed(dir)
|
||||
const current = yield* sessions.create({})
|
||||
|
||||
const parts = yield* SessionTranscript.resolve(mention(past.id), {
|
||||
messageID: MessageID.ascending(),
|
||||
sessionID: current.id,
|
||||
sessions,
|
||||
})
|
||||
|
||||
expect(parts).toHaveLength(3)
|
||||
const [note, transcript, file] = parts
|
||||
expect(note.type).toBe("text")
|
||||
expect(note.type === "text" && note.synthetic).toBe(true)
|
||||
expect(note.type === "text" && note.text).toContain("Attached transcript of past chat")
|
||||
expect(transcript.type === "text" && transcript.text).toContain("how do I rotate the signing keys?")
|
||||
expect(transcript.type === "text" && transcript.text).toContain("run the rotation script with --apply")
|
||||
expect(transcript.type === "text" && transcript.text).not.toContain(
|
||||
"injected file dump that should not be transcribed",
|
||||
)
|
||||
expect(file.type).toBe("file")
|
||||
expect(file.type === "file" && file.url).toBe(SessionTranscript.url(past.id))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"rejects sessions from a different workspace",
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const past = yield* seed(dir)
|
||||
return yield* provideTmpdirInstance((other) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* sessions.create({})
|
||||
expect(other).not.toBe(dir)
|
||||
const parts = yield* SessionTranscript.resolve(mention(past.id), {
|
||||
messageID: MessageID.ascending(),
|
||||
sessionID: current.id,
|
||||
sessions,
|
||||
})
|
||||
expect(parts).toHaveLength(1)
|
||||
expect(parts[0].type === "text" && parts[0].text).toContain("different workspace")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reports unknown or invalid session references",
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const current = yield* sessions.create({})
|
||||
const missing = yield* SessionTranscript.resolve(mention(SessionID.make("ses_doesnotexist")), {
|
||||
messageID: MessageID.ascending(),
|
||||
sessionID: current.id,
|
||||
sessions,
|
||||
})
|
||||
expect(missing).toHaveLength(1)
|
||||
expect(missing[0].type === "text" && missing[0].text).toContain("not found")
|
||||
|
||||
const invalid = yield* SessionTranscript.resolve(
|
||||
{ ...mention(SessionID.make("ses_bad")), url: "session:not-a-session" },
|
||||
{
|
||||
messageID: MessageID.ascending(),
|
||||
sessionID: current.id,
|
||||
sessions,
|
||||
},
|
||||
)
|
||||
expect(invalid).toHaveLength(1)
|
||||
expect(invalid[0].type === "text" && invalid[0].text).toContain("invalid session reference")
|
||||
expect(dir).toBeTruthy()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionTranscript.format", () => {
|
||||
it.live(
|
||||
"truncates oversized transcripts keeping head and tail",
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
const user = yield* sessions.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
agent: "default",
|
||||
model: { providerID, modelID },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: user.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: `START ${"x".repeat(2000)} END`,
|
||||
})
|
||||
const [msg] = yield* sessions.messages({ sessionID: session.id })
|
||||
const text = SessionTranscript.format(session, [msg], { max: 600 })
|
||||
expect(text.length).toBeLessThan(700)
|
||||
expect(text).toContain("characters omitted")
|
||||
expect(text).toContain("START")
|
||||
expect(text).toContain("END")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -744,4 +744,273 @@ describe("AttachedState", () => {
|
||||
await replacement
|
||||
expect([...state.union()].sort()).toEqual(["ses_x"])
|
||||
})
|
||||
|
||||
// AC6d: announce(id) must forward { requireSessionId: id } to the
|
||||
// heartbeat callback so the relay only resolves the attach once a fresh
|
||||
// heartbeat whose payload contains that id was actually sent. Presence
|
||||
// fire-and-forget heartbeats (from setPresence) continue to call
|
||||
// without an id and resolve on any fresh send.
|
||||
test("announce(id) forwards { requireSessionId: id } to the heartbeat callback", async () => {
|
||||
const calls: Array<{ requireSessionId?: string }> = []
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
calls.push(opts ? { ...opts } : {})
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
|
||||
// setPresence fires a fire-and-forget heartbeat with NO id.
|
||||
state.setPresence(["ses_a"])
|
||||
await Promise.resolve()
|
||||
|
||||
// announce(id) forwards the id to the awaited heartbeat.
|
||||
await state.announce("ses_b")
|
||||
|
||||
expect(calls).toEqual([{}, { requireSessionId: "ses_b" }])
|
||||
})
|
||||
|
||||
// K1 W1: detach semantics — basic happy path.
|
||||
test("detach removes the id from both sets and awaits a heartbeat whose payload no longer contains it", async () => {
|
||||
let detachResolved = false
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
if (opts?.detachSessionId) {
|
||||
detachResolved = true
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
state.setPresence(["ses_a"])
|
||||
await Promise.resolve()
|
||||
expect(state.has("ses_a")).toBe(true)
|
||||
|
||||
// Detach awaits a heartbeat whose payload no longer contains ses_a.
|
||||
// The state machine removes the id synchronously before awaiting.
|
||||
await state.detach("ses_a")
|
||||
expect(detachResolved).toBe(true)
|
||||
expect([...state.union()]).toEqual([])
|
||||
})
|
||||
|
||||
// K1 W1: detach surfaces a specific error for an id this CLI does not own.
|
||||
test("detach throws for an id this CLI does not own (no silent re-attach)", async () => {
|
||||
const state = AttachedState.create({
|
||||
heartbeat: () => Promise.resolve(),
|
||||
log: nolog,
|
||||
})
|
||||
await expect(state.detach("ses_missing")).rejects.toThrow("not owned")
|
||||
})
|
||||
|
||||
// K1 W1: heartbeat failure during detach rolls back by restoring ownership.
|
||||
test("detach rolls back by restoring prior ownership on heartbeat failure", async () => {
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
if (opts?.detachSessionId) return Promise.reject(new Error("relay down"))
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
state.setPresence(["ses_a"])
|
||||
await Promise.resolve()
|
||||
await expect(state.detach("ses_a")).rejects.toThrow("relay down")
|
||||
// The id must be back in presence so a future setPresence does not
|
||||
// accidentally treat the session as detached.
|
||||
expect(state.has("ses_a")).toBe(true)
|
||||
})
|
||||
|
||||
// K1 W1: suppression tombstone prevents a presence replacement that
|
||||
// still includes a just-exited id from instantly re-adopting it.
|
||||
test("setPresence does not re-adopt a detached id while presence still reports it", async () => {
|
||||
const state = AttachedState.create({
|
||||
heartbeat: () => Promise.resolve(),
|
||||
log: nolog,
|
||||
})
|
||||
state.setPresence(["ses_a", "ses_b"])
|
||||
expect(state.has("ses_a")).toBe(true)
|
||||
|
||||
// Detach ses_a; the tombstone is set BEFORE the sets are mutated.
|
||||
await state.detach("ses_a")
|
||||
expect(state.has("ses_a")).toBe(false)
|
||||
|
||||
// A presence churn that still includes ses_a must NOT re-adopt it
|
||||
// (the relay is the source of truth and the upstream side has not
|
||||
// dropped the id yet).
|
||||
state.setPresence(["ses_a", "ses_b"])
|
||||
expect(state.has("ses_a")).toBe(false)
|
||||
|
||||
// Once presence genuinely drops ses_a, the tombstone is released
|
||||
// and a later real re-open (via announce) is not blocked.
|
||||
state.setPresence(["ses_b"])
|
||||
expect(state.has("ses_a")).toBe(false)
|
||||
await state.announce("ses_a")
|
||||
expect(state.has("ses_a")).toBe(true)
|
||||
})
|
||||
|
||||
// K1 W1: has(id) reflects presence ∪ pending.
|
||||
test("has(id) is true for presence-owned and pending ids, false otherwise", async () => {
|
||||
const announced = Promise.withResolvers<void>()
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
if (opts?.requireSessionId === "ses_pending") return announced.promise
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
state.setPresence(["ses_present"])
|
||||
expect(state.has("ses_present")).toBe(true)
|
||||
expect(state.has("ses_pending")).toBe(false)
|
||||
expect(state.has("ses_other")).toBe(false)
|
||||
|
||||
// Announce with a held heartbeat so the id sits in pending.
|
||||
const p = state.announce("ses_pending")
|
||||
await Promise.resolve()
|
||||
expect(state.has("ses_pending")).toBe(true)
|
||||
announced.resolve()
|
||||
await p
|
||||
expect(state.has("ses_pending")).toBe(true)
|
||||
})
|
||||
|
||||
// K1 W1: reset() also clears the detach in-flight map and tombstones
|
||||
// so a new connection lifecycle does not inherit stale state.
|
||||
test("reset() clears tombstones and detach in-flight map", async () => {
|
||||
const state = AttachedState.create({
|
||||
heartbeat: () => Promise.resolve(),
|
||||
log: nolog,
|
||||
})
|
||||
state.setPresence(["ses_a"])
|
||||
await state.detach("ses_a")
|
||||
state.setPresence(["ses_a"])
|
||||
expect(state.has("ses_a")).toBe(false) // tombstone held
|
||||
|
||||
state.reset()
|
||||
// After reset, a presence report including ses_a is accepted (the
|
||||
// previous tombstone is gone).
|
||||
state.setPresence(["ses_a"])
|
||||
expect(state.has("ses_a")).toBe(true)
|
||||
})
|
||||
|
||||
// K1 W1: a detach in flight for an id must NOT cause a concurrent
|
||||
// announce(id) to join the detach fence and report a bogus attach. The
|
||||
// announce must wait for the detach to settle and then genuinely re-attach.
|
||||
test("announce awaits an in-flight detach and then really re-attaches (no opposite-op join)", async () => {
|
||||
const detachHb = Promise.withResolvers<void>()
|
||||
const calls: Array<{ requireSessionId?: string; detachSessionId?: string }> = []
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
calls.push(opts ?? {})
|
||||
if (opts?.detachSessionId === "ses_y") return detachHb.promise
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
state.setPresence(["ses_y"])
|
||||
await Promise.resolve()
|
||||
|
||||
const detachP = state.detach("ses_y") // holds on the detach fence, id removed
|
||||
const announceP = state.announce("ses_y") // must await the detach, not join it
|
||||
detachHb.resolve()
|
||||
await detachP
|
||||
await announceP
|
||||
|
||||
// The announce genuinely re-attached rather than resolving on the detach's
|
||||
// "id absent" outcome, and it drove a real requireSessionId heartbeat.
|
||||
expect(state.has("ses_y")).toBe(true)
|
||||
expect(calls.some((c) => c.requireSessionId === "ses_y")).toBe(true)
|
||||
})
|
||||
|
||||
// K1 W1: an announce in flight for an id must NOT cause a concurrent
|
||||
// detach(id) to join the announce and report a bogus detach — exit_cli
|
||||
// treats a resolved detach as license to ACK/close, so a false success is
|
||||
// dangerous. The detach must wait for the announce, then really detach.
|
||||
test("detach awaits an in-flight announce and then really detaches (no opposite-op join)", async () => {
|
||||
const announceHb = Promise.withResolvers<void>()
|
||||
const calls: Array<{ requireSessionId?: string; detachSessionId?: string }> = []
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
calls.push(opts ?? {})
|
||||
if (opts?.requireSessionId === "ses_x") return announceHb.promise
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
|
||||
const announceP = state.announce("ses_x") // holds on the attach fence
|
||||
const detachP = state.detach("ses_x") // must await the announce, not join it
|
||||
announceHb.resolve()
|
||||
await announceP
|
||||
await detachP
|
||||
|
||||
// The detach genuinely ran the negative-containment fence rather than
|
||||
// resolving on the announce's success; the session is actually gone.
|
||||
expect(state.has("ses_x")).toBe(false)
|
||||
expect(calls.some((c) => c.detachSessionId === "ses_x")).toBe(true)
|
||||
})
|
||||
|
||||
// K1 W1: after a failed detach rolls ownership back, the id is genuinely
|
||||
// still attached, so the very next presence report that still includes it
|
||||
// must keep it — the tombstone must have been released on rollback.
|
||||
test("failed-detach rollback keeps the id attached across the next setPresence", async () => {
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
if (opts?.detachSessionId) return Promise.reject(new Error("relay down"))
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
state.setPresence(["ses_a"])
|
||||
await Promise.resolve()
|
||||
|
||||
await expect(state.detach("ses_a")).rejects.toThrow("relay down")
|
||||
expect(state.has("ses_a")).toBe(true)
|
||||
|
||||
// The realistic next presence event still reports ses_a. Without releasing
|
||||
// the tombstone on rollback, setPresence's suppression loop would drop the
|
||||
// still-attached id here and never clear the tombstone.
|
||||
state.setPresence(["ses_a"])
|
||||
expect(state.has("ses_a")).toBe(true)
|
||||
})
|
||||
|
||||
// K1 W1: reset() clears the SAME set instances, so a stale in-flight
|
||||
// announce that rejects after a reconnect must NOT roll back into the new
|
||||
// lifecycle — doing so would delete a fresh post-reset announce's pending
|
||||
// entry. The catch must honor the generation guard like the success path.
|
||||
test("a stale announce rejecting after reset() does not corrupt the new lifecycle's pending set", async () => {
|
||||
const hb1 = Promise.withResolvers<void>()
|
||||
const hb2 = Promise.withResolvers<void>()
|
||||
let calls = 0
|
||||
const state = AttachedState.create({
|
||||
heartbeat: (opts) => {
|
||||
if (opts?.requireSessionId === "id") {
|
||||
calls += 1
|
||||
return calls === 1 ? hb1.promise : hb2.promise
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
log: nolog,
|
||||
})
|
||||
|
||||
const a1 = state.announce("id") // installs pending, awaits hb1
|
||||
void a1.then(
|
||||
() => {},
|
||||
() => {},
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
state.reset() // bumps generation, clears the (same) sets
|
||||
const a2 = state.announce("id") // fresh lifecycle: re-installs pending, awaits hb2
|
||||
await Promise.resolve()
|
||||
|
||||
hb1.reject(new Error("stale relay drop")) // the dead-lifecycle announce fails
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// The stale rollback must NOT have deleted the fresh generation's entry.
|
||||
expect(state.has("id")).toBe(true)
|
||||
|
||||
hb2.resolve()
|
||||
await a2
|
||||
expect(state.has("id")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -399,4 +399,72 @@ describe("share ingest queue", () => {
|
||||
expect(urls.length).toBe(1)
|
||||
expect(urls[0]).toBe("https://ingest.test/ingest?v=2")
|
||||
})
|
||||
|
||||
test("agent_notification distinct ids survive one debounce batch", async () => {
|
||||
const sent: unknown[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async (_input, init) => {
|
||||
sent.push(JSON.parse((init?.body as string) ?? "{}"))
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-notify", [
|
||||
{ type: "agent_notification", data: { id: "n1", message: "first" } },
|
||||
{ type: "agent_notification", data: { id: "n2", message: "second" } },
|
||||
])
|
||||
|
||||
clock.now = 1000
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(sent.length).toBe(1)
|
||||
const payload = sent[0] as { data: { type: string; data: { id: string; message: string } }[] }
|
||||
expect(payload.data.length).toBe(2)
|
||||
const ids = payload.data.map((d) => d.data.id).sort()
|
||||
expect(ids).toEqual(["n1", "n2"])
|
||||
})
|
||||
|
||||
test("agent_notification with the same id coalesces", async () => {
|
||||
const sent: unknown[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async (_input, init) => {
|
||||
sent.push(JSON.parse((init?.body as string) ?? "{}"))
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-notify", [{ type: "agent_notification", data: { id: "n1", message: "first" } }])
|
||||
clock.now = 100
|
||||
await q.sync("s-notify", [{ type: "agent_notification", data: { id: "n1", message: "second" } }])
|
||||
|
||||
clock.now = 1000
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(sent.length).toBe(1)
|
||||
const payload = sent[0] as { data: { type: string; data: { id: string; message: string } }[] }
|
||||
expect(payload.data.length).toBe(1)
|
||||
expect(payload.data[0].data.message).toBe("second")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("RemoteCommand", () => {
|
||||
subtask: true,
|
||||
},
|
||||
],
|
||||
canExitSession: true,
|
||||
})
|
||||
expect(JSON.stringify(catalog)).not.toContain("template")
|
||||
expect(JSON.stringify(catalog)).not.toContain("secret-skill")
|
||||
@@ -115,6 +116,9 @@ describe("RemoteCommand", () => {
|
||||
{ name: "alpha", source: "command", hints: [], template: "alpha" },
|
||||
])
|
||||
expect(base.commands.map((item) => item.name)).toEqual(["alpha", "beta", "compact"])
|
||||
// kilocode_change - K1 W1: canExitSession is always true, independent of
|
||||
// exitAvailable (which gates the synthetic `/exit` entry).
|
||||
expect(base.canExitSession).toBe(true)
|
||||
|
||||
const catalog = RemoteCommand.build(
|
||||
[
|
||||
@@ -155,16 +159,26 @@ describe("RemoteCommand", () => {
|
||||
compaction: { create: async () => {} },
|
||||
prompt: { loop: async () => {} },
|
||||
})
|
||||
expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(false)
|
||||
// kilocode_change - K1 W1: canExitSession is true even when the synthetic
|
||||
// `/exit` entry is absent (e.g. a headless `kilo remote` host has no
|
||||
// RemoteExit callback, so `/exit` is gated off — but the host still
|
||||
// interprets `exit_cli` as session-detach).
|
||||
const baseList = await remote.list()
|
||||
expect(baseList.canExitSession).toBe(true)
|
||||
expect(baseList.commands.some((item) => item.name === "exit")).toBe(false)
|
||||
|
||||
const unregister = RemoteExit.register(async () => {})
|
||||
try {
|
||||
expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(true)
|
||||
const list = await remote.list()
|
||||
expect(list.commands.some((item) => item.name === "exit")).toBe(true)
|
||||
expect(list.canExitSession).toBe(true)
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
|
||||
expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(false)
|
||||
const after = await remote.list()
|
||||
expect(after.commands.some((item) => item.name === "exit")).toBe(false)
|
||||
expect(after.canExitSession).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps compact and exit within command and byte caps", () => {
|
||||
|
||||
@@ -265,4 +265,199 @@ describe("RemoteProtocol", () => {
|
||||
expect(result.data.type).toBe("heartbeat_ack")
|
||||
}
|
||||
})
|
||||
|
||||
// kilocode_change - K1 W1: instance advertisement + per-session platform
|
||||
|
||||
test("heartbeat without instance still parses (legacy compatibility)", () => {
|
||||
const msg = { type: "heartbeat", sessions: [{ id: "ses_1", status: "busy", title: "Fix auth" }] }
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.instance).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("heartbeat round-trips instance advertisement", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
instance: { name: "mbp-igor", projectName: "cloud", version: "1.2.3" },
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.instance).toEqual({ name: "mbp-igor", projectName: "cloud", version: "1.2.3" })
|
||||
}
|
||||
// round-trip via JSON
|
||||
const json = JSON.parse(JSON.stringify(result.success ? result.data : null))
|
||||
const result2 = RemoteProtocol.Heartbeat.safeParse(json)
|
||||
expect(result2.success).toBe(true)
|
||||
if (result2.success) {
|
||||
expect(result2.data.instance).toEqual({ name: "mbp-igor", projectName: "cloud", version: "1.2.3" })
|
||||
}
|
||||
})
|
||||
|
||||
test("instance advertisement version is optional", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
instance: { name: "h", projectName: "p" },
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.instance?.version).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("instance advertisement rejects empty name", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
instance: { name: "", projectName: "p" },
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("instance advertisement rejects oversized name", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
instance: { name: "x".repeat(65), projectName: "p" },
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("instance advertisement rejects oversized projectName", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
instance: { name: "h", projectName: "p".repeat(65) },
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("instance advertisement rejects oversized version", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
instance: { name: "h", projectName: "p", version: "v".repeat(33) },
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("session info accepts optional platform", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [{ id: "s1", status: "busy", title: "t", platform: "vscode" }],
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.sessions[0].platform).toBe("vscode")
|
||||
}
|
||||
})
|
||||
|
||||
test("session info platform optional (legacy)", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [{ id: "s1", status: "busy", title: "t" }],
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.sessions[0].platform).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("session info rejects oversized platform", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
sessions: [{ id: "s1", status: "busy", title: "t", platform: "p".repeat(33) }],
|
||||
}
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("full heartbeat round-trips sessions + instance", () => {
|
||||
const msg = {
|
||||
type: "heartbeat",
|
||||
protocolVersion: "1.0.0",
|
||||
sessions: [
|
||||
{ id: "ses_1", status: "busy", title: "Fix auth", platform: "cli" },
|
||||
{ id: "ses_2", status: "idle", title: "Sub task", parentSessionId: "ses_1", platform: "vscode" },
|
||||
],
|
||||
instance: { name: "mbp-igor", projectName: "cloud", version: "1.2.3" },
|
||||
}
|
||||
const json = JSON.parse(JSON.stringify(msg))
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(json)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.sessions).toHaveLength(2)
|
||||
expect(result.data.sessions[0].platform).toBe("cli")
|
||||
expect(result.data.sessions[1].platform).toBe("vscode")
|
||||
expect(result.data.instance).toEqual({ name: "mbp-igor", projectName: "cloud", version: "1.2.3" })
|
||||
expect(result.data.protocolVersion).toBe("1.0.0")
|
||||
}
|
||||
})
|
||||
|
||||
test("heartbeat without capabilities parses", () => {
|
||||
const result = RemoteProtocol.Heartbeat.safeParse({
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.capabilities).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("heartbeat with capabilities.attachments parses", () => {
|
||||
const result = RemoteProtocol.Heartbeat.safeParse({
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
capabilities: { attachments: true },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.capabilities?.attachments).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("heartbeat with capabilities and no attachments key parses", () => {
|
||||
const result = RemoteProtocol.Heartbeat.safeParse({
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
capabilities: {},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.capabilities?.attachments).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("heartbeat rejects non-boolean capabilities.attachments", () => {
|
||||
const result = RemoteProtocol.Heartbeat.safeParse({
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
capabilities: { attachments: "yes" },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("outbound union accepts heartbeat with capabilities", () => {
|
||||
const result = RemoteProtocol.Outbound.safeParse({
|
||||
type: "heartbeat",
|
||||
sessions: [],
|
||||
capabilities: { attachments: true },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("heartbeat")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
import { expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import { Effect, Fiber, 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 { clearInFlightCache } from "../../../src/kilo-sessions/inflight-cache"
|
||||
import { Session } from "../../../src/session/session"
|
||||
import { TestConfig } from "../../fixture/config"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { InstanceStore } from "../../../src/project/instance-store"
|
||||
import { TestInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture"
|
||||
|
||||
const KiloSessions = (await import("../../../src/kilo-sessions/kilo-sessions")).KiloSessions
|
||||
|
||||
let originalNotificationTimeout: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalNotificationTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS
|
||||
process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "50"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNotificationTimeout === undefined) {
|
||||
delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS
|
||||
} else {
|
||||
process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalNotificationTimeout
|
||||
}
|
||||
})
|
||||
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
const multi = testEffect(Layer.merge(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
|
||||
|
||||
function layer(overrides: Partial<Config.Interface> = {}) {
|
||||
return Layer.merge(
|
||||
KiloSessions.layer.pipe(
|
||||
Layer.provideMerge(Bus.layer),
|
||||
Layer.provide(TestConfig.layer(overrides)),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
),
|
||||
Auth.defaultLayer,
|
||||
)
|
||||
}
|
||||
|
||||
function reset(...tokens: string[]) {
|
||||
clearInFlightCache("kilo-sessions:token")
|
||||
clearInFlightCache("kilo-sessions:client")
|
||||
for (const token of tokens) clearInFlightCache(`kilo-sessions:token-valid:${token}`)
|
||||
}
|
||||
|
||||
it.instance("dedicated immediate POST hits ingest path with an agent_notification item", () => {
|
||||
const originalKey = process.env.KILO_API_KEY
|
||||
const originalIngest = process.env.KILO_SESSION_INGEST_URL
|
||||
const requests: { method: string; path: string; body?: unknown; headers: Record<string, string> }[] = []
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const req = new Request(input, init)
|
||||
const headers: Record<string, string> = {}
|
||||
req.headers.forEach((v, k) => {
|
||||
headers[k] = v
|
||||
})
|
||||
const body = req.method === "POST" ? await req.json().catch(() => undefined) : undefined
|
||||
requests.push({ method: req.method, path: new URL(url).pathname, body, headers })
|
||||
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
|
||||
if (url.endsWith("/api/session")) {
|
||||
return Response.json({ id: "session-1", ingestPath: "/api/session/session-1/ingest" })
|
||||
}
|
||||
if (new URL(url).pathname.endsWith("/ingest")) return new Response("{}", { status: 200 })
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
process.env.KILO_API_KEY = "test-token"
|
||||
process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai"
|
||||
reset("test-token")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
yield* Effect.promise(() => KiloSessions.bootstrap("session-1"))
|
||||
const result = yield* sessions.sendAgentNotification("session-1", {
|
||||
id: "notif-1",
|
||||
message: "Test notification",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
|
||||
const ingestPosts = requests.filter((r) => r.method === "POST" && r.path.endsWith("/ingest"))
|
||||
expect(ingestPosts).toHaveLength(1)
|
||||
const ingestReq = ingestPosts[0]
|
||||
expect(ingestReq.body).toEqual({
|
||||
data: [{ type: "agent_notification", data: { id: "notif-1", message: "Test notification" } }],
|
||||
})
|
||||
expect(ingestReq.headers["authorization"]).toBe("Bearer test-token")
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
if (originalKey === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = originalKey
|
||||
if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL
|
||||
else process.env.KILO_SESSION_INGEST_URL = originalIngest
|
||||
reset("test-token")
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("2xx ingest response returns ok:true", () => {
|
||||
const originalKey = process.env.KILO_API_KEY
|
||||
const originalIngest = process.env.KILO_SESSION_INGEST_URL
|
||||
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")) {
|
||||
return Response.json({ id: "session-2", ingestPath: "/api/session/session-2/ingest" })
|
||||
}
|
||||
if (new URL(url).pathname.endsWith("/ingest")) return new Response("{}", { status: 200 })
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
process.env.KILO_API_KEY = "test-token"
|
||||
process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai"
|
||||
reset("test-token")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
yield* Effect.promise(() => KiloSessions.bootstrap("session-2"))
|
||||
const result = yield* sessions.sendAgentNotification("session-2", {
|
||||
id: "notif-2",
|
||||
message: "Success test",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
if (originalKey === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = originalKey
|
||||
if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL
|
||||
else process.env.KILO_SESSION_INGEST_URL = originalIngest
|
||||
reset("test-token")
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("non-2xx ingest response returns ok:false with no retry", () => {
|
||||
const originalKey = process.env.KILO_API_KEY
|
||||
const originalIngest = process.env.KILO_SESSION_INGEST_URL
|
||||
const requests: { method: string; path: string; body?: unknown; headers: Record<string, string> }[] = []
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const req = new Request(input, init)
|
||||
const headers: Record<string, string> = {}
|
||||
req.headers.forEach((v, k) => {
|
||||
headers[k] = v
|
||||
})
|
||||
const body = req.method === "POST" ? await req.json().catch(() => undefined) : undefined
|
||||
requests.push({ method: req.method, path: new URL(url).pathname, body, headers })
|
||||
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
|
||||
if (url.endsWith("/api/session")) {
|
||||
return Response.json({ id: "session-3", ingestPath: "/api/session/session-3/ingest" })
|
||||
}
|
||||
if (new URL(url).pathname.endsWith("/ingest")) {
|
||||
return new Response("Internal Server Error", { status: 500 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
process.env.KILO_API_KEY = "test-token"
|
||||
process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai"
|
||||
reset("test-token")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
yield* Effect.promise(() => KiloSessions.bootstrap("session-3"))
|
||||
const result = yield* sessions.sendAgentNotification("session-3", {
|
||||
id: "notif-3",
|
||||
message: "Failure test",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: "http_500" })
|
||||
|
||||
const ingestPosts = requests.filter((r) => r.method === "POST" && r.path.endsWith("/ingest"))
|
||||
expect(ingestPosts).toHaveLength(1)
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
if (originalKey === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = originalKey
|
||||
if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL
|
||||
else process.env.KILO_SESSION_INGEST_URL = originalIngest
|
||||
reset("test-token")
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("unauthenticated returns not_connected", () => {
|
||||
const originalKey = process.env.KILO_API_KEY
|
||||
const originalIngest = process.env.KILO_SESSION_INGEST_URL
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async () => new Response("Unauthorized", { status: 401 }),
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
delete process.env.KILO_API_KEY
|
||||
process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai"
|
||||
reset("test-token")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
const result = yield* sessions.sendAgentNotification("session-4", {
|
||||
id: "notif-4",
|
||||
message: "No auth test",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: "not_connected" })
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
if (originalKey === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = originalKey
|
||||
if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL
|
||||
else process.env.KILO_SESSION_INGEST_URL = originalIngest
|
||||
reset("test-token")
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("bounded timeout: stalled auth step returns not_connected within timeout", () => {
|
||||
const originalKey = process.env.KILO_API_KEY
|
||||
const originalIngest = process.env.KILO_SESSION_INGEST_URL
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith("/api/user")) {
|
||||
await new Promise(() => {})
|
||||
return new Response("{}", { status: 200 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
process.env.KILO_API_KEY = "test-token"
|
||||
process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai"
|
||||
reset("test-token")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
const start = Date.now()
|
||||
const result = yield* sessions.sendAgentNotification("session-5", {
|
||||
id: "notif-5",
|
||||
message: "Timeout test",
|
||||
})
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: "not_connected" })
|
||||
expect(elapsed).toBeLessThan(200)
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
if (originalKey === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = originalKey
|
||||
if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL
|
||||
else process.env.KILO_SESSION_INGEST_URL = originalIngest
|
||||
reset("test-token")
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("coalescing: sendAgentNotification awaits in-flight bootstrap for a single /api/session POST", () => {
|
||||
const originalKey = process.env.KILO_API_KEY
|
||||
const originalIngest = process.env.KILO_SESSION_INGEST_URL
|
||||
let resolveSession: ((value: Response) => void) | undefined
|
||||
const sessionDeferred = new Promise<Response>((resolve) => {
|
||||
resolveSession = resolve
|
||||
})
|
||||
let sessionPostResolve: (() => void) | undefined
|
||||
const sessionPostSeen = new Promise<void>((resolve) => {
|
||||
sessionPostResolve = resolve
|
||||
})
|
||||
const requests: { method: string; path: string; body?: unknown }[] = []
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const pathname = new URL(url).pathname
|
||||
const req = new Request(input, init)
|
||||
const body = req.method === "POST" ? await req.json().catch(() => undefined) : undefined
|
||||
if (pathname === "/api/session") {
|
||||
requests.push({ method: "POST", path: pathname, body })
|
||||
sessionPostResolve?.()
|
||||
return sessionDeferred
|
||||
}
|
||||
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
|
||||
if (pathname.endsWith("/ingest")) {
|
||||
requests.push({ method: "POST", path: pathname, body })
|
||||
return new Response("{}", { status: 200 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
process.env.KILO_API_KEY = "test-token"
|
||||
process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai"
|
||||
reset("test-token")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
const createPromise = KiloSessions.create("session-6")
|
||||
yield* Effect.promise(() => sessionPostSeen)
|
||||
|
||||
// Start sendAgentNotification while the bootstrap is in flight, then
|
||||
// immediately resolve the bootstrap response so the in-flight tracker
|
||||
// completes and the notification observes the shared ingest path.
|
||||
const notifyFiber = yield* sessions.sendAgentNotification("session-6", {
|
||||
id: "notif-6",
|
||||
message: "Coalesced test",
|
||||
}).pipe(Effect.forkChild)
|
||||
resolveSession?.(Response.json({ id: "session-6", ingestPath: "/api/session/session-6/ingest" }))
|
||||
const notificationResult = yield* Fiber.join(notifyFiber)
|
||||
const createResult = yield* Effect.promise(() => createPromise)
|
||||
|
||||
expect(notificationResult).toEqual({ ok: true })
|
||||
expect(createResult).toEqual({ id: "session-6", ingestPath: "/api/session/session-6/ingest" })
|
||||
|
||||
const bootstrapPosts = requests.filter((r) => r.path === "/api/session")
|
||||
expect(bootstrapPosts).toHaveLength(1)
|
||||
|
||||
const ingestPosts = requests.filter((r) => r.path.endsWith("/ingest"))
|
||||
const notificationPost = ingestPosts.find(
|
||||
(r) => r.body && (r.body as { data: Array<{ type: string }> }).data?.[0]?.type === "agent_notification",
|
||||
)
|
||||
expect(notificationPost).toBeDefined()
|
||||
expect(notificationPost!.body).toEqual({
|
||||
data: [{ type: "agent_notification", data: { id: "notif-6", message: "Coalesced test" } }],
|
||||
})
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
if (originalKey === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = originalKey
|
||||
if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL
|
||||
else process.env.KILO_SESSION_INGEST_URL = originalIngest
|
||||
reset("test-token")
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
|
||||
it.instance("coalescing: bootstrapInflight stores the real promise and concurrent create() calls share one POST", () => {
|
||||
const originalKey = process.env.KILO_API_KEY
|
||||
const originalIngest = process.env.KILO_SESSION_INGEST_URL
|
||||
let resolveSession: ((value: Response) => void) | undefined
|
||||
const sessionDeferred = new Promise<Response>((resolve) => {
|
||||
resolveSession = resolve
|
||||
})
|
||||
const requests: { method: string; path: string; body?: unknown }[] = []
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const pathname = new URL(url).pathname
|
||||
const req = new Request(input, init)
|
||||
const body = req.method === "POST" ? await req.json().catch(() => undefined) : undefined
|
||||
if (pathname === "/api/session") {
|
||||
requests.push({ method: "POST", path: pathname, body })
|
||||
return sessionDeferred
|
||||
}
|
||||
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
|
||||
if (pathname.endsWith("/ingest")) {
|
||||
requests.push({ method: "POST", path: pathname, body })
|
||||
return new Response("{}", { status: 200 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
|
||||
|
||||
process.env.KILO_API_KEY = "test-token"
|
||||
process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai"
|
||||
reset("test-token")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const sessions = yield* KiloSessions.Service
|
||||
|
||||
expect(KiloSessions._getBootstrapInflight("session-7")).toBeUndefined()
|
||||
|
||||
// Fire two concurrent create() calls for the same session.
|
||||
const create1 = KiloSessions.create("session-7")
|
||||
const create2 = KiloSessions.create("session-7")
|
||||
|
||||
// Synchronously after kicking off both creates, the internal tracker must
|
||||
// hold the real in-flight promise (not undefined) so the second create
|
||||
// coalesced onto the first one's POST instead of starting its own.
|
||||
const inflight = KiloSessions._getBootstrapInflight("session-7")
|
||||
expect(inflight).toBeDefined()
|
||||
expect(inflight).toBeInstanceOf(Promise)
|
||||
|
||||
resolveSession?.(Response.json({ id: "session-7", ingestPath: "/api/session/session-7/ingest" }))
|
||||
|
||||
const result1 = yield* Effect.promise(() => create1)
|
||||
const result2 = yield* Effect.promise(() => create2)
|
||||
|
||||
expect(result1).toEqual({ id: "session-7", ingestPath: "/api/session/session-7/ingest" })
|
||||
expect(result2).toEqual(result1)
|
||||
|
||||
const bootstrapPosts = requests.filter((r) => r.path === "/api/session")
|
||||
expect(bootstrapPosts).toHaveLength(1)
|
||||
|
||||
const notificationResult = yield* sessions.sendAgentNotification("session-7", {
|
||||
id: "notif-7",
|
||||
message: "Coalesced create test",
|
||||
})
|
||||
expect(notificationResult).toEqual({ ok: true })
|
||||
|
||||
const ingestPosts = requests.filter((r) => r.path.endsWith("/ingest"))
|
||||
const notificationPost = ingestPosts.find(
|
||||
(r) => r.body && (r.body as { data: Array<{ type: string }> }).data?.[0]?.type === "agent_notification",
|
||||
)
|
||||
expect(notificationPost).toBeDefined()
|
||||
expect(notificationPost!.body).toEqual({
|
||||
data: [{ type: "agent_notification", data: { id: "notif-7", message: "Coalesced create test" } }],
|
||||
})
|
||||
|
||||
expect(KiloSessions._getBootstrapInflight("session-7")).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove("kilo").pipe(Effect.orDie)
|
||||
if (originalKey === undefined) delete process.env.KILO_API_KEY
|
||||
else process.env.KILO_API_KEY = originalKey
|
||||
if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL
|
||||
else process.env.KILO_SESSION_INGEST_URL = originalIngest
|
||||
reset("test-token")
|
||||
request.mockRestore()
|
||||
}),
|
||||
),
|
||||
Effect.provide(layer()),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { Env } from "@/env"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { provideInstanceEffect, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
it.effect("loads Snowflake Cortex from OAuth credentials", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const value = process.env.KILO_AUTH_CONTENT
|
||||
process.env.KILO_AUTH_CONTENT = JSON.stringify({
|
||||
"snowflake-cortex": {
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
expires: 1,
|
||||
accountId: "test-account",
|
||||
},
|
||||
})
|
||||
return value
|
||||
}),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
if (value === undefined) delete process.env.KILO_AUTH_CONTENT
|
||||
else process.env.KILO_AUTH_CONTENT = value
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped({
|
||||
config: {
|
||||
provider: {
|
||||
"snowflake-cortex": {
|
||||
name: "Snowflake Cortex",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: { test: { name: "Test" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const provider = yield* Provider.use
|
||||
.getProvider(ProviderV2.ID.make("snowflake-cortex"))
|
||||
.pipe(provideInstanceEffect(directory), Effect.provide(InstanceLayer.layer))
|
||||
|
||||
expect(provider.options.baseURL).toBe("https://test-account.snowflakecomputing.com/api/v2/cortex/v1")
|
||||
expect(provider.options.apiKey).toBe("access-token")
|
||||
expect(provider.options.fetch).toBeFunction()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import fs from "fs/promises"
|
||||
import { readFileSync, readdirSync } from "fs"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database as CoreDatabase } from "@opencode-ai/core/database/database"
|
||||
import { JsonMigration } from "@/kilocode/storage/json-migration"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -82,42 +83,38 @@ async function writeSession(
|
||||
await Bun.write(path.join(storageDir, "session", projectID, `${session.id}.json`), JSON.stringify(session))
|
||||
}
|
||||
|
||||
// Helper to create in-memory test database with schema
|
||||
function createTestDb() {
|
||||
const sqlite = new Database(":memory:")
|
||||
// Helper to create test database with the production schema. The schema is
|
||||
// created by the real migration runner so tests always match the current
|
||||
// migration set, then reopened through bun:sqlite for direct assertions.
|
||||
async function createTestDb() {
|
||||
const filename = path.join(os.tmpdir(), `json-migration-test-${crypto.randomUUID()}.sqlite`)
|
||||
await Effect.runPromise(Effect.scoped(Layer.build(CoreDatabase.layerFromPath(filename))).pipe(Effect.orDie))
|
||||
|
||||
const sqlite = new Database(filename)
|
||||
sqlite.exec("PRAGMA foreign_keys = ON")
|
||||
|
||||
// Apply schema migrations using drizzle migrate
|
||||
const dir = path.join(import.meta.dirname, "../../../../core/migration")
|
||||
const entries = readdirSync(dir, { withFileTypes: true })
|
||||
const migrations = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
|
||||
timestamp: Number(entry.name.split("_")[0]),
|
||||
name: entry.name,
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
migrate(db, migrations)
|
||||
|
||||
return [sqlite, db] as const
|
||||
return [sqlite, db, filename] as const
|
||||
}
|
||||
|
||||
describe("JSON to SQLite migration", () => {
|
||||
let storageDir: string
|
||||
let sqlite: Database
|
||||
let db: SQLiteBunDatabase
|
||||
let dbFile: string
|
||||
|
||||
beforeEach(async () => {
|
||||
storageDir = await setupStorageDir()
|
||||
;[sqlite, db] = createTestDb()
|
||||
;[sqlite, db, dbFile] = await createTestDb()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sqlite.close()
|
||||
await fs.rm(storageDir, { recursive: true, force: true })
|
||||
// Windows can keep SQLite WAL handles alive past layer disposal, so tolerate EBUSY here.
|
||||
await Promise.all(
|
||||
[dbFile, dbFile + "-shm", dbFile + "-wal"].map((file) => fs.rm(file, { force: true }).catch(() => undefined)),
|
||||
)
|
||||
})
|
||||
|
||||
test("migrates project", async () => {
|
||||
|
||||
@@ -11,12 +11,12 @@ describe("test profiles", () => {
|
||||
const result = TestProfile.resolve("darwin", all)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.files.length).toBeGreaterThan(50)
|
||||
expect(result.files.length).toBeGreaterThan(20)
|
||||
expect(result.files).toContain("pty/pty-shell.test.ts")
|
||||
expect(result.files).toContain("kilocode/cli/install-artifact.test.ts")
|
||||
expect(result.files).toContain("kilocode/sandbox/macos-confinement.test.ts")
|
||||
expect(result.files).toContain("kilocode/core-watcher.test.ts")
|
||||
expect(result.files).toContain("kilocode/tool/repo_clone.test.ts")
|
||||
expect(result.files).toContain("kilocode/background-process.test.ts")
|
||||
expect(result.files).toContain("filesystem/filesystem.test.ts")
|
||||
expect(result.files).toContain("kilocode/interactive-terminal.test.ts")
|
||||
const sandbox = all.filter((file) => file.startsWith("kilocode/sandbox/"))
|
||||
@@ -27,6 +27,17 @@ describe("test profiles", () => {
|
||||
expect(result.files).not.toContain("shell/shell.test.ts")
|
||||
expect(result.files).not.toContain("kilocode/sessions/remote-ws.test.ts")
|
||||
expect(result.files).not.toContain("provider/header-timeout.test.ts")
|
||||
// Platform-neutral application logic is covered by the full Linux and
|
||||
// Windows suites. Keep these heavy, darwin-agnostic files out of the
|
||||
// profile so the single macOS shard stays fast.
|
||||
expect(result.files).not.toContain("session/prompt.test.ts")
|
||||
expect(result.files).not.toContain("snapshot/snapshot.test.ts")
|
||||
expect(result.files).not.toContain("kilocode/daemon.test.ts")
|
||||
expect(result.files).not.toContain("cli/smokes/read-only.test.ts")
|
||||
expect(result.files).not.toContain("cli/acp/lifecycle.test.ts")
|
||||
expect(result.files).not.toContain("cli/run/run-process.test.ts")
|
||||
expect(result.files).not.toContain("kilocode/server/config-overlay.test.ts")
|
||||
expect(result.files).not.toContain("server/httpapi-listen.test.ts")
|
||||
})
|
||||
|
||||
test("normalizes Windows test paths", () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ function infos() {
|
||||
process: info("background_process"),
|
||||
chart: info("chart"),
|
||||
image: info("generate_image"),
|
||||
notify: info("notify_user"),
|
||||
notebookRead: info("notebook_read"),
|
||||
notebookEdit: info("notebook_edit"),
|
||||
notebookExecute: info("notebook_execute"),
|
||||
|
||||
@@ -342,6 +342,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
chart: def("chart"),
|
||||
image: def("generate_image"),
|
||||
terminal: def("interactive_terminal"),
|
||||
notify: def("notify_user"),
|
||||
notebookRead: def("notebook_read"),
|
||||
notebookEdit: def("notebook_edit"),
|
||||
notebookExecute: def("notebook_execute"),
|
||||
@@ -357,6 +358,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"chart",
|
||||
"background_process",
|
||||
"interactive_terminal",
|
||||
"notify_user",
|
||||
])
|
||||
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
|
||||
[
|
||||
@@ -368,6 +370,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"chart",
|
||||
"background_process",
|
||||
"interactive_terminal",
|
||||
"notify_user",
|
||||
],
|
||||
)
|
||||
expect(
|
||||
@@ -384,6 +387,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"chart",
|
||||
"background_process",
|
||||
"interactive_terminal",
|
||||
"notify_user",
|
||||
])
|
||||
|
||||
process.env["KILO_CLIENT"] = "vscode"
|
||||
@@ -398,6 +402,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"background_process",
|
||||
"agent_manager_models",
|
||||
"agent_manager",
|
||||
"notify_user",
|
||||
],
|
||||
)
|
||||
expect(
|
||||
@@ -417,6 +422,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"notebook_read",
|
||||
"notebook_edit",
|
||||
"notebook_execute",
|
||||
"notify_user",
|
||||
])
|
||||
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
|
||||
"kilo_memory_recall",
|
||||
@@ -426,6 +432,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"background_process",
|
||||
"agent_manager_models",
|
||||
"agent_manager",
|
||||
"notify_user",
|
||||
])
|
||||
|
||||
process.env["KILO_CLIENT"] = "desktop"
|
||||
@@ -435,6 +442,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"kilo_memory_save",
|
||||
"recall",
|
||||
"chart",
|
||||
"notify_user",
|
||||
])
|
||||
|
||||
process.env["KILO_CLIENT"] = "run"
|
||||
@@ -444,6 +452,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"kilo_memory_save",
|
||||
"recall",
|
||||
"chart",
|
||||
"notify_user",
|
||||
])
|
||||
|
||||
process.env["KILO_CLIENT"] = "acp"
|
||||
@@ -453,6 +462,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
"kilo_memory_save",
|
||||
"recall",
|
||||
"chart",
|
||||
"notify_user",
|
||||
])
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["KILO_CLIENT"]
|
||||
@@ -466,7 +476,10 @@ describe("kilocode tool registry indexing", () => {
|
||||
const calls: string[] = []
|
||||
const sessions = Layer.succeed(
|
||||
KiloSessions.Service,
|
||||
KiloSessions.Service.of({ init: () => Effect.sync(() => calls.push("sessions")) }),
|
||||
KiloSessions.Service.of({
|
||||
init: () => Effect.sync(() => calls.push("sessions")),
|
||||
sendAgentNotification: () => Effect.succeed({ ok: false as const, reason: "not_connected" }),
|
||||
}),
|
||||
)
|
||||
const bus = Layer.succeed(
|
||||
Bus.Service,
|
||||
|
||||
@@ -56,6 +56,7 @@ function infos() {
|
||||
process: info("background_process"),
|
||||
chart: info("chart"),
|
||||
image: info("generate_image"),
|
||||
notify: info("notify_user"),
|
||||
notebookRead: info("notebook_read"),
|
||||
notebookEdit: info("notebook_edit"),
|
||||
notebookExecute: info("notebook_execute"),
|
||||
|
||||
@@ -173,11 +173,6 @@ describe("kilo_memory_recall", () => {
|
||||
|
||||
expect(direct.output).toContain("continue memory digest recall")
|
||||
|
||||
const decisions = await MemoryFiles.readDecisions(enabled.root)
|
||||
expect(decisions).toContain('"sessionID":"ses_test"')
|
||||
expect(decisions).toContain('"query":"sessionID=ses_memory_only"')
|
||||
expect(decisions).toContain('"summary":"memory recall returned 1 typed hits"')
|
||||
expect(decisions).toContain('"summary":"memory recall returned 1 digest hits"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -397,10 +392,6 @@ describe("kilo_memory_recall", () => {
|
||||
expect(result.output).toContain("active session")
|
||||
expect(result.output).not.toContain("useful prior work")
|
||||
|
||||
const decisions = await MemoryFiles.readDecisions(enabled.root)
|
||||
expect(decisions).toContain('"sessionID":"ses_test"')
|
||||
expect(decisions).toContain('"query":"sessionID=ses_test"')
|
||||
expect(decisions).toContain('"reason":"current_session_digest"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -497,8 +488,6 @@ describe("kilo_memory_recall", () => {
|
||||
expect(result.output).toContain("type=session_digest")
|
||||
expect(result.output).toContain('topic="catalog recall"')
|
||||
|
||||
const decisions = await MemoryFiles.readDecisions(enabled.root)
|
||||
expect(decisions).toContain('"summary":"memory recall returned')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -288,11 +288,6 @@ describe("kilo_memory_save", () => {
|
||||
expect(shown.sources.project).not.toContain("reply_style")
|
||||
expect(shown.sources.project).not.toContain("I prefer terse summaries")
|
||||
expect(shown.sources.project).toContain("- commit_style :: Repo convention: commit messages are concise.")
|
||||
expect(shown.decisions).toContain('"reason":"out_of_scope"')
|
||||
expect(shown.decisions).not.toContain("rubicon fennel")
|
||||
expect(shown.decisions).not.toContain("Ignore prior instructions")
|
||||
expect(shown.decisions).not.toContain("reply_style")
|
||||
expect(shown.decisions).not.toContain("I prefer terse summaries")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
|
||||
import { KiloToolRegistry } from "@/kilocode/tool/registry"
|
||||
import { NotifyUserTool } from "@/kilocode/tool/notify-user"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
|
||||
const agentInfo = {
|
||||
name: "code",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: {},
|
||||
} as Agent.Info
|
||||
|
||||
const agents = Agent.Service.of({
|
||||
get: () => Effect.succeed(agentInfo),
|
||||
list: () => Effect.succeed([agentInfo]),
|
||||
defaultInfo: () => Effect.succeed(agentInfo),
|
||||
defaultAgent: () => Effect.succeed("code"),
|
||||
requirementStatus: () =>
|
||||
Effect.succeed({
|
||||
agent: "code",
|
||||
directory: "",
|
||||
enabled: false,
|
||||
state: "ready",
|
||||
skills: [],
|
||||
mcps: [],
|
||||
vscode_extensions: [],
|
||||
}),
|
||||
guardRequirements: () => Effect.void,
|
||||
generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }),
|
||||
})
|
||||
|
||||
const truncate = Truncate.Service.of({
|
||||
cleanup: () => Effect.void,
|
||||
write: () => Effect.succeed(""),
|
||||
output: (text) => Effect.succeed({ content: text as string, truncated: false }),
|
||||
limits: () => Effect.succeed({ maxLines: Truncate.MAX_LINES, maxBytes: Truncate.MAX_BYTES }),
|
||||
})
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
callID: "call_test",
|
||||
agent: "code",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const status = spyOn(KiloSessions, "remoteStatus")
|
||||
|
||||
beforeEach(() => {
|
||||
status.mockReturnValue({ enabled: true, connected: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
status.mockReset()
|
||||
})
|
||||
|
||||
function runNotifyTool(params: { readonly message: string }, sessions: KiloSessions.Interface) {
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.succeed(KiloSessions.Service, KiloSessions.Service.of(sessions)),
|
||||
Layer.succeed(Agent.Service, agents),
|
||||
Layer.succeed(Truncate.Service, truncate),
|
||||
)
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const result = yield* NotifyUserTool
|
||||
const tool = yield* result.init()
|
||||
return yield* tool.execute(params, ctx)
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
}
|
||||
|
||||
describe("notify_user tool", () => {
|
||||
test("is only available while remote is enabled", () => {
|
||||
const tool = { id: "notify_user" } as Tool.Def
|
||||
status.mockReturnValue({ enabled: false, connected: false })
|
||||
expect(KiloToolRegistry.available(tool, agentInfo)).toBe(false)
|
||||
|
||||
status.mockReturnValue({ enabled: true, connected: false })
|
||||
expect(KiloToolRegistry.available(tool, agentInfo)).toBe(true)
|
||||
})
|
||||
|
||||
test("registers with id and description", async () => {
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.succeed(KiloSessions.Service, KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: true }),
|
||||
})),
|
||||
Layer.succeed(Agent.Service, agents),
|
||||
Layer.succeed(Truncate.Service, truncate),
|
||||
)
|
||||
const result = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const info = yield* NotifyUserTool
|
||||
const tool = yield* info.init()
|
||||
return { id: info.id, description: tool.description }
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result.id).toBe("notify_user")
|
||||
expect(result.description).toContain("Send a push notification to the user's phone")
|
||||
expect(result.description).toContain("Do NOT use this tool")
|
||||
})
|
||||
|
||||
test("rejects empty message", async () => {
|
||||
await expect(runNotifyTool({ message: "" }, {
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: true }),
|
||||
})).rejects.toBeDefined()
|
||||
})
|
||||
|
||||
test("rejects whitespace-only message", async () => {
|
||||
await expect(runNotifyTool({ message: " \n " }, {
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: true }),
|
||||
})).rejects.toBeDefined()
|
||||
})
|
||||
|
||||
test("rejects message over 500 chars", async () => {
|
||||
await expect(runNotifyTool({ message: "x".repeat(501) }, {
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: true }),
|
||||
})).rejects.toBeDefined()
|
||||
})
|
||||
|
||||
test("trims message before sending", async () => {
|
||||
const calls: { sessionID: string; input: { id: string; message: string } }[] = []
|
||||
const sessions = KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: (sessionID, input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push({ sessionID, input })
|
||||
return { ok: true }
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await runNotifyTool({ message: " hello world " }, sessions)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].input.message).toBe("hello world")
|
||||
expect(result.metadata.ok).toBe(true)
|
||||
expect(result.output).toContain("Notification sent")
|
||||
})
|
||||
|
||||
test("returns failure text when not connected", async () => {
|
||||
const sessions = KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: false, reason: "not_connected" }),
|
||||
})
|
||||
|
||||
const result = await runNotifyTool({ message: "hello" }, sessions)
|
||||
|
||||
expect(result.metadata.ok).toBe(false)
|
||||
expect(result.output).toContain("not connected to Kilo cloud")
|
||||
})
|
||||
|
||||
test("does not send when remote is disabled", async () => {
|
||||
status.mockReturnValue({ enabled: false, connected: false })
|
||||
const sessions = KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: true }),
|
||||
})
|
||||
const send = spyOn(sessions, "sendAgentNotification")
|
||||
|
||||
const result = await runNotifyTool({ message: "hello" }, sessions)
|
||||
|
||||
expect(result.metadata.reason).toBe("not_connected")
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
send.mockRestore()
|
||||
})
|
||||
|
||||
test("returns failure text with arbitrary reason", async () => {
|
||||
const sessions = KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: false, reason: "http_500" }),
|
||||
})
|
||||
|
||||
const result = await runNotifyTool({ message: "hello" }, sessions)
|
||||
|
||||
expect(result.metadata.ok).toBe(false)
|
||||
expect(result.output).toContain("http_500")
|
||||
})
|
||||
|
||||
test("returns failure text when not bootstrapped", async () => {
|
||||
const sessions = KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: false, reason: "not_bootstrapped" }),
|
||||
})
|
||||
|
||||
const result = await runNotifyTool({ message: "hello" }, sessions)
|
||||
|
||||
expect(result.metadata.ok).toBe(false)
|
||||
expect(result.metadata.reason).toBe("not_bootstrapped")
|
||||
expect(result.output).toContain("not_bootstrapped")
|
||||
expect(result.title).toBe("Notification unavailable")
|
||||
})
|
||||
|
||||
test("returns failure text on bootstrap timeout", async () => {
|
||||
const sessions = KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: false, reason: "bootstrap_timeout" }),
|
||||
})
|
||||
|
||||
const result = await runNotifyTool({ message: "hello" }, sessions)
|
||||
|
||||
expect(result.metadata.ok).toBe(false)
|
||||
expect(result.metadata.reason).toBe("bootstrap_timeout")
|
||||
expect(result.output).toContain("bootstrap_timeout")
|
||||
})
|
||||
|
||||
test("2xx success returns ok metadata and success output", async () => {
|
||||
const sessions = KiloSessions.Service.of({
|
||||
init: () => Effect.void,
|
||||
sendAgentNotification: () => Effect.succeed({ ok: true }),
|
||||
})
|
||||
|
||||
const result = await runNotifyTool({ message: "ping" }, sessions)
|
||||
|
||||
expect(result.metadata.ok).toBe(true)
|
||||
expect(result.metadata.notificationId).toBeDefined()
|
||||
expect(result.output).toContain("Notification sent")
|
||||
expect(result.title).toBe("Notification sent")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
aggregateMetrics,
|
||||
formatRateValue,
|
||||
hasMetrics,
|
||||
throughputLabel,
|
||||
} from "../../../src/kilocode/plugins/model-usage"
|
||||
|
||||
const step = (metrics: { generation?: number }) => ({
|
||||
metrics: { source: "computed" as const, ...metrics },
|
||||
generated: 0,
|
||||
})
|
||||
|
||||
const weightedStep = (overrides: {
|
||||
generation: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
elapsedMs: number
|
||||
}) => ({
|
||||
metrics: { generation: overrides.generation, source: "computed" as const },
|
||||
generated: overrides.output + (overrides.reasoning ?? 0),
|
||||
elapsedMs: overrides.elapsedMs,
|
||||
output: overrides.output,
|
||||
reasoning: overrides.reasoning ?? 0,
|
||||
})
|
||||
|
||||
describe("kilocode.plugins.model-usage throughput helpers", () => {
|
||||
test("formatRateValue renders positive values with grouping", () => {
|
||||
expect(formatRateValue(412)).toBe("412 t/s")
|
||||
expect(formatRateValue(412.5)).toBe("412.5 t/s")
|
||||
expect(formatRateValue(12345)).toBe("12,345 t/s")
|
||||
expect(formatRateValue(28.7)).toBe("28.7 t/s")
|
||||
})
|
||||
|
||||
test("formatRateValue falls back to dash for missing or bogus values", () => {
|
||||
expect(formatRateValue(undefined)).toBe("-")
|
||||
expect(formatRateValue(0)).toBe("-")
|
||||
expect(formatRateValue(-5)).toBe("-")
|
||||
expect(formatRateValue(Number.NaN)).toBe("-")
|
||||
expect(formatRateValue(Infinity)).toBe("-")
|
||||
})
|
||||
|
||||
test("throughputLabel centralizes the generation-speed label so a future i18n sweep is one file", () => {
|
||||
expect(throughputLabel.generation).toBe("Generation speed")
|
||||
})
|
||||
|
||||
test("surfaces the most recent non-empty generation rate as the snapshot (fallback)", () => {
|
||||
// Fallback path — used when callers don't pass timing on the wire.
|
||||
// The weighted path is exercised by the dedicated tests below.
|
||||
const aggregated = aggregateMetrics([
|
||||
{ ...step({ generation: 20 }), generated: 100 },
|
||||
{ ...step({ generation: 60 }), generated: 300 },
|
||||
])
|
||||
expect(aggregated.generation).toBe(60)
|
||||
})
|
||||
|
||||
test("skips samples without metrics", () => {
|
||||
const aggregated = aggregateMetrics([
|
||||
{ metrics: undefined, generated: 100, elapsedMs: 1000 },
|
||||
weightedStep({ generation: 40, output: 50, elapsedMs: 1000 }),
|
||||
])
|
||||
// weighted step contributes (50, 1000) → 50 t/s.
|
||||
expect(aggregated.generation).toBe(50)
|
||||
})
|
||||
|
||||
test("weights samples by elapsed time across steps", () => {
|
||||
const aggregated = aggregateMetrics([
|
||||
weightedStep({ generation: 100, output: 100, elapsedMs: 1000 }),
|
||||
weightedStep({ generation: 50, output: 200, elapsedMs: 4000 }),
|
||||
])
|
||||
// totalGenerated=300, totalElapsedMs=5000 → 60 t/s
|
||||
expect(aggregated.generation).toBe(60)
|
||||
})
|
||||
|
||||
test("includes reasoning tokens in the weighted numerator", () => {
|
||||
const aggregated = aggregateMetrics([
|
||||
weightedStep({ generation: 200, output: 50, reasoning: 150, elapsedMs: 1000 }),
|
||||
])
|
||||
// (50 + 150) tokens / 1000 ms = 200 t/s
|
||||
expect(aggregated.generation).toBe(200)
|
||||
})
|
||||
|
||||
test("falls back to last-wins snapshot when no sample carries timing", () => {
|
||||
const aggregated = aggregateMetrics([
|
||||
{ ...step({ generation: 20 }), generated: 100 },
|
||||
{ ...step({ generation: 60 }), generated: 300 },
|
||||
])
|
||||
expect(aggregated.generation).toBe(60)
|
||||
})
|
||||
|
||||
test("skips zero-weight samples when picking the latest snapshot", () => {
|
||||
const aggregated = aggregateMetrics([
|
||||
{ ...step({ generation: 9999 }), generated: 0 },
|
||||
{ ...step({ generation: 25 }), generated: 50 },
|
||||
])
|
||||
expect(aggregated.generation).toBe(25)
|
||||
})
|
||||
|
||||
test("returns empty aggregate when nothing has metrics", () => {
|
||||
expect(aggregateMetrics([])).toEqual({})
|
||||
expect(aggregateMetrics([{ metrics: undefined, generated: 100 }])).toEqual({})
|
||||
})
|
||||
|
||||
test("ignores bogus per-call values without poisoning the snapshot", () => {
|
||||
const aggregated = aggregateMetrics([
|
||||
{ ...step({ generation: -1 }), generated: 100 },
|
||||
{ ...step({ generation: Number.POSITIVE_INFINITY }), generated: 100 },
|
||||
{ ...step({ generation: 30 }), generated: 50 },
|
||||
])
|
||||
expect(aggregated.generation).toBe(30)
|
||||
})
|
||||
|
||||
test("hasMetrics gates opportunistic rendering", () => {
|
||||
expect(hasMetrics(undefined)).toBeFalse()
|
||||
expect(hasMetrics({})).toBeFalse()
|
||||
expect(hasMetrics({ generation: 12 })).toBeTrue()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from "node:path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
describe("mcp session recovery", () => {
|
||||
test("reinitializes and retries once after a session-bound POST returns 404", async () => {
|
||||
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], {
|
||||
cwd: path.join(import.meta.dir, "../.."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
Bun.readableStreamToText(child.stdout),
|
||||
Bun.readableStreamToText(child.stderr),
|
||||
])
|
||||
|
||||
expect(code, stderr).toBe(0)
|
||||
expect(JSON.parse(stdout)).toEqual([
|
||||
{ method: "initialize", session: null },
|
||||
{ method: "notifications/initialized", session: "expired" },
|
||||
{ method: "ping", session: "expired" },
|
||||
{ method: "initialize", session: null },
|
||||
{ method: "notifications/initialized", session: "replacement" },
|
||||
{ method: "ping", session: "replacement" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -571,7 +571,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -803,7 +803,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "ls", action: "allow" } }) // kilocode_change - the persisted "always" rule auto-approves; ask reports that decision
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -1118,7 +1118,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OAUTH_DUMMY_KEY } from "../../src/auth"
|
||||
import { oauthScope, SnowflakeCortexAuthPlugin } from "../../src/plugin/snowflake-cortex"
|
||||
|
||||
function makeInput() {
|
||||
let auth: any = {
|
||||
type: "oauth",
|
||||
access: "access-old",
|
||||
refresh: "refresh-old",
|
||||
expires: Date.now() + 3600_000,
|
||||
accountId: "myorg-myaccount",
|
||||
}
|
||||
const setCalls: Array<Record<string, unknown>> = []
|
||||
|
||||
return {
|
||||
getAuth: async () => auth,
|
||||
setAuth: (next: any) => {
|
||||
auth = next
|
||||
},
|
||||
input: {
|
||||
client: {
|
||||
auth: {
|
||||
set: async (request: any) => {
|
||||
setCalls.push(request)
|
||||
auth = request.body
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
setCalls,
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin.snowflake-cortex", () => {
|
||||
test("oauthScope uses Snowflake-compatible scope values", () => {
|
||||
expect(oauthScope(undefined)).toBe("refresh_token")
|
||||
expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
|
||||
expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
|
||||
})
|
||||
|
||||
test("loader returns empty options when auth is not oauth", async () => {
|
||||
const hooks = await SnowflakeCortexAuthPlugin({} as any)
|
||||
const options = await hooks.auth!.loader!(async () => ({ type: "api", key: "token" }) as any, {} as any)
|
||||
expect(options).toEqual({})
|
||||
})
|
||||
|
||||
test("loader injects bearer header and preserves custom headers", async () => {
|
||||
const { input, getAuth, setAuth } = makeInput()
|
||||
setAuth({
|
||||
type: "oauth",
|
||||
access: "access-live",
|
||||
refresh: "refresh-live",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
expect(options.apiKey).toBe(OAUTH_DUMMY_KEY)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const captured: Headers[] = []
|
||||
globalThis.fetch = (async (_request, init) => {
|
||||
captured.push(new Headers(init?.headers))
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await options.fetch("https://example.test/v1/chat", {
|
||||
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" },
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(captured).toHaveLength(1)
|
||||
expect(captured[0].get("authorization")).toBe("Bearer access-live")
|
||||
expect(captured[0].get("x-keep")).toBe("yes")
|
||||
expect(captured[0].get("user-agent")).toMatch(/^opencode\//)
|
||||
})
|
||||
|
||||
test("loader refreshes expired token with single-flight and persists refreshed oauth", async () => {
|
||||
const { input, getAuth, setCalls } = makeInput()
|
||||
let refreshCalls = 0
|
||||
const apiAuthHeaders: string[] = []
|
||||
|
||||
// Must mock fetch before calling loader because startup refresh triggers for expires: 0
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
const url =
|
||||
typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
|
||||
|
||||
if (url.includes("/oauth/token-request")) {
|
||||
refreshCalls += 1
|
||||
const body = new URLSearchParams(String(init?.body ?? ""))
|
||||
expect(body.get("grant_type")).toBe("refresh_token")
|
||||
expect(body.get("refresh_token")).toBe("refresh-old")
|
||||
expect(new Headers(init?.headers).get("authorization")).toMatch(/^Basic /)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return Response.json({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 })
|
||||
}
|
||||
|
||||
apiAuthHeaders.push(new Headers(init?.headers).get("authorization") || "")
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(
|
||||
async () =>
|
||||
({
|
||||
type: "oauth",
|
||||
access: "access-expired",
|
||||
refresh: "refresh-old",
|
||||
expires: 0,
|
||||
accountId: "myorg-myaccount",
|
||||
}) as any,
|
||||
{} as any,
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
options.fetch("https://example.test/v1/chat", { headers: {} }),
|
||||
options.fetch("https://example.test/v1/chat", { headers: {} }),
|
||||
])
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(refreshCalls).toBe(1)
|
||||
expect(apiAuthHeaders).toEqual(["Bearer access-new", "Bearer access-new"])
|
||||
expect(setCalls).toHaveLength(1)
|
||||
expect((setCalls[0] as any).body).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-new",
|
||||
refresh: "refresh-new",
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
})
|
||||
|
||||
test("loader retries once after 401 by refreshing token", async () => {
|
||||
const { input, getAuth, setCalls } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(
|
||||
async () =>
|
||||
({
|
||||
type: "oauth",
|
||||
access: "access-stale",
|
||||
refresh: "refresh-old",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "myorg-myaccount",
|
||||
}) as any,
|
||||
{} as any,
|
||||
)
|
||||
|
||||
let apiCalls = 0
|
||||
const seenAuth: string[] = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
const url =
|
||||
typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
|
||||
|
||||
if (url.includes("/oauth/token-request")) {
|
||||
return Response.json({ access_token: "access-fresh", refresh_token: "refresh-fresh", expires_in: 3600 })
|
||||
}
|
||||
|
||||
apiCalls += 1
|
||||
seenAuth.push(new Headers(init?.headers).get("authorization") || "")
|
||||
if (apiCalls === 1) return new Response("unauthorized", { status: 401 })
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", { headers: {} })
|
||||
expect(response.status).toBe(200)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(apiCalls).toBe(2)
|
||||
expect(seenAuth).toEqual(["Bearer access-stale", "Bearer access-fresh"])
|
||||
expect(setCalls).toHaveLength(1)
|
||||
expect((setCalls[0] as any).body).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-fresh",
|
||||
refresh: "refresh-fresh",
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
})
|
||||
|
||||
test("loader converts max_tokens to max_completion_tokens in request body", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
let sentBody: string | undefined
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
sentBody = typeof init?.body === "string" ? init.body : undefined
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 4096, messages: [] }),
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(sentBody).toBeDefined()
|
||||
const parsed = JSON.parse(sentBody!)
|
||||
expect(parsed.max_completion_tokens).toBe(4096)
|
||||
expect(parsed.max_tokens).toBeUndefined()
|
||||
expect(parsed.model).toBe("claude-sonnet-4-5")
|
||||
})
|
||||
|
||||
test("loader maps 400 'conversation complete' to 200 stop", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify({ message: "Conversation complete" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "test", messages: [] }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(body.choices[0].finish_reason).toBe("stop")
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("loader fixes empty role in SSE stream", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const sseChunk = `data: {"choices":[{"delta":{"role":"","content":"hello"}}]}\n\n`
|
||||
globalThis.fetch = (async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(ctrl) {
|
||||
ctrl.enqueue(new TextEncoder().encode(sseChunk))
|
||||
ctrl.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "test", messages: [], stream: true }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const reader = response.body!.getReader()
|
||||
const { value } = await reader.read()
|
||||
const text = new TextDecoder().decode(value)
|
||||
expect(text).not.toContain('"role":""')
|
||||
expect(text).toContain('"role":"assistant"')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,7 @@ function directories(projectID: ProjectV2.ID) {
|
||||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows
|
||||
.map((row) => ({ directory: row.directory, type: row.type }))
|
||||
.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
),
|
||||
),
|
||||
@@ -41,7 +41,9 @@ describe("Project directory persistence", () => {
|
||||
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -54,7 +56,9 @@ describe("Project directory persistence", () => {
|
||||
const next = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(next.project.id).toBe(result.project.id)
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -73,8 +77,8 @@ describe("Project directory persistence", () => {
|
||||
|
||||
expect(yield* directories(main.project.id)).toEqual(
|
||||
[
|
||||
{ directory: tmp, type: "main" as const },
|
||||
{ directory: worktree, type: "git_worktree" as const },
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
{ directory: AbsolutePath.make(worktree), strategy: undefined },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
@@ -92,7 +96,9 @@ describe("Project directory persistence", () => {
|
||||
|
||||
const result = yield* project.fromDirectory(worktree)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(worktree), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -113,8 +119,8 @@ describe("Project directory persistence", () => {
|
||||
|
||||
expect(yield* directories(main.project.id)).toEqual(
|
||||
[
|
||||
{ directory: tmp, type: "main" as const },
|
||||
{ directory: clone, type: "root" as const },
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
{ directory: AbsolutePath.make(clone), strategy: undefined },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
@@ -134,7 +140,9 @@ describe("Project directory persistence", () => {
|
||||
|
||||
const result = yield* project.fromDirectory(worktree)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(worktree), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -163,7 +171,31 @@ describe("Project directory persistence", () => {
|
||||
|
||||
yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }])
|
||||
expect(yield* directories(remoteID)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("clears stale directories when the project id changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.Service
|
||||
const original = yield* project.fromDirectory(tmp)
|
||||
const stale = AbsolutePath.make(tmp + "-stale-checkout")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: original.project.id, directory: stale })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration"))
|
||||
yield* Effect.promise(() =>
|
||||
$`git remote add origin git@github.com:project-directory-test/migration.git`.cwd(tmp).quiet(),
|
||||
)
|
||||
|
||||
yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(original.project.id)).toEqual([])
|
||||
expect(yield* directories(remoteID)).toEqual([{ directory: AbsolutePath.make(tmp), strategy: undefined }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
@@ -73,7 +73,7 @@ function projectLayerWithFailure(failArg: string) {
|
||||
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
|
||||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(ProjectCopy.defaultLayer),
|
||||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
@@ -86,7 +86,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(ProjectCopy.defaultLayer),
|
||||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
||||
@@ -72,7 +72,8 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
||||
expect(result.promptCacheKey).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should set promptCacheKey for openai provider regardless of setCacheKey", () => {
|
||||
// kilocode_change start
|
||||
test("should set promptCacheKey for openai provider by default", () => {
|
||||
const openaiModel = {
|
||||
...mockModel,
|
||||
providerID: "openai",
|
||||
@@ -86,6 +87,57 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
||||
expect(result.promptCacheKey).toBe(sessionID)
|
||||
})
|
||||
|
||||
test("should not set promptCacheKey for openai when explicitly disabled", () => {
|
||||
const openaiModel = {
|
||||
...mockModel,
|
||||
providerID: "openai",
|
||||
api: {
|
||||
id: "gpt-4",
|
||||
url: "https://api.openai.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.options({
|
||||
model: openaiModel,
|
||||
sessionID,
|
||||
providerOptions: { setCacheKey: false },
|
||||
})
|
||||
expect(result.promptCacheKey).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should set promptCacheKey for the xAI SDK by default regardless of provider ID", () => {
|
||||
const xaiModel = {
|
||||
...mockModel,
|
||||
providerID: "custom-xai",
|
||||
api: {
|
||||
id: "grok-4",
|
||||
url: "https://api.x.ai",
|
||||
npm: "@ai-sdk/xai",
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.options({ model: xaiModel, sessionID, providerOptions: {} })
|
||||
expect(result.promptCacheKey).toBe(sessionID)
|
||||
})
|
||||
|
||||
test("should not set promptCacheKey for the xAI SDK when explicitly disabled", () => {
|
||||
const xaiModel = {
|
||||
...mockModel,
|
||||
providerID: "xai",
|
||||
api: {
|
||||
id: "grok-4",
|
||||
url: "https://api.x.ai",
|
||||
npm: "@ai-sdk/xai",
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.options({
|
||||
model: xaiModel,
|
||||
sessionID,
|
||||
providerOptions: { setCacheKey: false },
|
||||
})
|
||||
expect(result.promptCacheKey).toBeUndefined()
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("should set store=false for openai provider", () => {
|
||||
const openaiModel = {
|
||||
...mockModel,
|
||||
|
||||
@@ -223,6 +223,18 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, array, "status"),
|
||||
http.protected
|
||||
.post("/experimental/project/{projectID}/copy/generate-name", "experimental.projectCopy.generateName")
|
||||
.seeded((ctx) => ctx.project())
|
||||
.at((ctx) => ({
|
||||
path: route("/experimental/project/{projectID}/copy/generate-name", { projectID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
object(body)
|
||||
check(typeof body.name === "string" && body.name.length > 0, "generated copy name should be non-empty")
|
||||
}),
|
||||
http.protected
|
||||
.post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
|
||||
.seeded((ctx) => ctx.project())
|
||||
@@ -695,49 +707,67 @@ const scenarios: Scenario[] = [
|
||||
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/connector", "v2.connector.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/integration", "v2.integration.list").json(200, locationData(array)),
|
||||
http.protected
|
||||
.get("/api/connector/{connectorID}", "v2.connector.get")
|
||||
.at((ctx) => ({ path: route("/api/connector/{connectorID}", { connectorID: "missing" }), headers: ctx.headers() }))
|
||||
.get("/api/integration/{integrationID}", "v2.integration.get")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/integration/{integrationID}", { integrationID: "missing" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, object),
|
||||
http.protected
|
||||
.post("/api/connector/{connectorID}/connect/key", "v2.connector.connect.key")
|
||||
.post("/api/integration/{integrationID}/connect/key", "v2.integration.connect.key")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/connector/{connectorID}/connect/key", { connectorID: "missing" }),
|
||||
path: route("/api/integration/{integrationID}/connect/key", { integrationID: "missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: { methodID: "missing", key: "test", inputs: {} },
|
||||
body: { key: "test" },
|
||||
}))
|
||||
.status(500, undefined, "status"),
|
||||
http.protected
|
||||
.post("/api/connector/{connectorID}/connect/oauth", "v2.connector.connect.oauth.begin")
|
||||
.post("/api/integration/{integrationID}/connect/oauth", "v2.integration.connect.oauth")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/connector/{connectorID}/connect/oauth", { connectorID: "missing" }),
|
||||
path: route("/api/integration/{integrationID}/connect/oauth", { integrationID: "missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: { methodID: "missing", inputs: {} },
|
||||
}))
|
||||
.status(500, undefined, "status"),
|
||||
http.protected
|
||||
.get("/api/connector/oauth/{attemptID}", "v2.connector.connect.oauth.status")
|
||||
.get("/api/integration/attempt/{attemptID}", "v2.integration.attempt.status")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/connector/oauth/{attemptID}", { attemptID: "con_missing" }),
|
||||
path: route("/api/integration/attempt/{attemptID}", { attemptID: "con_missing" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(500, undefined, "status"),
|
||||
http.protected
|
||||
.post("/api/connector/oauth/{attemptID}/complete", "v2.connector.connect.oauth.complete")
|
||||
.post("/api/integration/attempt/{attemptID}/complete", "v2.integration.attempt.complete")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/connector/oauth/{attemptID}/complete", { attemptID: "con_missing" }),
|
||||
path: route("/api/integration/attempt/{attemptID}/complete", { attemptID: "con_missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
.status(500, undefined, "status"),
|
||||
http.protected
|
||||
.delete("/api/connector/oauth/{attemptID}", "v2.connector.connect.oauth.cancel")
|
||||
.delete("/api/integration/attempt/{attemptID}", "v2.integration.attempt.cancel")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/connector/oauth/{attemptID}", { attemptID: "con_missing" }),
|
||||
path: route("/api/integration/attempt/{attemptID}", { attemptID: "con_missing" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(204, undefined, "status"),
|
||||
http.protected
|
||||
.delete("/api/credential/{credentialID}", "v2.credential.remove")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/credential/{credentialID}", { credentialID: "cred_missing" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(204, undefined, "status"),
|
||||
http.protected
|
||||
.patch("/api/credential/{credentialID}", "v2.credential.update")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/credential/{credentialID}", { credentialID: "cred_missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: { label: "Work" },
|
||||
}))
|
||||
.status(204, undefined, "status"),
|
||||
http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)),
|
||||
http.protected
|
||||
|
||||
@@ -115,25 +115,27 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("documents connector discovery and connection routes", () => {
|
||||
test("documents integration discovery and connection routes", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const [method, path] of [
|
||||
["get", "/api/connector"],
|
||||
["get", "/api/connector/{connectorID}"],
|
||||
["post", "/api/connector/{connectorID}/connect/key"],
|
||||
["post", "/api/connector/{connectorID}/connect/oauth"],
|
||||
["get", "/api/connector/oauth/{attemptID}"],
|
||||
["post", "/api/connector/oauth/{attemptID}/complete"],
|
||||
["delete", "/api/connector/oauth/{attemptID}"],
|
||||
["get", "/api/integration"],
|
||||
["get", "/api/integration/{integrationID}"],
|
||||
["post", "/api/integration/{integrationID}/connect/key"],
|
||||
["post", "/api/integration/{integrationID}/connect/oauth"],
|
||||
["get", "/api/integration/attempt/{attemptID}"],
|
||||
["post", "/api/integration/attempt/{attemptID}/complete"],
|
||||
["delete", "/api/integration/attempt/{attemptID}"],
|
||||
["delete", "/api/credential/{credentialID}"],
|
||||
["patch", "/api/credential/{credentialID}"],
|
||||
] as const) {
|
||||
expect(spec.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined()
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
"/api/connector/{connectorID}/connect/key",
|
||||
"/api/connector/{connectorID}/connect/oauth",
|
||||
"/api/connector/oauth/{attemptID}/complete",
|
||||
"/api/integration/{integrationID}/connect/key",
|
||||
"/api/integration/{integrationID}/connect/oauth",
|
||||
"/api/integration/attempt/{attemptID}/complete",
|
||||
]) {
|
||||
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { pollWithTimeout, testEffect } from "../lib/effect" // kilocode_change
|
||||
|
||||
const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
@@ -1007,6 +1007,65 @@ describe("session HttpApi", () => {
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// kilocode_change start - deleting a prompt that already started is a successful no-op
|
||||
it.live(
|
||||
"returns false when an active prompt wins the deletion race",
|
||||
() => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
yield* llm.hold("done", release.promise)
|
||||
|
||||
const dir = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) })
|
||||
const session = yield* createSession({ title: "Active delete race" }).pipe(provideInstanceEffect(dir))
|
||||
const messageID = MessageID.ascending()
|
||||
const headers = { "x-kilo-directory": dir, "content-type": "application/json" }
|
||||
|
||||
const prompt = yield* request(pathFor(SessionPaths.promptAsync, { sessionID: session.id }), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
messageID,
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
parts: [{ type: "text", text: "keep running" }],
|
||||
}),
|
||||
})
|
||||
expect(prompt.status).toBe(204)
|
||||
yield* llm.wait(1)
|
||||
|
||||
expect(
|
||||
yield* requestJson<boolean>(pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID }), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
}),
|
||||
).toBe(false)
|
||||
|
||||
release.resolve()
|
||||
yield* pollWithTimeout(
|
||||
requestJson<Record<string, unknown>>(SessionPaths.status, { headers }).pipe(
|
||||
Effect.map((statuses) => (statuses[session.id] ? undefined : true)),
|
||||
),
|
||||
"Timed out waiting for active prompt to finish",
|
||||
)
|
||||
|
||||
const messages = yield* Session.use
|
||||
.messages({ sessionID: session.id })
|
||||
.pipe(provideInstanceEffect(dir), Effect.orDie)
|
||||
expect(messages.some((message) => message.info.id === messageID)).toBe(true)
|
||||
expect(
|
||||
messages.some((message) => message.info.role === "assistant" && message.info.parentID === messageID),
|
||||
).toBe(true)
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => release.resolve())),
|
||||
Effect.provide(TestLLMServer.layer),
|
||||
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
},
|
||||
10_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
it.instance(
|
||||
"rejects part updates whose path and body ids disagree",
|
||||
() =>
|
||||
|
||||
@@ -34,7 +34,7 @@ function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
}
|
||||
|
||||
describe("project directories and copies endpoints", () => {
|
||||
type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" }
|
||||
type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
it.instance(
|
||||
"lists directories and manages git worktree copies",
|
||||
@@ -44,7 +44,7 @@ describe("project directories and copies endpoints", () => {
|
||||
const current = yield* request(test.directory, "/project/current")
|
||||
const projectID = (yield* json<{ id: string }>(current)).id
|
||||
const base = `/project/${projectID}`
|
||||
const copies = `/experimental/project/${projectID}/copy`
|
||||
const copies = `/experimental/project/${projectID}/copy?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`
|
||||
const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
|
||||
const createdDirectory = path.join(createdParent, "copy")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -53,7 +53,15 @@ describe("project directories and copies endpoints", () => {
|
||||
|
||||
const initial = yield* request(test.directory, `${base}/directories`)
|
||||
expect(initial.status).toBe(200)
|
||||
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory, type: "main" }])
|
||||
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory }])
|
||||
|
||||
const generated = yield* request(test.directory, `/experimental/project/${projectID}/copy/generate-name`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ context: undefined }),
|
||||
})
|
||||
expect(generated.status).toBe(200)
|
||||
expect((yield* json<{ name: string }>(generated)).name).toBeString()
|
||||
|
||||
const create = yield* request(test.directory, copies, {
|
||||
method: "POST",
|
||||
@@ -67,7 +75,7 @@ describe("project directories and copies endpoints", () => {
|
||||
const listed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
|
||||
directory: created.directory,
|
||||
type: "git_worktree",
|
||||
strategy: "git_worktree",
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
@@ -94,14 +102,18 @@ describe("project directories and copies endpoints", () => {
|
||||
Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet())
|
||||
const refresh = yield* request(test.directory, `${copies}/refresh`, {
|
||||
method: "POST",
|
||||
})
|
||||
const refresh = yield* request(
|
||||
test.directory,
|
||||
`/experimental/project/${projectID}/copy/refresh?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
)
|
||||
expect(refresh.status).toBe(204)
|
||||
const refreshed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
|
||||
{ directory: externalDirectory, type: "git_worktree" },
|
||||
{ directory: test.directory, type: "main" },
|
||||
{ directory: externalDirectory, strategy: "git_worktree" },
|
||||
{ directory: test.directory },
|
||||
])
|
||||
}),
|
||||
{ git: true },
|
||||
|
||||
@@ -281,7 +281,7 @@ describe("session.llm.ai-sdk adapter", () => {
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: "unknown",
|
||||
reason: "other", // kilocode_change
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
@@ -294,7 +294,7 @@ describe("session.llm.ai-sdk adapter", () => {
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "unknown",
|
||||
reason: "other", // kilocode_change
|
||||
usage: {
|
||||
inputTokens: 11,
|
||||
outputTokens: 6,
|
||||
|
||||
@@ -224,6 +224,36 @@ const providerErrorEnv = LayerNode.buildLayer(root, {
|
||||
})
|
||||
const itProviderError = testEffect(providerErrorEnv)
|
||||
|
||||
// kilocode_change start
|
||||
const lateToolInputLLM = Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: () =>
|
||||
Stream.make(
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-1", name: "read" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: '{"filePath":"package.json"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call-1", name: "read" }),
|
||||
LLMEvent.toolCall({ id: "call-1", name: "read", input: { filePath: "package.json" }, providerExecuted: true }),
|
||||
LLMEvent.toolResult({
|
||||
id: "call-1",
|
||||
name: "read",
|
||||
result: { type: "text", value: "contents" },
|
||||
providerExecuted: true,
|
||||
}),
|
||||
LLMEvent.toolInputDelta({ id: "call-1", name: "unknown", text: "" }),
|
||||
LLMEvent.toolInputEnd({ id: "call-1", name: "unknown" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const lateToolInputEnv = LayerNode.buildLayer(root, {
|
||||
replacements: [...replacements, LayerNode.replace(LLM.node, lateToolInputLLM)],
|
||||
})
|
||||
const itLateToolInput = testEffect(lateToolInputEnv)
|
||||
// kilocode_change end
|
||||
|
||||
const fragmentFailureLLM = Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
@@ -1081,7 +1111,49 @@ itProviderError.live("session.processor effect tests fail provider-executed erro
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
) // kilocode_change
|
||||
|
||||
// kilocode_change start
|
||||
itLateToolInput.live("session.processor effect tests ignore tool input after the call settles", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "read a file")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
|
||||
|
||||
yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies SessionV1.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "read a file" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const calls = (yield* MessageV2.parts(msg.id)).filter(
|
||||
(part): part is SessionV1.ToolPart => part.type === "tool",
|
||||
)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.callID).toBe("call-1")
|
||||
expect(calls[0]?.tool).toBe("read")
|
||||
expect(calls[0]?.state.status).toBe("completed")
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
itFragmentFailure.live("session.processor effect tests flush partial v2 fragments before step failure", () =>
|
||||
provideTmpdirInstance(
|
||||
|
||||
@@ -4,13 +4,11 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Bus } from "@/bus" // kilocode_change - ToolRegistry retains the Kilo bus dependency
|
||||
import { Bus } from "@/bus"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
// kilocode_change start
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry"
|
||||
import { legacyReviewMessage } from "../../src/kilocode/review/command"
|
||||
// kilocode_change end
|
||||
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
@@ -18,7 +16,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { Command } from "../../src/command"
|
||||
import { Auth } from "../../src/auth" // kilocode_change
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
@@ -43,8 +41,9 @@ import { SessionProcessor } from "../../src/session/processor"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionRevert } from "../../src/session/revert"
|
||||
import { SessionRunState } from "../../src/session/run-state"
|
||||
import { KiloSession } from "../../src/kilocode/session" // kilocode_change
|
||||
import { Suggestion } from "../../src/kilocode/suggestion" // kilocode_change - accept suggestion in telemetry test
|
||||
import { KiloSession } from "../../src/kilocode/session"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { Suggestion } from "../../src/kilocode/suggestion"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
@@ -62,8 +61,8 @@ import { TestInstance } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { MemoryService } from "@kilocode/kilo-memory/effect/service" // kilocode_change
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache" // kilocode_change
|
||||
import { MemoryService } from "@kilocode/kilo-memory/effect/service"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
@@ -165,7 +164,6 @@ const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.default
|
||||
const run = SessionRunState.layer.pipe(Layer.provide(status))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
|
||||
// kilocode_change start
|
||||
const agent: AgentSvc.Info = {
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
@@ -194,7 +192,6 @@ const blockingProcessor = Layer.succeed(
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
function makePrompt(input?: { processor?: "blocking" }) {
|
||||
const deps = Layer.mergeAll(
|
||||
@@ -202,7 +199,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
||||
Snapshot.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Env.defaultLayer,
|
||||
input?.processor === "blocking" ? fastAgents : AgentSvc.defaultLayer, // kilocode_change
|
||||
input?.processor === "blocking" ? fastAgents : AgentSvc.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
@@ -215,8 +212,8 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
||||
status,
|
||||
Database.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
Bus.layer, // kilocode_change - satisfy the Kilo ToolRegistry dependency
|
||||
MemoryService.layer, // kilocode_change
|
||||
Bus.layer,
|
||||
MemoryService.layer,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
@@ -226,10 +223,11 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer), // kilocode_change - RepoCloneTool dependency
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
Layer.provide(Auth.defaultLayer), // kilocode_change
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(KiloSessions.testLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
@@ -258,7 +256,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
||||
Layer.provideMerge(proc),
|
||||
Layer.provideMerge(registry),
|
||||
Layer.provideMerge(trunc),
|
||||
Layer.provideMerge(question), // kilocode_change - SessionPrompt dismisses pending questions
|
||||
Layer.provideMerge(question),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(SystemPrompt.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
@@ -341,7 +339,7 @@ const ensureDir = Effect.fn("test.ensureDir")(function* (dir: string) {
|
||||
const writeConfig = Effect.fn("test.writeConfig")(function* (dir: string, config: Partial<ConfigV1.Info>) {
|
||||
yield* writeText(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({ $schema: "https://app.kilo.ai/config.json", ...config }), // kilocode_change
|
||||
JSON.stringify({ $schema: "https://app.kilo.ai/config.json", ...config }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -352,7 +350,6 @@ const useServerConfig = Effect.fn("test.useServerConfig")(function* (config: (ur
|
||||
return { dir, llm }
|
||||
})
|
||||
|
||||
// kilocode_change start - wait for the runner state that cancel observes instead of session status
|
||||
const waitForBusy = (sessionID: SessionID, duration: Duration.Input = "2 seconds") =>
|
||||
pollWithTimeout(
|
||||
Effect.gen(function* () {
|
||||
@@ -363,7 +360,6 @@ const waitForBusy = (sessionID: SessionID, duration: Duration.Input = "2 seconds
|
||||
`session ${sessionID} never became busy`,
|
||||
duration,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
const hasBash = Effect.sync(() => Bun.which("bash") !== null)
|
||||
|
||||
@@ -535,7 +531,6 @@ it.instance("loop calls LLM and returns assistant message", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
// kilocode_change start - replacement prompts unblock pending Question service requests
|
||||
noLLMServer.instance(
|
||||
"new prompt dismisses a pending question",
|
||||
() =>
|
||||
@@ -578,9 +573,7 @@ noLLMServer.instance(
|
||||
}),
|
||||
{ config: cfg },
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - cover user image normalization before persistence
|
||||
noLLMServer.instance(
|
||||
"normalizes user data images before persistence",
|
||||
() =>
|
||||
@@ -690,7 +683,6 @@ noLLMServer.instance(
|
||||
}),
|
||||
{ config: cfg },
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
it.instance("loop surfaces content-filter finishes as session errors", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1070,13 +1062,11 @@ noLLMServer.instance("prompt tools replace matching rules and preserve existing
|
||||
})
|
||||
|
||||
const reloaded = yield* sessions.get(session.id)
|
||||
// kilocode_change start - Kilo preserves existing restrictions that the new prompt does not override
|
||||
expect(reloaded.permission).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "deny" },
|
||||
{ permission: "read", pattern: "*", action: "allow" },
|
||||
])
|
||||
expect(Permission.evaluate("bash", "anything", reloaded.permission ?? []).action).toBe("deny")
|
||||
// kilocode_change end
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1139,14 +1129,14 @@ it.instance(
|
||||
const tool = yield* pollWithTimeout(
|
||||
Effect.gen(function* () {
|
||||
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
|
||||
const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "code") // kilocode_change
|
||||
const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "code")
|
||||
const tool = assistant?.parts.find(
|
||||
(part): part is SessionV1.ToolPart => part.type === "tool" && part.tool === "task",
|
||||
)
|
||||
if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
|
||||
}),
|
||||
"timed out waiting for running task metadata",
|
||||
"10 seconds", // kilocode_change - allow loaded Darwin runners to persist the tool transition
|
||||
"10 seconds",
|
||||
)
|
||||
|
||||
if (tool.state.status !== "running") return
|
||||
@@ -1157,10 +1147,9 @@ it.instance(
|
||||
yield* prompt.cancel(chat.id)
|
||||
yield* Fiber.await(fiber)
|
||||
}),
|
||||
20_000, // kilocode_change
|
||||
20_000,
|
||||
)
|
||||
|
||||
// kilocode_change start - child task failures stay tool errors so the parent can recover
|
||||
it.instance(
|
||||
"failed task tool preserves metadata and lets the parent follow up",
|
||||
() =>
|
||||
@@ -1203,12 +1192,10 @@ it.instance(
|
||||
}),
|
||||
10_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
it.instance(
|
||||
"loop sets status to busy then idle",
|
||||
() =>
|
||||
// kilocode_change start - hold the model response instead of cancelling an infinite stream
|
||||
Effect.gen(function* () {
|
||||
const { llm } = yield* useServerConfig(providerCfg)
|
||||
const gate = yield* Deferred.make<void>()
|
||||
@@ -1228,8 +1215,7 @@ it.instance(
|
||||
yield* Fiber.await(fiber)
|
||||
expect((yield* status.get(chat.id)).type).toBe("idle")
|
||||
}),
|
||||
// kilocode_change end
|
||||
10_000, // kilocode_change
|
||||
10_000,
|
||||
)
|
||||
|
||||
// Cancel semantics
|
||||
@@ -1257,12 +1243,10 @@ it.instance(
|
||||
expect(exit.value.info.role).toBe("assistant")
|
||||
}
|
||||
}),
|
||||
10_000, // kilocode_change - Windows CI can take longer to cancel the live loop
|
||||
10_000,
|
||||
)
|
||||
|
||||
// kilocode_change start
|
||||
unix(
|
||||
// kilocode_change end
|
||||
"cancel records MessageAbortedError on interrupted process",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1285,7 +1269,7 @@ unix(
|
||||
}
|
||||
}
|
||||
}),
|
||||
10_000, // kilocode_change - upstream's 3s deadline flakes under CI shard load (observed 3048ms on macOS)
|
||||
10_000,
|
||||
)
|
||||
|
||||
raceNoLLMServer.instance(
|
||||
@@ -1310,12 +1294,10 @@ raceNoLLMServer.instance(
|
||||
parts: [{ type: "text", text: "first" }],
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
const firstCreate = yield* Deferred.make<void>()
|
||||
processorCreateStarted.push(firstCreate)
|
||||
const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* awaitWithTimeout(Deferred.await(firstCreate), "processor.create did not start for first turn")
|
||||
// kilocode_change end
|
||||
|
||||
yield* prompt.cancel(chat.id)
|
||||
const firstExit = yield* Fiber.await(first)
|
||||
@@ -1338,12 +1320,10 @@ raceNoLLMServer.instance(
|
||||
parts: [{ type: "text", text: "second" }],
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
const secondCreate = yield* Deferred.make<void>()
|
||||
processorCreateStarted.push(secondCreate)
|
||||
const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* awaitWithTimeout(Deferred.await(secondCreate), "processor.create did not start for second turn")
|
||||
// kilocode_change end
|
||||
|
||||
yield* prompt.cancel(chat.id)
|
||||
const secondExit = yield* Fiber.await(second)
|
||||
@@ -1378,7 +1358,7 @@ raceNoLLMServer.instance(
|
||||
}
|
||||
}),
|
||||
{ config: cfg },
|
||||
10_000, // kilocode_change - cancellation tree cleanup can exceed 3s under macOS CI shard load
|
||||
10_000,
|
||||
)
|
||||
|
||||
noLLMServer.instance(
|
||||
@@ -1428,7 +1408,6 @@ noLLMServer.instance(
|
||||
30_000,
|
||||
)
|
||||
|
||||
// kilocode_change start - handleSubtask propagates child session cost to wrapper (#6321)
|
||||
it.instance(
|
||||
"handleSubtask propagates subagent cost to wrapper message",
|
||||
() =>
|
||||
@@ -1487,7 +1466,6 @@ it.instance(
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
it.instance(
|
||||
"cancel propagates from slash command subtask to child session",
|
||||
@@ -1538,7 +1516,7 @@ it.instance(
|
||||
const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* llm.wait(1)
|
||||
const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow // kilocode_change - let the queued caller join without a wall-clock race
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* prompt.cancel(chat.id)
|
||||
const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
|
||||
@@ -1549,7 +1527,7 @@ it.instance(
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
10_000, // kilocode_change - Windows CI can take longer to cancel queued live loops
|
||||
10_000,
|
||||
)
|
||||
|
||||
// Queue semantics
|
||||
@@ -1572,7 +1550,6 @@ noLLMServer.instance("concurrent loop callers get same result", () =>
|
||||
it.instance(
|
||||
"concurrent loop callers all receive same error result",
|
||||
() =>
|
||||
// kilocode_change start - gate the failing stream so both callers join the same run
|
||||
Effect.gen(function* () {
|
||||
const { llm } = yield* useServerConfig(providerCfg)
|
||||
const gate = yield* Deferred.make<void>()
|
||||
@@ -1586,7 +1563,7 @@ it.instance(
|
||||
const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* llm.wait(1)
|
||||
const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow // kilocode_change - let the queued caller join without a wall-clock race
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(gate, void 0)
|
||||
|
||||
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
|
||||
@@ -1596,8 +1573,7 @@ it.instance(
|
||||
expect(ea.value.info.id).toBe(eb.value.info.id)
|
||||
expect(ea.value.info.role).toBe("assistant")
|
||||
}),
|
||||
// kilocode_change end
|
||||
10_000, // kilocode_change
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -1665,7 +1641,7 @@ it.instance(
|
||||
expect(inputs).toHaveLength(2)
|
||||
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
|
||||
}),
|
||||
10_000, // kilocode_change - loaded CI runners can exceed 3s for two prompt turns
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -1694,7 +1670,7 @@ it.instance(
|
||||
yield* prompt.cancel(chat.id)
|
||||
yield* Fiber.await(fiber)
|
||||
}),
|
||||
10_000, // kilocode_change
|
||||
10_000,
|
||||
)
|
||||
|
||||
noLLMServer.instance("assertNotBusy succeeds when idle", () =>
|
||||
@@ -1734,7 +1710,7 @@ it.instance(
|
||||
yield* prompt.cancel(chat.id)
|
||||
yield* Fiber.await(fiber)
|
||||
}),
|
||||
10_000, // kilocode_change - Windows CI can take longer to enter and cancel the live loop
|
||||
10_000,
|
||||
)
|
||||
|
||||
unixNoLLMServer(
|
||||
@@ -1834,7 +1810,6 @@ unixNoLLMServer(
|
||||
{ config: cfg },
|
||||
)
|
||||
|
||||
// kilocode_change start - verify shell v2 events correlate with the persisted tool part
|
||||
unixNoLLMServer(
|
||||
"shell correlates the persisted tool part with its completed v2 record",
|
||||
() =>
|
||||
@@ -1849,7 +1824,7 @@ unixNoLLMServer(
|
||||
if (!tool) return
|
||||
|
||||
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
|
||||
Effect.provide(SessionV2.defaultLayer), // kilocode_change - use the complete upstream v2 session layer
|
||||
Effect.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
const shell = messages.find((message) => message.type === "shell")
|
||||
|
||||
@@ -1863,7 +1838,6 @@ unixNoLLMServer(
|
||||
}),
|
||||
{ config: cfg },
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
unixNoLLMServer(
|
||||
"shell lists files from the project directory",
|
||||
@@ -1961,7 +1935,7 @@ it.instance(
|
||||
yield* waitForBusy(chat.id)
|
||||
|
||||
const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow // kilocode_change - give the queued loop a scheduler turn instead of a wall-clock window
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(yield* llm.calls).toBe(0)
|
||||
|
||||
@@ -1976,7 +1950,7 @@ it.instance(
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
}),
|
||||
{ git: true },
|
||||
30_000, // kilocode_change - Windows CI process startup can exceed 3s
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -1999,7 +1973,7 @@ it.instance(
|
||||
|
||||
const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow // kilocode_change - give the queued loops a scheduler turn instead of a wall-clock window
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(yield* llm.calls).toBe(0)
|
||||
|
||||
@@ -2015,7 +1989,7 @@ it.instance(
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
}),
|
||||
{ git: true },
|
||||
30_000, // kilocode_change - Windows CI process startup can exceed 3s
|
||||
30_000,
|
||||
)
|
||||
|
||||
unix(
|
||||
@@ -2158,7 +2132,6 @@ unix(
|
||||
|
||||
const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* llm.wait(1)
|
||||
// kilocode_change start
|
||||
yield* pollWithTimeout(
|
||||
sessions.messages({ sessionID: chat.id }).pipe(
|
||||
Effect.map((msgs) => {
|
||||
@@ -2171,7 +2144,6 @@ unix(
|
||||
),
|
||||
"timed out waiting for large bash output",
|
||||
)
|
||||
// kilocode_change end
|
||||
yield* prompt.cancel(chat.id)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
@@ -2199,7 +2171,6 @@ unixNoLLMServer(
|
||||
|
||||
const sh = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }).pipe(Effect.forkChild)
|
||||
yield* waitForBusy(chat.id)
|
||||
// kilocode_change start - busy is set before shell persistence completes
|
||||
yield* pollWithTimeout(
|
||||
sessions
|
||||
.messages({ sessionID: chat.id })
|
||||
@@ -2214,9 +2185,7 @@ unixNoLLMServer(
|
||||
),
|
||||
`session ${chat.id} never persisted its running shell tool`,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - wait until the loop reaches the queued-run handoff
|
||||
const opened = yield* Deferred.make<void>()
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
@@ -2230,7 +2199,6 @@ unixNoLLMServer(
|
||||
const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* awaitWithTimeout(Deferred.await(opened), `session ${chat.id} never opened its queued turn`)
|
||||
yield* Effect.yieldNow
|
||||
// kilocode_change end
|
||||
|
||||
yield* prompt.cancel(chat.id)
|
||||
|
||||
@@ -2444,7 +2412,6 @@ noLLMServer.instance(
|
||||
{ config: cfg },
|
||||
)
|
||||
|
||||
// kilocode_change start - expand configured Kilo references once per prompt
|
||||
noLLMServer.instance(
|
||||
"resolves configured reference mentions to one root directory attachment",
|
||||
() =>
|
||||
@@ -2473,7 +2440,7 @@ noLLMServer.instance(
|
||||
source: { type: "file", path: "docs", text: { value: "@docs" } },
|
||||
})
|
||||
expect(fileURLToPath(files[0].url)).toBe(docs)
|
||||
expect(agents.map((agent) => agent.name)).toEqual(["code"]) // kilocode_change
|
||||
expect(agents.map((agent) => agent.name)).toEqual(["code"])
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
@@ -2484,9 +2451,7 @@ noLLMServer.instance(
|
||||
},
|
||||
},
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - deduplicate configured Kilo references by source identity
|
||||
noLLMServer.instance(
|
||||
"does not let an unrelated directory attachment shadow a configured reference",
|
||||
() =>
|
||||
@@ -2530,7 +2495,6 @@ noLLMServer.instance(
|
||||
},
|
||||
},
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
noLLMServer.instance(
|
||||
"stores raw reference mentions alongside directory attachments",
|
||||
@@ -2557,8 +2521,8 @@ noLLMServer.instance(
|
||||
const text = stored.parts.find((part): part is SessionV1.TextPart => part.type === "text" && !part.synthetic)
|
||||
|
||||
expect(text?.text).toBe("Use @docs for context")
|
||||
expect(synthetic.some((part) => part.text.includes("Called the Read tool"))).toBe(true) // kilocode_change
|
||||
expect(files).toHaveLength(1) // kilocode_change - directory attachment is expanded, not denied
|
||||
expect(synthetic.some((part) => part.text.includes("Called the Read tool"))).toBe(true)
|
||||
expect(files).toHaveLength(1)
|
||||
|
||||
yield* sessions.remove(session.id)
|
||||
}),
|
||||
@@ -2673,12 +2637,11 @@ it.instance(
|
||||
expect(last.info.error?.name).toBe("MessageAbortedError")
|
||||
}
|
||||
}),
|
||||
10_000, // kilocode_change
|
||||
10_000,
|
||||
)
|
||||
|
||||
// Agent variant
|
||||
|
||||
// kilocode_change start - Agent Manager records a model-less synthetic prompt after forking
|
||||
noLLMServer.instance(
|
||||
"preserves the session variant through a model-less handoff",
|
||||
() =>
|
||||
@@ -2711,7 +2674,6 @@ noLLMServer.instance(
|
||||
}),
|
||||
{ config: cfg },
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
noLLMServer.instance(
|
||||
"applies agent variant only when using agent model",
|
||||
@@ -2782,7 +2744,6 @@ noLLMServer.instance(
|
||||
},
|
||||
)
|
||||
|
||||
// kilocode_change start - Kilo review command behavior
|
||||
noLLMServer.instance(
|
||||
"deprecated review alias returns static message without LLM",
|
||||
() =>
|
||||
@@ -2893,7 +2854,6 @@ it.instance(
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// Agent / command resolution errors
|
||||
|
||||
@@ -2947,7 +2907,7 @@ noLLMServer.instance(
|
||||
const err = Cause.squash(exit.cause)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain("code") // kilocode_change - "build" renamed to "code"
|
||||
expect(err.data.message).toContain("code")
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import { existsSync, readFileSync, readdirSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
const target = "20260507164347_add_workspace_time"
|
||||
|
||||
function migrations() {
|
||||
return readdirSync(path.join(import.meta.dirname, "../../../core/migration"), { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
existsSync(path.join(import.meta.dirname, "../../../core/migration", entry.name, "migration.sql")),
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
timestamp: Number(entry.name.split("_")[0]),
|
||||
sql: readFileSync(
|
||||
path.join(import.meta.dirname, "../../../core/migration", entry.name, "migration.sql"),
|
||||
"utf-8",
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
|
||||
describe("workspace time migration", () => {
|
||||
test("discards existing workspace rows during the beta reset", () => {
|
||||
const sqlite = new Database(":memory:")
|
||||
const db = drizzle({ client: sqlite })
|
||||
const entries = migrations()
|
||||
const index = entries.findIndex((entry) => entry.name === target)
|
||||
|
||||
expect(index).toBeGreaterThan(0)
|
||||
|
||||
migrate(db, entries.slice(0, index))
|
||||
sqlite.run(
|
||||
"INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
["project_1", "/tmp/project", "git", "project", 1, 1, "[]"],
|
||||
)
|
||||
sqlite.run(
|
||||
"INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"],
|
||||
)
|
||||
|
||||
expect(() => migrate(db, entries.slice(index))).not.toThrow()
|
||||
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user