From 80f187ebbac9ec217de3be1df1c0ea63e6487ed1 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Tue, 31 Mar 2026 17:13:34 +0200 Subject: [PATCH] feat(kilo-vscode): migrate legacy sessions into new extension (#7924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vscode): detect sessions in old extension * feat(vscode): added session types * feat(cli): add endpoints for inserting a session * feat(vscode): use session types from sdk for parsing * feat(kilo-vscode): parsing session project * feat(vscode): parse legacy session details * feat(kilo-vscode): refactoring * feat(vscode): parse legacy session messages * feat(vscode): parse legacy session parts * feat(vscode): added testing files and id creation * feat(vscode): merge tool parts on imported session * feat(vscode): session message part refactoring * feat(vscode): cover provider-specific reasoning parts on session migration * feat(kilo-vscode): handle completion result parts * feat: type hardening on session migration * feat(kilo-vscode): create tool part correctly * feat(kilo-vscode): adjust session part processing * feat(vscode): persist migrated session in kilo.db * feat(vscode): add error handling to session migration * feat(vscode): add directory field to session migration * feat(kilo-vscode): display completion check on session migration * feat(vscode): prevent migrating sessions twice * feat(cli): use correct project id when inserting migrated session * feat(kilo-vscode): Use correct project id for session migration * feat(kilo-vscode): load sessions on migration complete * feat(kilo-vscode): ignore environment details user part when migrating sessions * feat(cli): create project details following current backend logic * feat(kilo-vscode): cleaner messages without environment_details parts * feat(kilo-vscode): refactoring * feat(kilo-vscode): refactoring and organization * feat(kilo-vscode): method renaming to improve clarity * feat(kilo-vscode): added session migration test strategy * test(kilo-vscode): implement legacy migration unit tests for all migration modules Replace todo stubs with full test implementations across messages, parts, tools, reasoning, parser, migrate, project, and session test files. Add new project.test.ts and session.test.ts covering project construction, session metadata, and deterministic ID generation. * style(kilo-vscode): apply prettier formatting across legacy migration source and test files * fix(legacy-migration): filter out non-user/assistant entries before parsing messages Exclude system and other non-conversational roles from the conversation array prior to mapping, ensuring parentID references remain consistent across imported messages when skipped entries exist. * refactor(parts): skip part generation for non-conversational message roles Entries with roles other than user or assistant should not produce parts, keeping part output consistent with the messages that are actually imported during legacy migration. * fix(ids): use semantic kind strings to prevent part ID collisions for reasoning entries Replace numeric indices with descriptive kind strings ("reasoning", "provider-reasoning") in extra part ID generation to avoid collisions between reasoning parts and regular content parts within the same message. Introduce `createExtraPartID` to distinguish reasoning-specific IDs from standard sequential part IDs produced by `createPartID`. * refactor(session-import): guard against empty worktree in legacy project import Prevent silent misattribution of migrated sessions by rejecting empty worktree paths before resolving the project directory. Without this check, an empty string would fall back to the current process directory, causing the imported session to be attached to the wrong project. * perf(cli): cache MCP listTools results to avoid redundant RPCs per loop step (#7922) MCP.tools() called client.listTools() for every connected server on every agent loop iteration. With multiple servers this added hundreds of milliseconds of overhead per step (measured ~300-750ms for remote servers like Vercel). Cache listTools results in a module-level Map and return them on subsequent calls. The cache is invalidated on: - ToolListChangedNotification from the MCP server - Server add/connect/disconnect - Instance dispose (config change, project switch, shutdown) * fix(agent-manager): add retries to worktree cleanup for macOS .app bundles (#7921) * chore: hide non-English localization files in GitHub PR diffs Mark all non-English i18n locale files as linguist-generated=true so they are collapsed by default in GitHub PR diffs, reducing noise when reviewing changes that touch translation files. * feat(vscode): add Browse Marketplace button to MCP settings Add a "Browse Marketplace" button to the MCP Servers subtab in Agent Behaviour settings, guiding users to discover and install MCPs from the marketplace rather than only configuring them manually. - Add button above the MCP server list that opens the Marketplace panel - Add openMarketplacePanel webview message type and handler - Add translations for all 18 supported languages * fix(vscode): preserve project directory when opening marketplace from settings Pass the current panel's project directory through to the marketplace panel so project-scoped MCP installs work correctly in multi-root workspaces. Without this, the marketplace panel would fall back to null project directory since there is no active text editor when clicking from a webview. * feat(vscode): add Browse Marketplace button to agents and skills tabs Add the Browse Marketplace button to the Agents and Skills subtabs in Agent Behaviour settings, matching the existing button in the MCP Servers subtab. This lets users discover marketplace items from any of the three configuration tabs. - Agents tab: added as a ghost button in the header row alongside Import and Create Mode buttons - Skills tab: added as a secondary button at the top-right, matching the MCP tab layout - Extracted the browse handler to the component scope for reuse * chore: update kilo-vscode visual regression baselines * perf(agent-manager): extend slimPart to strip heavy tool metadata for faster session switching (#7927) Extend slim-metadata.ts to handle apply_patch, multiedit, write, and bash tools in addition to edit. Previously only edit tool parts had their heavy metadata stripped before IPC serialization. apply_patch parts carried full file before/after contents per file, write parts carried the entire written file, and bash parts carried up to 30KB of output — all serialized on every session switch. This reduces session-switch time by ~2x for sessions with many tool calls. * style(legacy-migration): reformat long reasoning push call for readability * refactor(legacy-migration): filter non-user/assistant entries before indexing Move the role filter from inside `parseParts` to `parsePartsFromConversation` so that message indices are derived only from the filtered list. This ensures part message IDs stay aligned with the imported messages and skipped entries (e.g. system role) do not cause index gaps in the generated IDs. * fix(legacy-migration): add `_migrated_` infix to generated IDs Prefix all migrated entity IDs (sessions, messages, parts) with a `_migrated_` segment so they are clearly distinguishable from IDs produced by the regular runtime. Updates tests to assert the new prefix format. * refactor(legacy-migration): consolidate conversation parsing into `parseSession` Remove the intermediate `createMessages` and `createParts` async wrappers that each independently read and parsed the conversation file. The file is now fetched and parsed once in `parseSession`, with the resulting conversation passed directly to `parseMessagesFromConversation` and `parsePartsFromConversation`, eliminating redundant I/O. * fix(parts-builder): remove `toTextWithinMessage` and use `toText` directly `toTextWithinMessage` was a duplicate of `toText` with identical behavior. Replace all call sites with `toText` and delete the redundant function. * fix(parts-builder): skip provider-specific reasoning when explicit reasoning entry exists Guard `isProviderSpecificReasoning` with `!isReasoning` to prevent duplicate reasoning parts when an entry carries both a `type: "reasoning"` field and a provider-specific reasoning field (e.g. `reasoning_content`). The explicit reasoning entry takes precedence. * test(legacy-migration): add coverage for edge cases in parts and tools parsing Add unit tests for two previously uncovered scenarios: - Task block extraction: verify only the content inside `` tags is kept when text exists outside the legacy task wrapper - Empty tool result: verify the tool name is used as fallback output when `tool_result` carries no readable text content Also replace the dynamic `FileType.Directory` workaround with the direct `vscode.FileType.Directory` enum reference, and harden `parseFile` to throw on non-array JSON instead of silently returning an empty array. * feat(legacy-migration): remove session migration tracking from global state Drop the `readSessionsToMigrate` filter and the post-migration `globalState` flag that tracked which sessions had already been migrated. Detection now returns all sessions found in global storage without filtering by prior migration status. * fix(session-import): skip duplicate session imports and child data Check for an existing session row before inserting and return a `skipped: true` flag when the session already exists. The migration client honours this flag to bypass message and part imports, avoiding redundant work when migration is re-run. Replace `onConflictDoUpdate` with `onConflictDoNothing` on the session insert path to align with the new pre-check behaviour. Propagate the optional `skipped` field through the result types and generated SDK types for project, session, message, and part responses. * refactor(parts-builder): mark legacy system error text parts as ignored Tag text parts whose content begins with `[ERROR]` with `ignored: true` and a `legacy-system-error` metadata source. Introduces a new `isLegacySystemErrorText` utility in `parts-util` to encapsulate the detection logic. * feat(parts-util): extract feedback text from tool_result content into visible parts Parse `` tags embedded in `tool_result` content and emit a dedicated text part so user feedback is surfaced as a visible message rather than buried inside tool result data. Introduces `getFeedbackText` utility to extract and trim the inner content of `` blocks from arbitrary input. * fix: formatting --------- Co-authored-by: Marius Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> Co-authored-by: Mark IJbema Co-authored-by: github-actions[bot] --- packages/kilo-vscode/src/KiloProvider.ts | 1 + .../src/kilo-provider/handlers/migration.ts | 4 + .../src/legacy-migration/legacy-types.ts | 4 +- .../src/legacy-migration/migration-service.ts | 33 +- .../src/legacy-migration/sessions/lib/ids.ts | 29 + .../sessions/lib/legacy-conversation.ts | 17 + .../sessions/lib/legacy-types.ts | 28 + .../legacy-migration/sessions/lib/messages.ts | 82 ++ .../sessions/lib/parts/merge-tools.ts | 66 ++ .../sessions/lib/parts/parts-builder.ts | 102 ++ .../sessions/lib/parts/parts-util.ts | 132 +++ .../sessions/lib/parts/parts.ts | 107 ++ .../legacy-migration/sessions/lib/project.ts | 29 + .../legacy-migration/sessions/lib/session.ts | 42 + .../src/legacy-migration/sessions/migrate.ts | 52 + .../src/legacy-migration/sessions/parser.ts | 35 + .../unit/legacy-migration/messages.test.ts | 106 ++ .../unit/legacy-migration/migrate.test.ts | 212 ++++ .../unit/legacy-migration/parser.test.ts | 36 + .../tests/unit/legacy-migration/parts.test.ts | 224 ++++ .../unit/legacy-migration/project.test.ts | 35 + .../unit/legacy-migration/reasoning.test.ts | 99 ++ .../unit/legacy-migration/session.test.ts | 41 + .../tests/unit/legacy-migration/tools.test.ts | 134 +++ .../components/migration/MigrationWizard.tsx | 49 +- .../webview-ui/src/types/messages.ts | 3 + .../src/kilocode/session-import/routes.ts | 94 ++ .../src/kilocode/session-import/service.ts | 97 ++ .../src/kilocode/session-import/types.ts | 216 ++++ .../opencode/src/server/routes/kilocode.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 395 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 319 ++++++ packages/sdk/openapi.json | 1009 ++++++++++++++++- 33 files changed, 3813 insertions(+), 21 deletions(-) create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/ids.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-conversation.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-types.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/merge-tools.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-builder.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-util.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts create mode 100644 packages/kilo-vscode/src/legacy-migration/sessions/parser.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/migrate.test.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/parts.test.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/project.test.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/reasoning.test.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts create mode 100644 packages/kilo-vscode/tests/unit/legacy-migration/tools.test.ts create mode 100644 packages/opencode/src/kilocode/session-import/routes.ts create mode 100644 packages/opencode/src/kilocode/session-import/service.ts create mode 100644 packages/opencode/src/kilocode/session-import/types.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 217cd12c020..f380ea6c01f 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2802,6 +2802,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper set migrationCheckInFlight(val) { self.migrationCheckInFlight = val }, + refreshSessions: () => this.refreshSessions(), disposeGlobal: () => this.disposeGlobal(), broadcastComplete: () => this.connectionService.notifyMigrationComplete(), } diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts b/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts index 5eb1ed51f88..ee8a32f28a2 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts @@ -28,6 +28,7 @@ export interface MigrationContext { readonly client: KiloClient | null readonly extensionContext: MigrationExtensionContext | undefined postMessage(msg: unknown): void + refreshSessions(): void cachedLegacyData: LegacyMigrationData | null migrationCheckInFlight: boolean disposeGlobal(): Promise @@ -69,6 +70,7 @@ export async function checkAndShowMigrationWizard(ctx: MigrationContext): Promis providers: data.providers, mcpServers: data.mcpServers, customModes: data.customModes, + sessions: data.sessions, defaultModel: data.defaultModel, settings: data.settings, }, @@ -88,6 +90,7 @@ export async function handleRequestLegacyMigrationData(ctx: MigrationContext): P providers: data.providers, mcpServers: data.mcpServers, customModes: data.customModes, + sessions: data.sessions, defaultModel: data.defaultModel, settings: data.settings, }, @@ -125,6 +128,7 @@ export async function handleStartLegacyMigration( "completed", ) ctx.broadcastComplete() + ctx.refreshSessions() } ctx.postMessage({ type: "legacyMigrationComplete", results }) diff --git a/packages/kilo-vscode/src/legacy-migration/legacy-types.ts b/packages/kilo-vscode/src/legacy-migration/legacy-types.ts index 3f3af3018f9..c5a9231d5ed 100644 --- a/packages/kilo-vscode/src/legacy-migration/legacy-types.ts +++ b/packages/kilo-vscode/src/legacy-migration/legacy-types.ts @@ -288,6 +288,7 @@ export interface LegacyMigrationData { providers: MigrationProviderInfo[] mcpServers: MigrationMcpServerInfo[] customModes: MigrationCustomModeInfo[] + sessions?: string[] defaultModel?: { provider: string; model: string } settings?: LegacySettings hasData: boolean @@ -318,13 +319,14 @@ export interface MigrationSelections { providers: string[] mcpServers: string[] customModes: string[] + sessions?: string[] defaultModel: boolean settings: MigrationSettingsSelections } export interface MigrationResultItem { item: string - category: "provider" | "mcpServer" | "customMode" | "defaultModel" | "settings" + category: "provider" | "mcpServer" | "customMode" | "defaultModel" | "settings" | "session" status: "success" | "warning" | "error" message?: string } diff --git a/packages/kilo-vscode/src/legacy-migration/migration-service.ts b/packages/kilo-vscode/src/legacy-migration/migration-service.ts index c0334429415..b3e4986aac8 100644 --- a/packages/kilo-vscode/src/legacy-migration/migration-service.ts +++ b/packages/kilo-vscode/src/legacy-migration/migration-service.ts @@ -32,6 +32,7 @@ import type { MigrationCustomModeInfo, MigrationResultItem, } from "./legacy-types" +import { migrate as migrateSession } from "./sessions/migrate" // --------------------------------------------------------------------------- // Constants @@ -68,6 +69,7 @@ export async function detectLegacyData(context: vscode.ExtensionContext): Promis const mcpSettings = await readLegacyMcpSettings(context) const customModes = await readLegacyCustomModes(context) const settings = readLegacySettings(context) + const sessions = await readSessionsInGlobalStorage(context) const oauthProviders = new Set() const codexRaw = await context.secrets.get(CODEX_OAUTH_SECRET_KEY) @@ -92,18 +94,29 @@ export async function detectLegacyData(context: vscode.ExtensionContext): Promis Boolean(settings.language) || Boolean(settings.autocomplete) - const hasData = providers.length > 0 || mcpServers.length > 0 || modes.length > 0 || hasSettings + const hasData = + providers.length > 0 || mcpServers.length > 0 || modes.length > 0 || hasSettings || sessions.length > 0 return { providers, mcpServers, customModes: modes, + sessions: sessions.length > 0 ? sessions : undefined, defaultModel, settings: hasSettings ? settings : undefined, hasData, } } +async function readSessionsInGlobalStorage(context: vscode.ExtensionContext) { + const dir = vscode.Uri.joinPath(context.globalStorageUri, "tasks") + const items = await vscode.workspace.fs.readDirectory(dir).then( + (items) => items, + () => [] as [string, vscode.FileType][], + ) + return items.filter(([, type]) => type === vscode.FileType.Directory).map(([name]) => name) +} + // --------------------------------------------------------------------------- // Migration // --------------------------------------------------------------------------- @@ -197,6 +210,24 @@ export async function migrate( } } + if (selections.sessions?.length) { + for (const id of selections.sessions) { + onProgress("Chat sessions", "migrating") + const result = await migrateSession(id, context, client) + results.push({ + item: id, + category: "session", + status: result.ok ? "success" : "error", + message: result.ok ? "Session migrated" : "Session migration failed", + }) + onProgress( + "Chat sessions", + result.ok ? "success" : "error", + result.ok ? "Session migrated" : "Session migration failed", + ) + } + } + // Migrate default model if (selections.defaultModel && profiles) { const activeName = profiles.currentApiConfigName diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/ids.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/ids.ts new file mode 100644 index 00000000000..8f15054c049 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/ids.ts @@ -0,0 +1,29 @@ +import { createHash } from "node:crypto" + +export function createProjectID(worktree?: string) { + return hash(worktree ?? "") +} + +export function createSessionID(id: string) { + return prefixed("ses", id) +} + +export function createMessageID(id: string, index: number) { + return prefixed("msg", `${id}:${index}`) +} + +export function createPartID(id: string, index: number, part: number) { + return prefixed("prt", `${id}:${index}:${part}`) +} + +export function createExtraPartID(id: string, index: number, kind: string) { + return prefixed("prt", `${id}:${index}:${kind}`) +} + +function prefixed(prefix: string, value: string) { + return `${prefix}_migrated_${hash(value).slice(0, 26)}` +} + +function hash(value: string) { + return createHash("sha1").update(value).digest("hex") +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-conversation.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-conversation.ts new file mode 100644 index 00000000000..e103ef589d2 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-conversation.ts @@ -0,0 +1,17 @@ +import * as path from "node:path" +import * as vscode from "vscode" +import type { LegacyApiMessage } from "./legacy-types" + +export async function getApiConversationHistory(id: string, dir: string) { + const file = path.join(dir, id, "api_conversation_history.json") + return vscode.workspace.fs.readFile(vscode.Uri.file(file)) +} + +export function parseFile(file: Uint8Array): LegacyApiMessage[] { + const text = Buffer.from(file).toString("utf8") + const json = JSON.parse(text) as unknown + if (!Array.isArray(json)) { + throw new Error("Legacy conversation history must be a JSON array") + } + return json.filter((entry): entry is LegacyApiMessage => Boolean(entry && typeof entry === "object")) +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-types.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-types.ts new file mode 100644 index 00000000000..7288fd51a99 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/legacy-types.ts @@ -0,0 +1,28 @@ +import type { Anthropic } from "@anthropic-ai/sdk" + +export interface LegacyHistoryItem { + id: string + rootTaskId?: string + parentTaskId?: string + ts?: number + task?: string + workspace?: string + mode?: string +} + +export type LegacyApiMessage = Anthropic.MessageParam & { + ts?: number + isSummary?: boolean + id?: string + type?: "reasoning" + summary?: unknown[] + encrypted_content?: string + text?: string + reasoning_details?: unknown[] + reasoning_content?: string + condenseId?: string + condenseParent?: string + truncationId?: string + truncationParent?: string + isTruncationMarker?: boolean +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts new file mode 100644 index 00000000000..e59616216df --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts @@ -0,0 +1,82 @@ +import type { KilocodeSessionImportMessageData as Message } from "@kilocode/sdk/v2" +import type { LegacyApiMessage, LegacyHistoryItem } from "./legacy-types" +import { createMessageID, createSessionID } from "./ids" + +type Body = NonNullable +type Data = Body["data"] +type User = Extract +type Assistant = Extract + +export function parseMessagesFromConversation( + conversation: LegacyApiMessage[], + id: string, + item?: LegacyHistoryItem, +): Array> { + return conversation + .filter((entry) => entry.role === "user" || entry.role === "assistant") + .map((entry, index) => parseMessage(entry, index, id, item)) + .filter((message): message is NonNullable => Boolean(message)) +} + +function parseMessage( + entry: LegacyApiMessage, + index: number, + id: string, + item?: LegacyHistoryItem, +): NonNullable | undefined { + const created = entry.ts ?? item?.ts ?? 0 + + if (entry.role === "user") { + const data: User = { + role: "user", + time: { created }, + agent: "user", + model: { + providerID: "legacy", + modelID: "legacy", + }, + } + + return { + id: createMessageID(id, index), + sessionID: createSessionID(id), + timeCreated: created, + data, + } + } + + if (entry.role === "assistant") { + const data: Assistant = { + role: "assistant", + time: { created, completed: created }, + parentID: index > 0 ? createMessageID(id, index - 1) : createMessageID(id, index), + modelID: "legacy", + providerID: "legacy", + mode: item?.mode ?? "code", + agent: "main", + path: { + cwd: item?.workspace ?? "", + root: item?.workspace ?? "", + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + } + + return { + id: createMessageID(id, index), + sessionID: createSessionID(id), + timeCreated: created, + data, + } + } + + return undefined +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/merge-tools.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/merge-tools.ts new file mode 100644 index 00000000000..1604654b7d8 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/merge-tools.ts @@ -0,0 +1,66 @@ +import type { KilocodeSessionImportPartData as Part } from "@kilocode/sdk/v2" +import type { LegacyApiMessage } from "../legacy-types" +import { getText, getToolUse, isToolResult, record } from "./parts-util" + +type Body = NonNullable +type Data = Body["data"] +type Tool = Extract +type ToolCompleted = Extract + +// This takes the "tool started" half and the matching "tool finished" half, +// and merges both so the migrated session keeps one complete tool action. +export function mergeToolUseAndResult( + partID: string, + messageID: string, + sessionID: string, + created: number, + conversation: LegacyApiMessage[], + result: { type?: string; tool_use_id?: string; content?: unknown }, +): NonNullable | undefined { + const tool = getToolUseFromConversation(conversation, result.tool_use_id) + if (!tool) return undefined + const callID = typeof tool.id === "string" ? tool.id : partID + const name = typeof tool.name === "string" ? tool.name : "unknown" + const output = getText(result.content) ?? name + const state: ToolCompleted = { + status: "completed", + input: record(tool.input), + output, + title: name, + metadata: {}, + time: { + start: created, + end: created, + }, + } + + const data: Tool = { + type: "tool", + callID, + tool: name, + state, + } + + return { + id: partID, + messageID, + sessionID, + timeCreated: created, + data, + } +} + +export function thereIsNoToolResult(conversation: LegacyApiMessage[], id: string | undefined) { + return !conversation.some( + (entry) => + Array.isArray(entry.content) && entry.content.some((part) => isToolResult(part) && part.tool_use_id === id), + ) +} + +function getToolUseFromConversation(conversation: LegacyApiMessage[], id: string | undefined) { + for (const entry of conversation) { + const tool = getToolUse(entry, id) + if (tool) return tool + } + return undefined +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-builder.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-builder.ts new file mode 100644 index 00000000000..fd91dbaa445 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-builder.ts @@ -0,0 +1,102 @@ +import type { KilocodeSessionImportPartData as Part } from "@kilocode/sdk/v2" +import { cleanLegacyTaskText, isLegacySystemErrorText, record } from "./parts-util" + +type Body = NonNullable +type Data = Body["data"] +type Text = Extract +type Reasoning = Extract +type Tool = Extract +type ToolCompleted = Extract + +export function toTool( + partID: string, + messageID: string, + sessionID: string, + created: number, + part: { type?: string; id?: string; name?: string; input?: unknown }, +): NonNullable { + const tool = typeof part.name === "string" ? part.name : "unknown" + const state: ToolCompleted = { + status: "completed", + input: record(part.input), + output: tool, + title: tool, + metadata: {}, + time: { + start: created, + end: created, + }, + } + + const data: Tool = { + type: "tool", + callID: part.id ?? partID, + tool, + state, + } + + return { + id: partID, + messageID, + sessionID, + timeCreated: created, + data, + } +} + +export function toText( + partID: string, + messageID: string, + sessionID: string, + created: number, + text: string, +): NonNullable { + const value = cleanLegacyTaskText(text) + const data: Text = { + type: "text", + text: value, + ...(isLegacySystemErrorText(value) && { + ignored: true, + metadata: { + source: "legacy-system-error", + }, + }), + time: { + start: created, + end: created, + }, + } + + return { + id: partID, + messageID, + sessionID, + timeCreated: created, + data, + } +} + +export function toReasoning( + partID: string, + messageID: string, + sessionID: string, + created: number, + text: string, +): NonNullable { + const data: Reasoning = { + type: "reasoning", + text, + time: { + start: created, + end: created, + }, + } + + return { + id: partID, + messageID, + sessionID, + timeCreated: created, + data, + } +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-util.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-util.ts new file mode 100644 index 00000000000..d6359eafd9d --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts-util.ts @@ -0,0 +1,132 @@ +import type { LegacyApiMessage } from "../legacy-types" + +type ToolUse = { + type?: string + id?: string + name?: string + input?: unknown +} + +export function isSimpleText(input: LegacyApiMessage): input is LegacyApiMessage & { content: string } { + return typeof input.content === "string" && Boolean(input.content) +} + +export function isReasoning(input: LegacyApiMessage): input is LegacyApiMessage & { type: "reasoning"; text: string } { + return input.type === "reasoning" && typeof input.text === "string" && Boolean(input.text) +} + +export function isProviderSpecificReasoning(input: LegacyApiMessage) { + return Boolean(getReasoningText(input)) +} + +export function getReasoningText(input: LegacyApiMessage) { + if (typeof input.reasoning_content === "string" && input.reasoning_content.trim()) { + // Some providers store the model thinking outside normal content blocks, so we need to lift it manually. + return input.reasoning_content.trim() + } + + if (!Array.isArray(input.reasoning_details)) return undefined + + const text = input.reasoning_details + .flatMap((item) => { + if (!item || typeof item !== "object") return [] + if (typeof (item as { text?: unknown }).text === "string") return [(item as { text: string }).text] + if (typeof (item as { reasoning?: unknown }).reasoning === "string") + return [(item as { reasoning: string }).reasoning] + return [] + }) + .join("\n") + .trim() + + // reasoning_details can come as provider-specific arrays, so we collapse the readable text we can find. + return text || undefined +} + +export function isSingleTextWithinMessage(input: unknown): input is { type?: string; text: string } { + return isText(input) && Boolean(input.text) +} + +export function isEnvironmentDetails(input: string) { + return /^\s*[\s\S]*<\/environment_details>\s*$/i.test(input) +} + +export function isCompletionResult( + input: unknown, +): input is { type?: string; name?: string; input: { result: string } } { + return Boolean( + input && + typeof input === "object" && + "type" in input && + input.type === "tool_use" && + "name" in input && + input.name === "attempt_completion" && + "input" in input && + input.input && + typeof input.input === "object" && + "result" in input.input && + typeof input.input.result === "string" && + input.input.result, + ) +} + +export function record(input: unknown): Record { + if (!input || typeof input !== "object" || Array.isArray(input)) return {} + return input as Record +} + +export function isToolUse(input: unknown): input is { type?: string; id?: string; name?: string; input?: unknown } { + return Boolean(input && typeof input === "object" && "type" in input && input.type === "tool_use") +} + +export function isText(input: unknown): input is { type?: string; text?: string } { + return Boolean(input && typeof input === "object" && "type" in input && input.type === "text") +} + +export function isToolResult(input: unknown): input is { type?: string; tool_use_id?: string; content?: unknown } { + return Boolean(input && typeof input === "object" && "type" in input && input.type === "tool_result") +} + +// This looks through the blocks inside one legacy message and finds the tool_use whose id +// matches the tool_result we are processing, so we know which tool call produced that result. +export function getToolUse(input: LegacyApiMessage, id: string | undefined) { + if (!Array.isArray(input.content)) return undefined + return input.content.find((part) => isToolUse(part) && part.id === id) as ToolUse | undefined +} + +export function getText(input: unknown) { + if (typeof input === "string") return input + if (!Array.isArray(input)) return undefined + const text = input + .flatMap((item) => { + if (isText(item) && item.text) return [item.text] + return [] + }) + .join("\n") + .trim() + return text || undefined +} + +export function cleanLegacyTaskText(input: string) { + // Legacy sometimes stores the real user prompt wrapped inside ..., followed by + // extra ... prompt scaffolding. We only want the + // actual task text to appear in the migrated conversation, so if a block exists we keep + // just that inner text and drop the wrapper plus the extra environment block. + const task = input.match(/([\s\S]*?)<\/task>/i)?.[1]?.trim() + if (task) return task + + if (isEnvironmentDetails(input)) return "" + + return input +} + +export function isLegacySystemErrorText(input: string) { + return input.trimStart().startsWith("[ERROR]") +} + +export function getFeedbackText(input: unknown) { + const text = getText(input) + if (!text) return undefined + const match = text.match(/([\s\S]*?)<\/feedback>/i) + const value = match?.[1]?.trim() + return value || undefined +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts.ts new file mode 100644 index 00000000000..4b122a2f0a5 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/parts.ts @@ -0,0 +1,107 @@ +import type { KilocodeSessionImportPartData as Part } from "@kilocode/sdk/v2" +import type { LegacyApiMessage, LegacyHistoryItem } from "../legacy-types" +import { createExtraPartID, createMessageID, createPartID, createSessionID } from "../ids" +import { toReasoning, toText, toTool } from "./parts-builder" +import { + getFeedbackText, + isCompletionResult, + isEnvironmentDetails, + getReasoningText, + isProviderSpecificReasoning, + isReasoning, + isSimpleText, + isSingleTextWithinMessage, + isToolResult, + isToolUse, +} from "./parts-util" +import { mergeToolUseAndResult, thereIsNoToolResult } from "./merge-tools" + +export function parsePartsFromConversation( + conversation: LegacyApiMessage[], + id: string, + item?: LegacyHistoryItem, +): Array> { + const list = conversation.filter((entry) => entry.role === "user" || entry.role === "assistant") + return list.flatMap((entry, index) => parseParts(entry, index, id, list, item)) +} + +function parseParts( + entry: LegacyApiMessage, + index: number, + id: string, + conversation: LegacyApiMessage[], + item?: LegacyHistoryItem, +): Array> { + const messageID = createMessageID(id, index) + const sessionID = createSessionID(id) + const created = entry.ts ?? item?.ts ?? 0 + + if (isSimpleText(entry)) { + // Ignore raw blocks because they are legacy prompt scaffolding, + // not actual user-visible conversation content we want to preserve in the migrated session. + if (isEnvironmentDetails(entry.content)) return [] + return [toText(createPartID(id, index, 0), messageID, sessionID, created, entry.content)] + } + + if (!Array.isArray(entry.content)) return [] + + const parts: Array> = [] + + if (isReasoning(entry)) { + parts.push(toReasoning(createExtraPartID(id, index, "reasoning"), messageID, sessionID, created, entry.text)) + } + + // Some providers store thinking outside normal content blocks, so this handles those provider-specific fields. + if (!isReasoning(entry) && isProviderSpecificReasoning(entry)) { + const reasoning = getReasoningText(entry) + if (reasoning) { + parts.push( + toReasoning(createExtraPartID(id, index, "provider-reasoning"), messageID, sessionID, created, reasoning), + ) + } + } + + entry.content.forEach((part, partIndex) => { + const partID = createPartID(id, index, partIndex) + + // Legacy can store a message as several pieces; this handles one text block inside that larger message. + if (isSingleTextWithinMessage(part)) { + // Ignore standalone text blocks for the same reason: they describe + // editor/runtime context for the old prompt, but they are not meaningful chat content. + if (isEnvironmentDetails(part.text)) return + parts.push(toText(partID, messageID, sessionID, created, part.text)) + return + } + + // The legacy session can contain a final completion message after an assistant interaction. + // Treat it like a regular assistant text part so the migrated session keeps that final visible answer. + if (isCompletionResult(part)) { + const text = part.input.result + parts.push(toText(partID, messageID, sessionID, created, text)) + return + } + + if (isToolUse(part) && thereIsNoToolResult(conversation, part.id)) { + parts.push(toTool(partID, messageID, sessionID, created, part)) + return + } + + if (isToolResult(part)) { + const feedback = getFeedbackText(part.content) + if (feedback) { + parts.push( + toText(createExtraPartID(id, index, `feedback-${partIndex}`), messageID, sessionID, created, feedback), + ) + } + + // tool_result usually lives in the following user message, while the matching tool_use lives + // in the earlier assistant message, so we need the whole conversation to reconcile both halves. + const tool = mergeToolUseAndResult(partID, messageID, sessionID, created, conversation, part) + if (!tool) return + parts.push(tool) + return + } + }) + + return parts +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts new file mode 100644 index 00000000000..b073f535d19 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts @@ -0,0 +1,29 @@ +import type { KilocodeSessionImportProjectData as Project } from "@kilocode/sdk/v2" +import type { LegacyHistoryItem } from "./legacy-types" +import { createProjectID } from "./ids" + +export function createProject(item?: LegacyHistoryItem): NonNullable { + const project = makeProject() + + project.id = createProjectID(item?.workspace) + + project.worktree = item?.workspace ?? "" + + project.sandboxes = item?.workspace ? [item.workspace] : [] + + project.timeCreated = item?.ts ?? 0 + + project.timeUpdated = item?.ts ?? 0 + + return project +} + +function makeProject(): NonNullable { + return { + id: "", + worktree: "", + sandboxes: [], + timeCreated: 0, + timeUpdated: 0, + } +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts new file mode 100644 index 00000000000..14058c6e94f --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts @@ -0,0 +1,42 @@ +import type { KilocodeSessionImportSessionData as Session } from "@kilocode/sdk/v2" +import type { LegacyHistoryItem } from "./legacy-types" +import { createSessionID } from "./ids" + +export function createSession( + id: string, + item: LegacyHistoryItem | undefined, + projectID: string, +): NonNullable { + const session = makeSession() + + session.id = createSessionID(id) + + session.projectID = projectID + + session.slug = id + + session.directory = item?.workspace ?? "" + + session.title = item?.task ?? id + + session.version = "v2" + + session.timeCreated = item?.ts ?? 0 + + session.timeUpdated = item?.ts ?? 0 + + return session +} + +function makeSession(): NonNullable { + return { + id: "", + projectID: "", + slug: "", + directory: "", + title: "", + version: "", + timeCreated: 0, + timeUpdated: 0, + } +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts b/packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts new file mode 100644 index 00000000000..8ca481e3178 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts @@ -0,0 +1,52 @@ +import * as vscode from "vscode" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { LegacyHistoryItem } from "./lib/legacy-types" +import { parseSession } from "./parser" + +export async function migrate(id: string, context: vscode.ExtensionContext, client: KiloClient) { + const dir = vscode.Uri.joinPath(context.globalStorageUri, "tasks").fsPath + const items = context.globalState.get("taskHistory", []) + const item = items.find((item) => item.id === id) + const payload = await parseSession(id, dir, item) + + try { + const project = await client.kilocode.sessionImport.project(payload.project, { throwOnError: true }) + const projectID = project.data?.id ?? payload.project.id + const session = await client.kilocode.sessionImport.session( + { + ...payload.session, + projectID, + query_directory: payload.session.directory, + body_directory: payload.session.directory, + }, + { throwOnError: true }, + ) + // Skip child imports when the session already exists so rerunning migration only imports missing sessions. + if (session.data?.skipped) { + return { + ok: true, + skipped: true, + payload, + } + } + + for (const msg of payload.messages) { + await client.kilocode.sessionImport.message(msg, { throwOnError: true }) + } + + for (const part of payload.parts) { + await client.kilocode.sessionImport.part(part, { throwOnError: true }) + } + + return { + ok: true, + payload, + } + } catch (error) { + return { + ok: false, + payload, + error, + } + } +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts b/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts new file mode 100644 index 00000000000..76a063b3f80 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts @@ -0,0 +1,35 @@ +import type { LegacyHistoryItem } from "./lib/legacy-types" +import type { + KilocodeSessionImportMessageData as Message, + KilocodeSessionImportPartData as Part, + KilocodeSessionImportProjectData as Project, + KilocodeSessionImportSessionData as Session, +} from "@kilocode/sdk/v2" +import { getApiConversationHistory, parseFile } from "./lib/legacy-conversation" +import { parseMessagesFromConversation } from "./lib/messages" +import { parsePartsFromConversation } from "./lib/parts/parts" +import { createProject } from "./lib/project" +import { createSession } from "./lib/session" + +export interface NormalizedSession { + project: NonNullable + session: NonNullable + messages: Array> + parts: Array> +} + +export async function parseSession(id: string, dir: string, item?: LegacyHistoryItem): Promise { + const project = createProject(item) + const session = createSession(id, item, project.id) + const file = await getApiConversationHistory(id, dir) + const conversation = parseFile(file) + const messages = parseMessagesFromConversation(conversation, id, item) + const parts = parsePartsFromConversation(conversation, id, item) + + return { + project, + session, + messages, + parts, + } +} diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts new file mode 100644 index 00000000000..69450d78025 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test" +import { parseMessagesFromConversation } from "../../../src/legacy-migration/sessions/lib/messages" +import type { LegacyApiMessage } from "../../../src/legacy-migration/sessions/lib/legacy-types" + +const id = "019d3df5-d5d9-73dc-bc2c-43a6304ac62c" +const item = { + id, + ts: 1774861014564, + task: "In this folder I need you to create 3 python files with random content, oriented towards web development", + workspace: "/workspace/testing-4", + mode: "code", +} + +function sample(): LegacyApiMessage[] { + return [ + { + role: "user", + content: [ + { + type: "text", + text: "\nIn this folder I need you to create 3 python files with random content, oriented towards web development\n", + }, + ], + ts: 1774861014564, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nI'll create 3 Python files with web development-oriented content.", + }, + ], + ts: 1774861031791, + }, + { + role: "system", + content: "ignored", + ts: 1774861032000, + } as unknown as LegacyApiMessage, + ] +} + +describe("legacy migration messages", () => { + it("parses a basic legacy conversation into ordered user and assistant messages with stable ids", () => { + const list = parseMessagesFromConversation(sample(), id, item) + + expect(list).toHaveLength(2) + expect(list[0]?.data.role).toBe("user") + expect(list[1]?.data.role).toBe("assistant") + expect(list[0]?.id).toBeDefined() + expect(list[1]?.id).toBeDefined() + }) + + it("creates valid assistant message metadata for the SDK/backend shape", () => { + const list = parseMessagesFromConversation(sample(), id, item) + const msg = list.find((x) => x.data.role === "assistant") + + expect(msg?.data.role).toBe("assistant") + if (msg?.data.role !== "assistant") throw new Error("assistant message not found") + expect(msg.data.mode).toBe("code") + expect(msg.data.path.cwd).toBe("/workspace/testing-4") + expect(msg.data.path.root).toBe("/workspace/testing-4") + expect(msg.data.tokens.input).toBe(0) + expect(msg.data.tokens.output).toBe(0) + }) + + it("ignores unsupported legacy entries instead of producing broken messages", () => { + const list = parseMessagesFromConversation(sample(), id, item) + + expect(list).toHaveLength(2) + expect(list.some((x) => x.data.role !== "user" && x.data.role !== "assistant")).toBe(false) + }) + + it("keeps assistant parentID pointing to the previous imported message when skipped entries exist", () => { + const list = parseMessagesFromConversation( + [ + { + role: "user", + content: "hello", + ts: 1, + }, + { + role: "system", + content: "ignored", + ts: 2, + } as unknown as LegacyApiMessage, + { + role: "assistant", + content: "hi", + ts: 3, + }, + ], + id, + item, + ) + + const user = list[0] + const assistant = list[1] + + expect(user?.id).toBeDefined() + expect(assistant?.data.role).toBe("assistant") + if (assistant?.data.role !== "assistant") throw new Error("assistant message not found") + expect(assistant.data.parentID).toBe(user?.id) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/migrate.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/migrate.test.ts new file mode 100644 index 00000000000..5eede710e73 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/migrate.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +const parseSession = mock(async () => ({ + project: { + id: "project-local", + worktree: "/workspace/testing", + sandboxes: ["/workspace/testing"], + timeCreated: 1, + timeUpdated: 1, + }, + session: { + id: "ses_local", + projectID: "project-local", + slug: "legacy-task-1", + directory: "/workspace/testing", + title: "Legacy task", + version: "v2", + timeCreated: 1, + timeUpdated: 1, + }, + messages: [], + parts: [], +})) + +mock.module("../../../src/legacy-migration/sessions/parser", () => ({ + parseSession, +})) + +const { migrate } = await import("../../../src/legacy-migration/sessions/migrate") + +function ctx() { + return { + globalStorageUri: { path: "/storage" }, + globalState: { + get: (key: string) => { + if (key === "taskHistory") return [{ id: "legacy-task-1", workspace: "/workspace/testing" }] + return undefined + }, + update: async () => undefined, + }, + } +} + +function client() { + const calls: Array<{ name: string; body: unknown }> = [] + return { + calls, + kilocode: { + sessionImport: { + project: async (body: unknown) => { + calls.push({ name: "project", body }) + return { data: { id: "project-real" } } + }, + session: async (body: unknown) => { + calls.push({ name: "session", body }) + return { data: { ok: true } } + }, + message: async (body: unknown) => { + calls.push({ name: "message", body }) + return { data: { ok: true } } + }, + part: async (body: unknown) => { + calls.push({ name: "part", body }) + return { data: { ok: true } } + }, + }, + }, + } +} + +describe("legacy migration migrate", () => { + beforeEach(() => { + parseSession.mockClear() + }) + + it("inserts project then session then message then part in the correct order", async () => { + const calls: string[] = [] + const api = { + kilocode: { + sessionImport: { + project: async () => { + calls.push("project") + return { data: { id: "project-real" } } + }, + session: async () => { + calls.push("session") + return { data: { ok: true } } + }, + message: async () => { + calls.push("message") + return { data: { ok: true } } + }, + part: async () => { + calls.push("part") + return { data: { ok: true } } + }, + }, + }, + } + + parseSession.mockResolvedValueOnce({ + project: { + id: "project-local", + worktree: "/workspace/testing", + sandboxes: ["/workspace/testing"], + timeCreated: 1, + timeUpdated: 1, + }, + session: { + id: "ses_local", + projectID: "project-local", + slug: "legacy-task-1", + directory: "/workspace/testing", + title: "Legacy task", + version: "v2", + timeCreated: 1, + timeUpdated: 1, + }, + messages: [ + { + id: "msg_1", + sessionID: "ses_local", + timeCreated: 1, + data: { + role: "user", + time: { created: 1 }, + agent: "user", + model: { providerID: "legacy", modelID: "legacy" }, + }, + }, + ] as never, + parts: [ + { + id: "prt_1", + sessionID: "ses_local", + messageID: "msg_1", + timeCreated: 1, + data: { + type: "text", + text: "hello", + time: { start: 1, end: 1 }, + }, + }, + ] as never, + }) + + await migrate("legacy-task-1", ctx() as never, api as never) + + expect(calls).toEqual(["project", "session", "message", "part"]) + }) + + it("uses the projectID returned by backend when inserting the session", async () => { + const api = client() + + await migrate("legacy-task-1", ctx() as never, api as never) + + const call = api.calls.find((x) => x.name === "session") + expect(call).toBeDefined() + expect((call?.body as { projectID: string }).projectID).toBe("project-real") + }) + + it("returns ok false without throwing when one backend insert fails", async () => { + const api = { + kilocode: { + sessionImport: { + project: async () => ({ data: { id: "project-real" } }), + session: async () => { + throw new Error("session failed") + }, + message: async () => ({ data: { ok: true } }), + part: async () => ({ data: { ok: true } }), + }, + }, + } + + const result = await migrate("legacy-task-1", ctx() as never, api as never) + + expect(result.ok).toBe(false) + }) + + it("skips message and part imports when backend reports the session already exists", async () => { + const calls: string[] = [] + const api = { + kilocode: { + sessionImport: { + project: async () => { + calls.push("project") + return { data: { id: "project-real" } } + }, + session: async () => { + calls.push("session") + return { data: { ok: true, skipped: true } } + }, + message: async () => { + calls.push("message") + return { data: { ok: true } } + }, + part: async () => { + calls.push("part") + return { data: { ok: true } } + }, + }, + }, + } + + const result = await migrate("legacy-task-1", ctx() as never, api as never) + + expect(result.ok).toBe(true) + expect(result).toHaveProperty("skipped", true) + expect(calls).toEqual(["project", "session"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts new file mode 100644 index 00000000000..db2477b265d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test" +import { createProject } from "../../../src/legacy-migration/sessions/lib/project" +import { createSession } from "../../../src/legacy-migration/sessions/lib/session" +import { createMessageID, createPartID } from "../../../src/legacy-migration/sessions/lib/ids" + +const id = "019d3df5-d5d9-73dc-bc2c-43a6304ac62c" +const item = { + id, + ts: 1774861014564, + task: "In this folder I need you to create 3 python files with random content, oriented towards web development", + workspace: "/workspace/testing-4", + mode: "code", +} + +describe("legacy migration parser", () => { + it("uses the final deterministic ids expected for migration", () => { + const project = createProject(item) + const session = createSession(id, item, project.id) + const msg1 = createMessageID(id, 0) + const msg2 = createMessageID(id, 1) + const prt1 = createPartID(id, 0, 0) + const prt2 = createPartID(id, 1, 0) + + expect(project.id).toBe(createProject(item).id) + expect(session.id).toBe(createSession(id, item, project.id).id) + expect(session.id.startsWith("ses_migrated_")).toBe(true) + expect(msg1).toBe(createMessageID(id, 0)) + expect(msg2).toBe(createMessageID(id, 1)) + expect(msg1.startsWith("msg_migrated_")).toBe(true) + expect(msg2.startsWith("msg_migrated_")).toBe(true) + expect(prt1).toBe(createPartID(id, 0, 0)) + expect(prt2).toBe(createPartID(id, 1, 0)) + expect(prt1.startsWith("prt_migrated_")).toBe(true) + expect(prt2.startsWith("prt_migrated_")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/parts.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/parts.test.ts new file mode 100644 index 00000000000..60b81062410 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/parts.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "bun:test" +import { parsePartsFromConversation } from "../../../src/legacy-migration/sessions/lib/parts/parts" +import type { LegacyApiMessage } from "../../../src/legacy-migration/sessions/lib/legacy-types" + +type Data = ReturnType[number]["data"] +type Text = Extract + +function text(list: ReturnType) { + return list.filter((x): x is (typeof list)[number] & { data: Text } => x.data.type === "text").map((x) => x.data.text) +} + +const id = "019d3df5-d5d9-73dc-bc2c-43a6304ac62c" +const item = { + id, + ts: 1774861014564, + task: "In this folder I need you to create 3 python files with random content, oriented towards web development", + workspace: "/workspace/testing-4", + mode: "code", +} + +function sample(): LegacyApiMessage[] { + return [ + { + role: "user", + content: [ + { + type: "text", + text: "\nIn this folder I need you to create 3 python files with random content, oriented towards web development\n", + }, + { + type: "text", + text: "\nCurrent time: 2026-03-30T12:54:59+02:00\n", + }, + ], + ts: 1774861014564, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nI'll create 3 Python files with web development-oriented content.", + }, + ], + ts: 1774861031791, + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_attempt_1", + name: "attempt_completion", + input: { + result: "Created 3 Python files with web development content.", + }, + }, + ], + ts: 1774861079951, + }, + ] +} + +describe("legacy migration parts", () => { + it("converts plain text and text blocks into visible text parts without losing content", async () => { + const list = parsePartsFromConversation(sample(), id, item) + + const items = text(list) + + expect(items.some((x) => x.includes("In this folder I need you to create 3 python files"))).toBe(true) + expect(items.some((x) => x.includes("I'll create 3 Python files with web development-oriented content."))).toBe( + true, + ) + }) + + it("drops standalone environment_details blocks but keeps the real task text", async () => { + const list = parsePartsFromConversation(sample(), id, item) + + const items = text(list) + + expect(items.some((x) => x.includes(""))).toBe(false) + expect(items.some((x) => x.includes("In this folder I need you to create 3 python files"))).toBe(true) + }) + + it("keeps only the first task block content when text exists outside the legacy task wrapper", () => { + const list = parsePartsFromConversation( + [ + { + role: "user", + content: [ + { + type: "text", + text: "Some preamble\nactual task\nSome postamble", + }, + ], + ts: 1774861014564, + }, + ] as LegacyApiMessage[], + id, + item, + ) + + expect(text(list)).toEqual(["actual task"]) + }) + + it("preserves attempt_completion input.result as assistant-visible text", async () => { + const list = parsePartsFromConversation(sample(), id, item) + + const items = text(list) + + expect(items.some((x) => x.includes("Created 3 Python files with web development content"))).toBe(true) + }) + + it("adds a visible user text part for feedback embedded in tool_result", () => { + const list = parsePartsFromConversation( + [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_feedback_1", + name: "attempt_completion", + input: { + result: "Done.", + }, + }, + ], + ts: 10, + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_feedback_1", + content: [ + { + type: "text", + text: "The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\nAdd two more lines\n", + }, + ], + }, + ], + ts: 11, + }, + ] as LegacyApiMessage[], + id, + item, + ) + + expect(text(list)).toContain("Add two more lines") + }) + + it("does not create parts for skipped legacy entries like system messages", () => { + const list = parsePartsFromConversation( + [ + { + role: "system", + content: "ignored", + ts: 1774861015000, + } as unknown as LegacyApiMessage, + ], + id, + item, + ) + + expect(list).toEqual([]) + }) + + it("keeps part message ids aligned with imported message ids when skipped entries exist", () => { + const list = parsePartsFromConversation( + [ + { + role: "user", + content: "hello", + ts: 1, + }, + { + role: "system", + content: "ignored", + ts: 2, + } as unknown as LegacyApiMessage, + { + role: "assistant", + content: "hi", + ts: 3, + }, + ], + id, + item, + ) + + const ids = list.map((x) => x.messageID) + + expect(ids.some((x) => x === "msg_legacy-task-1_2")).toBe(false) + }) + + it("uses non-colliding ids for reasoning and normal content parts in the same message", () => { + const list = parsePartsFromConversation( + [ + { + role: "assistant", + type: "reasoning", + text: "Think first", + content: [ + { + type: "text", + text: "Visible answer", + }, + ], + ts: 1774861015000, + } as unknown as LegacyApiMessage, + ], + id, + item, + ) + + const ids = list.map((x) => x.id) + + expect(ids).toHaveLength(2) + expect(new Set(ids).size).toBe(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/project.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/project.test.ts new file mode 100644 index 00000000000..2f7331af489 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/project.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test" +import { createProject } from "../../../src/legacy-migration/sessions/lib/project" + +describe("legacy migration project", () => { + it("builds a project from the legacy workspace and timestamp", () => { + const project = createProject({ + id: "legacy-task-1", + ts: 1774861014564, + workspace: "/workspace/testing", + task: "Understand this project", + mode: "code", + }) + + expect(project.worktree).toBe("/workspace/testing") + expect(project.sandboxes).toEqual(["/workspace/testing"]) + expect(project.timeCreated).toBe(1774861014564) + expect(project.timeUpdated).toBe(1774861014564) + }) + + it("creates a deterministic project id for the same workspace", () => { + const a = createProject({ id: "legacy-1", workspace: "/workspace/testing" }) + const b = createProject({ id: "legacy-1", workspace: "/workspace/testing" }) + + expect(a.id).toBe(b.id) + }) + + it("falls back to empty defaults when legacy workspace data is missing", () => { + const project = createProject() + + expect(project.worktree).toBe("") + expect(project.sandboxes).toEqual([]) + expect(project.timeCreated).toBe(0) + expect(project.timeUpdated).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/reasoning.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/reasoning.test.ts new file mode 100644 index 00000000000..f5c9a688849 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/reasoning.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "bun:test" +import { parsePartsFromConversation } from "../../../src/legacy-migration/sessions/lib/parts/parts" +import type { LegacyApiMessage } from "../../../src/legacy-migration/sessions/lib/legacy-types" + +type Reasoning = Extract[number]["data"], { type: "reasoning" }> + +function reasoning(list: ReturnType) { + return list + .filter((x): x is (typeof list)[number] & { data: Reasoning } => x.data.type === "reasoning") + .map((x) => x.data.text) +} + +const id = "legacy-reasoning-1" +const item = { + id, + ts: 1774861014564, + task: "Reasoning parsing test", + workspace: "/workspace/testing", + mode: "code", +} + +describe("legacy migration reasoning", () => { + it("converts type reasoning plus text into a reasoning part", () => { + const list = parsePartsFromConversation( + [ + { + role: "assistant", + type: "reasoning", + text: "I should inspect the files first.", + content: [], + ts: 1774861015000, + } as unknown as LegacyApiMessage, + ], + id, + item, + ) + + const out = reasoning(list) + expect(out).toEqual(["I should inspect the files first."]) + }) + + it("extracts reasoning from reasoning_content", () => { + const list = parsePartsFromConversation( + [ + { + role: "assistant", + content: [], + reasoning_content: "I should read the markdown before answering.", + ts: 1774861015000, + } as unknown as LegacyApiMessage, + ], + id, + item, + ) + + const out = reasoning(list) + expect(out).toEqual(["I should read the markdown before answering."]) + }) + + it("extracts readable reasoning text from reasoning_details", () => { + const list = parsePartsFromConversation( + [ + { + role: "assistant", + content: [], + reasoning_details: [{ text: "First inspect the repo." }, { reasoning: "Then summarize the structure." }], + ts: 1774861015000, + } as unknown as LegacyApiMessage, + ], + id, + item, + ) + + const out = reasoning(list) + expect(out).toHaveLength(1) + expect(out[0]).toContain("First inspect the repo.") + expect(out[0]).toContain("Then summarize the structure.") + }) + + it("prefers explicit reasoning entries over provider-specific reasoning fields when both exist", () => { + const list = parsePartsFromConversation( + [ + { + role: "assistant", + type: "reasoning", + text: "I should inspect the files first.", + reasoning_content: "I should inspect the files first.", + content: [], + ts: 1774861015000, + } as unknown as LegacyApiMessage, + ], + id, + item, + ) + + const out = reasoning(list) + expect(out).toEqual(["I should inspect the files first."]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts new file mode 100644 index 00000000000..7764b88ea4d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test" +import { createSession } from "../../../src/legacy-migration/sessions/lib/session" + +describe("legacy migration session", () => { + it("builds a session from legacy task metadata and the resolved project id", () => { + const session = createSession( + "019d3df5-d5d9-73dc-bc2c-43a6304ac62c", + { + id: "019d3df5-d5d9-73dc-bc2c-43a6304ac62c", + ts: 1774861014564, + workspace: "/workspace/testing", + task: "Understand this project", + mode: "code", + }, + "project-1", + ) + + expect(session.projectID).toBe("project-1") + expect(session.directory).toBe("/workspace/testing") + expect(session.title).toBe("Understand this project") + expect(session.version).toBe("v2") + expect(session.timeCreated).toBe(1774861014564) + expect(session.timeUpdated).toBe(1774861014564) + }) + + it("creates a deterministic session id from the legacy task id", () => { + const a = createSession("legacy-task-1", undefined, "project-1") + const b = createSession("legacy-task-1", undefined, "project-1") + + expect(a.id).toBe(b.id) + expect(a.id.startsWith("ses_migrated_")).toBe(true) + }) + + it("falls back to the legacy id as title when task metadata is missing", () => { + const session = createSession("legacy-task-1", undefined, "project-1") + + expect(session.slug).toBe("legacy-task-1") + expect(session.title).toBe("legacy-task-1") + expect(session.directory).toBe("") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/tools.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/tools.test.ts new file mode 100644 index 00000000000..e1262a352be --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/tools.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "bun:test" +import { parsePartsFromConversation } from "../../../src/legacy-migration/sessions/lib/parts/parts" +import type { LegacyApiMessage } from "../../../src/legacy-migration/sessions/lib/legacy-types" + +type Tool = Extract[number]["data"], { type: "tool" }> + +function tools(list: ReturnType) { + return list.filter((x): x is (typeof list)[number] & { data: Tool } => x.data.type === "tool").map((x) => x.data) +} + +const id = "legacy-task-1" +const item = { + id, + ts: 1774861014564, + task: "Tool parsing test", + workspace: "/workspace/testing", + mode: "code", +} + +function fallback(): LegacyApiMessage[] { + return [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_fallback_1", + name: "read_file", + input: { path: "app.py" }, + }, + ], + ts: 1774861015000, + }, + ] +} + +function merged(): LegacyApiMessage[] { + return [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_merge_1", + name: "read_file", + input: { path: "app.py" }, + }, + ], + ts: 1774861015000, + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_merge_1", + content: [ + { + type: "text", + text: "File: app.py\nHello world", + }, + ], + }, + ], + ts: 1774861016000, + }, + ] +} + +function noTextResult(): LegacyApiMessage[] { + return [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_merge_2", + name: "read_file", + input: { path: "app.py" }, + }, + ], + ts: 1774861015000, + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_merge_2", + content: [], + }, + ], + ts: 1774861016000, + }, + ] +} + +describe("legacy migration tools", () => { + it("creates a fallback tool part from tool_use when there is no matching tool_result", () => { + const list = parsePartsFromConversation(fallback(), id, item) + const out = tools(list) + + expect(out).toHaveLength(1) + expect(out[0]?.tool).toBe("read_file") + expect(out[0]?.state.status).toBe("completed") + }) + + it("merges tool_use and tool_result into one completed tool part using the correct tool id", () => { + const list = parsePartsFromConversation(merged(), id, item) + const out = tools(list) + + expect(out).toHaveLength(1) + expect(out[0]?.callID).toBe("toolu_merge_1") + expect(out[0]?.tool).toBe("read_file") + if (out[0]?.state.status !== "completed") throw new Error("tool was not completed") + expect(out[0].state.output).toContain("File: app.py") + }) + + it("does not duplicate a tool part when a matching tool_result exists later in the conversation", () => { + const list = parsePartsFromConversation(merged(), id, item) + const out = tools(list) + + expect(out).toHaveLength(1) + }) + + it("falls back to the tool name when tool_result has no readable text content", () => { + const list = parsePartsFromConversation(noTextResult(), id, item) + const out = tools(list) + + expect(out).toHaveLength(1) + if (out[0]?.state.status !== "completed") throw new Error("tool was not completed") + expect(out[0].state.output).toBe("read_file") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx b/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx index 1358048b543..3cfda340070 100644 --- a/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx @@ -6,7 +6,8 @@ * 2. Migrate — grouped selection, live in-place progress, cleanup */ -import { Component, Show, createSignal, onMount, onCleanup, JSX } from "solid-js" +import { Show, createSignal, onMount, onCleanup } from "solid-js" +import type { Component, JSX } from "solid-js" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import type { @@ -182,6 +183,7 @@ const MigrationWizard: Component = (props) => { const [providers, setProviders] = createSignal([]) const [mcpServers, setMcpServers] = createSignal([]) const [customModes, setCustomModes] = createSignal([]) + const [sessions, setSessions] = createSignal([]) const [defaultModel, setDefaultModel] = createSignal<{ provider: string; model: string } | undefined>(undefined) const [legacySettings, setLegacySettings] = createSignal(undefined) @@ -189,6 +191,7 @@ const MigrationWizard: Component = (props) => { const [migrateProviders, setMigrateProviders] = createSignal(true) const [migrateMcpServers, setMigrateMcpServers] = createSignal(true) const [migrateModes, setMigrateModes] = createSignal(true) + const [migrateSessions, setMigrateSessions] = createSignal(true) const [migrateDefaultModel, setMigrateDefaultModel] = createSignal(true) const [migrateAutoApproval, setMigrateAutoApproval] = createSignal(true) const [migrateLanguage, setMigrateLanguage] = createSignal(true) @@ -213,6 +216,7 @@ const MigrationWizard: Component = (props) => { setProviders(data.providers) setMcpServers(data.mcpServers) setCustomModes(data.customModes) + setSessions(data.sessions ?? []) setDefaultModel(data.defaultModel) setLegacySettings(data.settings) @@ -220,6 +224,7 @@ const MigrationWizard: Component = (props) => { setMigrateProviders(data.providers.some((p) => p.supported && p.hasApiKey)) setMigrateMcpServers(data.mcpServers.length > 0) setMigrateModes(data.customModes.length > 0) + setMigrateSessions((data.sessions?.length ?? 0) > 0) setMigrateDefaultModel(Boolean(data.defaultModel)) const s = data.settings @@ -318,6 +323,9 @@ const MigrationWizard: Component = (props) => { const mode = customModes().find((m) => m.slug === slug) return { item: mode?.name ?? slug, group: "customModes", status: "pending" as const } }), + ...(migrateSessions() && sessions().length > 0 + ? [{ item: "Chat sessions", group: "sessions", status: "pending" as const }] + : []), ...(migrateDefaultModel() && defaultModel() ? [{ item: "Default model", group: "defaultModel", status: "pending" as const }] : []), @@ -356,6 +364,7 @@ const MigrationWizard: Component = (props) => { providers: selectedProviderNames, mcpServers: selectedMcpNames, customModes: selectedModesSlugs, + sessions: migrateSessions() ? sessions() : [], defaultModel: migrateDefaultModel(), settings: { autoApproval, @@ -398,11 +407,13 @@ const MigrationWizard: Component = (props) => { const hasLanguageData = () => Boolean(legacySettings()?.language) const hasAutocompleteData = () => Boolean(legacySettings()?.autocomplete) + const hasSessions = () => sessions().length > 0 const hasAnySelection = () => (migrateProviders() && supportedProviderCount() > 0) || (migrateMcpServers() && mcpServers().length > 0) || (migrateModes() && customModes().length > 0) || + (migrateSessions() && hasSessions()) || (migrateDefaultModel() && Boolean(defaultModel())) || (migrateAutoApproval() && hasAnyAutoApprovalData()) || (migrateLanguage() && hasLanguageData()) || @@ -412,6 +423,7 @@ const MigrationWizard: Component = (props) => { supportedProviderCount() === 0 && mcpServers().length === 0 && customModes().length === 0 && + !hasSessions() && !defaultModel() && !hasAnyAutoApprovalData() && !hasLanguageData() && @@ -650,6 +662,27 @@ const MigrationWizard: Component = (props) => { + +
+ }> + + +
+
Chat Sessions & History
+
{sessions().length} sessions detected
+
+
+
+ {/* Default Model */}
@@ -734,20 +767,6 @@ const MigrationWizard: Component = (props) => {
- {/* Divider + Cannot be migrated */} -
- -
- -
-
{language.t("migration.migrate.chatHistory")}
-
{language.t("migration.migrate.chatHistoryDesc")}
-
-
- {/* Cleanup option after done */}
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index cd7e25b9656..bc35beb8cc5 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1053,6 +1053,7 @@ export interface MigrationStateMessage { providers: MigrationProviderInfo[] mcpServers: MigrationMcpServerInfo[] customModes: MigrationCustomModeInfo[] + sessions?: string[] defaultModel?: { provider: string; model: string } settings?: LegacySettings } @@ -1064,6 +1065,7 @@ export interface LegacyMigrationDataMessage { providers: MigrationProviderInfo[] mcpServers: MigrationMcpServerInfo[] customModes: MigrationCustomModeInfo[] + sessions?: string[] defaultModel?: { provider: string; model: string } settings?: LegacySettings } @@ -1100,6 +1102,7 @@ export interface StartLegacyMigrationMessage { providers: string[] mcpServers: string[] customModes: string[] + sessions?: string[] defaultModel: boolean settings: { autoApproval: MigrationAutoApprovalSelections diff --git a/packages/opencode/src/kilocode/session-import/routes.ts b/packages/opencode/src/kilocode/session-import/routes.ts new file mode 100644 index 00000000000..755a725f240 --- /dev/null +++ b/packages/opencode/src/kilocode/session-import/routes.ts @@ -0,0 +1,94 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import { lazy } from "../../util/lazy" +import { errors } from "../../server/error" +import { SessionImportService } from "./service" +import { SessionImportType } from "./types" + +export const SessionImportRoutes = lazy(() => + new Hono() + .post( + "/project", + describeRoute({ + summary: "Insert project for session import", + description: "Insert or update a project row used by legacy session import.", + operationId: "kilocode.sessionImport.project", + responses: { + 200: { + description: "Project import result", + content: { + "application/json": { + schema: resolver(SessionImportType.Result), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionImportType.Project), + async (c) => c.json(await SessionImportService.project(c.req.valid("json"))), + ) + .post( + "/session", + describeRoute({ + summary: "Insert session for session import", + description: "Insert or update a session row used by legacy session import.", + operationId: "kilocode.sessionImport.session", + responses: { + 200: { + description: "Session import result", + content: { + "application/json": { + schema: resolver(SessionImportType.Result), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionImportType.Session), + async (c) => c.json(await SessionImportService.session(c.req.valid("json"))), + ) + .post( + "/message", + describeRoute({ + summary: "Insert message for session import", + description: "Insert or update a message row used by legacy session import.", + operationId: "kilocode.sessionImport.message", + responses: { + 200: { + description: "Message import result", + content: { + "application/json": { + schema: resolver(SessionImportType.Result), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionImportType.Message), + async (c) => c.json(await SessionImportService.message(c.req.valid("json"))), + ) + .post( + "/part", + describeRoute({ + summary: "Insert part for session import", + description: "Insert or update a part row used by legacy session import.", + operationId: "kilocode.sessionImport.part", + responses: { + 200: { + description: "Part import result", + content: { + "application/json": { + schema: resolver(SessionImportType.Result), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionImportType.Part), + async (c) => c.json(await SessionImportService.part(c.req.valid("json"))), + ), +) diff --git a/packages/opencode/src/kilocode/session-import/service.ts b/packages/opencode/src/kilocode/session-import/service.ts new file mode 100644 index 00000000000..9c61a257182 --- /dev/null +++ b/packages/opencode/src/kilocode/session-import/service.ts @@ -0,0 +1,97 @@ +import { Database } from "../../storage/db" +import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" +import { SessionImportType } from "./types" +import { Project } from "../../project/project" +import { eq } from "drizzle-orm" + +const key = (input: unknown) => [input] as never +const target = (input: unknown) => input as never + +export namespace SessionImportService { + export async function project(input: SessionImportType.Project): Promise { + // Do not resolve an empty legacy worktree, because that would fall back to the current + // process directory and silently attach the migrated session to the wrong project. + if (!input.worktree.trim()) { + throw new Error("Legacy project import requires a non-empty worktree") + } + + const result = await Project.fromDirectory(input.worktree) + return { ok: true, id: result.project.id } + } + + export async function session(input: SessionImportType.Session): Promise { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(target(SessionTable.id), input.id)).get()) + if (row) return { ok: true, id: input.id, skipped: true } + + Database.use((db) => { + db.insert(SessionTable) + .values({ + id: input.id, + project_id: input.projectID, + workspace_id: input.workspaceID, + parent_id: input.parentID, + slug: input.slug, + directory: input.directory, + title: input.title, + version: input.version, + share_url: input.shareURL, + summary_additions: input.summary?.additions, + summary_deletions: input.summary?.deletions, + summary_files: input.summary?.files, + summary_diffs: input.summary?.diffs as never, + revert: input.revert, + permission: input.permission as never, + time_created: input.timeCreated, + time_updated: input.timeUpdated, + time_compacting: input.timeCompacting, + time_archived: input.timeArchived, + }) + .onConflictDoNothing({ + target: key(SessionTable.id), + }) + .run() + }) + return { ok: true, id: input.id } + } + + export async function message(input: SessionImportType.Message): Promise { + Database.use((db) => { + db.insert(MessageTable) + .values({ + id: input.id, + session_id: input.sessionID, + time_created: input.timeCreated, + data: input.data as never, + }) + .onConflictDoUpdate({ + target: key(MessageTable.id), + set: { + data: input.data as never, + }, + }) + .run() + }) + return { ok: true, id: input.id } + } + + export async function part(input: SessionImportType.Part): Promise { + Database.use((db) => { + db.insert(PartTable) + .values({ + id: input.id, + message_id: input.messageID, + session_id: input.sessionID, + time_created: input.timeCreated, + data: input.data as never, + }) + .onConflictDoUpdate({ + target: key(PartTable.id), + set: { + data: input.data as never, + }, + }) + .run() + }) + return { ok: true, id: input.id } + } +} diff --git a/packages/opencode/src/kilocode/session-import/types.ts b/packages/opencode/src/kilocode/session-import/types.ts new file mode 100644 index 00000000000..7bd5d6b9f14 --- /dev/null +++ b/packages/opencode/src/kilocode/session-import/types.ts @@ -0,0 +1,216 @@ +import z from "zod" + +export namespace SessionImportType { + export const UserMessageData = z.object({ + role: z.literal("user"), + time: z.object({ + created: z.number(), + }), + agent: z.string(), + model: z.object({ + providerID: z.string(), + modelID: z.string(), + }), + tools: z.record(z.string(), z.boolean()).optional(), + }) + + export const AssistantMessageData = z.object({ + role: z.literal("assistant"), + time: z.object({ + created: z.number(), + completed: z.number().optional(), + }), + parentID: z.string(), + modelID: z.string(), + providerID: z.string(), + mode: z.string(), + agent: z.string(), + path: z.object({ + cwd: z.string(), + root: z.string(), + }), + summary: z.boolean().optional(), + cost: z.number(), + tokens: z.object({ + total: z.number().optional(), + input: z.number(), + output: z.number(), + reasoning: z.number(), + cache: z.object({ + read: z.number(), + write: z.number(), + }), + }), + structured: z.unknown().optional(), + variant: z.string().optional(), + finish: z.string().optional(), + }) + + export const MessageData = z.discriminatedUnion("role", [UserMessageData, AssistantMessageData]) + + export const TextPartData = z.object({ + type: z.literal("text"), + text: z.string(), + synthetic: z.boolean().optional(), + ignored: z.boolean().optional(), + time: z + .object({ + start: z.number(), + end: z.number().optional(), + }) + .optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + + export const ReasoningPartData = z.object({ + type: z.literal("reasoning"), + text: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + time: z.object({ + start: z.number(), + end: z.number().optional(), + }), + }) + + export const ToolStatePending = z.object({ + status: z.literal("pending"), + input: z.record(z.string(), z.unknown()), + raw: z.string(), + }) + + export const ToolStateRunning = z.object({ + status: z.literal("running"), + input: z.record(z.string(), z.unknown()), + title: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + time: z.object({ + start: z.number(), + }), + }) + + export const ToolStateCompleted = z.object({ + status: z.literal("completed"), + input: z.record(z.string(), z.unknown()), + output: z.string(), + title: z.string(), + metadata: z.record(z.string(), z.unknown()), + time: z.object({ + start: z.number(), + end: z.number(), + compacted: z.number().optional(), + }), + }) + + export const ToolStateError = z.object({ + status: z.literal("error"), + input: z.record(z.string(), z.unknown()), + error: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + time: z.object({ + start: z.number(), + end: z.number(), + }), + }) + + export const ToolState = z.discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) + + export const ToolPartData = z.object({ + type: z.literal("tool"), + callID: z.string(), + tool: z.string(), + state: ToolState, + metadata: z.record(z.string(), z.unknown()).optional(), + }) + + export const PartData = z.discriminatedUnion("type", [TextPartData, ReasoningPartData, ToolPartData]) + + export const Result = z.object({ + ok: z.boolean(), + id: z.string(), + skipped: z.boolean().optional(), + }) + + export const Project = z.object({ + id: z.string(), + worktree: z.string(), + vcs: z.string().optional(), + name: z.string().optional(), + iconUrl: z.string().optional(), + iconColor: z.string().optional(), + timeCreated: z.number(), + timeUpdated: z.number(), + timeInitialized: z.number().optional(), + sandboxes: z.array(z.string()), + commands: z + .object({ + start: z.string().optional(), + }) + .optional(), + }) + + export const Session = z.object({ + id: z.string(), + projectID: z.string(), + workspaceID: z.string().optional(), + parentID: z.string().optional(), + slug: z.string(), + directory: z.string(), + title: z.string(), + version: z.string(), + shareURL: z.string().optional(), + summary: z + .object({ + additions: z.number(), + deletions: z.number(), + files: z.number(), + diffs: z.array(z.record(z.string(), z.unknown())).optional(), + }) + .optional(), + revert: z + .object({ + messageID: z.string(), + partID: z.string().optional(), + snapshot: z.string().optional(), + diff: z.string().optional(), + }) + .optional(), + permission: z.record(z.string(), z.unknown()).optional(), + timeCreated: z.number(), + timeUpdated: z.number(), + timeCompacting: z.number().optional(), + timeArchived: z.number().optional(), + }) + + export const Message = z.object({ + id: z.string(), + sessionID: z.string(), + timeCreated: z.number(), + data: MessageData, + }) + + export const Part = z.object({ + id: z.string(), + messageID: z.string(), + sessionID: z.string(), + timeCreated: z.number().optional(), + data: PartData, + }) + + export type Result = z.infer + export type UserMessageData = z.infer + export type AssistantMessageData = z.infer + export type MessageData = z.infer + export type TextPartData = z.infer + export type ReasoningPartData = z.infer + export type ToolStatePending = z.infer + export type ToolStateRunning = z.infer + export type ToolStateCompleted = z.infer + export type ToolStateError = z.infer + export type ToolState = z.infer + export type ToolPartData = z.infer + export type PartData = z.infer + export type Project = z.infer + export type Session = z.infer + export type Message = z.infer + export type Part = z.infer +} diff --git a/packages/opencode/src/server/routes/kilocode.ts b/packages/opencode/src/server/routes/kilocode.ts index aa7d0503994..75572a9ad84 100644 --- a/packages/opencode/src/server/routes/kilocode.ts +++ b/packages/opencode/src/server/routes/kilocode.ts @@ -8,9 +8,11 @@ import { Skill } from "../../skill/skill" import { Agent } from "../../agent/agent" import { lazy } from "../../util/lazy" import { errors } from "../error" +import { SessionImportRoutes } from "../../kilocode/session-import/routes" export const KilocodeRoutes = lazy(() => new Hono() + .route("/session-import", SessionImportRoutes()) .post( "/skill/remove", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 640cc66fb0d..84355f79a57 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -61,6 +61,14 @@ import type { KilocodeRemoveAgentResponses, KilocodeRemoveSkillErrors, KilocodeRemoveSkillResponses, + KilocodeSessionImportMessageErrors, + KilocodeSessionImportMessageResponses, + KilocodeSessionImportPartErrors, + KilocodeSessionImportPartResponses, + KilocodeSessionImportProjectErrors, + KilocodeSessionImportProjectResponses, + KilocodeSessionImportSessionErrors, + KilocodeSessionImportSessionResponses, KiloFimErrors, KiloFimResponses, KiloModesResponses, @@ -3095,6 +3103,388 @@ export class EnhancePrompt extends HeyApiClient { } } +export class SessionImport extends HeyApiClient { + /** + * Insert project for session import + * + * Insert or update a project row used by legacy session import. + */ + public project( + parameters?: { + directory?: string + workspace?: string + id?: string + worktree?: string + vcs?: string + name?: string + iconUrl?: string + iconColor?: string + timeCreated?: number + timeUpdated?: number + timeInitialized?: number + sandboxes?: Array + commands?: { + start?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "worktree" }, + { in: "body", key: "vcs" }, + { in: "body", key: "name" }, + { in: "body", key: "iconUrl" }, + { in: "body", key: "iconColor" }, + { in: "body", key: "timeCreated" }, + { in: "body", key: "timeUpdated" }, + { in: "body", key: "timeInitialized" }, + { in: "body", key: "sandboxes" }, + { in: "body", key: "commands" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeSessionImportProjectResponses, + KilocodeSessionImportProjectErrors, + ThrowOnError + >({ + url: "/kilocode/session-import/project", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Insert session for session import + * + * Insert or update a session row used by legacy session import. + */ + public session( + parameters?: { + query_directory?: string + workspace?: string + id?: string + projectID?: string + workspaceID?: string + parentID?: string + slug?: string + body_directory?: string + title?: string + version?: string + shareURL?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array<{ + [key: string]: unknown + }> + } + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } + permission?: { + [key: string]: unknown + } + timeCreated?: number + timeUpdated?: number + timeCompacting?: number + timeArchived?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { + in: "query", + key: "query_directory", + map: "directory", + }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "projectID" }, + { in: "body", key: "workspaceID" }, + { in: "body", key: "parentID" }, + { in: "body", key: "slug" }, + { + in: "body", + key: "body_directory", + map: "directory", + }, + { in: "body", key: "title" }, + { in: "body", key: "version" }, + { in: "body", key: "shareURL" }, + { in: "body", key: "summary" }, + { in: "body", key: "revert" }, + { in: "body", key: "permission" }, + { in: "body", key: "timeCreated" }, + { in: "body", key: "timeUpdated" }, + { in: "body", key: "timeCompacting" }, + { in: "body", key: "timeArchived" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeSessionImportSessionResponses, + KilocodeSessionImportSessionErrors, + ThrowOnError + >({ + url: "/kilocode/session-import/session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Insert message for session import + * + * Insert or update a message row used by legacy session import. + */ + public message( + parameters?: { + directory?: string + workspace?: string + id?: string + sessionID?: string + timeCreated?: number + data?: + | { + role: "user" + time: { + created: number + } + agent: string + model: { + providerID: string + modelID: string + } + tools?: { + [key: string]: boolean + } + } + | { + role: "assistant" + time: { + created: number + completed?: number + } + parentID: string + modelID: string + providerID: string + mode: string + agent: string + path: { + cwd: string + root: string + } + summary?: boolean + cost: number + tokens: { + total?: number + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + structured?: unknown + variant?: string + finish?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "sessionID" }, + { in: "body", key: "timeCreated" }, + { in: "body", key: "data" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeSessionImportMessageResponses, + KilocodeSessionImportMessageErrors, + ThrowOnError + >({ + url: "/kilocode/session-import/message", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Insert part for session import + * + * Insert or update a part row used by legacy session import. + */ + public part( + parameters?: { + directory?: string + workspace?: string + id?: string + messageID?: string + sessionID?: string + timeCreated?: number + data?: + | { + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } + } + | { + type: "reasoning" + text: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end?: number + } + } + | { + type: "tool" + callID: string + tool: string + state: + | { + status: "pending" + input: { + [key: string]: unknown + } + raw: string + } + | { + status: "running" + input: { + [key: string]: unknown + } + title?: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + } + } + | { + status: "completed" + input: { + [key: string]: unknown + } + output: string + title: string + metadata: { + [key: string]: unknown + } + time: { + start: number + end: number + compacted?: number + } + } + | { + status: "error" + input: { + [key: string]: unknown + } + error: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end: number + } + } + metadata?: { + [key: string]: unknown + } + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "messageID" }, + { in: "body", key: "sessionID" }, + { in: "body", key: "timeCreated" }, + { in: "body", key: "data" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeSessionImportPartResponses, + KilocodeSessionImportPartErrors, + ThrowOnError + >({ + url: "/kilocode/session-import/part", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Kilocode extends HeyApiClient { /** * Remove a skill @@ -3173,6 +3563,11 @@ export class Kilocode extends HeyApiClient { }, ) } + + private _sessionImport?: SessionImport + get sessionImport(): SessionImport { + return (this._sessionImport ??= new SessionImport({ client: this.client })) + } } export class Organization extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fa05a8e95f9..b22f7018043 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4529,6 +4529,325 @@ export type EnhancePromptEnhanceResponses = { export type EnhancePromptEnhanceResponse = EnhancePromptEnhanceResponses[keyof EnhancePromptEnhanceResponses] +export type KilocodeSessionImportProjectData = { + body?: { + id: string + worktree: string + vcs?: string + name?: string + iconUrl?: string + iconColor?: string + timeCreated: number + timeUpdated: number + timeInitialized?: number + sandboxes: Array + commands?: { + start?: string + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/session-import/project" +} + +export type KilocodeSessionImportProjectErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeSessionImportProjectError = + KilocodeSessionImportProjectErrors[keyof KilocodeSessionImportProjectErrors] + +export type KilocodeSessionImportProjectResponses = { + /** + * Project import result + */ + 200: { + ok: boolean + id: string + skipped?: boolean + } +} + +export type KilocodeSessionImportProjectResponse = + KilocodeSessionImportProjectResponses[keyof KilocodeSessionImportProjectResponses] + +export type KilocodeSessionImportSessionData = { + body?: { + id: string + projectID: string + workspaceID?: string + parentID?: string + slug: string + directory: string + title: string + version: string + shareURL?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array<{ + [key: string]: unknown + }> + } + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } + permission?: { + [key: string]: unknown + } + timeCreated: number + timeUpdated: number + timeCompacting?: number + timeArchived?: number + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/session-import/session" +} + +export type KilocodeSessionImportSessionErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeSessionImportSessionError = + KilocodeSessionImportSessionErrors[keyof KilocodeSessionImportSessionErrors] + +export type KilocodeSessionImportSessionResponses = { + /** + * Session import result + */ + 200: { + ok: boolean + id: string + skipped?: boolean + } +} + +export type KilocodeSessionImportSessionResponse = + KilocodeSessionImportSessionResponses[keyof KilocodeSessionImportSessionResponses] + +export type KilocodeSessionImportMessageData = { + body?: { + id: string + sessionID: string + timeCreated: number + data: + | { + role: "user" + time: { + created: number + } + agent: string + model: { + providerID: string + modelID: string + } + tools?: { + [key: string]: boolean + } + } + | { + role: "assistant" + time: { + created: number + completed?: number + } + parentID: string + modelID: string + providerID: string + mode: string + agent: string + path: { + cwd: string + root: string + } + summary?: boolean + cost: number + tokens: { + total?: number + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + structured?: unknown + variant?: string + finish?: string + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/session-import/message" +} + +export type KilocodeSessionImportMessageErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeSessionImportMessageError = + KilocodeSessionImportMessageErrors[keyof KilocodeSessionImportMessageErrors] + +export type KilocodeSessionImportMessageResponses = { + /** + * Message import result + */ + 200: { + ok: boolean + id: string + skipped?: boolean + } +} + +export type KilocodeSessionImportMessageResponse = + KilocodeSessionImportMessageResponses[keyof KilocodeSessionImportMessageResponses] + +export type KilocodeSessionImportPartData = { + body?: { + id: string + messageID: string + sessionID: string + timeCreated?: number + data: + | { + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } + } + | { + type: "reasoning" + text: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end?: number + } + } + | { + type: "tool" + callID: string + tool: string + state: + | { + status: "pending" + input: { + [key: string]: unknown + } + raw: string + } + | { + status: "running" + input: { + [key: string]: unknown + } + title?: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + } + } + | { + status: "completed" + input: { + [key: string]: unknown + } + output: string + title: string + metadata: { + [key: string]: unknown + } + time: { + start: number + end: number + compacted?: number + } + } + | { + status: "error" + input: { + [key: string]: unknown + } + error: string + metadata?: { + [key: string]: unknown + } + time: { + start: number + end: number + } + } + metadata?: { + [key: string]: unknown + } + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/session-import/part" +} + +export type KilocodeSessionImportPartErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeSessionImportPartError = KilocodeSessionImportPartErrors[keyof KilocodeSessionImportPartErrors] + +export type KilocodeSessionImportPartResponses = { + /** + * Part import result + */ + 200: { + ok: boolean + id: string + skipped?: boolean + } +} + +export type KilocodeSessionImportPartResponse = + KilocodeSessionImportPartResponses[keyof KilocodeSessionImportPartResponses] + export type KilocodeRemoveSkillData = { body?: { location: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3f4f142364e..7211c8fd359 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5042,6 +5042,15 @@ "isFree": { "type": "boolean" }, + "ai_sdk_provider": { + "type": "string", + "enum": [ + "anthropic", + "openai", + "openai-compatible", + "openrouter" + ] + }, "experimental": { "type": "boolean" }, @@ -5473,7 +5482,10 @@ "type": "boolean" } }, - "required": ["enabled", "connected"] + "required": [ + "enabled", + "connected" + ] } } } @@ -5523,7 +5535,10 @@ "type": "boolean" } }, - "required": ["enabled", "connected"] + "required": [ + "enabled", + "connected" + ] } } } @@ -5573,7 +5588,10 @@ "type": "boolean" } }, - "required": ["enabled", "connected"] + "required": [ + "enabled", + "connected" + ] } } } @@ -5753,6 +5771,946 @@ ] } }, + "/kilocode/session-import/project": { + "post": { + "operationId": "kilocode.sessionImport.project", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Insert project for session import", + "description": "Insert or update a project row used by legacy session import.", + "responses": { + "200": { + "description": "Project import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "ok", + "id" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string" + }, + "name": { + "type": "string" + }, + "iconUrl": { + "type": "string" + }, + "iconColor": { + "type": "string" + }, + "timeCreated": { + "type": "number" + }, + "timeUpdated": { + "type": "number" + }, + "timeInitialized": { + "type": "number" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string" + } + } + } + }, + "required": [ + "id", + "worktree", + "timeCreated", + "timeUpdated", + "sandboxes" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.sessionImport.project({\n ...\n})" + } + ] + } + }, + "/kilocode/session-import/session": { + "post": { + "operationId": "kilocode.sessionImport.session", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Insert session for session import", + "description": "Insert or update a session row used by legacy session import.", + "responses": { + "200": { + "description": "Session import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "ok", + "id" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string" + }, + "parentID": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "shareURL": { + "type": "string" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + } + }, + "required": [ + "additions", + "deletions", + "files" + ] + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": [ + "messageID" + ] + }, + "permission": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "timeCreated": { + "type": "number" + }, + "timeUpdated": { + "type": "number" + }, + "timeCompacting": { + "type": "number" + }, + "timeArchived": { + "type": "number" + } + }, + "required": [ + "id", + "projectID", + "slug", + "directory", + "title", + "version", + "timeCreated", + "timeUpdated" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.sessionImport.session({\n ...\n})" + } + ] + } + }, + "/kilocode/session-import/message": { + "post": { + "operationId": "kilocode.sessionImport.message", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Insert message for session import", + "description": "Insert or update a message row used by legacy session import.", + "responses": { + "200": { + "description": "Message import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "ok", + "id" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "timeCreated": { + "type": "number" + }, + "data": { + "anyOf": [ + { + "type": "object", + "properties": { + "role": { + "type": "string", + "const": "user" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": [ + "role", + "time", + "agent", + "model" + ] + }, + { + "type": "object", + "properties": { + "role": { + "type": "string", + "const": "assistant" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ] + }, + "parentID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "cwd", + "root" + ] + }, + "summary": { + "type": "boolean" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ] + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ] + }, + "structured": {}, + "variant": { + "type": "string" + }, + "finish": { + "type": "string" + } + }, + "required": [ + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ] + } + ] + } + }, + "required": [ + "id", + "sessionID", + "timeCreated", + "data" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.sessionImport.message({\n ...\n})" + } + ] + } + }, + "/kilocode/session-import/part": { + "post": { + "operationId": "kilocode.sessionImport.part", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Insert part for session import", + "description": "Insert or update a part row used by legacy session import.", + "responses": { + "200": { + "description": "Part import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "ok", + "id" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "timeCreated": { + "type": "number" + }, + "data": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start" + ] + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "reasoning" + }, + "text": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start" + ] + } + }, + "required": [ + "type", + "text", + "time" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "state": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "pending" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "raw": { + "type": "string" + } + }, + "required": [ + "status", + "input", + "raw" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "running" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + } + }, + "required": [ + "start" + ] + } + }, + "required": [ + "status", + "input", + "time" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "completed" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "output": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "compacted": { + "type": "number" + } + }, + "required": [ + "start", + "end" + ] + } + }, + "required": [ + "status", + "input", + "output", + "title", + "metadata", + "time" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "error" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "error": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start", + "end" + ] + } + }, + "required": [ + "status", + "input", + "error", + "time" + ] + } + ] + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "callID", + "tool", + "state" + ] + } + ] + } + }, + "required": [ + "id", + "messageID", + "sessionID", + "data" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.sessionImport.part({\n ...\n})" + } + ] + } + }, "/kilocode/skill/remove": { "post": { "operationId": "kilocode.removeSkill", @@ -6158,7 +7116,32 @@ }, "groups": { "type": "array", - "items": {} + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "fileRegex": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + ] + } + ] + } } } } @@ -12659,6 +13642,15 @@ "isFree": { "type": "boolean" }, + "ai_sdk_provider": { + "type": "string", + "enum": [ + "anthropic", + "openai", + "openai-compatible", + "openrouter" + ] + }, "experimental": { "type": "boolean" }, @@ -13740,6 +14732,15 @@ }, "isFree": { "type": "boolean" + }, + "ai_sdk_provider": { + "type": "string", + "enum": [ + "anthropic", + "openai", + "openai-compatible", + "openrouter" + ] } }, "required": [