Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer `Promise.withResolvers<T>()` for deferreds when runtime/types support it; allow callback/event executors, not async executors or redundant Promise wrapping.

### Avoid let statements

Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/kilocode/zero-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function zeroID(...parts: (string | number | boolean)[]) {
if (parts.length === 2) return `${parts[0]}\0${parts[1]}`
if (parts.length === 3) return `${parts[0]}\0${parts[1]}\0${parts[2]}`
return parts.join("\0")
}
58 changes: 58 additions & 0 deletions packages/core/test/kilocode/zero-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, expectTypeOf, test } from "bun:test"
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"

describe("zeroID", () => {
test("accepts only string, number, and boolean parts", () => {
expectTypeOf<Parameters<typeof zeroID>>().toEqualTypeOf<(string | number | boolean)[]>()
expectTypeOf<ReturnType<typeof zeroID>>().toEqualTypeOf<string>()
})

test("matches template literals for short composite keys", () => {
const values = ["", "a", "a\0b", "路径", "null", "undefined", 0, -0, -1, 0.5, NaN, Infinity, -Infinity, true, false]
for (const first of values) {
for (const second of values) {
expect(zeroID(first, second)).toBe(`${first}\0${second}`)
for (const third of values) {
expect(zeroID(first, second, third)).toBe(`${first}\0${second}\0${third}`)
}
}
}
})

test("matches array joins across arities and preserves empty parts", () => {
const cases: Parameters<typeof zeroID>[] = [
[],
[""],
[false],
[0],
["", ""],
["", "", ""],
["", "", "", ""],
["prefix", "", 0, false, "suffix"],
["/repo", "", "ancestor", "file.ts", true, "modified", 3, 0, false, ""],
["\0", "a\0b", "", "路径", NaN, -0, -Infinity, true, false],
]
for (const parts of cases) {
const expected = parts.join("\0")
expect(zeroID(...parts)).toBe(expected)
expect(Buffer.from(zeroID(...parts))).toEqual(Buffer.from(expected))
}
})

test("preserves namespace prefixes, suffixes, and nested keys", () => {
expect(zeroID("project", "")).toBe("project\0")
expect(zeroID("", "file.ts")).toBe("\0file.ts")
expect(zeroID("error", "message")).toBe("error\0message")
expect(zeroID(zeroID("project", "session"), "file.ts")).toBe("project\0session\0file.ts")
expect(zeroID("ab", "c")).not.toBe(zeroID("a", "bc"))
expect(zeroID("project", "session").startsWith(zeroID("project", ""))).toBe(true)
expect(zeroID("project-other", "session").startsWith(zeroID("project", ""))).toBe(false)
})

test("supports explicit caller-specific nullish coercion", () => {
for (const value of [null, undefined]) {
expect(zeroID("scope", String(value))).toBe(`scope\0${value}`)
expect(zeroID("scope", value ?? "")).toBe(["scope", value].join("\0"))
}
})
})
13 changes: 7 additions & 6 deletions packages/kilo-vscode/src/agent-manager/local-diff-cache.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
import { imageMime } from "../diff/shared/image"
import type { Batch, Meta } from "./local-diff-batch"
import type { WorktreeDiffEntry } from "./types"
Expand Down Expand Up @@ -58,7 +59,7 @@ export function createDiffCache(load: Loader) {
}

