fix: restore cloud session previews

This commit is contained in:
marius-kilocode
2026-06-03 12:50:53 +02:00
parent 0f87ebaa5d
commit a6b005dfed
9 changed files with 249 additions and 40 deletions
@@ -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.
@@ -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<FetchResult> {
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<FetchResult> {
const response = await fetch(exportUrl(sessionId), {
signal: AbortSignal.timeout(TIMEOUT),
headers: {
Authorization: `Bearer ${token}`,
...buildKiloHeaders(),
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { fetchCloudSession, fetchCloudSessionForImport } from "../src/cloud-sessions"
async function expectStalledFetchToTimeOut(run: () => Promise<unknown>) {
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<Response>((_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"))
})
})
+8 -1
View File
@@ -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<string | null> {
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<boolean> {
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 {
@@ -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)
@@ -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<never>((_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<string>(),
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
}
})
})
@@ -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),
})
@@ -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)
})
})
+4 -9
View File
@@ -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"
}
}