Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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-import-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Surface the underlying reason when `kilo --cloud-fork` fails to import a cloud session (HTTP status, server message, or fetch error) in both the user-visible message and the DEBUG log stream.
13 changes: 7 additions & 6 deletions packages/opencode/src/cli/cmd/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const AttachCommand = cmd({
}

// kilocode_change start
const { importCloudSession, validateCloudFork } = await import("@/kilocode/cloud-session")
const { importCloudSession, validateCloudFork, reportCloudImportError } = await import("@/kilocode/cloud-session")
const cloudForkError = validateCloudFork(args)
if (cloudForkError) {
UI.error(cloudForkError)
Expand Down Expand Up @@ -88,14 +88,15 @@ export const AttachCommand = cmd({
directory,
headers,
})
const id = await importCloudSession(sdk, args.session).catch(() => undefined)
if (!id) {
UI.error("Failed to import session from cloud")
try {
const id = await importCloudSession(sdk, args.session)
args.session = id
args.cloudFork = false
} catch (err) {
reportCloudImportError(err)
process.exitCode = 1
return
}
args.session = id
args.cloudFork = false
}
// kilocode_change end
const config = await TuiConfig.get()
Expand Down
42 changes: 21 additions & 21 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export const RunCommand = effectCmd({
// kilocode_change start - lazy Kilo implementations (see top-of-file note)
const { createKiloClient } = yield* Effect.promise(() => import("@kilocode/sdk/v2"))
const { buildRunMessage } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run-message"))
const { importCloudSession, validateCloudFork } = yield* Effect.promise(() => import("@/kilocode/cloud-session"))
const { importCloudSession, validateCloudFork, reportCloudImportError } = yield* Effect.promise(() => import("@/kilocode/cloud-session"))
const { KiloRunAuto } = yield* Effect.promise(() => import("@/kilocode/cli/run-auto"))
const { KiloHeadless } = yield* Effect.promise(() => import("@/kilocode/permission/headless"))
const { KiloRun, KiloRunDaemon } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run"))
Expand Down Expand Up @@ -442,29 +442,29 @@ export const RunCommand = effectCmd({
async function session(sdk: KiloClient): Promise<SessionInfo | undefined> {
// kilocode_change start - import cloud session before local lookup
if (args.session && args["cloud-fork"]) {
const id = await importCloudSession(sdk, args.session).catch(() => undefined)
if (!id) {
UI.error("Failed to import session from cloud")
process.exit(1)
}

const current = await sdk.session
.get({
sessionID: id,
})
.catch(() => undefined)
try {
const id = await importCloudSession(sdk, args.session)
const current = await sdk.session
.get({
sessionID: id,
})
.catch(() => undefined)

if (!current?.data) {
UI.error("Session not found")
process.exit(1)
}

if (!current?.data) {
UI.error("Session not found")
return {
id: current.data.id,
title: current.data.title,
directory: current.data.directory,
model: current.data.model,
}
} catch (err) {
reportCloudImportError(err)
process.exit(1)
}

return {
id: current.data.id,
title: current.data.title,
directory: current.data.directory,
model: current.data.model,
}
}
// kilocode_change end

Expand Down
13 changes: 7 additions & 6 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export const TuiThreadCommand = cmd({
handler: async (args) => {
// kilocode_change start - lazy Kilo implementations so other CLI commands
// don't pay their module cost at startup
const { importCloudSession, localSessionID, validateCloudFork } = await import("@/kilocode/cloud-session")
const { importCloudSession, localSessionID, validateCloudFork, reportCloudImportError } = await import("@/kilocode/cloud-session")
const { KiloTuiThreadDaemon } = await import("@/kilocode/cli/cmd/tui/thread")
const { preload } = await import("@/kilocode/cli/cmd/tui")
// kilocode_change end
Expand Down Expand Up @@ -364,14 +364,15 @@ export const TuiThreadCommand = cmd({
headers: transport.headers, // kilocode_change
directory: cwd,
})
const id = await importCloudSession(sdk, args.session).catch(() => undefined)
if (!id) {
UI.error("Failed to import session from cloud")
try {
const id = await importCloudSession(sdk, args.session)
args.session = id
args.cloudFork = false
} catch (err) {
reportCloudImportError(err)
shutdownAndExit({ reason: "cloud-fork-failed", code: 1 })
return
}
args.session = id
args.cloudFork = false
}
// kilocode_change end

Expand Down
18 changes: 10 additions & 8 deletions packages/opencode/src/kilocode/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { UI } from "@/cli/ui"
import type { NetworkOptions } from "@/cli/network"
import { ServerAuth } from "@/server/auth"
import { Flag } from "@opencode-ai/core/flag/flag"
import { errorMessage } from "@/util/error"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { TuiConfig } from "@/config/tui"
import { validateSession } from "@/cli/tui/validate-session"
import { importCloudSession } from "@/kilocode/cloud-session"
import { importCloudSession, reportCloudImportError } from "@/kilocode/cloud-session"
import { DaemonClient } from "@/kilocode/daemon/client"
import { createKiloClient } from "@kilocode/sdk/v2"

Expand Down Expand Up @@ -39,12 +39,14 @@ async function session(input: Input, daemon: DaemonClient.Connection) {
directory: input.cwd,
headers: daemon.headers,
})
const id = await importCloudSession(client, input.args.session).catch(() => undefined)
if (id) return { ok: true as const, id }

UI.error("Failed to import session from cloud")
process.exitCode = 1
return { ok: false as const }
try {
const id = await importCloudSession(client, input.args.session)
return { ok: true as const, id }
} catch (err) {
reportCloudImportError(err)
process.exitCode = 1
return { ok: false as const }
}
}

export namespace KiloTuiThreadDaemon {
Expand Down
42 changes: 39 additions & 3 deletions packages/opencode/src/kilocode/cloud-session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { errorMessage } from "@/util/error"
import { Log } from "@opencode-ai/core/util/log"
import { UI } from "@/cli/ui"

const log = Log.create({ service: "kilocode.cloud-session" })

/**
* Validate --cloud-fork flag combinations and return an error message if invalid.
*/
Expand All @@ -21,20 +27,50 @@ export function localSessionID(args: { cloudFork?: boolean; session?: string })
* Import a cloud session to local storage and return the new local session ID.
* Wraps the SDK's `.kilo.cloud.session.import()` which returns `unknown` due to
* the OpenAPI spec not typing the response.
*
* Throws when the import fails: with the server's error message on an HTTP
* error, or with "cloud session import returned no session id" when the
* response was malformed.
*/
export async function importCloudSession(

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.

SUGGESTION: Consider consolidating the duplicated try/catch pattern

The try/catch + log.debug("failed to import cloud session", { err }) + UI.error(\Failed to import session from cloud: ${errorMessage(err)}`) sequence is repeated near-verbatim across four call sites, three of which are shared upstream files (run.ts, attach.ts, thread.ts). Since each site's only real difference is the exit mechanism (process.exit, process.exitCode, shutdownAndExit), a small helper here (e.g. importCloudSessionOrThrowthat logs before rethrowing, or a shareddescribeCloudImportError(err)formatter) could shrink the diff each shared file carries against upstream — this repo's stated top priority for files outsidekilocode`-named paths.


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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — addressed in commit 7138f93. Extracted a shared
eportCloudImportError(err)\ helper in \packages/opencode/src/kilocode/cloud-session.ts\ that logs the failure at DEBUG, prints the real reason via \UI.error, then rethrows. The four call sites now delegate to it and keep their own exit semantics, so the three shared upstream files only carry a single marked import instead of a duplicated try/catch — shrinking the diff against upstream as suggested.

client: {
kilo: {
cloud: {
session: {
import: (params: { sessionId: string }) => Promise<{ data?: unknown }>
import: (params: { sessionId: string }) => Promise<{ data?: unknown; error?: unknown }>
}
}
}
},
sessionId: string,
): Promise<string | undefined> {
): Promise<string> {
const result = await client.kilo.cloud.session.import({ sessionId })
if (result.error) throw new Error(importErrorReason(result.error))
const id = (result.data as Record<string, unknown>)?.id
return typeof id === "string" ? id : undefined
if (typeof id !== "string") throw new Error("cloud session import returned no session id")
return id
}

/**
* Extract a human-readable reason from a failed cloud-session import.
* The gateway returns errors as `{ error: string }` (400/500), while other
* SDK error shapes carry `.message`/`.data.message`. Prefer the `error`
* field so the real server reason reaches the user instead of `[object Object]`.
*/
function importErrorReason(error: unknown): string {
const err = error as { error?: unknown }
if (typeof err.error === "string" && err.error) return err.error
return errorMessage(error)
}

/**
* Report a failed cloud-session import: log the cause at DEBUG and surface a
* human-readable message via `UI.error`. Returns `void` on purpose — it does
* not throw, so each caller keeps its own deterministic exit semantics
* (`process.exit` / `exitCode` / `shutdownAndExit` / typed `return`). The
* caller must still perform that exit after calling this.
*/
export function reportCloudImportError(err: unknown): void {

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: reportCloudImportError is typed void but the docstring says its return type should be never since it always rethrows — and that mismatch has a real effect: every one of the four call sites (run.ts, attach.ts, tui.ts, kilocode/cli/cmd/tui/thread.ts) calls this then follows with process.exit(1) / process.exitCode = 1; return / shutdownAndExit(...), which is now unreachable dead code — the throw err fires first. The intended per-site exit semantics no longer run explicitly; instead the caller's async handler rejects and the exit code/behavior depends on how that rejection is handled upstream (or isn't, for the plain cmd()-registered handlers like attach.ts and tui.ts which have no wrapping try/catch or global rejection handler), rather than the clean, deterministic exit the docstring claims to preserve.

Declaring reportCloudImportError(err: unknown): never (matching the existing die/dieInteractive pattern in run.ts) would make TypeScript flag the now-dead lines after each call, or the call sites could just drop the redundant process.exit(1) etc. and rely on the throw, matching this repo's other never-typed helpers.


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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch on the dead-code mismatch — fixed (helper is now void, call sites keep their own exits, docstring matches).

Deliberate deviation from the literal : never: a throw would (1) drop tui.ts's shutdownAndExit TUI-worker teardown and orphan the subprocess, (2) break kilo-mirror/thread.ts's { ok: false } contract that attach() relies on, and (3) double-print via src/index.ts:148's top-level catch.

With void + per-site exit, behavior is identical to the earlier inline version: single message, process.exit/exitCode/shutdownAndExit all still run, no dead code.

Net: dead code gone, docstring accurate, graceful per-site exits preserved.

Happy to switch to : never if you prefer the throw style, but I'd first guard the worker teardown and avoid the double-print.

log.debug("failed to import cloud session", { err })
UI.error(`Failed to import session from cloud: ${errorMessage(err)}`)
}
54 changes: 54 additions & 0 deletions packages/opencode/test/kilocode/cloud-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test, mock } from "bun:test"
import { importCloudSession, reportCloudImportError } from "../../src/kilocode/cloud-session"

const errorMock = mock()
mock.module("@/cli/ui", () => ({ UI: { error: errorMock } }))

type ImportResult = { data?: unknown; error?: unknown }

const client = (imp: (params: { sessionId: string }) => Promise<ImportResult>) =>
({ kilo: { cloud: { session: { import: imp } } } }) as Parameters<typeof importCloudSession>[0]

describe("importCloudSession", () => {
test("returns local id on success", async () => {
const c = client(async () => ({ data: { id: "ses_local" } }))
const id = await importCloudSession(c, "ses_cloud")
expect(id).toBe("ses_local")
})

test("throws when server returns HTTP error", async () => {
const c = client(async () => ({
data: undefined,
error: { name: "GatewayError", message: "session not found", status: 404 },
}))
await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow("session not found")
})

test("throws with the gateway's { error } reason (400/500 contract)", async () => {
const c = client(async () => ({
data: undefined,
error: { error: "Invalid export data" },
}))
await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow("Invalid export data")
})

test("throws when data.id is missing", async () => {
const c = client(async () => ({ data: {} }))
await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow()
})

test("propagates thrown fetch exceptions", async () => {
const c = client(async () => {
throw new Error("network down")
})
await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow("network down")
})
})

describe("reportCloudImportError", () => {
test("surfaces the reason via UI.error and does not throw", () => {
const err = new Error("session not found")
expect(() => reportCloudImportError(err)).not.toThrow()
expect(errorMock).toHaveBeenCalledWith("Failed to import session from cloud: session not found")
})
})
Loading