const identity = (dir: string, base: string, anc: string, meta: Meta) =>
[
zeroID(
dir,
base,
anc,
Expand All @@ -69,7 +70,7 @@ export function createDiffCache(load: Loader) {
meta.deletions,
meta.binary,
meta.stamp,
].join("\0")
)

const cached = (id: string) => {
const value = details.get(id)
Expand Down Expand Up @@ -236,8 +237,8 @@ export function createDiffCache(load: Loader) {
}

const queued = (id: string, dir: string, base: string, anc: string, meta: Meta, signal?: AbortSignal) => {
const scope = `${dir}\0${base}`
const key = `${scope}\0${anc}`
const scope = zeroID(dir, base)
const key = zeroID(scope, anc)
let queue = queues.get(key)
if (!queue) {
queue = new Map()
Expand Down Expand Up @@ -270,7 +271,7 @@ export function createDiffCache(load: Loader) {

const file = (dir: string, base: string, path: string, signal?: AbortSignal): Promise<Value> => {
if (signal?.aborted) return Promise.reject(new Error("Diff detail aborted"))
const state = states.get(`${dir}\0${base}`)
const state = states.get(zeroID(dir, base))
if (!state) return load.file(dir, base, path, signal)
const meta = state.metas.get(path)
if (!meta) return Promise.resolve(null)
Expand All @@ -292,7 +293,7 @@ export function createDiffCache(load: Loader) {

return {
summary: async (dir: string, base: string): Promise<WorktreeDiffEntry[]> => {
const id = `${dir}\0${base}`
const id = zeroID(dir, base)
const generation = (generations.get(id) ?? 0) + 1
generations.set(id, generation)
const result = await load.summary(dir, base)
Expand Down
4 changes: 2 additions & 2 deletions packages/kilo-vscode/src/agent-manager/project/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/** Strict project/resource ownership for Agent Manager multi-project routing. */

import { zeroID as key } from "@opencode-ai/core/kilocode/zero-id"

export interface ProjectRef {
projectId: string
}
Expand Down Expand Up @@ -45,8 +47,6 @@ interface SessionRoute {
generation: number
}

const key = (projectId: string, id: string) => `${projectId}\0${id}`

export class ProjectRouteService {
private readonly projects = new Map<string, ProjectRoute>()
private readonly sessions = new Map<string, SessionRoute>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "node:path"
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
import type { SSEPayload } from "./sdk-sse-adapter"

type Buffered = { event: SSEPayload; directory?: string }
Expand Down Expand Up @@ -75,12 +76,12 @@ export class ExplicitAbortState {
const key = scope(sessionID, directory)
return this.states.has(key) ? [key] : []
}
const prefix = `${sessionID}\0`
const prefix = zeroID(sessionID, "")
return [...this.states.keys()].filter((key) => key.startsWith(prefix))
}
}

function scope(sessionID: string, directory: string) {
const dir = path.resolve(directory)
return `${sessionID}\0${process.platform === "win32" ? dir.toLowerCase() : dir}`
return zeroID(sessionID, process.platform === "win32" ? dir.toLowerCase() : dir)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import { createWorktreeDiffs } from "../../webview-ui/agent-manager/worktree-diffs"
import { createWorktreeDiffs, diffDataKey } from "../../webview-ui/agent-manager/worktree-diffs"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"

const diff = (file: string, additions = 1): WorktreeFileDiff => ({
Expand Down Expand Up @@ -30,7 +30,37 @@ const withDiffs = (fn: (diffs: ReturnType<typeof createWorktreeDiffs>, sent: Sen
})
}

describe("diffDataKey", () => {
it("preserves the nullish fallback without replacing an empty project", () => {
expect(diffDataKey(undefined, "s1")).toBe("single\0s1")
expect(diffDataKey("single", "s1")).toBe("single\0s1")
expect(diffDataKey("", "s1")).toBe("\0s1")
expect(diffDataKey("project", "")).toBe("project\0")
expect(diffDataKey("project", "s1\0file.ts")).toBe("project\0s1\0file.ts")
})
})

describe("createWorktreeDiffs", () => {
it.each([undefined, "", "project"])("prunes only the complete project namespace %j", (project) => {
createRoot((dispose) => {
const store = createWorktreeDiffs(vscode([]), () => project)
const sibling = `${project ?? "single"}-other`
for (const owner of [project, sibling]) {
store.onWorktreeDiff({
type: "agentManager.worktreeDiff",
projectId: owner,
sessionId: "gone#branch",
diffs: [diff("a.ts")],
})
}

store.prune(new Set())
expect(store.diffDatas()[`${project ?? "single"}\0gone#branch`]).toBeUndefined()
expect(store.diffDatas()[`${sibling}\0gone#branch`]).toHaveLength(1)
dispose()
})
})

it("stores full diffs per session", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] })
Expand Down
7 changes: 7 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-project-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ describe("ProjectRouteService", () => {
)
})

it("preserves fallback, precedence, and empty fields in composite UI keys", () => {
expect(ProjectRouteService.key({ projectId: "a" })).toBe("a\0local")
expect(ProjectRouteService.key({ projectId: "a", sessionId: "s", worktreeId: "wt" })).toBe("a\0s")
expect(ProjectRouteService.key({ projectId: "a", sessionId: "", worktreeId: "wt" })).toBe("a\0")
expect(ProjectRouteService.key({ projectId: "", worktreeId: "" })).toBe("\0")
})

describe("safe resolution (non-throwing)", () => {
it("trySessionDirectory returns the exact dir for an unambiguous raw id", () => {
const routes = new ProjectRouteService()
Expand Down
24 changes: 24 additions & 0 deletions packages/kilo-vscode/tests/unit/explicit-abort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,30 @@ describe("explicit abort state", () => {
expect(state.event(close("interrupted"), "/repo/a")).toBe(false)
})

it.each(["", "session"])("removes only the exact session prefix %j across directories", (session) => {
const state = new ExplicitAbortState()
for (const directory of ["/repo/a", "/repo/b"]) {
const id = state.begin(session, directory)
state.finish(session, directory, id, true)
}
const sibling = `${session}-other`
const id = state.begin(sibling, "/repo/a")
state.finish(sibling, "/repo/a", id, true)

state.remove(session)
expect(state.event(close("interrupted", session), "/repo/a")).toBe(true)
expect(state.event(close("interrupted", session), "/repo/b")).toBe(true)
expect(state.event(close("interrupted", sibling))).toBe(false)
})

it("normalizes directories before building scope keys", () => {
const state = new ExplicitAbortState()
const id = state.begin("session", "/repo/nested/..")
state.finish("session", "/repo", id, true)

expect(state.event(close("interrupted"), "/repo/.")).toBe(false)
})

it("clears suppression when an idle session becomes busy again", () => {
const state = new ExplicitAbortState()
const id = state.begin("session", "/repo")
Expand Down
20 changes: 20 additions & 0 deletions packages/kilo-vscode/tests/unit/local-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,26 @@ describe("diffFile", () => {
})
})

it("keeps empty and named base cache identities separate", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncommitted\n")
runSync(dir, ["commit", "-am", "change seed"])
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncommitted\nworking\n")
const local = createLocalDiff(git())
await local.summary(dir, "")
await local.summary(dir, base)

const current = await local.file(dir, "", "seed.txt")
const ancestor = await local.file(dir, base, "seed.txt")
expect(current?.before).toBe("seed\ncommitted\n")
expect(ancestor?.before).toBe("seed\n")
expect(current?.after).toBe("seed\ncommitted\nworking\n")
expect(ancestor?.after).toBe(current?.after)
expect(await local.file(dir, "", "seed.txt")).toBe(current)
expect(await local.file(dir, base, "seed.txt")).toBe(ancestor)
})
})

it("does not cache detail that is aborted before Git completes", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { createSignal, type Accessor } from "solid-js"
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
import { parseDiffId } from "./diff-scope-state"
import type { useVSCode } from "../src/context/vscode"
Expand All @@ -33,7 +34,7 @@ export function wireDiffId(id: string) {
}

export function diffDataKey(project: string | undefined, id: string): string {
return `${project ?? "single"}\0${id}`
return zeroID(project ?? "single", id)
}

export function createWorktreeDiffs(
Expand Down Expand Up @@ -79,7 +80,7 @@ export function createWorktreeDiffs(
}

const prune = (ids: Set<string>) => {
const prefix = `${project() ?? "single"}\0`
const prefix = key("")
Comment thread
WebReflection marked this conversation as resolved.
const keys = new Set([
...Object.keys(diffDatas()),
...Object.keys(diffLoadings()),
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/kilocode/indexing-warning.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"

export const INDEXING_WARNING_CODES = ["qdrant.version-incompatible", "qdrant.version-unavailable"] as const

Expand All @@ -21,7 +22,7 @@ export function parseQdrantWarning(value: unknown): IndexingWarning | undefined
}

export function indexingWarningKey(warning: IndexingWarning): string {
return `${warning.code}\u0000${warning.message}`
return zeroID(warning.code, warning.message)
}

export function indexingErrorMessage(status: IndexingStatus): string | undefined {
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/kilocode/indexing-worker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
VectorStoreSearchResult,
} from "@kilocode/kilo-indexing/engine"
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
import { withTimeout } from "@/util/timeout"
import type { Event, Log, Message, Request, Result } from "./indexing-worker-protocol"
import type { IndexingWarning } from "./indexing-warning"
Expand Down Expand Up @@ -123,7 +124,7 @@ export namespace IndexingWorker {
}

const worker = (directory: string, root: string, hooks: Hooks): Host => {
const key = `${directory}\0${root}`
const key = zeroID(directory, root)
const state = channel()
let active = true
let callbacks = hooks
Expand Down Expand Up @@ -205,7 +206,7 @@ export namespace IndexingWorker {

export function create(directory: string, root: string, hooks: Hooks) {
if (factory) return factory(directory, root, hooks)
const key = `${directory}\0${root}`
const key = zeroID(directory, root)
const existing = pool.get(key)
if (existing) {
existing.use(hooks)
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/test/kilocode/indexing-warning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@ test("indexingWarningKey includes the warning code and message", () => {
"qdrant.version-unavailable\u0000warning",
)
})

test("indexingWarningKey preserves empty messages and embedded delimiters", () => {
for (const code of ["qdrant.version-unavailable", "qdrant.version-incompatible"] as const) {
for (const message of ["", "\0", "warning\0detail", "null", "undefined", "警告"]) {
expect(indexingWarningKey({ code, message })).toBe(`${code}\0${message}`)
}
}
})
32 changes: 32 additions & 0 deletions packages/opencode/test/kilocode/indexing-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,38 @@ test("routes multiple directories through the shared indexing worker", async ()
expect(failures).toEqual([])
})

test("pools workers by both directory and root", async () => {
await using tmp = await tmpdir()
await using root = await tmpdir()
const failures: unknown[] = []
const hooks: IndexingWorker.Hooks = {
status() {},
telemetry() {},
warning() {},
log() {},
failure(err) {
failures.push(err)
},
}
const first = IndexingWorker.create(tmp.path, tmp.path, hooks)
const again = IndexingWorker.create(tmp.path, tmp.path, hooks)
const second = IndexingWorker.create(tmp.path, root.path, hooks)

try {
expect(again).toBe(first)
expect(second).not.toBe(first)
const statuses = await Promise.all([
first.init({ enabled: false, embedderProvider: "openai" }),
second.init({ enabled: false, embedderProvider: "openai" }),
])
expect(statuses.map((status) => status.state)).toEqual(["Disabled", "Disabled"])
} finally {
await Promise.all([first.dispose(), second.dispose()])
}

expect(failures).toEqual([])
})

test("waits for the primary index instead of scanning a worktree independently", async () => {
await using tmp = await tmpdir()
const main = path.join(tmp.path, "main")
Expand Down
Loading