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
5 changes: 5 additions & 0 deletions .changeset/cloud-fork-session-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Fix cloud session fork commands so they import cloud sessions before validating the local session.
28 changes: 14 additions & 14 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.()
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions packages/opencode/src/kilocode/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
})
Expand All @@ -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,
Expand Down
68 changes: 65 additions & 3 deletions packages/opencode/test/kilocode/cli/tui/thread.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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"
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()
Expand All @@ -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"
Expand All @@ -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 })
},
})
Expand Down Expand Up @@ -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" } }
},
},
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: expect inside mock will be silently swallowed

The expect(input.sessionId).toBe("ses_cloud") assertion sits inside the mocked import function. When importCloudSession is called, it invokes this mock — but the call site wraps the whole thing with .catch(() => undefined) (see thread.ts line 38). If this assertion throws (e.g. the wrong session ID is passed), the error is caught and id becomes undefined, causing an early return with fork.ok = false. The test then only fails on the later expect(seen).toEqual(["ses_local"]) with a confusing message rather than pointing directly at the bad session ID.

Consider moving the assertion out of the mock and instead capturing the received sessionId in a variable, then asserting after the await:

let importedId: string | undefined
mock.module("@kilocode/sdk/v2", () => ({
  createKiloClient: () => ({
    kilo: {
      cloud: {
        session: {
          import: async (input: { sessionId: string }) => {
            importedId = input.sessionId
            return { data: { id: "ses_local" } }
          },
        },
      },
    },
  }),
}))
// ... after `await mod.KiloTuiThreadDaemon.attach(...)`
expect(importedId).toBe("ses_cloud")

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

},
}),
}))
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"])
})
})
Loading