From 1565516f26caea3ac2960f07f2c577b045fab092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Jun 2026 14:27:37 +0200 Subject: [PATCH 01/12] feat(cli): restore cloud session diffs on import --- .changeset/cloud-session-diff-restore.md | 6 + .../server/httpapi/handlers/kilo-gateway.ts | 57 ++++++--- .../session-diff-restore.ts | 110 ++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 2 + .../kilocode/session-diff-restore.test.ts | 92 +++++++++++++++ 5 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 .changeset/cloud-session-diff-restore.md create mode 100644 packages/opencode/src/kilocode/session-portability/session-diff-restore.ts create mode 100644 packages/opencode/test/kilocode/session-diff-restore.test.ts diff --git a/.changeset/cloud-session-diff-restore.md b/.changeset/cloud-session-diff-restore.md new file mode 100644 index 00000000000..da52d83fe4f --- /dev/null +++ b/.changeset/cloud-session-diff-restore.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Restore cloud session filesystem changes from synced session diffs when importing sessions. diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index e1ac80a0a77..a99e73f9fd9 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -39,7 +39,9 @@ import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { MessageTable, PartTable, SessionTable } from "@/session/session.sql" import { Session } from "@/session/session" import { Database } from "@/storage/db" +import { Storage } from "@/storage/storage" import { AudioTranscriptionsBody, ClawStatus, EditBody, FimBody } from "../groups/kilo-gateway" +import { extractSessionDiffs, restoreSessionDiffs } from "../../../session-portability/session-diff-restore" const FIM_TIMEOUT_MS = 30_000 const log = Log.create({ service: "kilo-gateway" }) @@ -440,23 +442,52 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", if (!fetched.ok) return jsonError(fetched.error, fetched.status) if (!fetched.data?.info?.id) return yield* Effect.fail(new HttpApiError.BadRequest({})) + const diffs = extractSessionDiffs(fetched.data) const bridge = yield* EffectBridge.make() return yield* Effect.tryPromise({ try: () => bridge.promise( - Effect.sync(() => - importSessionToDb(fetched.data, { - Database, - Instance, - SessionTable, - MessageTable, - PartTable, - SessionToRow: Session.toRow, - Bus, - SessionCreatedEvent: Session.Event.Created, - Identifier, - }), - ), + Effect.gen(function* () { + if (diffs.length > 0) { + yield* Effect.try({ + try: () => restoreSessionDiffs({ directory: Instance.directory, diffs }), + catch: (err) => err, + }).pipe( + Effect.catch((err) => + Effect.sync(() => { + logError("cloud/session/import/restore", err) + return undefined + }), + ), + ) + } + + const imported = yield* Effect.sync(() => + importSessionToDb(fetched.data, { + Database, + Instance, + SessionTable, + MessageTable, + PartTable, + SessionToRow: Session.toRow, + Bus, + SessionCreatedEvent: Session.Event.Created, + Identifier, + }), + ) + + if (diffs.length > 0) { + yield* Storage.Service.use((storage) => storage.write(["session_diff", imported.id], diffs)).pipe( + Effect.catch((err) => + Effect.sync(() => { + logError("cloud/session/import/diff", err) + }), + ), + ) + } + + return imported + }), ), catch: () => new HttpApiError.BadRequest({}), }) diff --git a/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts new file mode 100644 index 00000000000..76fa2696f51 --- /dev/null +++ b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts @@ -0,0 +1,110 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" + +type Diff = { + file?: string + patch?: string + after?: string + additions?: number + deletions?: number + status?: string +} + +export type RestoreResult = { + applied: number + skipped: number + total: number +} + +function diffs(value: unknown): Diff[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is Diff => typeof item === "object" && item !== null) +} + +export function extractSessionDiffs(data: unknown): Diff[] { + if (typeof data !== "object" || data === null) return [] + const root = data as { sessionDiff?: unknown; session_diff?: unknown; messages?: unknown } + const top = diffs(root.sessionDiff).length > 0 ? diffs(root.sessionDiff) : diffs(root.session_diff) + if (top.length > 0) return top + if (!Array.isArray(root.messages)) return [] + + const map = new Map() + for (const msg of root.messages) { + if (typeof msg !== "object" || msg === null) continue + const info = (msg as { info?: unknown }).info + if (typeof info !== "object" || info === null) continue + const summary = (info as { summary?: unknown }).summary + if (typeof summary !== "object" || summary === null) continue + for (const diff of diffs((summary as { diffs?: unknown }).diffs)) { + if (typeof diff.file === "string") map.set(diff.file, diff) + } + } + return Array.from(map.values()) +} + +function safe(root: string, file: string) { + const fp = path.resolve(root, file) + if (!fp.startsWith(root + path.sep)) return + return fp +} + +function apply(dir: string, diff: Diff) { + if (!diff.patch) return false + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-session-diff-")) + const file = path.join(tmp, "change.patch") + try { + fs.writeFileSync(file, diff.patch) + const proc = Bun.spawnSync(["git", "apply", "--3way", "--whitespace=nowarn", file], { + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }) + return proc.exitCode === 0 + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +} + +export function restoreSessionDiffs(input: { directory: string; diffs: Diff[] }): RestoreResult { + const root = path.resolve(input.directory) + const total = input.diffs.length + const result = { applied: 0, skipped: 0, total } + + for (const diff of input.diffs) { + if (diff.patch) { + if (apply(root, diff)) { + result.applied++ + continue + } + result.skipped++ + continue + } + + if (typeof diff.file !== "string") { + result.skipped++ + continue + } + const fp = safe(root, diff.file) + if (!fp) { + result.skipped++ + continue + } + + if (diff.status === "deleted") { + fs.rmSync(fp, { force: true }) + result.applied++ + continue + } + + if (typeof diff.after !== "string" || diff.after.length === 0) { + result.skipped++ + continue + } + fs.mkdirSync(path.dirname(fp), { recursive: true }) + fs.writeFileSync(fp, diff.after) + result.applied++ + } + + return result +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index ad2a569f485..bcb3ce6241a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -48,6 +48,7 @@ import { SessionShare } from "@/share/session" import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" import { Snapshot } from "@/snapshot" +import { Storage } from "@/storage/storage" // kilocode_change import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" @@ -218,6 +219,7 @@ export function createRoutes(corsOptions?: CorsOptions) { SessionSummary.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, + Storage.defaultLayer, // kilocode_change SyncEvent.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts new file mode 100644 index 00000000000..590c3b2b9ec --- /dev/null +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { extractSessionDiffs, restoreSessionDiffs } from "../../src/kilocode/session-portability/session-diff-restore" + +const dirs: string[] = [] + +function tmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-session-diff-")) + dirs.push(dir) + return dir +} + +function git(dir: string, args: string[]) { + const proc = Bun.spawnSync(["git", ...args], { + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }) + if (proc.exitCode !== 0) { + const text = new TextDecoder().decode(proc.stderr) + throw new Error(text) + } + return new TextDecoder().decode(proc.stdout) +} + +function repo() { + const dir = tmp() + fs.mkdirSync(path.join(dir, "src"), { recursive: true }) + git(dir, ["init"]) + git(dir, ["config", "user.email", "test@example.com"]) + git(dir, ["config", "user.name", "Test User"]) + fs.writeFileSync(path.join(dir, "src/index.ts"), "before\n") + git(dir, ["add", "."]) + git(dir, ["commit", "-m", "initial"]) + return dir +} + +function patch(dir: string) { + fs.writeFileSync(path.join(dir, "src/index.ts"), "after\n") + return git(dir, ["diff", "--src-prefix=a/", "--dst-prefix=b/"]) +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +describe("session diff restore", () => { + test("extracts top-level sessionDiff before legacy message summaries", () => { + const diff = { + file: "src/index.ts", + patch: "diff --git a/src/index.ts b/src/index.ts\n", + additions: 1, + deletions: 0, + status: "modified", + } + const data = { + sessionDiff: [diff], + messages: [{ info: { summary: { diffs: [{ file: "legacy.txt", additions: 1, deletions: 0 }] } } }], + } + + expect(extractSessionDiffs(data)).toEqual([diff]) + }) + + test("falls back to legacy message summary diffs", () => { + const data = { + messages: [ + { info: { summary: { diffs: [{ file: "a.txt", after: "first", additions: 1, deletions: 0 }] } } }, + { info: { summary: { diffs: [{ file: "a.txt", after: "second", additions: 1, deletions: 0 }] } } }, + ], + } + + expect(extractSessionDiffs(data)).toEqual([{ file: "a.txt", after: "second", additions: 1, deletions: 0 }]) + }) + + test("applies patch diffs in a git workspace", async () => { + const dir = repo() + const text = patch(dir) + git(dir, ["checkout", "--", "."]) + + const result = await restoreSessionDiffs({ + directory: dir, + diffs: [{ file: "src/index.ts", patch: text, additions: 1, deletions: 1, status: "modified" }], + }) + + expect(result).toEqual({ applied: 1, skipped: 0, total: 1 }) + expect(fs.readFileSync(path.join(dir, "src/index.ts"), "utf8")).toBe("after\n") + }) +}) From faae71e59d36f85f89ef1a87702b89c99afb8e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Jun 2026 14:36:10 +0200 Subject: [PATCH 02/12] fix(cli): hide git diff restore subprocesses --- .../src/kilocode/session-portability/session-diff-restore.ts | 1 + packages/opencode/test/kilocode/session-diff-restore.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts index 76fa2696f51..3255a3531a4 100644 --- a/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts +++ b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts @@ -59,6 +59,7 @@ function apply(dir: string, diff: Diff) { cwd: dir, stdout: "pipe", stderr: "pipe", + windowsHide: true, }) return proc.exitCode === 0 } finally { diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index 590c3b2b9ec..eea86d6e383 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -17,6 +17,7 @@ function git(dir: string, args: string[]) { cwd: dir, stdout: "pipe", stderr: "pipe", + windowsHide: true, }) if (proc.exitCode !== 0) { const text = new TextDecoder().decode(proc.stderr) From 71600b28e24ad0d2d3e59432f6a22bdfc7f33d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Jun 2026 14:36:38 +0200 Subject: [PATCH 03/12] fix(cli): harden session diff path guard --- .../session-portability/session-diff-restore.ts | 3 ++- .../test/kilocode/session-diff-restore.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts index 3255a3531a4..4cddd31af44 100644 --- a/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts +++ b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts @@ -45,7 +45,8 @@ export function extractSessionDiffs(data: unknown): Diff[] { function safe(root: string, file: string) { const fp = path.resolve(root, file) - if (!fp.startsWith(root + path.sep)) return + const rel = path.relative(root, fp) + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return return fp } diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index eea86d6e383..e98f2c55985 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -90,4 +90,18 @@ describe("session diff restore", () => { expect(result).toEqual({ applied: 1, skipped: 0, total: 1 }) expect(fs.readFileSync(path.join(dir, "src/index.ts"), "utf8")).toBe("after\n") }) + + test("skips snapshot diffs outside the workspace", () => { + const dir = tmp() + const out = path.join(path.dirname(dir), `${path.basename(dir)}-outside.txt`) + fs.rmSync(out, { force: true }) + + const result = restoreSessionDiffs({ + directory: dir, + diffs: [{ file: `../${path.basename(out)}`, after: "outside", additions: 1, deletions: 0, status: "modified" }], + }) + + expect(result).toEqual({ applied: 0, skipped: 1, total: 1 }) + expect(fs.existsSync(out)).toBe(false) + }) }) From 9be85f179c1081bb07d7a9dc2130ece359ae19c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Jun 2026 14:36:53 +0200 Subject: [PATCH 04/12] test(cli): keep diff restore test synchronous --- packages/opencode/test/kilocode/session-diff-restore.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index e98f2c55985..becbd022690 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -77,12 +77,12 @@ describe("session diff restore", () => { expect(extractSessionDiffs(data)).toEqual([{ file: "a.txt", after: "second", additions: 1, deletions: 0 }]) }) - test("applies patch diffs in a git workspace", async () => { + test("applies patch diffs in a git workspace", () => { const dir = repo() const text = patch(dir) git(dir, ["checkout", "--", "."]) - const result = await restoreSessionDiffs({ + const result = restoreSessionDiffs({ directory: dir, diffs: [{ file: "src/index.ts", patch: text, additions: 1, deletions: 1, status: "modified" }], }) From 6c571726e46a5417f37a553c573d4b8ef1ecb3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Jun 2026 15:37:17 +0200 Subject: [PATCH 05/12] test(cli): normalize diff restore line endings --- packages/opencode/test/kilocode/session-diff-restore.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index becbd022690..fa0d06f6bb8 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -88,7 +88,7 @@ describe("session diff restore", () => { }) expect(result).toEqual({ applied: 1, skipped: 0, total: 1 }) - expect(fs.readFileSync(path.join(dir, "src/index.ts"), "utf8")).toBe("after\n") + expect(fs.readFileSync(path.join(dir, "src/index.ts"), "utf8").replace(/\r\n/g, "\n")).toBe("after\n") }) test("skips snapshot diffs outside the workspace", () => { From eba8769af0d14b67d829d62048355294d9fa5723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 8 Jun 2026 15:11:13 +0200 Subject: [PATCH 06/12] fix(cli): preserve imported diffs across session forks --- .changeset/cloud-session-diff-restore.md | 2 +- ...session-ingest-portability-e2e-findings.md | 126 ++++++++++++++++++ .../src/kilo-sessions/kilo-sessions.ts | 16 ++- .../server/httpapi/handlers/kilo-gateway.ts | 8 +- .../session-portability/cumulative-diff.ts | 29 ++++ packages/opencode/src/session/session.ts | 11 ++ .../kilocode/session-diff-restore.test.ts | 9 ++ 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 .plans/session-ingest-portability-e2e-findings.md create mode 100644 packages/opencode/src/kilocode/session-portability/cumulative-diff.ts diff --git a/.changeset/cloud-session-diff-restore.md b/.changeset/cloud-session-diff-restore.md index da52d83fe4f..70c636c2249 100644 --- a/.changeset/cloud-session-diff-restore.md +++ b/.changeset/cloud-session-diff-restore.md @@ -3,4 +3,4 @@ "kilo-code": minor --- -Restore cloud session filesystem changes from synced session diffs when importing sessions. +Restore cloud session filesystem changes from synced session diffs when importing sessions, including inherited changes across imported session forks. diff --git a/.plans/session-ingest-portability-e2e-findings.md b/.plans/session-ingest-portability-e2e-findings.md new file mode 100644 index 00000000000..3e7c9c571c2 --- /dev/null +++ b/.plans/session-ingest-portability-e2e-findings.md @@ -0,0 +1,126 @@ +# Session Ingest Diff Portability E2E Findings + +Date: 2026-06-08 + +PR under test: https://github.com/Kilo-Org/kilocode/pull/10948 + +Local ingest override: + +```sh +KILO_SESSION_INGEST_URL=http://localhost:8800 +``` + +This override is read by the CLI sync path in `packages/opencode/src/kilo-sessions/kilo-sessions.ts`, by `kilo import` in `packages/opencode/src/cli/cmd/import.ts`, and by `@kilocode/kilo-gateway` in `packages/kilo-gateway/src/cloud-sessions.ts`. + +## E2E Run + +The successful run used three tmux-backed CLI servers, each with isolated `XDG_*` and `KILO_DB` directories: + +- `cli1`: `127.0.0.1:48931` +- `cli2`: `127.0.0.1:48932` +- `cli3`: `127.0.0.1:48933` + +Run artifact: + +```text +/var/folders/pz/_kmbp8vs2755j415slh2hz100000gn/T/kilo-session-ingest-e2e-2026-06-08T12-32-56-062Z +``` + +Sensitive launcher files were removed from the artifact. The remaining repo folders and server logs were left in place for inspection. + +Flow: + +1. Created one base git repo and cloned it into `cli1`, `cli2`, and `cli3`. +2. `cli1` created a 100-file diff with 100 lines per file under `e2e/cli1`. +3. Seeded the local session-ingest service with the `cli1` session export payload and verified `/api/session/:id/export` returned `sessionDiff.length === 100`. +4. `cli2` imported the `cli1` session through `POST /kilo/cloud/session/import` and `x-kilo-directory`. +5. Asserted `cli2` had all 100 `e2e/cli1` files with 100 lines each. +6. `cli2` created a second 100-file diff with 100 lines per file under `e2e/cli2`. +7. Seeded the local session-ingest service with a second session containing only the `cli2` diff and verified export returned `sessionDiff.length === 100`. +8. `cli3` imported the second session through `POST /kilo/cloud/session/import` and `x-kilo-directory`. +9. Asserted `cli3` had all 100 `e2e/cli2` files with 100 lines each. +10. Checked whether `cli3` also had the ancestor `e2e/cli1` files. + +Result: + +```json +{ + "cli1Session": "ses_k5SaVWUMb18AXmwPGKb0AM5SMw", + "cli2Session": "ses_R8hZAKsPAxpA0ZTPSw0wAEMKtw", + "cli2ImportedCli1": true, + "cli3ImportedCli2": true, + "cli3HasCli1AncestorFiles": false +} +``` + +## Findings + +### 1. Nested fork cumulative diffs now restore through a 5-session chain + +Initial testing showed `cli2` correctly restored `cli1`'s 100-file diff and `cli3` correctly restored `cli2`'s 100-file diff, but `cli3` did not restore `cli1`'s ancestor files when the second export contained only `cli2`'s new diff. + +The opencode server now preserves imported diffs as a base layer and sends `base + local` as the cloud `session_diff`. A follow-up e2e run created a 5-session chain and imported the fifth session into a clean verifier workspace. + +Passing run artifact: + +```text +/var/folders/pz/_kmbp8vs2755j415slh2hz100000gn/T/kilo-session-ingest-chain5-2026-06-08T13-02-17-124Z +``` + +Result: + +```json +{ + "sessions": [ + "ses_n9PWIGocAGrjP5mQAODs3fclZg", + "ses_158ac4c56ffe6eaWzTUsnVgnSZ", + "ses_158ac3cc0ffeB5HGLUlkUmT1nK", + "ses_158ac28abffeYW21k35bwFrLRk", + "ses_158ac13beffexNmX7aNonFse8M" + ], + "verifierSession": "ses_158abfa10ffeetXDlbBKHmX706", + "finalDiffCount": 500, + "replicatedLabels": 5 +} +``` + +The verifier import restored all five labels (`cli1` through `cli5`), each with 100 files and 100 lines per file. + +Remaining action item: + +- Add a durable automated e2e/regression test that imports `A -> B -> C -> D -> E` and asserts `E` contains all ancestor filesystem changes. The local tmux/service test is strong manual evidence, but it depends on the local session-ingest stack. + +### 2. Local service auth is not compatible with plain CLI sync by URL override alone + +Setting only `KILO_SESSION_INGEST_URL=http://localhost:8800` is not enough for the normal CLI share/sync path against the local service. The local session-ingest service expects an internal Kilo JWT signed with `NEXTAUTH_SECRET_PROD` and backed by an existing `kilocode_users` row. The CLI's normal Kilo auth token returned `401 Invalid or expired token`. + +For the e2e run, I used a local-only internal JWT to seed and import sessions. That exercised the local service and the PR import/restore path, but it bypassed normal `KiloSessions.share()` ingestion from the CLI because that path also validates the Kilo auth context before syncing. + +Action items: + +- Add a local-dev auth path for session-ingest testing, such as `KILO_SESSION_INGEST_TOKEN`, or document how to point both Kilo API auth validation and session-ingest at the same local token source. +- Improve `KILO_SESSION_INGEST_URL` docs to state that URL override alone does not override auth. +- Consider making local import/share failures surface the upstream 401 body instead of collapsing into a generic server error. + +### 3. The original tmux launch form left high-CPU orphaned Bun processes + +The initial launcher used: + +```sh +bun run --cwd packages/opencode --conditions=browser src/index.ts --print-logs serve ... +``` + +Those processes failed to become usable servers and kept running at roughly one CPU core each after tmux cleanup. They were killed with `SIGKILL`. + +The working launcher used: + +```sh +bun --conditions=browser packages/opencode/src/index.ts serve ... +``` + +That form started normally and had low CPU use. + +Action items: + +- Use the direct `bun --conditions=browser ... src/index.ts serve` form in local e2e scripts. +- Add process cleanup by command-line match and verify no leftover serve processes after tmux session teardown. diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 31a21e90f77..e82bad54211 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -30,6 +30,8 @@ import { Telemetry } from "@kilocode/kilo-telemetry" import { Question } from "@/question" import { Permission } from "@/permission" import { withTimeout } from "@/util/timeout" +import { Snapshot } from "@/snapshot" +import { cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" async function provide(input: { directory: string; fn: () => R }): Promise { const { WithInstance } = await import("@/project/with-instance") @@ -225,6 +227,13 @@ export namespace KiloSessions { await ingest.sync(sessionID, [{ type: "session_status", data: { status } }]) } + async function cumulative(sessionId: string, local: Snapshot.FileDiff[]) { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise( + Storage.Service.use((storage) => cumulativeSessionDiff(storage, SessionID.make(sessionId), local)), + ) + } + export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -273,7 +282,9 @@ export namespace KiloSessions { ingest.sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), ) yield* watch(Session.Event.Diff, (evt) => - ingest.sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), + cumulative(evt.properties.sessionID, evt.properties.diff).then((diff) => + ingest.sync(evt.properties.sessionID, [{ type: "session_diff", data: diff }]), + ), ) yield* watch(Session.Event.TurnOpen, (evt) => ingest.sync(evt.properties.sessionID, [{ type: "session_open", data: {} }]), @@ -673,7 +684,7 @@ export namespace KiloSessions { log.info("full sync", { sessionId }) const { AppRuntime } = await import("@/effect/app-runtime") - const [session, diffs] = await AppRuntime.runPromise( + const [session, local] = await AppRuntime.runPromise( Effect.gen(function* () { const sessions = yield* Session.Service const summary = yield* SessionSummary.Service @@ -683,6 +694,7 @@ export namespace KiloSessions { ]) }), ) + const diffs = await cumulative(sessionId, local) const messages = await Array.fromAsync(MessageV2.stream(SessionID.make(sessionId))) messages.reverse() const mdls = await models( diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index a99e73f9fd9..a9472bc234a 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -41,6 +41,7 @@ import { Session } from "@/session/session" import { Database } from "@/storage/db" import { Storage } from "@/storage/storage" import { AudioTranscriptionsBody, ClawStatus, EditBody, FimBody } from "../groups/kilo-gateway" +import { baseKey } from "../../../session-portability/cumulative-diff" import { extractSessionDiffs, restoreSessionDiffs } from "../../../session-portability/session-diff-restore" const FIM_TIMEOUT_MS = 30_000 @@ -477,7 +478,12 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", ) if (diffs.length > 0) { - yield* Storage.Service.use((storage) => storage.write(["session_diff", imported.id], diffs)).pipe( + yield* Storage.Service.use((storage) => + Effect.all([ + storage.write(baseKey(imported.id), diffs), + storage.write(["session_diff", imported.id], diffs), + ]), + ).pipe( Effect.catch((err) => Effect.sync(() => { logError("cloud/session/import/diff", err) diff --git a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts new file mode 100644 index 00000000000..2f67f184f97 --- /dev/null +++ b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect" +import { Snapshot } from "@/snapshot" +import { Storage } from "@/storage/storage" +import type { SessionID } from "@/session/schema" + +export type PortableDiff = Snapshot.FileDiff & { + after?: string +} + +export const baseKey = (id: SessionID | string) => ["session_diff_base", String(id)] + +function same(left: PortableDiff[], right: PortableDiff[]) { + return JSON.stringify(left) === JSON.stringify(right) +} + +export function mergeSessionDiffs(input: { base: PortableDiff[]; local: PortableDiff[] }) { + if (input.base.length === 0) return input.local + if (input.local.length === 0) return input.base + if (same(input.base, input.local)) return input.base + return [...input.base, ...input.local] +} + +export function readSessionDiffBase(storage: Storage.Interface, id: SessionID | string) { + return storage.read(baseKey(id)).pipe(Effect.catch(() => Effect.succeed([] as PortableDiff[]))) +} + +export function cumulativeSessionDiff(storage: Storage.Interface, id: SessionID | string, local: PortableDiff[]) { + return readSessionDiffBase(storage, id).pipe(Effect.map((base) => mergeSessionDiffs({ base, local }))) +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 3d4617c0ec1..5df32ea4f87 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -33,6 +33,7 @@ import { Global } from "@opencode-ai/core/global" import { BackgroundProcess } from "@/kilocode/background-process" import { KiloSession, kiloSessionFork } from "@/kilocode/session" import { SessionExport } from "@/kilocode/session-export" +import { baseKey, cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" // kilocode_change // kilocode_change end import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { zod } from "@opencode-ai/core/effect-zod" @@ -740,6 +741,16 @@ export const layer: Layer.Layer(["session_diff", input.sessionID]) + .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) + const base = yield* cumulativeSessionDiff(storage, input.sessionID, local) + if (base.length > 0) { + yield* storage.write(baseKey(session.id), base).pipe(Effect.ignore) + yield* storage.write(["session_diff", session.id], base).pipe(Effect.ignore) + } + // kilocode_change end return session }) diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index fa0d06f6bb8..f24996fb24b 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "node:fs" import os from "node:os" import path from "node:path" +import { mergeSessionDiffs } from "../../src/kilocode/session-portability/cumulative-diff" import { extractSessionDiffs, restoreSessionDiffs } from "../../src/kilocode/session-portability/session-diff-restore" const dirs: string[] = [] @@ -50,6 +51,14 @@ afterEach(() => { }) describe("session diff restore", () => { + test("merges imported base diffs before local diffs without duplicating unchanged imports", () => { + const base = [{ file: "a.txt", patch: "base", additions: 1, deletions: 0, status: "added" as const }] + const local = [{ file: "b.txt", patch: "local", additions: 1, deletions: 0, status: "added" as const }] + + expect(mergeSessionDiffs({ base, local })).toEqual([...base, ...local]) + expect(mergeSessionDiffs({ base, local: base })).toEqual(base) + }) + test("extracts top-level sessionDiff before legacy message summaries", () => { const diff = { file: "src/index.ts", From 5ecd805d495d1ae2882631b01280d319243d9fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 8 Jun 2026 15:14:50 +0200 Subject: [PATCH 07/12] chore: remove session ingest findings doc --- ...session-ingest-portability-e2e-findings.md | 126 ------------------ 1 file changed, 126 deletions(-) delete mode 100644 .plans/session-ingest-portability-e2e-findings.md diff --git a/.plans/session-ingest-portability-e2e-findings.md b/.plans/session-ingest-portability-e2e-findings.md deleted file mode 100644 index 3e7c9c571c2..00000000000 --- a/.plans/session-ingest-portability-e2e-findings.md +++ /dev/null @@ -1,126 +0,0 @@ -# Session Ingest Diff Portability E2E Findings - -Date: 2026-06-08 - -PR under test: https://github.com/Kilo-Org/kilocode/pull/10948 - -Local ingest override: - -```sh -KILO_SESSION_INGEST_URL=http://localhost:8800 -``` - -This override is read by the CLI sync path in `packages/opencode/src/kilo-sessions/kilo-sessions.ts`, by `kilo import` in `packages/opencode/src/cli/cmd/import.ts`, and by `@kilocode/kilo-gateway` in `packages/kilo-gateway/src/cloud-sessions.ts`. - -## E2E Run - -The successful run used three tmux-backed CLI servers, each with isolated `XDG_*` and `KILO_DB` directories: - -- `cli1`: `127.0.0.1:48931` -- `cli2`: `127.0.0.1:48932` -- `cli3`: `127.0.0.1:48933` - -Run artifact: - -```text -/var/folders/pz/_kmbp8vs2755j415slh2hz100000gn/T/kilo-session-ingest-e2e-2026-06-08T12-32-56-062Z -``` - -Sensitive launcher files were removed from the artifact. The remaining repo folders and server logs were left in place for inspection. - -Flow: - -1. Created one base git repo and cloned it into `cli1`, `cli2`, and `cli3`. -2. `cli1` created a 100-file diff with 100 lines per file under `e2e/cli1`. -3. Seeded the local session-ingest service with the `cli1` session export payload and verified `/api/session/:id/export` returned `sessionDiff.length === 100`. -4. `cli2` imported the `cli1` session through `POST /kilo/cloud/session/import` and `x-kilo-directory`. -5. Asserted `cli2` had all 100 `e2e/cli1` files with 100 lines each. -6. `cli2` created a second 100-file diff with 100 lines per file under `e2e/cli2`. -7. Seeded the local session-ingest service with a second session containing only the `cli2` diff and verified export returned `sessionDiff.length === 100`. -8. `cli3` imported the second session through `POST /kilo/cloud/session/import` and `x-kilo-directory`. -9. Asserted `cli3` had all 100 `e2e/cli2` files with 100 lines each. -10. Checked whether `cli3` also had the ancestor `e2e/cli1` files. - -Result: - -```json -{ - "cli1Session": "ses_k5SaVWUMb18AXmwPGKb0AM5SMw", - "cli2Session": "ses_R8hZAKsPAxpA0ZTPSw0wAEMKtw", - "cli2ImportedCli1": true, - "cli3ImportedCli2": true, - "cli3HasCli1AncestorFiles": false -} -``` - -## Findings - -### 1. Nested fork cumulative diffs now restore through a 5-session chain - -Initial testing showed `cli2` correctly restored `cli1`'s 100-file diff and `cli3` correctly restored `cli2`'s 100-file diff, but `cli3` did not restore `cli1`'s ancestor files when the second export contained only `cli2`'s new diff. - -The opencode server now preserves imported diffs as a base layer and sends `base + local` as the cloud `session_diff`. A follow-up e2e run created a 5-session chain and imported the fifth session into a clean verifier workspace. - -Passing run artifact: - -```text -/var/folders/pz/_kmbp8vs2755j415slh2hz100000gn/T/kilo-session-ingest-chain5-2026-06-08T13-02-17-124Z -``` - -Result: - -```json -{ - "sessions": [ - "ses_n9PWIGocAGrjP5mQAODs3fclZg", - "ses_158ac4c56ffe6eaWzTUsnVgnSZ", - "ses_158ac3cc0ffeB5HGLUlkUmT1nK", - "ses_158ac28abffeYW21k35bwFrLRk", - "ses_158ac13beffexNmX7aNonFse8M" - ], - "verifierSession": "ses_158abfa10ffeetXDlbBKHmX706", - "finalDiffCount": 500, - "replicatedLabels": 5 -} -``` - -The verifier import restored all five labels (`cli1` through `cli5`), each with 100 files and 100 lines per file. - -Remaining action item: - -- Add a durable automated e2e/regression test that imports `A -> B -> C -> D -> E` and asserts `E` contains all ancestor filesystem changes. The local tmux/service test is strong manual evidence, but it depends on the local session-ingest stack. - -### 2. Local service auth is not compatible with plain CLI sync by URL override alone - -Setting only `KILO_SESSION_INGEST_URL=http://localhost:8800` is not enough for the normal CLI share/sync path against the local service. The local session-ingest service expects an internal Kilo JWT signed with `NEXTAUTH_SECRET_PROD` and backed by an existing `kilocode_users` row. The CLI's normal Kilo auth token returned `401 Invalid or expired token`. - -For the e2e run, I used a local-only internal JWT to seed and import sessions. That exercised the local service and the PR import/restore path, but it bypassed normal `KiloSessions.share()` ingestion from the CLI because that path also validates the Kilo auth context before syncing. - -Action items: - -- Add a local-dev auth path for session-ingest testing, such as `KILO_SESSION_INGEST_TOKEN`, or document how to point both Kilo API auth validation and session-ingest at the same local token source. -- Improve `KILO_SESSION_INGEST_URL` docs to state that URL override alone does not override auth. -- Consider making local import/share failures surface the upstream 401 body instead of collapsing into a generic server error. - -### 3. The original tmux launch form left high-CPU orphaned Bun processes - -The initial launcher used: - -```sh -bun run --cwd packages/opencode --conditions=browser src/index.ts --print-logs serve ... -``` - -Those processes failed to become usable servers and kept running at roughly one CPU core each after tmux cleanup. They were killed with `SIGKILL`. - -The working launcher used: - -```sh -bun --conditions=browser packages/opencode/src/index.ts serve ... -``` - -That form started normally and had low CPU use. - -Action items: - -- Use the direct `bun --conditions=browser ... src/index.ts serve` form in local e2e scripts. -- Add process cleanup by command-line match and verify no leftover serve processes after tmux session teardown. From 84546293d49d51edb35fec9ae2bd539c6527c5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 8 Jun 2026 15:19:57 +0200 Subject: [PATCH 08/12] fix(cli): avoid duplicate cumulative session diffs --- .../src/kilocode/session-portability/cumulative-diff.ts | 9 +++++++-- .../opencode/test/kilocode/session-diff-restore.test.ts | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts index 2f67f184f97..ea6f6ab3326 100644 --- a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts +++ b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts @@ -9,14 +9,19 @@ export type PortableDiff = Snapshot.FileDiff & { export const baseKey = (id: SessionID | string) => ["session_diff_base", String(id)] -function same(left: PortableDiff[], right: PortableDiff[]) { +function equal(left: unknown, right: unknown) { return JSON.stringify(left) === JSON.stringify(right) } +function starts(base: PortableDiff[], local: PortableDiff[]) { + if (local.length < base.length) return false + return base.every((diff, index) => equal(diff, local[index])) +} + export function mergeSessionDiffs(input: { base: PortableDiff[]; local: PortableDiff[] }) { if (input.base.length === 0) return input.local if (input.local.length === 0) return input.base - if (same(input.base, input.local)) return input.base + if (starts(input.base, input.local)) return input.local return [...input.base, ...input.local] } diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index f24996fb24b..3b9b280e767 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -57,6 +57,7 @@ describe("session diff restore", () => { expect(mergeSessionDiffs({ base, local })).toEqual([...base, ...local]) expect(mergeSessionDiffs({ base, local: base })).toEqual(base) + expect(mergeSessionDiffs({ base, local: [...base, ...local] })).toEqual([...base, ...local]) }) test("extracts top-level sessionDiff before legacy message summaries", () => { From a6a3ce61a9e77d9961ca7620e2e2688204e34f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 8 Jun 2026 15:50:08 +0200 Subject: [PATCH 09/12] test(cli): isolate session export test failures --- .../src/kilocode/session-export/worker.ts | 2 +- .../src/kilocode/session-export/worker/ipc.ts | 10 +- .../session-export/worker/validate.ts | 1 + .../opencode/src/kilocode/tool/registry.ts | 15 ++- .../test/kilocode/session-export/e2e.test.ts | 7 +- .../kilocode/session-export/respawn.test.ts | 3 +- .../session-export/worker/validate.test.ts | 8 ++ ...l-registry-indexing-import-failure.test.ts | 92 ++++++++-------- ...l-registry-semantic-import-failure.test.ts | 103 +++++++++--------- 9 files changed, 141 insertions(+), 100 deletions(-) diff --git a/packages/opencode/src/kilocode/session-export/worker.ts b/packages/opencode/src/kilocode/session-export/worker.ts index 6d150833b80..45d4766b276 100644 --- a/packages/opencode/src/kilocode/session-export/worker.ts +++ b/packages/opencode/src/kilocode/session-export/worker.ts @@ -75,7 +75,7 @@ scope.onmessage = (event) => { endpoint: resolveEndpoint({ endpoint: msg.endpoint, env: process.env.KILO_SESSION_EXPORT_INGEST, - allowCustom: process.env.KILO_SESSION_EXPORT_ALLOW_CUSTOM_INGEST === "1", + allowCustom: msg.allowCustomEndpoint || process.env.KILO_SESSION_EXPORT_ALLOW_CUSTOM_INGEST === "1", }), fetch: globalThis.fetch, reportTelemetry: (item) => scope.postMessage(item), diff --git a/packages/opencode/src/kilocode/session-export/worker/ipc.ts b/packages/opencode/src/kilocode/session-export/worker/ipc.ts index d99f1e17fed..7a4c484b4d0 100644 --- a/packages/opencode/src/kilocode/session-export/worker/ipc.ts +++ b/packages/opencode/src/kilocode/session-export/worker/ipc.ts @@ -1,7 +1,15 @@ import type { ExportEvent } from "../events" export type ToWorker = - | { kind: "init"; dbPath: string; agentVersion?: string; endpoint?: string; surface?: string; anonId?: string } + | { + kind: "init" + dbPath: string + agentVersion?: string + endpoint?: string + allowCustomEndpoint?: boolean + surface?: string + anonId?: string + } | { kind: "event"; envelope: ExportEvent; approxBytes: number } | { kind: "shutdown"; timeoutMs: number } | { kind: "network_reconnect" } diff --git a/packages/opencode/src/kilocode/session-export/worker/validate.ts b/packages/opencode/src/kilocode/session-export/worker/validate.ts index 776c21c6883..7d2a9e36c54 100644 --- a/packages/opencode/src/kilocode/session-export/worker/validate.ts +++ b/packages/opencode/src/kilocode/session-export/worker/validate.ts @@ -14,6 +14,7 @@ export function parseMessage(value: unknown): ToWorker | undefined { dbPath: value.dbPath, agentVersion: text(value.agentVersion), endpoint: text(value.endpoint), + allowCustomEndpoint: value.allowCustomEndpoint === true, surface: text(value.surface), anonId: text(value.anonId), } diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index e99b8c8ebad..d1c842c5dcb 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -12,6 +12,10 @@ import * as Truncate from "@/tool/truncate" const log = Log.create({ service: "kilocode-tool-registry" }) type Deps = { agent: Agent.Interface; truncate: Truncate.Interface } +type Loaders = { + indexing?: () => Promise<{ KiloIndexing: { ready: () => boolean } }> + semantic?: () => Promise> +} export namespace KiloToolRegistry { const hint = @@ -34,6 +38,7 @@ export namespace KiloToolRegistry { export function build( tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info }, deps: Deps, + loaders: Loaders = {}, ) { return Effect.gen(function* () { const base = yield* Effect.all({ @@ -42,15 +47,16 @@ export namespace KiloToolRegistry { manager: Tool.init(tools.manager), process: Tool.init(tools.process), }) - const semantic = yield* semanticTool(deps) + const semantic = yield* semanticTool(deps, loaders) return { ...base, semantic } }) } - function semanticTool(deps: Deps) { + function semanticTool(deps: Deps, loaders: Loaders) { return Effect.gen(function* () { + const indexing = loaders.indexing ?? (() => import("@/kilocode/indexing")) const ready = yield* Effect.tryPromise(() => - import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.ready()), + indexing().then((mod) => mod.KiloIndexing.ready()), ).pipe( Effect.catch((err) => Effect.sync(() => { @@ -61,7 +67,8 @@ export namespace KiloToolRegistry { ) if (!ready) return undefined - const mod = yield* Effect.tryPromise(() => import("@/kilocode/tool/semantic-search")).pipe( + const semantic = loaders.semantic ?? (() => import("@/kilocode/tool/semantic-search")) + const mod = yield* Effect.tryPromise(() => semantic()).pipe( Effect.catch((err) => Effect.sync(() => { log.warn("semantic search tool unavailable", { err }) diff --git a/packages/opencode/test/kilocode/session-export/e2e.test.ts b/packages/opencode/test/kilocode/session-export/e2e.test.ts index ec57eb20b3a..02b4a6c68d8 100644 --- a/packages/opencode/test/kilocode/session-export/e2e.test.ts +++ b/packages/opencode/test/kilocode/session-export/e2e.test.ts @@ -92,7 +92,12 @@ function ready(worker: Worker, db: string): Promise { clearTimeout(timer) resolve() } - worker.postMessage({ kind: "init", dbPath: db, endpoint: "http://127.0.0.1:1/session-export" }) + worker.postMessage({ + kind: "init", + dbPath: db, + endpoint: "http://127.0.0.1:1/session-export", + allowCustomEndpoint: true, + }) }) } diff --git a/packages/opencode/test/kilocode/session-export/respawn.test.ts b/packages/opencode/test/kilocode/session-export/respawn.test.ts index 3876f21f721..980ebaa9ea4 100644 --- a/packages/opencode/test/kilocode/session-export/respawn.test.ts +++ b/packages/opencode/test/kilocode/session-export/respawn.test.ts @@ -5,7 +5,8 @@ import { getKillSwitchReason, resetEligibility } from "@/kilocode/session-export describe("SessionExport worker respawn", () => { let feature: string | undefined - beforeEach(() => { + beforeEach(async () => { + await SessionExport.shutdown() feature = process.env.KILOCODE_FEATURE resetEligibility() }) diff --git a/packages/opencode/test/kilocode/session-export/worker/validate.test.ts b/packages/opencode/test/kilocode/session-export/worker/validate.test.ts index 65bfad05c5a..68534a738bf 100644 --- a/packages/opencode/test/kilocode/session-export/worker/validate.test.ts +++ b/packages/opencode/test/kilocode/session-export/worker/validate.test.ts @@ -6,6 +6,14 @@ describe("session export worker validation", () => { expect(parseMessage({ kind: "init", dbPath: 42 })).toBeUndefined() }) + test("accepts init messages with custom endpoint opt-in", () => { + expect(parseMessage({ kind: "init", dbPath: ":memory:", allowCustomEndpoint: true })).toEqual({ + kind: "init", + dbPath: ":memory:", + allowCustomEndpoint: true, + }) + }) + test("rejects event messages without a valid envelope", () => { expect(parseMessage({ kind: "event", approxBytes: 10, envelope: { type: "nope" } })).toBeUndefined() }) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 465e3d5095a..26da3cd254f 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -1,49 +1,55 @@ -import { afterEach, describe, expect, mock, spyOn } from "bun:test" -import { Effect, Layer } from "effect" +import { describe, expect, spyOn, test } from "bun:test" +import { Effect, Schema } from "effect" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../../src/project/instance" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" -import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" -import { testEffect } from "../lib/effect" +import { KiloToolRegistry } from "../../src/kilocode/tool/registry" +import { Agent } from "../../src/agent/agent" +import * as Truncate from "../../src/tool/truncate" +import type * as Tool from "../../src/tool/tool" -const err = new Error("indexing import failed") - -mock.module("@/kilocode/indexing", () => { - throw err -}) - -const { ToolRegistry } = await import("../../src/tool/registry") - -const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) - -afterEach(async () => { - await disposeAllInstances() -}) +const logger = Log.create({ service: "kilocode-tool-registry" }) +const deps = { agent: {} as Agent.Interface, truncate: {} as Truncate.Interface } describe("kilocode tool registry indexing import failure", () => { - it.live("keeps non-indexing tools when the indexing module cannot load", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const logger = Log.create({ service: "kilocode-tool-registry" }) - const warn = spyOn(logger, "warn").mockImplementation(() => {}) - - try { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - - expect(ids).not.toContain("semantic_search") - expect(ids).toContain("question") - expect(ids).toContain("read") - expect(ids).toContain("suggest") - expect(warn.mock.calls[0]?.[0]).toBe("semantic search unavailable") - expect(warn.mock.calls[0]?.[1]?.err).toBeDefined() - } finally { - warn.mockRestore() - } + test("omits semantic_search when the indexing module cannot load", async () => { + const err = new Error("indexing import failed") + const warn = spyOn(logger, "warn").mockImplementation(() => {}) + + try { + const result = await Effect.runPromise( + KiloToolRegistry.build(infos(), deps, { + indexing: async () => { + throw err + }, }), - { git: true }, - ), - ) + ) + + expect(result.semantic).toBeUndefined() + expect(result.recall.id).toBe("recall") + expect(warn.mock.calls[0]?.[0]).toBe("semantic search unavailable") + expect(warn.mock.calls[0]?.[1]?.err).toBeDefined() + } finally { + warn.mockRestore() + } + }) }) + +function infos() { + return { + codebase: info("codebase_search"), + recall: info("recall"), + manager: info("agent_manager"), + process: info("background_process"), + } +} + +function info(id: string): Tool.Info { + return { + id, + init: () => + Effect.succeed({ + description: id, + parameters: Schema.String, + execute: () => Effect.succeed({ title: id, output: id, metadata: {} }), + }), + } +} diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index 89d85c6fe69..682680ccbdf 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -1,55 +1,60 @@ -import { afterEach, describe, expect, mock, spyOn } from "bun:test" -import { Effect, Layer } from "effect" +import { describe, expect, spyOn, test } from "bun:test" +import { Effect, Schema } from "effect" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "../../src/project/instance" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" -import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" -import { testEffect } from "../lib/effect" +import { KiloToolRegistry } from "../../src/kilocode/tool/registry" +import { Agent } from "../../src/agent/agent" +import * as Truncate from "../../src/tool/truncate" +import type * as Tool from "../../src/tool/tool" -const err = new Error("semantic tool import failed") - -mock.module("@/kilocode/indexing", () => ({ - KiloIndexing: { - ready: () => true, - }, -})) - -mock.module("@/kilocode/tool/semantic-search", () => { - throw err -}) - -const { ToolRegistry } = await import("../../src/tool/registry") - -const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) - -afterEach(async () => { - await disposeAllInstances() -}) +const logger = Log.create({ service: "kilocode-tool-registry" }) +const deps = { agent: {} as Agent.Interface, truncate: {} as Truncate.Interface } describe("kilocode tool registry semantic tool import failure", () => { - it.live("keeps non-indexing tools when the semantic search tool cannot load", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const logger = Log.create({ service: "kilocode-tool-registry" }) - const warn = spyOn(logger, "warn").mockImplementation(() => {}) - - try { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - - expect(ids).not.toContain("semantic_search") - expect(ids).toContain("question") - expect(ids).toContain("read") - expect(ids).toContain("suggest") - expect(warn.mock.calls[0]?.[0]).toBe("semantic search tool unavailable") - expect(warn.mock.calls[0]?.[1]?.err).toBeDefined() - } finally { - warn.mockRestore() - } + test("omits semantic_search when the semantic search tool cannot load", async () => { + const err = new Error("semantic tool import failed") + const warn = spyOn(logger, "warn").mockImplementation(() => {}) + + try { + const result = await Effect.runPromise( + KiloToolRegistry.build(infos(), deps, { + indexing: async () => ({ + KiloIndexing: { + ready: () => true, + }, + }), + semantic: async () => { + throw err + }, }), - { git: true }, - ), - ) + ) + + expect(result.semantic).toBeUndefined() + expect(result.recall.id).toBe("recall") + expect(warn.mock.calls[0]?.[0]).toBe("semantic search tool unavailable") + expect(warn.mock.calls[0]?.[1]?.err).toBeDefined() + } finally { + warn.mockRestore() + } + }) }) + +function infos() { + return { + codebase: info("codebase_search"), + recall: info("recall"), + manager: info("agent_manager"), + process: info("background_process"), + } +} + +function info(id: string): Tool.Info { + return { + id, + init: () => + Effect.succeed({ + description: id, + parameters: Schema.String, + execute: () => Effect.succeed({ title: id, output: id, metadata: {} }), + }), + } +} From b87d86355955465b0a8b9a957e824c1ca59a5a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 8 Jun 2026 16:46:02 +0200 Subject: [PATCH 10/12] fix(cli): preserve cumulative cloud fork diffs --- .../session-portability/cumulative-diff.ts | 14 +++++++++++ .../session-diff-restore.ts | 3 ++- packages/opencode/src/session/summary.ts | 22 +++++++++++----- .../kilocode/session-diff-restore.test.ts | 25 ++++++++++++++++++- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts index ea6f6ab3326..ac16af896d0 100644 --- a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts +++ b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts @@ -18,6 +18,12 @@ function starts(base: PortableDiff[], local: PortableDiff[]) { return base.every((diff, index) => equal(diff, local[index])) } +function ends(base: PortableDiff[], local: PortableDiff[]) { + if (base.length < local.length) return false + const start = base.length - local.length + return local.every((diff, index) => equal(diff, base[start + index])) +} + export function mergeSessionDiffs(input: { base: PortableDiff[]; local: PortableDiff[] }) { if (input.base.length === 0) return input.local if (input.local.length === 0) return input.base @@ -25,6 +31,14 @@ export function mergeSessionDiffs(input: { base: PortableDiff[]; local: Portable return [...input.base, ...input.local] } +export function appendSessionDiffs(input: { existing: PortableDiff[]; next: PortableDiff[] }) { + if (input.existing.length === 0) return input.next + if (input.next.length === 0) return input.existing + if (starts(input.existing, input.next)) return input.next + if (ends(input.existing, input.next)) return input.existing + return [...input.existing, ...input.next] +} + export function readSessionDiffBase(storage: Storage.Interface, id: SessionID | string) { return storage.read(baseKey(id)).pipe(Effect.catch(() => Effect.succeed([] as PortableDiff[]))) } diff --git a/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts index 4cddd31af44..0637f61cf3c 100644 --- a/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts +++ b/packages/opencode/src/kilocode/session-portability/session-diff-restore.ts @@ -55,7 +55,8 @@ function apply(dir: string, diff: Diff) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-session-diff-")) const file = path.join(tmp, "change.patch") try { - fs.writeFileSync(file, diff.patch) + const text = diff.patch.endsWith("\n") ? diff.patch : diff.patch + "\n" + fs.writeFileSync(file, text) const proc = Bun.spawnSync(["git", "apply", "--3way", "--whitespace=nowarn", file], { cwd: dir, stdout: "pipe", diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 0d6b97b2e4b..6560960f601 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -7,6 +7,7 @@ import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" +import { appendSessionDiffs, readSessionDiffBase } from "@/kilocode/session-portability/cumulative-diff" // kilocode_change function unquoteGitPath(input: string) { if (!input.startsWith('"')) return input @@ -107,7 +108,21 @@ export const layer = Layer.effect( const all = yield* sessions.messages({ sessionID: input.sessionID }) if (!all.length) return - const diffs = yield* computeDiff({ messages: all }) + const messages = all.filter( + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), + ) + const target = messages.find((m) => m.info.id === input.messageID) + const msgDiffs = target?.info.role === "user" ? yield* computeDiff({ messages }) : [] + const base = yield* readSessionDiffBase(storage, input.sessionID) + const diffs = + base.length > 0 + ? yield* storage + .read(["session_diff", input.sessionID]) + .pipe( + Effect.orElseSucceed((): Snapshot.FileDiff[] => base), + Effect.map((existing) => appendSessionDiffs({ existing: existing.length > 0 ? existing : base, next: msgDiffs })), + ) + : yield* computeDiff({ messages: all }) yield* sessions.setSummary({ sessionID: input.sessionID, summary: { @@ -119,12 +134,7 @@ export const layer = Layer.effect( yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) - const messages = all.filter( - (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), - ) - const target = messages.find((m) => m.info.id === input.messageID) if (!target || target.info.role !== "user") return - const msgDiffs = yield* computeDiff({ messages }) target.info.summary = { ...target.info.summary, diffs: msgDiffs } yield* sessions.updateMessage(target.info) }) diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index 3b9b280e767..b661dd4dc4a 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "node:fs" import os from "node:os" import path from "node:path" -import { mergeSessionDiffs } from "../../src/kilocode/session-portability/cumulative-diff" +import { appendSessionDiffs, mergeSessionDiffs } from "../../src/kilocode/session-portability/cumulative-diff" import { extractSessionDiffs, restoreSessionDiffs } from "../../src/kilocode/session-portability/session-diff-restore" const dirs: string[] = [] @@ -60,6 +60,15 @@ describe("session diff restore", () => { expect(mergeSessionDiffs({ base, local: [...base, ...local] })).toEqual([...base, ...local]) }) + test("appends turn diffs to imported cumulative diffs without repeating existing tails", () => { + const base = [{ file: "a.txt", patch: "base", additions: 1, deletions: 0, status: "added" as const }] + const local = [{ file: "b.txt", patch: "local", additions: 1, deletions: 0, status: "added" as const }] + + expect(appendSessionDiffs({ existing: base, next: local })).toEqual([...base, ...local]) + expect(appendSessionDiffs({ existing: [...base, ...local], next: local })).toEqual([...base, ...local]) + expect(appendSessionDiffs({ existing: base, next: [...base, ...local] })).toEqual([...base, ...local]) + }) + test("extracts top-level sessionDiff before legacy message summaries", () => { const diff = { file: "src/index.ts", @@ -101,6 +110,20 @@ describe("session diff restore", () => { expect(fs.readFileSync(path.join(dir, "src/index.ts"), "utf8").replace(/\r\n/g, "\n")).toBe("after\n") }) + test("applies patch diffs missing a final patch newline", () => { + const dir = repo() + const text = patch(dir).trimEnd() + git(dir, ["checkout", "--", "."]) + + const result = restoreSessionDiffs({ + directory: dir, + diffs: [{ file: "src/index.ts", patch: text, additions: 1, deletions: 1, status: "modified" }], + }) + + expect(result).toEqual({ applied: 1, skipped: 0, total: 1 }) + expect(fs.readFileSync(path.join(dir, "src/index.ts"), "utf8").replace(/\r\n/g, "\n")).toBe("after\n") + }) + test("skips snapshot diffs outside the workspace", () => { const dir = tmp() const out = path.join(path.dirname(dir), `${path.basename(dir)}-outside.txt`) From 0c28e05f03bb86858645bd8759c64ac012d048e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 8 Jun 2026 16:59:58 +0200 Subject: [PATCH 11/12] fix(cli): avoid cumulative diff prefix duplication --- .../src/kilocode/session-portability/cumulative-diff.ts | 1 + packages/opencode/src/session/summary.ts | 9 ++++++--- .../opencode/test/kilocode/session-diff-restore.test.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts index ac16af896d0..62bf1ab1146 100644 --- a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts +++ b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts @@ -35,6 +35,7 @@ export function appendSessionDiffs(input: { existing: PortableDiff[]; next: Port if (input.existing.length === 0) return input.next if (input.next.length === 0) return input.existing if (starts(input.existing, input.next)) return input.next + if (starts(input.next, input.existing)) return input.existing if (ends(input.existing, input.next)) return input.existing return [...input.existing, ...input.next] } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 6560960f601..8b2711945b9 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -108,21 +108,23 @@ export const layer = Layer.effect( const all = yield* sessions.messages({ sessionID: input.sessionID }) if (!all.length) return + // kilocode_change start - preserve imported cumulative diffs when summarizing cloud-forked sessions + const base = yield* readSessionDiffBase(storage, input.sessionID) const messages = all.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) const target = messages.find((m) => m.info.id === input.messageID) - const msgDiffs = target?.info.role === "user" ? yield* computeDiff({ messages }) : [] - const base = yield* readSessionDiffBase(storage, input.sessionID) + const local = base.length > 0 && target?.info.role === "user" ? yield* computeDiff({ messages }) : [] const diffs = base.length > 0 ? yield* storage .read(["session_diff", input.sessionID]) .pipe( Effect.orElseSucceed((): Snapshot.FileDiff[] => base), - Effect.map((existing) => appendSessionDiffs({ existing: existing.length > 0 ? existing : base, next: msgDiffs })), + Effect.map((existing) => appendSessionDiffs({ existing: existing.length > 0 ? existing : base, next: local })), ) : yield* computeDiff({ messages: all }) + // kilocode_change end yield* sessions.setSummary({ sessionID: input.sessionID, summary: { @@ -135,6 +137,7 @@ export const layer = Layer.effect( yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) if (!target || target.info.role !== "user") return + const msgDiffs = base.length > 0 ? local : yield* computeDiff({ messages }) // kilocode_change target.info.summary = { ...target.info.summary, diffs: msgDiffs } yield* sessions.updateMessage(target.info) }) diff --git a/packages/opencode/test/kilocode/session-diff-restore.test.ts b/packages/opencode/test/kilocode/session-diff-restore.test.ts index b661dd4dc4a..f3ad951ced3 100644 --- a/packages/opencode/test/kilocode/session-diff-restore.test.ts +++ b/packages/opencode/test/kilocode/session-diff-restore.test.ts @@ -67,6 +67,7 @@ describe("session diff restore", () => { expect(appendSessionDiffs({ existing: base, next: local })).toEqual([...base, ...local]) expect(appendSessionDiffs({ existing: [...base, ...local], next: local })).toEqual([...base, ...local]) expect(appendSessionDiffs({ existing: base, next: [...base, ...local] })).toEqual([...base, ...local]) + expect(appendSessionDiffs({ existing: [...base, ...local], next: base })).toEqual([...base, ...local]) }) test("extracts top-level sessionDiff before legacy message summaries", () => { From bbf727868c4ba81332e3429ed7195ec9bb6fb95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 8 Jun 2026 17:27:29 +0200 Subject: [PATCH 12/12] test(cli): relax prompt busy checks on windows --- packages/opencode/test/session/prompt.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ffc46c1909b..7ebee7a0c5e 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1264,7 +1264,7 @@ it.live( }), { git: true, config: providerCfg }, ), - 3_000, + 10_000, // kilocode_change - Windows CI can take longer to enter and cancel the live loop ) it.live("assertNotBusy succeeds when idle", () => @@ -1309,7 +1309,7 @@ it.live( }), { git: true, config: providerCfg }, ), - 3_000, + 10_000, // kilocode_change - Windows CI can take longer to enter and cancel the live loop ) unix("shell captures stdout and stderr in completed tool output", () =>