diff --git a/.changeset/cloud-fork-session-import.md b/.changeset/cloud-fork-session-import.md new file mode 100644 index 00000000000..a8fd70504fd --- /dev/null +++ b/.changeset/cloud-fork-session-import.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix cloud session fork commands so they import cloud sessions before validating the local session. diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 5cf44aa4965..4cc4859a2b6 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -320,20 +320,6 @@ export const TuiThreadCommand = cmd({ events: createEventSource(client), } - try { - await validateSession({ - url: transport.url, // kilocode_change - sessionID: localSessionID(args), // kilocode_change - directory: cwd, - fetch: transport.fetch, - headers: transport.headers, // kilocode_change - }) - } catch (error) { - UI.error(errorMessage(error)) - process.exitCode = 1 - return - } - setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) }, 1000).unref?.() @@ -359,6 +345,20 @@ export const TuiThreadCommand = cmd({ } // kilocode_change end + try { + await validateSession({ + url: transport.url, // kilocode_change + sessionID: localSessionID(args), // kilocode_change + directory: cwd, + fetch: transport.fetch, + headers: transport.headers, // kilocode_change + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + // kilocode_change start await start({ // kilocode_change - shared lazy loader also supports daemon attach diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts index ad414345a08..d9a9441175a 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts @@ -6,7 +6,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { errorMessage } from "@/util/error" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { validateSession } from "@/cli/cmd/tui/validate-session" -import { importCloudSession, localSessionID } from "@/kilocode/cloud-session" +import { importCloudSession } from "@/kilocode/cloud-session" import { DaemonClient } from "@/kilocode/daemon/client" import { createKiloClient } from "@kilocode/sdk/v2" @@ -67,10 +67,13 @@ export namespace KiloTuiThreadDaemon { const prompt = await input.input() const config = await TuiConfig.get() + const fork = await session(input, daemon) + if (!fork.ok) return true + try { await validateSession({ url: daemon.url, - sessionID: localSessionID(input.args), + sessionID: fork.id, directory: input.cwd, headers: daemon.headers, }) @@ -80,9 +83,6 @@ export namespace KiloTuiThreadDaemon { return true } - const fork = await session(input, daemon) - if (!fork.ok) return true - await input.start({ url: daemon.url, config, diff --git a/packages/opencode/test/kilocode/cli/tui/thread.test.ts b/packages/opencode/test/kilocode/cli/tui/thread.test.ts index d75a75c2b57..a6076a03bd8 100644 --- a/packages/opencode/test/kilocode/cli/tui/thread.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/thread.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, spyOn, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../../fixture/fixture" @@ -6,6 +6,10 @@ import { resolveThreadDirectory } from "../../../../src/cli/cmd/tui/thread" import { KiloTuiThreadDaemon } from "../../../../src/kilocode/cli/cmd/tui/thread" import { DaemonClient } from "../../../../src/kilocode/daemon/client" +afterEach(() => { + mock.restore() +}) + describe("kilo tui thread", () => { test("ignores stale PWD after cwd is changed by a process wrapper", async () => { await using root = await tmpdir() @@ -31,7 +35,7 @@ describe("kilo tui thread", () => { } }) - test("skips local validation before importing cloud sessions", async () => { + test("validates imported daemon session over HTTP after importing from cloud", async () => { await using root = await tmpdir() const cloud = "ses_cloud" const local = "ses_local" @@ -43,6 +47,7 @@ describe("kilo tui thread", () => { const route = `${request.method} ${new URL(request.url).pathname}` calls.push(route) if (route === "POST /kilo/cloud/session/import") return Response.json({ id: local }) + if (route === `GET /session/${local}`) return Response.json({ id: local }) return new Response(null, { status: 404 }) }, }) @@ -76,10 +81,67 @@ describe("kilo tui thread", () => { start, }) - expect(calls).toEqual(["POST /kilo/cloud/session/import"]) + expect(calls).toEqual(["POST /kilo/cloud/session/import", `GET /session/${local}`]) expect(opened).toEqual([local]) } finally { daemon.mockRestore() } }) + + test("imports cloud fork before validating daemon session", async () => { + const seen: string[] = [] + const started: string[] = [] + + mock.module("@kilocode/sdk/v2", () => ({ + createKiloClient: () => ({ + kilo: { + cloud: { + session: { + import: async (input: { sessionId: string }) => { + expect(input.sessionId).toBe("ses_cloud") + return { data: { id: "ses_local" } } + }, + }, + }, + }, + }), + })) + mock.module("@/cli/cmd/tui/validate-session", () => ({ + validateSession: async (input: { sessionID?: string }) => { + seen.push(input.sessionID ?? "") + }, + })) + mock.module("@/cli/cmd/tui/config/tui", () => ({ + TuiConfig: { + get: async () => ({}), + }, + })) + mock.module("@/kilocode/daemon/client", () => ({ + DaemonClient: { + maybe: async () => ({ url: "http://127.0.0.1:4096", headers: {} }), + }, + })) + mock.module("@/cli/ui", () => ({ + UI: { + println: () => {}, + error: () => {}, + }, + })) + + const key = JSON.stringify({ time: Date.now(), rand: Math.random() }) + const mod = await import(`../../../../src/kilocode/cli/cmd/tui/thread?${key}`) + + const handled = await mod.KiloTuiThreadDaemon.attach({ + args: { session: "ses_cloud", cloudFork: true }, + cwd: "/tmp/project", + input: async () => undefined, + start: async (input: { args: { sessionID?: string } }) => { + started.push(input.args.sessionID ?? "") + }, + }) + + expect(handled).toBe(true) + expect(seen).toEqual(["ses_local"]) + expect(started).toEqual(["ses_local"]) + }) })