diff --git a/.changeset/quiet-cloud-session-timeouts.md b/.changeset/quiet-cloud-session-timeouts.md new file mode 100644 index 0000000000..eaa5659e08 --- /dev/null +++ b/.changeset/quiet-cloud-session-timeouts.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +"kilo-code": patch +--- + +Restore Cloud Agent transcripts in VS Code session previews and stop cloud session previews or continuation from loading indefinitely when a request stalls. diff --git a/packages/kilo-gateway/src/cloud-sessions.ts b/packages/kilo-gateway/src/cloud-sessions.ts index fd5e9d4c99..2752073db1 100644 --- a/packages/kilo-gateway/src/cloud-sessions.ts +++ b/packages/kilo-gateway/src/cloud-sessions.ts @@ -7,6 +7,7 @@ export interface DrizzleDb { } const INGEST_BASE = process.env.KILO_SESSION_INGEST_URL ?? "https://ingest.kilosessions.ai" +const TIMEOUT = 30_000 function exportUrl(sessionId: string) { return UUID_RE.test(sessionId) @@ -18,6 +19,7 @@ export type FetchResult = { ok: true; data: any } | { ok: false; status: number; export async function fetchCloudSession(token: string, sessionId: string): Promise { const response = await fetch(exportUrl(sessionId), { + signal: AbortSignal.timeout(TIMEOUT), headers: { Authorization: `Bearer ${token}`, ...buildKiloHeaders(), @@ -33,6 +35,7 @@ export async function fetchCloudSession(token: string, sessionId: string): Promi export async function fetchCloudSessionForImport(token: string, sessionId: string): Promise { const response = await fetch(exportUrl(sessionId), { + signal: AbortSignal.timeout(TIMEOUT), headers: { Authorization: `Bearer ${token}`, ...buildKiloHeaders(), diff --git a/packages/kilo-gateway/test/cloud-sessions.test.ts b/packages/kilo-gateway/test/cloud-sessions.test.ts new file mode 100644 index 0000000000..2c292bb5ef --- /dev/null +++ b/packages/kilo-gateway/test/cloud-sessions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { fetchCloudSession, fetchCloudSessionForImport } from "../src/cloud-sessions" + +async function expectStalledFetchToTimeOut(run: () => Promise) { + const fetch = globalThis.fetch + const timeout = AbortSignal.timeout + let delay: number | undefined + + AbortSignal.timeout = (ms) => { + delay = ms + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + })) as typeof globalThis.fetch + + try { + const outcome = await Promise.race([ + run().then( + () => "resolved" as const, + (err) => { + if (err instanceof DOMException && err.name === "TimeoutError") return "timed-out" as const + throw err + }, + ), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("timed-out") + expect(delay).toBe(30_000) + } finally { + globalThis.fetch = fetch + AbortSignal.timeout = timeout + } +} + +describe("cloud session export requests", () => { + test("times out a stalled preview request", async () => { + await expectStalledFetchToTimeOut(() => fetchCloudSession("token", "session-id")) + }) + + test("times out a stalled import request", async () => { + await expectStalledFetchToTimeOut(() => fetchCloudSessionForImport("token", "session-id")) + }) +}) diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 951d0f8ce4..51c50e7c9b 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -23,6 +23,7 @@ const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const opencodeDir = join(packagesDir, "opencode") const coreDir = join(packagesDir, "core") +const gatewayDir = join(packagesDir, "kilo-gateway") const indexingDir = join(packagesDir, "kilo-indexing") const targetBinDir = join(kiloVscodeDir, "bin") @@ -38,8 +39,12 @@ async function cliSourceHash(): Promise { try { const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet() + const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() - return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${indexingResult.text().trim()}` || null + return ( + `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}` || + null + ) } catch { return null } @@ -49,10 +54,12 @@ async function isDirty(): Promise { try { const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet() + const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() return ( opencodeResult.text().trim().length > 0 || coreResult.text().trim().length > 0 || + gatewayResult.text().trim().length > 0 || indexingResult.text().trim().length > 0 ) } catch { diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index 29ce097953..f2cab56fab 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -10,6 +10,8 @@ import type { CloudSessionData, EditorContext } from "../../services/cli-backend import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils" import type { MessageFile } from "../message-files" +const TIMEOUT = 30_000 + export interface CloudSessionContext { readonly client: KiloClient | null currentSession: Session | null @@ -73,7 +75,7 @@ export async function handleRequestCloudSessionData(ctx: CloudSessionContext, se } try { - const result = await ctx.client.kilo.cloud.session.get({ id: sessionId }) + const result = await ctx.client.kilo.cloud.session.get({ id: sessionId }, { signal: AbortSignal.timeout(TIMEOUT) }) const data = result.data as CloudSessionData | undefined if (!data) { ctx.postMessage({ @@ -135,10 +137,13 @@ export async function handleImportAndSend( // Step 1: Import the cloud session with fresh IDs let session: Session | undefined try { - const result = await ctx.client.kilo.cloud.session.import({ - sessionId: cloudSessionId, - directory: dir, - }) + const result = await ctx.client.kilo.cloud.session.import( + { + sessionId: cloudSessionId, + directory: dir, + }, + { signal: AbortSignal.timeout(TIMEOUT) }, + ) session = result.data as Session | undefined } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Cloud session import failed:", error) diff --git a/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts new file mode 100644 index 0000000000..accf86ee0a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test" +import { + handleImportAndSend, + handleRequestCloudSessionData, + type CloudSessionContext, +} from "../../src/kilo-provider/handlers/cloud-session" + +function stalled(options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }) + }) +} + +function context(sent: unknown[]) { + return { + client: { + kilo: { + cloud: { + session: { + get: (_params: { id: string }, options?: { signal?: AbortSignal }) => stalled(options), + import: (_params: { sessionId: string; directory: string }, options?: { signal?: AbortSignal }) => + stalled(options), + }, + }, + }, + }, + currentSession: null, + trackedSessionIds: new Set(), + connectionService: { recordMessageSessionId: () => undefined }, + postMessage: (message: unknown) => sent.push(message), + getWorkspaceDirectory: () => "/repo", + gatherEditorContext: async () => ({}), + } as unknown as CloudSessionContext +} + +describe("cloud session preview handler", () => { + it("reports a failure when the CLI preview request stalls", async () => { + const timeout = AbortSignal.timeout + AbortSignal.timeout = () => { + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + + try { + const sent: unknown[] = [] + const outcome = await Promise.race([ + handleRequestCloudSessionData(context(sent), "cloud-session").then(() => "resolved" as const), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("resolved") + expect(sent).toEqual([ + { + type: "cloudSessionImportFailed", + cloudSessionId: "cloud-session", + error: "The operation timed out", + }, + ]) + } finally { + AbortSignal.timeout = timeout + } + }) + + it("reports a failure when the CLI import request stalls", async () => { + const timeout = AbortSignal.timeout + AbortSignal.timeout = () => { + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + + try { + const sent: unknown[] = [] + const outcome = await Promise.race([ + handleImportAndSend(context(sent), "cloud-session", "Continue").then(() => "resolved" as const), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("resolved") + expect(sent).toEqual([ + { + type: "cloudSessionImportFailed", + cloudSessionId: "cloud-session", + error: "The operation timed out", + }, + ]) + } finally { + AbortSignal.timeout = timeout + } + }) +}) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index 79112d93b2..92012557e8 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts @@ -188,35 +188,49 @@ export const TranscriptionResponse = Schema.Struct({ usage: Schema.optional(Schema.Unknown), }) -export const CloudMessage = Schema.Struct({ - info: Schema.Struct({ - id: Schema.String, - sessionID: Schema.String, - role: Schema.Literals(["user", "assistant"]), - time: Schema.Struct({ - created: Schema.Finite, - completed: Schema.optional(Schema.Finite), - }), +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown) + +export const CloudMessage = Schema.StructWithRest( + Schema.Struct({ + info: Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + sessionID: Schema.String, + role: Schema.Literals(["user", "assistant"]), + time: Schema.Struct({ + created: Schema.Finite, + completed: Schema.optional(Schema.Finite), + }), + }), + [UnknownRecord], + ), + parts: Schema.Array( + Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + sessionID: Schema.String, + messageID: Schema.String, + type: Schema.String, + }), + [UnknownRecord], + ), + ), }), - parts: Schema.Array( - Schema.Struct({ - id: Schema.String, - sessionID: Schema.String, - messageID: Schema.String, - type: Schema.String, - }), - ), -}) + [UnknownRecord], +) export const CloudSessionData = Schema.Struct({ - info: Schema.Struct({ - id: Schema.String, - title: Schema.String, - time: Schema.Struct({ - created: Schema.Finite, - updated: Schema.Finite, + info: Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + title: Schema.String, + time: Schema.Struct({ + created: Schema.Finite, + updated: Schema.Finite, + }), }), - }), + [UnknownRecord], + ), messages: Schema.Array(CloudMessage), }) diff --git a/packages/opencode/test/kilocode/cloud-session-schema.test.ts b/packages/opencode/test/kilocode/cloud-session-schema.test.ts new file mode 100644 index 0000000000..c45642af9e --- /dev/null +++ b/packages/opencode/test/kilocode/cloud-session-schema.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { CloudSessionData } from "../../src/kilocode/server/httpapi/groups/kilo-gateway" + +describe("cloud session HTTP schema", () => { + test("preserves transcript fields needed by the VS Code preview", () => { + const input = { + info: { + id: "ses_cloud", + title: "Cloud transcript", + slug: "cloud-transcript", + time: { created: 1, updated: 2 }, + }, + messages: [ + { + info: { + id: "msg_user", + sessionID: "ses_cloud", + role: "user" as const, + agent: "code", + time: { created: 3 }, + }, + parts: [ + { + id: "prt_text", + sessionID: "ses_cloud", + messageID: "msg_user", + type: "text", + text: "Show this cloud message", + }, + ], + }, + ], + } + + expect(Schema.encodeUnknownSync(CloudSessionData)(input)).toEqual(input) + }) +}) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6f845c28f5..66750773bf 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12025,8 +12025,7 @@ "additionalProperties": false } }, - "required": ["id", "title", "time"], - "additionalProperties": false + "required": ["id", "title", "time"] }, "messages": { "type": "array", @@ -12060,8 +12059,7 @@ "additionalProperties": false } }, - "required": ["id", "sessionID", "role", "time"], - "additionalProperties": false + "required": ["id", "sessionID", "role", "time"] }, "parts": { "type": "array", @@ -12081,13 +12079,11 @@ "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"], - "additionalProperties": false + "required": ["id", "sessionID", "messageID", "type"] } } }, - "required": ["info", "parts"], - "additionalProperties": false + "required": ["info", "parts"] } } }, @@ -12170,7 +12166,6 @@ } }, "required": ["id", "title", "time"], - "additionalProperties": false, "description": "Imported session info" } }