diff --git a/.changeset/cloud-fork-import-errors.md b/.changeset/cloud-fork-import-errors.md new file mode 100644 index 00000000000..1a12f5309b5 --- /dev/null +++ b/.changeset/cloud-fork-import-errors.md @@ -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. diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 3c1810be7ce..a55082db36e 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -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) @@ -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() diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 580cbc93ea3..e529bcac11a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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")) @@ -442,29 +442,29 @@ export const RunCommand = effectCmd({ async function session(sdk: KiloClient): Promise { // 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 diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index a02a468b470..0f449825e3f 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -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 @@ -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 diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts index c24f2bb0f8f..f99f05e0351 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts @@ -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" @@ -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 { diff --git a/packages/opencode/src/kilocode/cloud-session.ts b/packages/opencode/src/kilocode/cloud-session.ts index 28e7899472a..f148284ab98 100644 --- a/packages/opencode/src/kilocode/cloud-session.ts +++ b/packages/opencode/src/kilocode/cloud-session.ts @@ -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. */ @@ -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( client: { kilo: { cloud: { session: { - import: (params: { sessionId: string }) => Promise<{ data?: unknown }> + import: (params: { sessionId: string }) => Promise<{ data?: unknown; error?: unknown }> } } } }, sessionId: string, -): Promise { +): Promise { const result = await client.kilo.cloud.session.import({ sessionId }) + if (result.error) throw new Error(importErrorReason(result.error)) const id = (result.data as Record)?.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 { + log.debug("failed to import cloud session", { err }) + UI.error(`Failed to import session from cloud: ${errorMessage(err)}`) } diff --git a/packages/opencode/test/kilocode/cloud-session.test.ts b/packages/opencode/test/kilocode/cloud-session.test.ts new file mode 100644 index 00000000000..edcce1b5f95 --- /dev/null +++ b/packages/opencode/test/kilocode/cloud-session.test.ts @@ -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) => + ({ kilo: { cloud: { session: { import: imp } } } }) as Parameters[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") + }) +})