mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into hardly-wish
# Conflicts: # packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Filter marketplace items to those relevant to the current workspace's files and installed VS Code extensions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support safely reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code, including absolute workspace paths and content-based edit conflict detection.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6eb9d7670f55c5529dce90d11fceb5c9da7a0d3a5f7270fdfd27e66ca1f3d72f
|
||||
size 9851
|
||||
oid sha256:3545994fd7a126ea860b31e2ac07f9cbeaa03e872f805e3fd2ec7012556c6ea8
|
||||
size 12665
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4a7bcef3102838812625393f0cead3a10067ae32e67dd3f174d0e74d03116c8c
|
||||
size 54735
|
||||
oid sha256:10adc41427bfb41fb25fb25e14c771443c1b3cf23a546b05665370974fbc2209
|
||||
size 54839
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:572b5a2bfd530b743a8ebaaed2ddacc9611aaa31f450df0f2904f314d8cfff6d
|
||||
size 52660
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "بحث...",
|
||||
"marketplace.filter.all": "جميع العناصر",
|
||||
"marketplace.filter.notInstalled": "غير مثبت",
|
||||
"marketplace.filter.relevant": "ذات صلة بمساحة العمل الخاصة بي",
|
||||
"marketplace.empty": "لم يتم العثور على عناصر",
|
||||
"marketplace.empty.relevant": "لم يتم العثور على عناصر سوق ذات صلة بمساحة العمل هذه.",
|
||||
"marketplace.badge.mcpServer": "خادم MCP",
|
||||
"marketplace.badge.mode": "وضع",
|
||||
"marketplace.card.by": "بواسطة {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Pesquisar...",
|
||||
"marketplace.filter.all": "Todos os Itens",
|
||||
"marketplace.filter.notInstalled": "Não Instalado",
|
||||
"marketplace.filter.relevant": "Relevantes para meu espaço de trabalho",
|
||||
"marketplace.empty": "Nenhum item encontrado",
|
||||
"marketplace.empty.relevant": "Nenhum item relevante do marketplace foi encontrado para este espaço de trabalho.",
|
||||
"marketplace.badge.mcpServer": "Servidor MCP",
|
||||
"marketplace.badge.mode": "Modo",
|
||||
"marketplace.card.by": "por {{author}}",
|
||||
|
||||
Generated
+2
@@ -64,7 +64,9 @@ export const dict = {
|
||||
"marketplace.search": "Pretraži...",
|
||||
"marketplace.filter.all": "Sve stavke",
|
||||
"marketplace.filter.notInstalled": "Nije instalirano",
|
||||
"marketplace.filter.relevant": "Relevantno za moj radni prostor",
|
||||
"marketplace.empty": "Nema pronađenih stavki",
|
||||
"marketplace.empty.relevant": "Nisu pronađene relevantne stavke marketplacea za ovaj radni prostor.",
|
||||
"marketplace.badge.mcpServer": "MCP Server",
|
||||
"marketplace.badge.mode": "Režim",
|
||||
"marketplace.card.by": "od {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Søg...",
|
||||
"marketplace.filter.all": "Alle elementer",
|
||||
"marketplace.filter.notInstalled": "Ikke installeret",
|
||||
"marketplace.filter.relevant": "Relevante for mit arbejdsområde",
|
||||
"marketplace.empty": "Ingen elementer fundet",
|
||||
"marketplace.empty.relevant": "Der blev ikke fundet relevante marketplace-elementer for dette arbejdsområde.",
|
||||
"marketplace.badge.mcpServer": "MCP-server",
|
||||
"marketplace.badge.mode": "Tilstand",
|
||||
"marketplace.card.by": "af {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Suchen...",
|
||||
"marketplace.filter.all": "Alle Elemente",
|
||||
"marketplace.filter.notInstalled": "Nicht installiert",
|
||||
"marketplace.filter.relevant": "Relevant für meinen Arbeitsbereich",
|
||||
"marketplace.empty": "Keine Elemente gefunden",
|
||||
"marketplace.empty.relevant": "Keine relevanten Marketplace-Elemente für diesen Arbeitsbereich gefunden.",
|
||||
"marketplace.badge.mcpServer": "MCP-Server",
|
||||
"marketplace.badge.mode": "Modus",
|
||||
"marketplace.card.by": "von {{author}}",
|
||||
|
||||
@@ -61,7 +61,9 @@ export const dict = {
|
||||
"marketplace.search": "Search...",
|
||||
"marketplace.filter.all": "All Items",
|
||||
"marketplace.filter.notInstalled": "Not Installed",
|
||||
"marketplace.filter.relevant": "Relevant to my workspace",
|
||||
"marketplace.empty": "No items found",
|
||||
"marketplace.empty.relevant": "No relevant marketplace items found for this workspace.",
|
||||
"marketplace.badge.mcpServer": "MCP Server",
|
||||
"marketplace.badge.mode": "Mode",
|
||||
"marketplace.card.by": "by {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Buscar...",
|
||||
"marketplace.filter.all": "Todos los elementos",
|
||||
"marketplace.filter.notInstalled": "No instalado",
|
||||
"marketplace.filter.relevant": "Relevantes para mi espacio de trabajo",
|
||||
"marketplace.empty": "No se encontraron elementos",
|
||||
"marketplace.empty.relevant": "No se encontraron elementos relevantes del marketplace para este espacio de trabajo.",
|
||||
"marketplace.badge.mcpServer": "Servidor MCP",
|
||||
"marketplace.badge.mode": "Modo",
|
||||
"marketplace.card.by": "por {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Rechercher...",
|
||||
"marketplace.filter.all": "Tous les éléments",
|
||||
"marketplace.filter.notInstalled": "Non installé",
|
||||
"marketplace.filter.relevant": "Pertinents pour mon espace de travail",
|
||||
"marketplace.empty": "Aucun élément trouvé",
|
||||
"marketplace.empty.relevant": "Aucun élément pertinent du marketplace trouvé pour cet espace de travail.",
|
||||
"marketplace.badge.mcpServer": "Serveur MCP",
|
||||
"marketplace.badge.mode": "Mode",
|
||||
"marketplace.card.by": "par {{author}}",
|
||||
|
||||
Generated
+2
@@ -60,7 +60,9 @@ export const dict = {
|
||||
"marketplace.search": "Cerca...",
|
||||
"marketplace.filter.all": "Tutti gli elementi",
|
||||
"marketplace.filter.notInstalled": "Non installati",
|
||||
"marketplace.filter.relevant": "Rilevanti per il mio spazio di lavoro",
|
||||
"marketplace.empty": "Nessun elemento trovato",
|
||||
"marketplace.empty.relevant": "Nessun elemento rilevante del marketplace trovato per questo spazio di lavoro.",
|
||||
"marketplace.badge.mcpServer": "Server MCP",
|
||||
"marketplace.badge.mode": "Modalità",
|
||||
"marketplace.card.by": "di {{author}}",
|
||||
|
||||
Generated
+2
@@ -57,7 +57,9 @@ export const dict = {
|
||||
"marketplace.search": "検索...",
|
||||
"marketplace.filter.all": "すべてのアイテム",
|
||||
"marketplace.filter.notInstalled": "未インストール",
|
||||
"marketplace.filter.relevant": "自分のワークスペースに関連",
|
||||
"marketplace.empty": "アイテムが見つかりません",
|
||||
"marketplace.empty.relevant": "このワークスペースに関連するマーケットプレイスのアイテムが見つかりませんでした。",
|
||||
"marketplace.badge.mcpServer": "MCPサーバー",
|
||||
"marketplace.badge.mode": "モード",
|
||||
"marketplace.card.by": "作成者: {{author}}",
|
||||
|
||||
Generated
+2
@@ -57,7 +57,9 @@ export const dict = {
|
||||
"marketplace.search": "검색...",
|
||||
"marketplace.filter.all": "모든 항목",
|
||||
"marketplace.filter.notInstalled": "설치되지 않음",
|
||||
"marketplace.filter.relevant": "내 작업 영역과 관련됨",
|
||||
"marketplace.empty": "항목을 찾을 수 없음",
|
||||
"marketplace.empty.relevant": "이 작업 영역과 관련된 마켓플레이스 항목을 찾을 수 없습니다.",
|
||||
"marketplace.badge.mcpServer": "MCP 서버",
|
||||
"marketplace.badge.mode": "모드",
|
||||
"marketplace.card.by": "제작: {{author}}",
|
||||
|
||||
Generated
+2
@@ -61,7 +61,9 @@ export const dict = {
|
||||
"marketplace.search": "Zoeken...",
|
||||
"marketplace.filter.all": "Alle items",
|
||||
"marketplace.filter.notInstalled": "Niet geïnstalleerd",
|
||||
"marketplace.filter.relevant": "Relevant voor mijn werkruimte",
|
||||
"marketplace.empty": "Geen items gevonden",
|
||||
"marketplace.empty.relevant": "Geen relevante marketplace-items gevonden voor deze werkruimte.",
|
||||
"marketplace.badge.mcpServer": "MCP Server",
|
||||
"marketplace.badge.mode": "Modus",
|
||||
"marketplace.card.by": "door {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Søk...",
|
||||
"marketplace.filter.all": "Alle elementer",
|
||||
"marketplace.filter.notInstalled": "Ikke installert",
|
||||
"marketplace.filter.relevant": "Relevant for arbeidsområdet mitt",
|
||||
"marketplace.empty": "Ingen elementer funnet",
|
||||
"marketplace.empty.relevant": "Ingen relevante marketplace-elementer funnet for dette arbeidsområdet.",
|
||||
"marketplace.badge.mcpServer": "MCP-server",
|
||||
"marketplace.badge.mode": "Modus",
|
||||
"marketplace.card.by": "av {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Szukaj...",
|
||||
"marketplace.filter.all": "Wszystkie elementy",
|
||||
"marketplace.filter.notInstalled": "Nie zainstalowano",
|
||||
"marketplace.filter.relevant": "Istotne dla mojego obszaru roboczego",
|
||||
"marketplace.empty": "Nie znaleziono elementów",
|
||||
"marketplace.empty.relevant": "Nie znaleziono elementów marketplace istotnych dla tego obszaru roboczego.",
|
||||
"marketplace.badge.mcpServer": "Serwer MCP",
|
||||
"marketplace.badge.mode": "Tryb",
|
||||
"marketplace.card.by": "autor: {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Поиск...",
|
||||
"marketplace.filter.all": "Все элементы",
|
||||
"marketplace.filter.notInstalled": "Не установлено",
|
||||
"marketplace.filter.relevant": "Подходящие для моего рабочего пространства",
|
||||
"marketplace.empty": "Элементы не найдены",
|
||||
"marketplace.empty.relevant": "Для этого рабочего пространства не найдено подходящих элементов маркетплейса.",
|
||||
"marketplace.badge.mcpServer": "MCP-сервер",
|
||||
"marketplace.badge.mode": "Режим",
|
||||
"marketplace.card.by": "от {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "ค้นหา...",
|
||||
"marketplace.filter.all": "รายการทั้งหมด",
|
||||
"marketplace.filter.notInstalled": "ยังไม่ได้ติดตั้ง",
|
||||
"marketplace.filter.relevant": "เกี่ยวข้องกับพื้นที่ทำงานของฉัน",
|
||||
"marketplace.empty": "ไม่พบรายการ",
|
||||
"marketplace.empty.relevant": "ไม่พบรายการในมาร์เก็ตเพลสที่เกี่ยวข้องกับพื้นที่ทำงานนี้",
|
||||
"marketplace.badge.mcpServer": "เซิร์ฟเวอร์ MCP",
|
||||
"marketplace.badge.mode": "โหมด",
|
||||
"marketplace.card.by": "โดย {{author}}",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Ara...",
|
||||
"marketplace.filter.all": "Tüm Öğeler",
|
||||
"marketplace.filter.notInstalled": "Yüklü Değil",
|
||||
"marketplace.filter.relevant": "Çalışma alanımla ilgili",
|
||||
"marketplace.empty": "Öğe bulunamadı",
|
||||
"marketplace.empty.relevant": "Bu çalışma alanıyla ilgili marketplace öğesi bulunamadı.",
|
||||
"marketplace.badge.mcpServer": "MCP Sunucusu",
|
||||
"marketplace.badge.mode": "Mod",
|
||||
"marketplace.card.by": "{{author}} tarafından",
|
||||
|
||||
Generated
+2
@@ -59,7 +59,9 @@ export const dict = {
|
||||
"marketplace.search": "Пошук...",
|
||||
"marketplace.filter.all": "Усі елементи",
|
||||
"marketplace.filter.notInstalled": "Не встановлено",
|
||||
"marketplace.filter.relevant": "Відповідні моєму робочому простору",
|
||||
"marketplace.empty": "Елементів не знайдено",
|
||||
"marketplace.empty.relevant": "Для цього робочого простору не знайдено відповідних елементів маркетплейсу.",
|
||||
"marketplace.badge.mcpServer": "MCP-сервер",
|
||||
"marketplace.badge.mode": "Режим",
|
||||
"marketplace.card.by": "від {{author}}",
|
||||
|
||||
Generated
+2
@@ -56,7 +56,9 @@ export const dict = {
|
||||
"marketplace.search": "搜索...",
|
||||
"marketplace.filter.all": "所有项目",
|
||||
"marketplace.filter.notInstalled": "未安装",
|
||||
"marketplace.filter.relevant": "与我的工作区相关",
|
||||
"marketplace.empty": "未找到项目",
|
||||
"marketplace.empty.relevant": "未找到与此工作区相关的市场项目。",
|
||||
"marketplace.badge.mcpServer": "MCP 服务器",
|
||||
"marketplace.badge.mode": "模式",
|
||||
"marketplace.card.by": "作者:{{author}}",
|
||||
|
||||
Generated
+2
@@ -56,7 +56,9 @@ export const dict = {
|
||||
"marketplace.search": "搜尋...",
|
||||
"marketplace.filter.all": "所有項目",
|
||||
"marketplace.filter.notInstalled": "未安裝",
|
||||
"marketplace.filter.relevant": "與我的工作區相關",
|
||||
"marketplace.empty": "找不到項目",
|
||||
"marketplace.empty.relevant": "找不到與此工作區相關的市集項目。",
|
||||
"marketplace.badge.mcpServer": "MCP 伺服器",
|
||||
"marketplace.badge.mode": "模式",
|
||||
"marketplace.card.by": "作者:{{author}}",
|
||||
|
||||
@@ -33,6 +33,8 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
private panel: vscode.WebviewPanel | undefined
|
||||
private project: string | null = null
|
||||
private ready = false
|
||||
private generation = 0
|
||||
private refresh: ReturnType<typeof setTimeout> | undefined
|
||||
private statuses = new Map<string, SessionStatus["type"]>()
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private subscriptions: Array<() => void> = []
|
||||
@@ -60,6 +62,7 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
if (this.panel) {
|
||||
this.setProjectDirectory(project)
|
||||
this.panel.reveal(vscode.ViewColumn.One)
|
||||
this.scheduleRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,6 +108,10 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
panel.webview.onDidReceiveMessage((msg) => void this.handle(msg as MarketplaceMessage)),
|
||||
panel.onDidDispose(() => this.cleanup()),
|
||||
watchFontSizeConfig((msg) => this.post(msg)),
|
||||
vscode.extensions.onDidChange(() => this.scheduleRefresh()),
|
||||
vscode.workspace.onDidCreateFiles(() => this.scheduleRefresh()),
|
||||
vscode.workspace.onDidDeleteFiles(() => this.scheduleRefresh()),
|
||||
vscode.workspace.onDidRenameFiles(() => this.scheduleRefresh()),
|
||||
)
|
||||
this.subscriptions.push(
|
||||
this.connection.onStateChange((state, err) => {
|
||||
@@ -123,12 +130,15 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.refresh) clearTimeout(this.refresh)
|
||||
this.refresh = undefined
|
||||
for (const disposable of this.disposables) disposable.dispose()
|
||||
for (const unsubscribe of this.subscriptions) unsubscribe()
|
||||
this.disposables = []
|
||||
this.subscriptions = []
|
||||
this.panel = undefined
|
||||
this.ready = false
|
||||
this.generation++
|
||||
this.statuses.clear()
|
||||
}
|
||||
|
||||
@@ -198,19 +208,32 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
if (!this.ready) return
|
||||
if (this.refresh) clearTimeout(this.refresh)
|
||||
this.refresh = setTimeout(() => {
|
||||
this.refresh = undefined
|
||||
void this.fetchData()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
private async fetchData(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
try {
|
||||
const project = this.project ?? undefined
|
||||
const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory())
|
||||
const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory(), this.relevanceRoots())
|
||||
if (generation !== this.generation) return
|
||||
const dismissed = this.context.globalState.get<boolean>("kilo.agentMigrationBannerDismissed") ?? false
|
||||
this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed })
|
||||
} catch (err) {
|
||||
if (generation !== this.generation) return
|
||||
const error = err instanceof Error ? err.message : String(err)
|
||||
console.warn("[Kilo New] Marketplace data fetch failed:", err)
|
||||
this.post({
|
||||
type: "marketplaceData",
|
||||
marketplaceItems: [],
|
||||
marketplaceInstalledMetadata: { project: {}, global: {} },
|
||||
marketplaceRelevance: {},
|
||||
errors: [error],
|
||||
})
|
||||
}
|
||||
@@ -247,6 +270,7 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
|
||||
private setProjectDirectory(project: string | null): void {
|
||||
if (this.project === project) return
|
||||
this.generation++
|
||||
this.project = project
|
||||
this.post({ type: "workspaceDirectoryChanged", directory: project ?? "" })
|
||||
}
|
||||
@@ -264,6 +288,12 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
return this.project ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir()
|
||||
}
|
||||
|
||||
private relevanceRoots(): vscode.Uri[] {
|
||||
if (!this.project) return vscode.workspace.workspaceFolders?.map((folder) => folder.uri) ?? []
|
||||
const folder = vscode.workspace.workspaceFolders?.find((item) => item.uri.fsPath === this.project)
|
||||
return [folder?.uri ?? vscode.Uri.file(this.project)]
|
||||
}
|
||||
|
||||
private openExternal(raw: unknown): void {
|
||||
if (typeof raw !== "string") return
|
||||
const uri = vscode.Uri.parse(raw)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { registerToggleAutoApprove } from "./commands/toggle-auto-approve"
|
||||
import { registerHeapSnapshot } from "./commands/heap-snapshot"
|
||||
import { RemoteStatusService } from "./services/RemoteStatusService"
|
||||
import { markWorkspace } from "./util/spotlight"
|
||||
import { createNotebookBridge } from "./services/notebook"
|
||||
|
||||
let agentManager: AgentManagerProvider | undefined
|
||||
let shuttingDown = false
|
||||
@@ -49,6 +50,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
// Create shared connection service (one server for all webviews)
|
||||
const connectionService = new KiloConnectionService(context)
|
||||
const notebookBridge = createNotebookBridge(connectionService)
|
||||
let restore = context.workspaceState.get<RestoreState>(RESTORE_KEY) ?? {}
|
||||
const remember = (patch: RestoreState) => {
|
||||
const next = { ...restore, ...patch }
|
||||
@@ -541,6 +543,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
attention.dispose()
|
||||
browserAutomationService.dispose()
|
||||
provider.dispose()
|
||||
notebookBridge.dispose()
|
||||
connectionService.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import * as vscode from "vscode"
|
||||
import { KiloConnectionService } from "./connection-service"
|
||||
|
||||
function state(value: boolean) {
|
||||
@@ -19,6 +20,25 @@ describe("KiloConnectionService sandbox preference", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService clients", () => {
|
||||
test("returns a connected client without a workspace folder", async () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
const client = {}
|
||||
const workspace = vscode.workspace as { workspaceFolders?: readonly vscode.WorkspaceFolder[] }
|
||||
const folders = workspace.workspaceFolders
|
||||
|
||||
;(service as any).client = client
|
||||
;(service as any).state = "connected"
|
||||
workspace.workspaceFolders = undefined
|
||||
|
||||
try {
|
||||
expect(await service.getClientAsync()).toBe(client)
|
||||
} finally {
|
||||
workspace.workspaceFolders = folders
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService viewed sessions", () => {
|
||||
test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
|
||||
@@ -72,6 +72,8 @@ export class KiloConnectionService {
|
||||
private readonly favoritesChangeListeners: Set<FavoritesChangeListener> = new Set()
|
||||
private readonly clearPendingPromptsListeners: Set<ClearPendingPromptsListener> = new Set()
|
||||
private readonly directoryProviders: Set<DirectoryProvider> = new Set()
|
||||
private rootDirectory: string | undefined = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
private currentDirectory: string | undefined
|
||||
private readonly permissionDirectories: Map<string, string> = new Map()
|
||||
private readonly questionDirectories: Map<string, string> = new Map()
|
||||
private questionRevision = 0
|
||||
@@ -106,6 +108,7 @@ export class KiloConnectionService {
|
||||
* Lazily start server + SSE. Multiple callers share the same promise.
|
||||
*/
|
||||
async connect(workspaceDir: string): Promise<void> {
|
||||
this.trackDirectory(workspaceDir)
|
||||
if (this.connectPromise) {
|
||||
return this.connectPromise
|
||||
}
|
||||
@@ -145,13 +148,28 @@ export class KiloConnectionService {
|
||||
* or if the connection fails.
|
||||
*/
|
||||
async getClientAsync(dir?: string): Promise<KiloClient> {
|
||||
if (dir) this.trackDirectory(dir)
|
||||
if (this.client && this.state === "connected") return this.client
|
||||
const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!root) throw new Error("No workspace folder open")
|
||||
this.trackDirectory(root)
|
||||
await this.connect(root)
|
||||
return this.getClient()
|
||||
}
|
||||
|
||||
/** Directories that may own directory-scoped requests on the shared backend. */
|
||||
getKnownDirectories(): string[] {
|
||||
const dirs = new Set<string>()
|
||||
if (this.rootDirectory) dirs.add(this.rootDirectory)
|
||||
if (this.currentDirectory) dirs.add(this.currentDirectory)
|
||||
for (const provider of this.directoryProviders) {
|
||||
for (const dir of provider()) {
|
||||
if (dir) dirs.add(dir)
|
||||
}
|
||||
}
|
||||
return [...dirs]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server info (port). Returns null if not connected.
|
||||
*/
|
||||
@@ -434,6 +452,12 @@ export class KiloConnectionService {
|
||||
}
|
||||
}
|
||||
|
||||
private trackDirectory(dir: string): void {
|
||||
if (!dir) return
|
||||
this.rootDirectory ??= dir
|
||||
this.currentDirectory = dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all pending permission requests and questions across every
|
||||
* directory known to any currently-mounted KiloProvider.
|
||||
@@ -580,6 +604,8 @@ export class KiloConnectionService {
|
||||
this.favoritesChangeListeners.clear()
|
||||
this.clearPendingPromptsListeners.clear()
|
||||
this.directoryProviders.clear()
|
||||
this.rootDirectory = undefined
|
||||
this.currentDirectory = undefined
|
||||
this.messageSessionIdsByMessageId.clear()
|
||||
this.permissionDirectories.clear()
|
||||
this.questionDirectories.clear()
|
||||
|
||||
@@ -28,9 +28,10 @@ export async function fetchMarketplaceData(
|
||||
ctx: MarketplaceActionContext,
|
||||
project: string | undefined,
|
||||
dir: string | undefined,
|
||||
roots: readonly vscode.Uri[],
|
||||
): Promise<MarketplaceDataResponse> {
|
||||
const skills = dir ? await fetchSkills(ctx, dir) : undefined
|
||||
return ctx.marketplace.fetchData(project, skills)
|
||||
return ctx.marketplace.fetchData(project, skills, roots)
|
||||
}
|
||||
|
||||
export async function installMarketplaceItem(
|
||||
|
||||
@@ -38,6 +38,7 @@ function transformSkill(raw: RawSkill): SkillMarketplaceItem {
|
||||
displayCategory: kebabToTitleCase(raw.category),
|
||||
githubUrl: raw.githubUrl,
|
||||
content: raw.content,
|
||||
suggest_for: raw.suggest_for,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import { MarketplaceApiClient } from "./api"
|
||||
import { MarketplacePaths } from "./paths"
|
||||
import { InstallationDetector, type CliSkill } from "./detection"
|
||||
import { MarketplaceInstaller } from "./installer"
|
||||
import { detectMarketplaceRelevance } from "./relevance"
|
||||
import type {
|
||||
MarketplaceItem,
|
||||
InstallMarketplaceItemOptions,
|
||||
MarketplaceDataResponse,
|
||||
MarketplaceRelevanceMetadata,
|
||||
InstallResult,
|
||||
RemoveResult,
|
||||
} from "./types"
|
||||
@@ -16,6 +18,7 @@ export class MarketplaceService {
|
||||
private paths: MarketplacePaths
|
||||
private detector: InstallationDetector
|
||||
private installer: MarketplaceInstaller
|
||||
private scans = new Map<string, Promise<MarketplaceRelevanceMetadata>>()
|
||||
|
||||
constructor() {
|
||||
this.paths = new MarketplacePaths()
|
||||
@@ -24,16 +27,30 @@ export class MarketplaceService {
|
||||
this.installer = new MarketplaceInstaller(this.paths)
|
||||
}
|
||||
|
||||
async fetchData(workspace?: string, skills?: CliSkill[]): Promise<MarketplaceDataResponse> {
|
||||
const [fetched, metadata] = await Promise.all([this.api.fetchAll(), this.detector.detect(workspace, skills)])
|
||||
async fetchData(workspace: string | undefined, skills: CliSkill[] | undefined, roots: readonly vscode.Uri[]) {
|
||||
const fetched = this.api.fetchAll()
|
||||
const metadata = this.detector.detect(workspace, skills)
|
||||
const relevance = fetched.then((result) => this.relevance(result.items, roots))
|
||||
const [items, installed, matches] = await Promise.all([fetched, metadata, relevance])
|
||||
|
||||
return {
|
||||
marketplaceItems: fetched.items,
|
||||
marketplaceInstalledMetadata: metadata,
|
||||
errors: fetched.errors.length > 0 ? fetched.errors : undefined,
|
||||
marketplaceItems: items.items,
|
||||
marketplaceInstalledMetadata: installed,
|
||||
marketplaceRelevance: matches,
|
||||
errors: items.errors.length > 0 ? items.errors : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
private relevance(items: MarketplaceItem[], roots: readonly vscode.Uri[]): Promise<MarketplaceRelevanceMetadata> {
|
||||
const key = `${roots.map((root) => root.toString()).join(",")}:${items.map((item) => `${item.type}:${item.id}`).join(",")}`
|
||||
const current = this.scans.get(key)
|
||||
if (current) return current
|
||||
|
||||
const scan = detectMarketplaceRelevance(items, roots).finally(() => this.scans.delete(key))
|
||||
this.scans.set(key, scan)
|
||||
return scan
|
||||
}
|
||||
|
||||
async install(
|
||||
item: MarketplaceItem,
|
||||
options: InstallMarketplaceItemOptions,
|
||||
@@ -59,6 +76,7 @@ export class MarketplaceService {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.scans.clear()
|
||||
this.api.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as vscode from "vscode"
|
||||
import type { MarketplaceItem, MarketplaceRelevanceMetadata } from "./types"
|
||||
|
||||
const EXCLUDE = "**/{node_modules,.git,dist,build,out,.kilo,.opencode,.kilocode}/**"
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
interface RelevanceHost {
|
||||
extensions: readonly string[]
|
||||
find: (root: vscode.Uri, pattern: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function context(): RelevanceHost {
|
||||
return {
|
||||
extensions: vscode.extensions.all.map((extension) => extension.id),
|
||||
find: async (root, pattern) => {
|
||||
const glob = new vscode.RelativePattern(root, `**/${pattern}`)
|
||||
return (await vscode.workspace.findFiles(glob, EXCLUDE, 1)).length > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectMarketplaceRelevance(
|
||||
items: MarketplaceItem[],
|
||||
roots: readonly vscode.Uri[],
|
||||
source: RelevanceHost = context(),
|
||||
): Promise<MarketplaceRelevanceMetadata> {
|
||||
const patterns = Array.from(new Set(items.flatMap((item) => strings(item.suggest_for?.filename))))
|
||||
const files = new Map<string, boolean>()
|
||||
|
||||
const batches = Array.from({ length: Math.ceil(patterns.length / 4) }, (_, index) =>
|
||||
patterns.slice(index * 4, index * 4 + 4),
|
||||
)
|
||||
for (const batch of batches) {
|
||||
await Promise.all(
|
||||
batch.map(async (pattern) => {
|
||||
const found = await Promise.all(
|
||||
roots.map((root) =>
|
||||
source.find(root, pattern).catch((err: unknown) => {
|
||||
console.warn(`[Kilo New] Marketplace relevance scan failed for ${pattern}:`, err)
|
||||
return false
|
||||
}),
|
||||
),
|
||||
)
|
||||
files.set(pattern, found.some(Boolean))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const extensions = new Set(source.extensions.map((id) => id.toLowerCase()))
|
||||
return Object.fromEntries(
|
||||
items.flatMap((item) => {
|
||||
const filename = strings(item.suggest_for?.filename).filter((pattern) => files.get(pattern))
|
||||
const vscodeExtension = strings(item.suggest_for?.vscode_extension).filter((id) =>
|
||||
extensions.has(id.toLowerCase()),
|
||||
)
|
||||
if (!filename?.length && !vscodeExtension?.length) return []
|
||||
return [
|
||||
[
|
||||
`${item.type}:${item.id}`,
|
||||
{
|
||||
...(filename?.length && { filename }),
|
||||
...(vscodeExtension?.length && { vscodeExtension }),
|
||||
},
|
||||
],
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,11 @@ export interface McpInstallationMethod {
|
||||
prerequisites?: string[]
|
||||
}
|
||||
|
||||
export interface MarketplaceSuggestFor {
|
||||
filename?: string[]
|
||||
vscode_extension?: string[]
|
||||
}
|
||||
|
||||
export interface MarketplaceItemBase {
|
||||
id: string
|
||||
name: string
|
||||
@@ -20,6 +25,7 @@ export interface MarketplaceItemBase {
|
||||
author?: string
|
||||
authorUrl?: string
|
||||
prerequisites?: string[]
|
||||
suggest_for?: MarketplaceSuggestFor
|
||||
}
|
||||
|
||||
export interface McpMarketplaceItem extends MarketplaceItemBase {
|
||||
@@ -48,6 +54,7 @@ export interface RawSkill {
|
||||
category: string
|
||||
githubUrl: string
|
||||
content: string
|
||||
suggest_for?: MarketplaceSuggestFor
|
||||
}
|
||||
|
||||
export interface SkillMarketplaceItem extends MarketplaceItemBase {
|
||||
@@ -71,9 +78,17 @@ export interface MarketplaceInstalledMetadata {
|
||||
global: Record<string, { type: string }>
|
||||
}
|
||||
|
||||
export interface MarketplaceRelevance {
|
||||
filename?: string[]
|
||||
vscodeExtension?: string[]
|
||||
}
|
||||
|
||||
export type MarketplaceRelevanceMetadata = Record<string, MarketplaceRelevance>
|
||||
|
||||
export interface MarketplaceDataResponse {
|
||||
marketplaceItems: MarketplaceItem[]
|
||||
marketplaceInstalledMetadata: MarketplaceInstalledMetadata
|
||||
marketplaceRelevance: MarketplaceRelevanceMetadata
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
import path from "node:path"
|
||||
import * as vscode from "vscode"
|
||||
import { normalizeOutputs, normalizeSource } from "./output"
|
||||
import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path"
|
||||
import { cellFingerprint, fingerprint, notebookState, sameCell, type NotebookState } from "./revision"
|
||||
import {
|
||||
NOTEBOOK_LIMITS,
|
||||
type NotebookAccess,
|
||||
type NotebookAdapterDeps,
|
||||
type NotebookCell,
|
||||
type NotebookEditRequest,
|
||||
type NotebookEditResult,
|
||||
type NotebookExecuteRequest,
|
||||
type NotebookExecuteResult,
|
||||
type NotebookExecution,
|
||||
type NotebookReadRequest,
|
||||
type NotebookReadResult,
|
||||
} from "./types"
|
||||
|
||||
const RETAINED_REVISIONS = 1_000
|
||||
const revisions = new Map<string, NotebookState>()
|
||||
const locks = new Map<string, Promise<void>>()
|
||||
|
||||
function revisionKey(target: string, revision: string): string {
|
||||
return `${target}\0${revision}`
|
||||
}
|
||||
|
||||
export interface NotebookAdapterOptions {
|
||||
deps?: NotebookAdapterDeps
|
||||
paths?: NotebookPathDeps
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
function execution(summary: vscode.NotebookCellExecutionSummary | undefined): NotebookExecution | undefined {
|
||||
if (!summary) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
order: summary.executionOrder,
|
||||
success: summary.success,
|
||||
started: summary.timing?.startTime,
|
||||
ended: summary.timing?.endTime,
|
||||
}
|
||||
}
|
||||
|
||||
function changed(
|
||||
base: vscode.NotebookCellExecutionSummary | undefined,
|
||||
summary: vscode.NotebookCellExecutionSummary | undefined,
|
||||
): boolean {
|
||||
if (!summary) return false
|
||||
return (
|
||||
summary.executionOrder !== base?.executionOrder ||
|
||||
summary.success !== base?.success ||
|
||||
summary.timing?.startTime !== base?.timing?.startTime ||
|
||||
summary.timing?.endTime !== base?.timing?.endTime
|
||||
)
|
||||
}
|
||||
|
||||
function defaults(): NotebookAdapterDeps {
|
||||
return {
|
||||
documents: () => vscode.workspace.notebookDocuments,
|
||||
open: (uri) => Promise.resolve(vscode.workspace.openNotebookDocument(uri)),
|
||||
apply: (edit) => Promise.resolve(vscode.workspace.applyEdit(edit)),
|
||||
execute: (command, ...args) => Promise.resolve(vscode.commands.executeCommand(command, ...args)),
|
||||
change: (listener) => vscode.workspace.onDidChangeNotebookDocument(listener),
|
||||
close: (listener) => vscode.workspace.onDidCloseNotebookDocument(listener),
|
||||
uri: vscode.Uri.file,
|
||||
edit: (uri, edits) => {
|
||||
const edit = new vscode.WorkspaceEdit()
|
||||
edit.set(uri, edits)
|
||||
return edit
|
||||
},
|
||||
insert: (index, cells) => vscode.NotebookEdit.insertCells(index, cells),
|
||||
replace: (index, cells) => vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(index, index + 1), cells),
|
||||
delete: (index) => vscode.NotebookEdit.deleteCells(new vscode.NotebookRange(index, index + 1)),
|
||||
cell: (input) =>
|
||||
new vscode.NotebookCellData(
|
||||
input.kind === "code" ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup,
|
||||
input.source,
|
||||
input.language ?? (input.kind === "code" ? "plaintext" : "markdown"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export class NotebookAdapter {
|
||||
private readonly deps: NotebookAdapterDeps
|
||||
private readonly timeout: number
|
||||
|
||||
constructor(
|
||||
private readonly access: NotebookAccess,
|
||||
private readonly options: NotebookAdapterOptions = {},
|
||||
) {
|
||||
this.deps = options.deps ?? defaults()
|
||||
this.timeout = options.timeout ?? 9 * 60_000
|
||||
}
|
||||
|
||||
private async document(
|
||||
directory: string,
|
||||
input: string,
|
||||
): Promise<{ document: vscode.NotebookDocument; path: string; target: string }> {
|
||||
const resolved = await resolveNotebookPath(directory, input, this.access, this.options.paths)
|
||||
const open = this.deps
|
||||
.documents()
|
||||
.find((document) => path.resolve(document.uri.fsPath) === path.resolve(resolved.target))
|
||||
const document = open ?? (await this.deps.open(this.deps.uri(resolved.target)))
|
||||
if (document.isClosed) {
|
||||
throw new NotebookError("closed", `Notebook ${JSON.stringify(resolved.relative)} is closed`, {
|
||||
path: resolved.relative,
|
||||
})
|
||||
}
|
||||
return { document, path: resolved.relative, target: resolved.target }
|
||||
}
|
||||
|
||||
async read(request: NotebookReadRequest): Promise<NotebookReadResult> {
|
||||
const loaded = await this.document(request.directory, request.path)
|
||||
const cells: NotebookCell[] = []
|
||||
const budget = { sources: 0, outputs: 0 }
|
||||
const flags = { sources: false, outputs: false }
|
||||
|
||||
const items = loaded.document.getCells()
|
||||
const state = this.remember(loaded.document, loaded.target)
|
||||
if (items.length > 2_000) {
|
||||
flags.sources = true
|
||||
}
|
||||
for (const [index, cell] of items.slice(0, 2_000).entries()) {
|
||||
const limit = Math.max(0, Math.min(NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources))
|
||||
const source = (() => {
|
||||
if (limit === 0) {
|
||||
flags.sources = true
|
||||
return { text: "", bytes: 0 }
|
||||
}
|
||||
const end = cell.document.positionAt(limit + 2)
|
||||
const range = new vscode.Range(new vscode.Position(0, 0), end)
|
||||
return normalizeSource(cell.document.getText(range), limit)
|
||||
})()
|
||||
budget.sources += source.bytes
|
||||
flags.sources ||= source.truncated === true
|
||||
const value: NotebookCell = {
|
||||
index,
|
||||
kind: cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown",
|
||||
language: cell.document.languageId.slice(0, 200),
|
||||
source: source.text,
|
||||
execution: execution(cell.executionSummary),
|
||||
}
|
||||
if (request.includeOutputs) {
|
||||
const normalized = normalizeOutputs(
|
||||
cell.outputs,
|
||||
Math.max(0, Math.min(NOTEBOOK_LIMITS.output, NOTEBOOK_LIMITS.outputs - budget.outputs)),
|
||||
)
|
||||
value.outputs = normalized.outputs
|
||||
budget.outputs += normalized.bytes
|
||||
if (normalized.truncated) {
|
||||
flags.outputs = true
|
||||
}
|
||||
}
|
||||
cells.push(value)
|
||||
}
|
||||
|
||||
return {
|
||||
operation: "read",
|
||||
path: loaded.path,
|
||||
requestPath: request.path,
|
||||
revision: state.revision,
|
||||
cells,
|
||||
...(flags.sources || flags.outputs ? { truncated: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async edit(request: NotebookEditRequest): Promise<NotebookEditResult> {
|
||||
const loaded = await this.document(request.directory, request.path)
|
||||
return this.lock(loaded.target, async () => {
|
||||
const before = this.remember(loaded.document, loaded.target)
|
||||
this.revision(before, request.expectedRevision, loaded.path, request.index)
|
||||
const count = loaded.document.cellCount
|
||||
const max = request.edit.action === "insert" ? count : count - 1
|
||||
if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) {
|
||||
throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, {
|
||||
path: loaded.path,
|
||||
index: request.index,
|
||||
})
|
||||
}
|
||||
|
||||
const expected = [...before.cells]
|
||||
const edits = (() => {
|
||||
if (request.edit.action === "delete") {
|
||||
expected.splice(request.index, 1)
|
||||
return [this.deps.delete(request.index)]
|
||||
}
|
||||
const language = request.edit.language ?? (request.edit.kind === "code" ? "plaintext" : "markdown")
|
||||
const cell = this.deps.cell({
|
||||
kind: request.edit.kind,
|
||||
language: request.edit.language,
|
||||
source: request.edit.source,
|
||||
})
|
||||
const value = fingerprint(request.edit.kind, language, request.edit.source)
|
||||
if (request.edit.action === "insert") {
|
||||
expected.splice(request.index, 0, value)
|
||||
return [this.deps.insert(request.index, [cell])]
|
||||
}
|
||||
expected.splice(request.index, 1, value)
|
||||
return [this.deps.replace(request.index, [cell])]
|
||||
})()
|
||||
this.revision(this.remember(loaded.document, loaded.target), request.expectedRevision, loaded.path, request.index)
|
||||
if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) {
|
||||
throw new NotebookError("unsupported", "VS Code rejected the notebook edit", {
|
||||
path: loaded.path,
|
||||
index: request.index,
|
||||
})
|
||||
}
|
||||
const after = this.remember(loaded.document, loaded.target)
|
||||
if (after.cells.length !== expected.length || after.cells.some((value, index) => value !== expected[index])) {
|
||||
throw this.stale(loaded.path, request.index, after.revision, "Notebook content changed while applying the edit")
|
||||
}
|
||||
const result: NotebookEditResult = {
|
||||
operation: "edit",
|
||||
path: loaded.path,
|
||||
requestPath: request.path,
|
||||
revision: after.revision,
|
||||
index: request.index,
|
||||
action: request.edit.action,
|
||||
}
|
||||
if (request.edit.action !== "delete" && request.index < loaded.document.cellCount) {
|
||||
result.cell = this.cell(loaded.document.cellAt(request.index), request.index)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
async execute(request: NotebookExecuteRequest): Promise<NotebookExecuteResult> {
|
||||
const loaded = await this.document(request.directory, request.path)
|
||||
const state = this.remember(loaded.document, loaded.target)
|
||||
if (!Number.isInteger(request.index) || request.index < 0 || request.index >= loaded.document.cellCount) {
|
||||
throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, {
|
||||
path: loaded.path,
|
||||
index: request.index,
|
||||
})
|
||||
}
|
||||
const expected = revisions.get(revisionKey(loaded.target, request.expectedRevision))
|
||||
if (
|
||||
state.revision !== request.expectedRevision &&
|
||||
!sameCell(expected?.cells[request.index], state.cells[request.index])
|
||||
) {
|
||||
throw this.stale(loaded.path, request.index, state.revision, "The targeted notebook cell changed")
|
||||
}
|
||||
const cell = loaded.document.cellAt(request.index)
|
||||
if (cell.kind !== vscode.NotebookCellKind.Code) {
|
||||
throw new NotebookError("invalid_cell", `Cell ${request.index} is not a code cell`, {
|
||||
path: loaded.path,
|
||||
index: request.index,
|
||||
})
|
||||
}
|
||||
|
||||
const result = this.wait(loaded.document, cell, loaded.target, loaded.path, state.cells[request.index]!, request)
|
||||
void this.deps
|
||||
.execute("notebook.cell.execute", {
|
||||
ranges: [{ start: request.index, end: request.index + 1 }],
|
||||
document: loaded.document.uri,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
result.reject(
|
||||
new NotebookError("execution_failed", `Notebook execution could not start: ${detail}`, {
|
||||
path: loaded.path,
|
||||
index: request.index,
|
||||
}),
|
||||
)
|
||||
})
|
||||
return result.promise
|
||||
}
|
||||
|
||||
private wait(
|
||||
document: vscode.NotebookDocument,
|
||||
cell: vscode.NotebookCell,
|
||||
target: string,
|
||||
path: string,
|
||||
fingerprint: NotebookState["cells"][number],
|
||||
request: NotebookExecuteRequest,
|
||||
) {
|
||||
const state: {
|
||||
done: boolean
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
startup?: ReturnType<typeof setTimeout>
|
||||
} = { done: false }
|
||||
const base = cell.executionSummary
|
||||
const disposables: vscode.Disposable[] = []
|
||||
const cleanup = () => {
|
||||
if (state.done) {
|
||||
return false
|
||||
}
|
||||
state.done = true
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer)
|
||||
}
|
||||
if (state.startup) {
|
||||
clearTimeout(state.startup)
|
||||
}
|
||||
for (const disposable of disposables) {
|
||||
disposable.dispose()
|
||||
}
|
||||
request.signal?.removeEventListener("abort", abort)
|
||||
return true
|
||||
}
|
||||
const holder: {
|
||||
resolve?: (value: NotebookExecuteResult) => void
|
||||
reject?: (error: Error) => void
|
||||
} = {}
|
||||
const promise = new Promise<NotebookExecuteResult>((resolve, reject) => {
|
||||
holder.resolve = resolve
|
||||
holder.reject = reject
|
||||
})
|
||||
const reject = (error: Error) => {
|
||||
if (cleanup()) {
|
||||
holder.reject?.(error)
|
||||
}
|
||||
}
|
||||
const resolve = () => {
|
||||
if (!cleanup()) {
|
||||
return
|
||||
}
|
||||
const normalized = normalizeOutputs(cell.outputs)
|
||||
holder.resolve?.({
|
||||
operation: "execute",
|
||||
path,
|
||||
requestPath: request.path,
|
||||
revision: this.remember(document, target).revision,
|
||||
index: request.index,
|
||||
status: cell.executionSummary?.success === false ? "error" : "success",
|
||||
outputs: normalized.outputs,
|
||||
...(normalized.truncated ? { truncated: true } : {}),
|
||||
})
|
||||
}
|
||||
const stop = () =>
|
||||
void this.deps.execute("notebook.cell.cancelExecution", {
|
||||
ranges: [{ start: request.index, end: request.index + 1 }],
|
||||
document: document.uri,
|
||||
})
|
||||
const abort = () => {
|
||||
stop()
|
||||
reject(
|
||||
new NotebookError("cancelled", "Notebook execution cancellation was requested", {
|
||||
path,
|
||||
index: request.index,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
disposables.push(
|
||||
this.deps.change((event) => {
|
||||
if (event.notebook !== document) {
|
||||
return
|
||||
}
|
||||
const live = document.cellCount > request.index ? cellFingerprint(document.cellAt(request.index)) : undefined
|
||||
if (
|
||||
document.isClosed ||
|
||||
document.cellCount <= request.index ||
|
||||
document.cellAt(request.index) !== cell ||
|
||||
!sameCell(fingerprint, live)
|
||||
) {
|
||||
const current = this.remember(document, target)
|
||||
reject(
|
||||
this.stale(path, request.index, current.revision, "The targeted notebook cell changed during execution"),
|
||||
)
|
||||
stop()
|
||||
return
|
||||
}
|
||||
const change = event.cellChanges.find((item) => item.cell === cell)
|
||||
if (!change) {
|
||||
return
|
||||
}
|
||||
if (state.startup && (change.outputs !== undefined || change.executionSummary !== undefined)) {
|
||||
clearTimeout(state.startup)
|
||||
state.startup = undefined
|
||||
}
|
||||
const summary = change.executionSummary
|
||||
if (!summary || !changed(base, summary)) return
|
||||
if (summary.success !== undefined || summary.timing?.endTime !== undefined) resolve()
|
||||
}),
|
||||
this.deps.close((closed) => {
|
||||
if (closed === document) {
|
||||
reject(new NotebookError("closed", "Notebook closed during execution", { path, index: request.index }))
|
||||
}
|
||||
}),
|
||||
)
|
||||
const timeout = request.timeout ?? this.timeout
|
||||
state.startup = setTimeout(
|
||||
() => {
|
||||
stop()
|
||||
reject(
|
||||
new NotebookError(
|
||||
"execution_failed",
|
||||
"The execution command was dispatched, but VS Code reported no execution activity before the startup timeout. Ensure a kernel is selected and try again",
|
||||
{ path, index: request.index },
|
||||
),
|
||||
)
|
||||
},
|
||||
Math.min(timeout, 60_000),
|
||||
)
|
||||
state.timer = setTimeout(() => {
|
||||
stop()
|
||||
reject(new NotebookError("timeout", "Notebook execution timed out", { path, index: request.index }))
|
||||
}, timeout)
|
||||
if (request.signal?.aborted) {
|
||||
abort()
|
||||
} else {
|
||||
request.signal?.addEventListener("abort", abort, { once: true })
|
||||
}
|
||||
return { promise, reject }
|
||||
}
|
||||
|
||||
private cell(cell: vscode.NotebookCell, index: number): NotebookCell {
|
||||
return {
|
||||
index,
|
||||
kind: cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown",
|
||||
language: cell.document.languageId.slice(0, 200),
|
||||
source: cell.document.getText().slice(0, 200_000),
|
||||
execution: execution(cell.executionSummary),
|
||||
}
|
||||
}
|
||||
|
||||
private remember(document: vscode.NotebookDocument, target: string): NotebookState {
|
||||
const state = notebookState(document)
|
||||
const key = revisionKey(target, state.revision)
|
||||
revisions.delete(key)
|
||||
revisions.set(key, state)
|
||||
if (revisions.size > RETAINED_REVISIONS) {
|
||||
const oldest = revisions.keys().next().value
|
||||
if (oldest !== undefined) revisions.delete(oldest)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private revision(state: NotebookState, expected: string, path: string, index: number): void {
|
||||
if (state.revision !== expected) {
|
||||
throw this.stale(path, index, state.revision, "Notebook content changed")
|
||||
}
|
||||
}
|
||||
|
||||
private stale(path: string, index: number, revision: string, detail: string): NotebookError {
|
||||
return new NotebookError(
|
||||
"stale_revision",
|
||||
`${detail}. Re-read ${JSON.stringify(path)} before retrying; do not blindly replay an index-based edit`,
|
||||
{ path, index, currentRevision: revision },
|
||||
)
|
||||
}
|
||||
|
||||
private async lock<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const prior = locks.get(key) ?? Promise.resolve()
|
||||
const gate: { release?: () => void } = {}
|
||||
const current = new Promise<void>((resolve) => (gate.release = resolve))
|
||||
const next = prior.then(() => current)
|
||||
locks.set(key, next)
|
||||
await prior
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
gate.release?.()
|
||||
if (locks.get(key) === next) locks.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { realpath } from "node:fs/promises"
|
||||
import type {
|
||||
EventKilocodeNotebookCancelled,
|
||||
EventKilocodeNotebookRequested,
|
||||
KiloClient,
|
||||
NotebookFailure,
|
||||
NotebookRequest,
|
||||
NotebookResult,
|
||||
} from "@kilocode/sdk/v2/client"
|
||||
import { FileIgnoreController } from "../autocomplete/shims/FileIgnoreController"
|
||||
import type { ConnectionState, KiloConnectionService } from "../cli-backend/connection-service"
|
||||
import type { SSEPayload } from "../cli-backend/sdk-sse-adapter"
|
||||
import { NotebookAdapter } from "./adapter"
|
||||
import { NotebookError } from "./path"
|
||||
|
||||
const RETAINED_REQUESTS = 1_000
|
||||
const CODES = new Set<NotebookFailure["code"]>([
|
||||
"cancelled",
|
||||
"closed",
|
||||
"disconnected",
|
||||
"execution_failed",
|
||||
"invalid_cell",
|
||||
"invalid_path",
|
||||
"no_kernel",
|
||||
"not_found",
|
||||
"stale_revision",
|
||||
"timeout",
|
||||
"unsupported",
|
||||
])
|
||||
|
||||
type NotebookAdapterLike = Pick<NotebookAdapter, "read" | "edit" | "execute">
|
||||
|
||||
export interface NotebookBridgeContext {
|
||||
adapter: NotebookAdapterLike
|
||||
refresh?(): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export interface NotebookBridgeOptions {
|
||||
create?: (directory: string) => Promise<NotebookBridgeContext>
|
||||
canonical?: (directory: string) => Promise<string>
|
||||
}
|
||||
|
||||
interface NotebookConnection {
|
||||
onEvent(listener: (event: SSEPayload, directory?: string) => void): () => void
|
||||
onStateChange(listener: (state: ConnectionState, error?: Error) => void): () => void
|
||||
getClient(): KiloClient
|
||||
getKnownDirectories(): string[]
|
||||
}
|
||||
|
||||
interface ActiveRequest {
|
||||
controller: AbortController
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
interface RequestOrigin {
|
||||
directory: string
|
||||
root: string
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
type NotebookOutcome = { result: NotebookResult } | { error: NotebookFailure }
|
||||
|
||||
async function createContext(directory: string): Promise<NotebookBridgeContext> {
|
||||
const controller = new FileIgnoreController(directory)
|
||||
await controller.initialize()
|
||||
return {
|
||||
adapter: new NotebookAdapter(controller),
|
||||
refresh: () => controller.initialize(),
|
||||
dispose: () => controller.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
function failure(error: unknown): NotebookFailure {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
const message = (detail || "Notebook operation failed without an error message").slice(0, 10_000)
|
||||
if (error instanceof NotebookError && CODES.has(error.code as NotebookFailure["code"])) {
|
||||
return {
|
||||
code: error.code as NotebookFailure["code"],
|
||||
message,
|
||||
...(error.path !== undefined ? { path: error.path } : {}),
|
||||
...(error.index !== undefined ? { index: error.index } : {}),
|
||||
...(error.currentRevision !== undefined ? { currentRevision: error.currentRevision } : {}),
|
||||
}
|
||||
}
|
||||
return { code: "execution_failed", message }
|
||||
}
|
||||
|
||||
export class NotebookBridge {
|
||||
private readonly contexts = new Map<string, Promise<NotebookBridgeContext>>()
|
||||
private readonly active = new Map<string, ActiveRequest>()
|
||||
private readonly admitting = new Set<string>()
|
||||
private readonly origins = new Map<string, RequestOrigin>()
|
||||
private readonly outcomes = new Map<string, NotebookOutcome>()
|
||||
private readonly settled = new Set<string>()
|
||||
private readonly unsubscribeEvent: () => void
|
||||
private readonly unsubscribeState: () => void
|
||||
private readonly create: (directory: string) => Promise<NotebookBridgeContext>
|
||||
private readonly canonical: (directory: string) => Promise<string>
|
||||
private disposed = false
|
||||
private revision = 0
|
||||
private backend: KiloClient | undefined
|
||||
|
||||
constructor(
|
||||
private readonly connection: NotebookConnection,
|
||||
options: NotebookBridgeOptions = {},
|
||||
) {
|
||||
this.create = options.create ?? createContext
|
||||
this.canonical = options.canonical ?? realpath
|
||||
this.unsubscribeEvent = connection.onEvent((event, directory) => this.event(event, directory))
|
||||
this.unsubscribeState = connection.onStateChange((state) => {
|
||||
if (state !== "connected") return
|
||||
const backend = connection.getClient()
|
||||
if (this.backend && this.backend !== backend) this.reset()
|
||||
this.backend = backend
|
||||
const revision = ++this.revision
|
||||
void this.recover(revision).catch((error: unknown) => {
|
||||
console.error("[Kilo New] NotebookBridge: pending request recovery failed:", error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.revision += 1
|
||||
this.unsubscribeEvent()
|
||||
this.unsubscribeState()
|
||||
for (const request of this.active.values()) {
|
||||
request.cancelled = true
|
||||
request.controller.abort()
|
||||
}
|
||||
this.active.clear()
|
||||
this.admitting.clear()
|
||||
for (const context of this.contexts.values()) {
|
||||
void context
|
||||
.then((value) => value.dispose())
|
||||
.catch((error: unknown) => console.error("[Kilo New] NotebookBridge: context disposal failed:", error))
|
||||
}
|
||||
this.contexts.clear()
|
||||
this.origins.clear()
|
||||
this.outcomes.clear()
|
||||
this.settled.clear()
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
for (const request of this.active.values()) {
|
||||
request.cancelled = true
|
||||
request.controller.abort()
|
||||
}
|
||||
this.active.clear()
|
||||
this.admitting.clear()
|
||||
this.origins.clear()
|
||||
this.outcomes.clear()
|
||||
this.settled.clear()
|
||||
}
|
||||
|
||||
private event(event: SSEPayload, directory?: string): void {
|
||||
if (event.type === "kilocode.notebook.requested") {
|
||||
this.request(event as EventKilocodeNotebookRequested, directory)
|
||||
return
|
||||
}
|
||||
if (event.type === "kilocode.notebook.cancelled") {
|
||||
this.cancel(event as EventKilocodeNotebookCancelled, directory)
|
||||
}
|
||||
}
|
||||
|
||||
private request(event: EventKilocodeNotebookRequested, directory?: string): void {
|
||||
const request = event.properties
|
||||
const origin = this.origins.get(request.id)
|
||||
if (origin) {
|
||||
if (origin.sessionID !== request.sessionID || (directory && origin.directory !== directory)) return
|
||||
this.start(request, origin)
|
||||
return
|
||||
}
|
||||
if (!directory || this.disposed || this.admitting.has(request.id) || this.settled.has(request.id)) return
|
||||
this.admitting.add(request.id)
|
||||
void this.admit(request, directory).finally(() => this.admitting.delete(request.id))
|
||||
}
|
||||
|
||||
private async admit(request: NotebookRequest, directory: string): Promise<void> {
|
||||
const root = await this.allowed(directory)
|
||||
if (this.disposed || this.settled.has(request.id)) return
|
||||
if (!root) {
|
||||
const accepted = await this.reject(request.id, directory, {
|
||||
code: "invalid_path",
|
||||
message: "Notebook request directory is not an active VS Code workspace",
|
||||
})
|
||||
if (accepted) this.remember(this.settled, request.id)
|
||||
return
|
||||
}
|
||||
const origin = { directory, root, sessionID: request.sessionID }
|
||||
this.rememberOrigin(request.id, origin)
|
||||
this.start(request, origin)
|
||||
}
|
||||
|
||||
private start(request: NotebookRequest, origin: RequestOrigin): void {
|
||||
if (this.disposed || this.active.has(request.id) || this.settled.has(request.id)) return
|
||||
const active = { controller: new AbortController(), cancelled: false }
|
||||
this.active.set(request.id, active)
|
||||
void this.run(request, origin, active).catch((error: unknown) => {
|
||||
console.error(`[Kilo New] NotebookBridge: request ${request.id} failed:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
private cancel(event: EventKilocodeNotebookCancelled, directory?: string): void {
|
||||
const id = event.properties.requestID
|
||||
const origin = this.origins.get(id)
|
||||
if (origin && (origin.sessionID !== event.properties.sessionID || (directory && origin.directory !== directory)))
|
||||
return
|
||||
this.remember(this.settled, id)
|
||||
const active = this.active.get(id)
|
||||
if (!active) return
|
||||
active.cancelled = true
|
||||
active.controller.abort()
|
||||
}
|
||||
|
||||
private async run(request: NotebookRequest, origin: RequestOrigin, active: ActiveRequest): Promise<void> {
|
||||
try {
|
||||
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, origin.root, active))
|
||||
if (!outcome || this.disposed || active.cancelled) return
|
||||
this.rememberOutcome(request.id, outcome)
|
||||
const accepted =
|
||||
"result" in outcome
|
||||
? await this.reply(request.id, origin.directory, outcome.result)
|
||||
: await this.reject(request.id, origin.directory, outcome.error)
|
||||
if (accepted) {
|
||||
this.outcomes.delete(request.id)
|
||||
this.remember(this.settled, request.id)
|
||||
}
|
||||
} finally {
|
||||
if (this.active.get(request.id) === active) this.active.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(
|
||||
request: NotebookRequest,
|
||||
directory: string,
|
||||
active: ActiveRequest,
|
||||
): Promise<NotebookOutcome | undefined> {
|
||||
try {
|
||||
const context = await this.context(directory)
|
||||
if (this.disposed || active.cancelled) return undefined
|
||||
await context.refresh?.()
|
||||
if (this.disposed || active.cancelled) return undefined
|
||||
const result = await this.dispatch(context.adapter, request, directory, active.controller.signal)
|
||||
return { result }
|
||||
} catch (error) {
|
||||
if (this.disposed || active.cancelled) return undefined
|
||||
return { error: failure(error) }
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(
|
||||
adapter: NotebookAdapterLike,
|
||||
request: NotebookRequest,
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<NotebookResult> {
|
||||
if (request.operation === "read") {
|
||||
return adapter.read({ path: request.path, directory, includeOutputs: request.includeOutputs })
|
||||
}
|
||||
if (request.operation === "edit") {
|
||||
return adapter.edit({
|
||||
path: request.path,
|
||||
directory,
|
||||
expectedRevision: request.expectedRevision,
|
||||
index: request.index,
|
||||
edit: request.edit,
|
||||
})
|
||||
}
|
||||
return adapter.execute({
|
||||
path: request.path,
|
||||
directory,
|
||||
expectedRevision: request.expectedRevision,
|
||||
index: request.index,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
private async allowed(directory: string): Promise<string | undefined> {
|
||||
const root = await this.canonical(directory).catch(() => undefined)
|
||||
if (!root) return undefined
|
||||
const known = await Promise.all(
|
||||
this.connection.getKnownDirectories().map((dir) => this.canonical(dir).catch(() => undefined)),
|
||||
)
|
||||
return known.includes(root) ? root : undefined
|
||||
}
|
||||
|
||||
private context(directory: string): Promise<NotebookBridgeContext> {
|
||||
const existing = this.contexts.get(directory)
|
||||
if (existing) return existing
|
||||
const context = this.create(directory).catch((error: unknown) => {
|
||||
this.contexts.delete(directory)
|
||||
throw error
|
||||
})
|
||||
this.contexts.set(directory, context)
|
||||
return context
|
||||
}
|
||||
|
||||
private async reply(requestID: string, directory: string, result: NotebookResult): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.connection.getClient().kilocode.notebook.reply({ requestID, directory, result })
|
||||
if (!response.error) return true
|
||||
console.error(`[Kilo New] NotebookBridge: reply ${requestID} failed:`, response.error)
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error(`[Kilo New] NotebookBridge: reply ${requestID} failed:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async reject(requestID: string, directory: string, error: NotebookFailure): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.connection.getClient().kilocode.notebook.reject({ requestID, directory, error })
|
||||
if (!response.error) return true
|
||||
console.error(`[Kilo New] NotebookBridge: rejection ${requestID} failed:`, response.error)
|
||||
return false
|
||||
} catch (cause) {
|
||||
console.error(`[Kilo New] NotebookBridge: rejection ${requestID} failed:`, cause)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async recover(revision: number): Promise<void> {
|
||||
const client = this.connection.getClient()
|
||||
for (const directory of this.connection.getKnownDirectories()) {
|
||||
try {
|
||||
const response = await client.kilocode.notebook.list({ directory })
|
||||
if (this.disposed || revision !== this.revision) return
|
||||
if (response.error) {
|
||||
console.error(`[Kilo New] NotebookBridge: could not list requests for ${directory}:`, response.error)
|
||||
continue
|
||||
}
|
||||
for (const request of response.data ?? []) {
|
||||
this.request({ id: request.id, type: "kilocode.notebook.requested", properties: request }, directory)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Kilo New] NotebookBridge: could not list requests for ${directory}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rememberOutcome(id: string, outcome: NotebookOutcome): void {
|
||||
this.outcomes.set(id, outcome)
|
||||
if (this.outcomes.size <= RETAINED_REQUESTS) return
|
||||
const oldest = this.outcomes.keys().next().value
|
||||
if (oldest !== undefined) this.outcomes.delete(oldest)
|
||||
}
|
||||
|
||||
private rememberOrigin(id: string, origin: RequestOrigin): void {
|
||||
this.origins.set(id, origin)
|
||||
if (this.origins.size <= RETAINED_REQUESTS) return
|
||||
const oldest = this.origins.keys().next().value
|
||||
if (oldest !== undefined) this.origins.delete(oldest)
|
||||
}
|
||||
|
||||
private remember(set: Set<string>, id: string): void {
|
||||
set.add(id)
|
||||
if (set.size <= RETAINED_REQUESTS) return
|
||||
const oldest = set.keys().next().value
|
||||
if (oldest !== undefined) set.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotebookBridge(connection: KiloConnectionService): NotebookBridge {
|
||||
return new NotebookBridge(connection)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { NotebookBridge, createNotebookBridge, type NotebookBridgeContext, type NotebookBridgeOptions } from "./bridge"
|
||||
export { NotebookAdapter, type NotebookAdapterOptions } from "./adapter"
|
||||
export { normalizeOutputs, normalizeSource } from "./output"
|
||||
export { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path"
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,95 @@
|
||||
import type * as vscode from "vscode"
|
||||
import { NOTEBOOK_LIMITS, type NotebookOutput, type NotebookText } from "./types"
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
const ERROR_MIME = "application/vnd.code.notebook.error"
|
||||
const TEXT_MIMES = new Set([
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
"application/vnd.code.notebook.stdout",
|
||||
"application/vnd.code.notebook.stderr",
|
||||
])
|
||||
|
||||
function slice(data: Uint8Array, limit: number): NotebookText {
|
||||
const bytes = data.byteLength
|
||||
if (bytes <= limit) return { text: decoder.decode(data), bytes }
|
||||
const end = (() => {
|
||||
let index = limit
|
||||
while (index > 0 && (data[index] & 0xc0) === 0x80) index--
|
||||
return index
|
||||
})()
|
||||
return { text: decoder.decode(data.subarray(0, end)), bytes, truncated: true }
|
||||
}
|
||||
|
||||
export function normalizeSource(source: string, limit = NOTEBOOK_LIMITS.source): NotebookText {
|
||||
return slice(encoder.encode(source), limit)
|
||||
}
|
||||
|
||||
function field(value: unknown, limit: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined
|
||||
return value.slice(0, limit)
|
||||
}
|
||||
|
||||
function error(item: vscode.NotebookCellOutputItem, limit: number): NotebookOutput {
|
||||
const value = slice(item.data, limit)
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return JSON.parse(value.text) as unknown
|
||||
} catch (err) {
|
||||
void err
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
const data = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {}
|
||||
return {
|
||||
mime: item.mime.slice(0, 200),
|
||||
text: value.text,
|
||||
name: field(data.name, 500),
|
||||
message: field(data.message, 10_000),
|
||||
stack: field(data.stack, 50_000),
|
||||
truncated: value.truncated,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOutputs(
|
||||
outputs: readonly vscode.NotebookCellOutput[],
|
||||
limit = NOTEBOOK_LIMITS.output,
|
||||
): { outputs: NotebookOutput[]; truncated: boolean; bytes: number } {
|
||||
const result: NotebookOutput[] = []
|
||||
let used = 0
|
||||
let truncated = false
|
||||
|
||||
for (const output of outputs) {
|
||||
for (const item of output.items) {
|
||||
if (result.length >= 100) {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
const text = TEXT_MIMES.has(item.mime) || item.mime.startsWith("text/")
|
||||
if (item.mime !== ERROR_MIME && !text) {
|
||||
result.push({ mime: item.mime.slice(0, 200), omitted: true })
|
||||
continue
|
||||
}
|
||||
const available = Math.max(0, Math.min(NOTEBOOK_LIMITS.item, limit - used))
|
||||
if (available === 0) {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
if (item.mime === ERROR_MIME) {
|
||||
const value = error(item, available)
|
||||
result.push(value)
|
||||
used += Math.min(item.data.byteLength, available)
|
||||
truncated ||= value.truncated === true
|
||||
continue
|
||||
}
|
||||
const value = slice(item.data, available)
|
||||
result.push({ mime: item.mime.slice(0, 200), text: value.text, truncated: value.truncated })
|
||||
used += Math.min(item.data.byteLength, available)
|
||||
truncated ||= value.truncated === true
|
||||
}
|
||||
}
|
||||
|
||||
return { outputs: result, truncated, bytes: used }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { NotebookAccess } from "./types"
|
||||
|
||||
const WINDOWS_ABSOLUTE = /^[a-zA-Z]:[/\\]/
|
||||
|
||||
export interface NotebookErrorDetails {
|
||||
path?: string
|
||||
index?: number
|
||||
currentRevision?: string
|
||||
}
|
||||
|
||||
export class NotebookError extends Error {
|
||||
readonly path?: string
|
||||
readonly index?: number
|
||||
readonly currentRevision?: string
|
||||
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
details: NotebookErrorDetails = {},
|
||||
) {
|
||||
super(message)
|
||||
this.name = "NotebookError"
|
||||
this.path = details.path
|
||||
this.index = details.index
|
||||
this.currentRevision = details.currentRevision
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotebookPathDeps {
|
||||
realpath(path: string): Promise<string>
|
||||
}
|
||||
|
||||
export interface NotebookPath {
|
||||
target: string
|
||||
relative: string
|
||||
}
|
||||
|
||||
const defaults: NotebookPathDeps = { realpath: fs.realpath }
|
||||
|
||||
function contained(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target)
|
||||
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function invalid(input: string, reason: string): NotebookError {
|
||||
return new NotebookError(
|
||||
"invalid_path",
|
||||
`Invalid notebook path ${JSON.stringify(input)}: ${reason}. Use a path relative to the request directory or an absolute path inside it`,
|
||||
{ path: input },
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveNotebookPath(
|
||||
directory: string,
|
||||
input: string,
|
||||
access: NotebookAccess,
|
||||
deps: NotebookPathDeps = defaults,
|
||||
): Promise<NotebookPath> {
|
||||
if (!input || input.length > 4_096 || input.includes("\0")) {
|
||||
throw invalid(input, "the path is empty, too long, or malformed")
|
||||
}
|
||||
if (WINDOWS_ABSOLUTE.test(input) && !path.win32.isAbsolute(directory)) {
|
||||
throw invalid(input, "the absolute path uses a different platform format")
|
||||
}
|
||||
|
||||
const base = path.resolve(directory)
|
||||
const root = await deps.realpath(base)
|
||||
const candidate = path.resolve(base, input)
|
||||
if (!path.isAbsolute(input) && !contained(base, candidate)) {
|
||||
throw invalid(input, "it is outside the request directory")
|
||||
}
|
||||
|
||||
const target = await deps.realpath(candidate).catch((error: unknown) => {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new NotebookError("not_found", `Cannot resolve notebook ${JSON.stringify(input)}: ${detail}`, { path: input })
|
||||
})
|
||||
if (!contained(root, target)) {
|
||||
throw invalid(input, "it resolves through a symlink outside the request directory")
|
||||
}
|
||||
if (!(await access.validateAccess(target))) {
|
||||
throw invalid(input, "it is excluded by workspace access or ignore rules")
|
||||
}
|
||||
return { target, relative: path.relative(root, target).split(path.sep).join("/") }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export interface NotebookState {
|
||||
revision: string
|
||||
cells: string[]
|
||||
}
|
||||
|
||||
function hash(parts: string[]): string {
|
||||
const value = createHash("sha256")
|
||||
for (const part of parts) {
|
||||
value.update(String(Buffer.byteLength(part)))
|
||||
value.update(":")
|
||||
value.update(part)
|
||||
}
|
||||
return value.digest("base64url")
|
||||
}
|
||||
|
||||
export function fingerprint(kind: "code" | "markdown", language: string, source: string): string {
|
||||
return hash([kind, language, source])
|
||||
}
|
||||
|
||||
export function cellFingerprint(cell: vscode.NotebookCell): string {
|
||||
return fingerprint(
|
||||
cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown",
|
||||
cell.document.languageId,
|
||||
cell.document.getText(),
|
||||
)
|
||||
}
|
||||
|
||||
export function notebookState(document: vscode.NotebookDocument): NotebookState {
|
||||
const cells = document.getCells().map(cellFingerprint)
|
||||
return { revision: `content:${hash(cells)}`, cells }
|
||||
}
|
||||
|
||||
export function sameCell(left: string | undefined, right: string | undefined): boolean {
|
||||
return left !== undefined && left === right
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type * as vscode from "vscode"
|
||||
|
||||
export const NOTEBOOK_LIMITS = {
|
||||
source: 64 * 1024,
|
||||
sources: 256 * 1024,
|
||||
item: 16 * 1024,
|
||||
output: 64 * 1024,
|
||||
outputs: 256 * 1024,
|
||||
} as const
|
||||
|
||||
export type NotebookCellKind = "code" | "markdown"
|
||||
|
||||
export interface NotebookText {
|
||||
text: string
|
||||
bytes: number
|
||||
truncated?: true
|
||||
}
|
||||
|
||||
export interface NotebookOutput {
|
||||
mime: string
|
||||
text?: string
|
||||
name?: string
|
||||
message?: string
|
||||
stack?: string
|
||||
omitted?: boolean
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface NotebookExecution {
|
||||
order?: number
|
||||
success?: boolean
|
||||
started?: number
|
||||
ended?: number
|
||||
}
|
||||
|
||||
export interface NotebookCell {
|
||||
index: number
|
||||
kind: NotebookCellKind
|
||||
language: string
|
||||
source: string
|
||||
execution?: NotebookExecution
|
||||
outputs?: NotebookOutput[]
|
||||
}
|
||||
|
||||
export interface NotebookReadResult {
|
||||
operation: "read"
|
||||
path: string
|
||||
requestPath: string
|
||||
revision: string
|
||||
cells: NotebookCell[]
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface NotebookEditResult {
|
||||
operation: "edit"
|
||||
path: string
|
||||
requestPath: string
|
||||
revision: string
|
||||
index: number
|
||||
action: "insert" | "replace" | "delete"
|
||||
cell?: NotebookCell
|
||||
}
|
||||
|
||||
export interface NotebookExecuteResult {
|
||||
operation: "execute"
|
||||
path: string
|
||||
requestPath: string
|
||||
revision: string
|
||||
index: number
|
||||
status: "success" | "error"
|
||||
outputs: NotebookOutput[]
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface NotebookCellInput {
|
||||
kind: NotebookCellKind
|
||||
language?: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export type NotebookEdit =
|
||||
| ({ action: "insert" } & NotebookCellInput)
|
||||
| ({ action: "replace" } & NotebookCellInput)
|
||||
| { action: "delete" }
|
||||
|
||||
export interface NotebookReadRequest {
|
||||
path: string
|
||||
directory: string
|
||||
includeOutputs: boolean
|
||||
}
|
||||
|
||||
export interface NotebookEditRequest {
|
||||
path: string
|
||||
directory: string
|
||||
expectedRevision: string
|
||||
index: number
|
||||
edit: NotebookEdit
|
||||
}
|
||||
|
||||
export interface NotebookExecuteRequest {
|
||||
path: string
|
||||
directory: string
|
||||
expectedRevision: string
|
||||
index: number
|
||||
signal?: AbortSignal
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface NotebookAccess {
|
||||
validateAccess(path: string): boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
export interface NotebookAdapterDeps {
|
||||
documents(): readonly vscode.NotebookDocument[]
|
||||
open(uri: vscode.Uri): Promise<vscode.NotebookDocument>
|
||||
apply(edit: vscode.WorkspaceEdit): Promise<boolean>
|
||||
execute(command: string, ...args: unknown[]): Promise<unknown>
|
||||
change(listener: (event: vscode.NotebookDocumentChangeEvent) => void): vscode.Disposable
|
||||
close(listener: (document: vscode.NotebookDocument) => void): vscode.Disposable
|
||||
uri(path: string): vscode.Uri
|
||||
edit(uri: vscode.Uri, edits: vscode.NotebookEdit[]): vscode.WorkspaceEdit
|
||||
insert(index: number, cells: vscode.NotebookCellData[]): vscode.NotebookEdit
|
||||
replace(index: number, cells: vscode.NotebookCellData[]): vscode.NotebookEdit
|
||||
delete(index: number): vscode.NotebookEdit
|
||||
cell(input: NotebookCellInput): vscode.NotebookCellData
|
||||
}
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
type MarketplaceRemoveContext,
|
||||
} from "../../src/services/marketplace/actions"
|
||||
import type { McpMarketplaceItem } from "../../src/services/marketplace/types"
|
||||
import { filterItems, installedScopes, retain } from "../../webview-ui/src/components/marketplace/utils"
|
||||
import {
|
||||
filterItems,
|
||||
hasRelevantItems,
|
||||
installedScopes,
|
||||
retain,
|
||||
} from "../../webview-ui/src/components/marketplace/utils"
|
||||
import type { MarketplaceItem } from "../../webview-ui/src/types/marketplace"
|
||||
|
||||
const project = "/repo"
|
||||
@@ -127,6 +132,16 @@ describe("Marketplace installation metadata", () => {
|
||||
])
|
||||
expect(filterItems(items, metadata, "", "installed", [], []).map((item) => item.id)).toEqual(["warehouse"])
|
||||
expect(filterItems(items, metadata, "", "all", [], ["mcp"]).map((item) => item.id)).toEqual(["warehouse"])
|
||||
expect(
|
||||
filterItems(items, metadata, "", "all", [], [], {}, true, {
|
||||
"agent:reviewer": { filename: ["*.review.ts"] },
|
||||
"mcp:warehouse": { vscodeExtension: ["data.warehouse"] },
|
||||
}).map((item) => item.id),
|
||||
).toEqual(["reviewer", "warehouse"])
|
||||
const relevance = { "agent:reviewer": { filename: ["*.review.ts"] } }
|
||||
expect(filterItems(items, metadata, "warehouse", "all", [], [], {}, true, relevance)).toEqual([])
|
||||
expect(hasRelevantItems(items, relevance)).toBe(true)
|
||||
expect(hasRelevantItems(items, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import * as vscode from "vscode"
|
||||
import { detectMarketplaceRelevance } from "../../src/services/marketplace/relevance"
|
||||
import type { MarketplaceItem } from "../../src/services/marketplace/types"
|
||||
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
type: "agent",
|
||||
id: "angular",
|
||||
name: "Angular",
|
||||
description: "Angular specialist",
|
||||
category: "development",
|
||||
content: { mode: "all", description: "Angular specialist", prompt: "Help with Angular" },
|
||||
suggest_for: { filename: ["*.component.ts"] },
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
id: "jupyter",
|
||||
name: "Jupyter",
|
||||
description: "Jupyter notebooks",
|
||||
category: "data",
|
||||
url: "https://example.com",
|
||||
content: "{}",
|
||||
suggest_for: {
|
||||
filename: ["*.component.ts", "*.ipynb"],
|
||||
vscode_extension: ["ms-toolsai.jupyter"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "skill",
|
||||
id: "unmatched",
|
||||
name: "Unmatched",
|
||||
displayName: "Unmatched",
|
||||
description: "No matching context",
|
||||
category: "development",
|
||||
displayCategory: "Development",
|
||||
githubUrl: "https://example.com",
|
||||
content: "https://example.com/skill.tar.gz",
|
||||
suggest_for: { filename: ["*.rs"] },
|
||||
},
|
||||
]
|
||||
|
||||
describe("Marketplace relevance", () => {
|
||||
it("matches workspace files and installed extensions with deduplicated bounded searches", async () => {
|
||||
const root = vscode.Uri.file("/repo")
|
||||
const find = mock(async (_root: vscode.Uri, pattern: string) => pattern === "*.component.ts")
|
||||
|
||||
const relevance = await detectMarketplaceRelevance(items, [root], {
|
||||
extensions: ["MS-ToolsAI.Jupyter"],
|
||||
find,
|
||||
})
|
||||
|
||||
expect(relevance).toEqual({
|
||||
"agent:angular": { filename: ["*.component.ts"] },
|
||||
"mcp:jupyter": {
|
||||
filename: ["*.component.ts"],
|
||||
vscodeExtension: ["ms-toolsai.jupyter"],
|
||||
},
|
||||
})
|
||||
expect(find).toHaveBeenCalledTimes(3)
|
||||
expect(find.mock.calls).toContainEqual([root, "*.component.ts"])
|
||||
expect(find.mock.calls).toContainEqual([root, "*.ipynb"])
|
||||
expect(find.mock.calls).toContainEqual([root, "*.rs"])
|
||||
})
|
||||
|
||||
it("searches every workspace root and preserves remote URIs", async () => {
|
||||
const local = vscode.Uri.file("/repo")
|
||||
const remote = vscode.Uri.parse("vscode-remote://ssh-remote+host/workspace")
|
||||
const find = mock(async (root: vscode.Uri, pattern: string) => root === remote && pattern === "*.ipynb")
|
||||
|
||||
const relevance = await detectMarketplaceRelevance(items, [local, remote], { extensions: [], find })
|
||||
|
||||
expect(relevance).toEqual({ "mcp:jupyter": { filename: ["*.ipynb"] } })
|
||||
expect(find.mock.calls).toContainEqual([remote, "*.ipynb"])
|
||||
})
|
||||
|
||||
it("ignores malformed suggestion metadata", async () => {
|
||||
const malformed = {
|
||||
...items[0],
|
||||
suggest_for: { filename: "*.component.ts", vscode_extension: [42] },
|
||||
} as unknown as MarketplaceItem
|
||||
const find = mock(async () => true)
|
||||
|
||||
const relevance = await detectMarketplaceRelevance([malformed], [vscode.Uri.file("/repo")], {
|
||||
extensions: ["test.extension"],
|
||||
find,
|
||||
})
|
||||
|
||||
expect(relevance).toEqual({})
|
||||
expect(find).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("still matches installed extensions without a workspace", async () => {
|
||||
const find = mock(async () => true)
|
||||
|
||||
const relevance = await detectMarketplaceRelevance(items, [], {
|
||||
extensions: ["ms-toolsai.jupyter"],
|
||||
find,
|
||||
})
|
||||
|
||||
expect(relevance).toEqual({ "mcp:jupyter": { vscodeExtension: ["ms-toolsai.jupyter"] } })
|
||||
expect(find).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import type { NotebookRequest } from "@kilocode/sdk/v2/client"
|
||||
import * as vscode from "vscode"
|
||||
import { KiloConnectionService } from "../../src/services/cli-backend/connection-service"
|
||||
import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter"
|
||||
import { NotebookBridge, type NotebookBridgeContext } from "../../src/services/notebook/bridge"
|
||||
import { NotebookError } from "../../src/services/notebook/path"
|
||||
|
||||
const read: NotebookRequest = {
|
||||
id: "notebook-1",
|
||||
sessionID: "session-1",
|
||||
operation: "read",
|
||||
path: "book.ipynb",
|
||||
includeOutputs: true,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
const state: { resolve?: (value: T) => void; reject?: (error: Error) => void } = {}
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
state.resolve = resolve
|
||||
state.reject = reject
|
||||
})
|
||||
return { promise, resolve: state.resolve!, reject: state.reject! }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
function harness(context: NotebookBridgeContext, dirs = ["/repo"]) {
|
||||
const replies: unknown[] = []
|
||||
const rejections: unknown[] = []
|
||||
const lists = new Map<string, NotebookRequest[]>()
|
||||
const state = { failReply: false }
|
||||
const handlers: {
|
||||
event?: (event: SSEPayload, directory?: string) => void
|
||||
state?: (state: "connecting" | "connected" | "disconnected" | "error") => void
|
||||
} = {}
|
||||
const client = {
|
||||
kilocode: {
|
||||
notebook: {
|
||||
list: async ({ directory }: { directory?: string }) => ({ data: lists.get(directory ?? "") ?? [] }),
|
||||
reply: async (input: unknown) => {
|
||||
replies.push(input)
|
||||
return state.failReply ? { error: "offline" } : { data: true }
|
||||
},
|
||||
reject: async (input: unknown) => {
|
||||
rejections.push(input)
|
||||
return { data: true }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const connection = {
|
||||
onEvent: (listener: typeof handlers.event) => {
|
||||
handlers.event = listener
|
||||
return () => {
|
||||
handlers.event = undefined
|
||||
}
|
||||
},
|
||||
onStateChange: (listener: typeof handlers.state) => {
|
||||
handlers.state = listener
|
||||
return () => {
|
||||
handlers.state = undefined
|
||||
}
|
||||
},
|
||||
getClient: () => client,
|
||||
getKnownDirectories: () => dirs,
|
||||
}
|
||||
const create = mock(async () => context)
|
||||
const bridge = new NotebookBridge(connection as never, { create, canonical: async (directory) => directory })
|
||||
const request = (value: NotebookRequest = read, directory = "/repo") =>
|
||||
handlers.event?.(
|
||||
{ id: `event-${value.id}`, type: "kilocode.notebook.requested", properties: value } as SSEPayload,
|
||||
directory,
|
||||
)
|
||||
const cancel = (id = read.id, directory = "/repo") =>
|
||||
handlers.event?.(
|
||||
{
|
||||
id: `cancel-${id}`,
|
||||
type: "kilocode.notebook.cancelled",
|
||||
properties: { requestID: id, sessionID: "session-1", reason: "cancelled" },
|
||||
} as SSEPayload,
|
||||
directory,
|
||||
)
|
||||
return { bridge, cancel, client, connection, create, handlers, lists, rejections, replies, request, state }
|
||||
}
|
||||
|
||||
function context(overrides: Partial<NotebookBridgeContext["adapter"]> = {}) {
|
||||
const dispose = mock(() => undefined)
|
||||
const adapter = {
|
||||
read: mock(async () => ({
|
||||
operation: "read" as const,
|
||||
path: "book.ipynb",
|
||||
requestPath: "book.ipynb",
|
||||
revision: "content:2",
|
||||
cells: [],
|
||||
})),
|
||||
edit: mock(async () => ({
|
||||
operation: "edit" as const,
|
||||
path: "book.ipynb",
|
||||
requestPath: "book.ipynb",
|
||||
revision: "content:2",
|
||||
index: 0,
|
||||
action: "replace" as const,
|
||||
})),
|
||||
execute: mock(async () => ({
|
||||
operation: "execute" as const,
|
||||
path: "book.ipynb",
|
||||
requestPath: "book.ipynb",
|
||||
revision: "content:2",
|
||||
index: 0,
|
||||
status: "success" as const,
|
||||
outputs: [],
|
||||
})),
|
||||
...overrides,
|
||||
}
|
||||
return { value: { adapter, dispose }, adapter, dispose }
|
||||
}
|
||||
|
||||
describe("NotebookBridge", () => {
|
||||
it("deduplicates requests, retains their directory, and posts replies", async () => {
|
||||
const ctx = context()
|
||||
const test = harness(ctx.value)
|
||||
|
||||
test.request()
|
||||
test.request()
|
||||
await flush()
|
||||
|
||||
expect(test.create).toHaveBeenCalledTimes(1)
|
||||
expect(test.create).toHaveBeenCalledWith("/repo")
|
||||
expect(ctx.adapter.read).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.adapter.read).toHaveBeenCalledWith({ path: "book.ipynb", directory: "/repo", includeOutputs: true })
|
||||
expect(test.replies).toEqual([
|
||||
{
|
||||
requestID: "notebook-1",
|
||||
directory: "/repo",
|
||||
result: { operation: "read", path: "book.ipynb", requestPath: "book.ipynb", revision: "content:2", cells: [] },
|
||||
},
|
||||
])
|
||||
const bridge = test.bridge as unknown as { outcomes: Map<string, unknown>; settled: Set<string> }
|
||||
expect(bridge.outcomes.size).toBe(0)
|
||||
expect(bridge.settled.has("notebook-1")).toBe(true)
|
||||
|
||||
test.bridge.dispose()
|
||||
await flush()
|
||||
expect(ctx.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(test.handlers.event).toBeUndefined()
|
||||
expect(test.handlers.state).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects live requests outside known VS Code directories", async () => {
|
||||
const ctx = context()
|
||||
const test = harness(ctx.value, ["/repo"])
|
||||
|
||||
test.request(read, "/outside")
|
||||
await flush()
|
||||
|
||||
expect(test.create).not.toHaveBeenCalled()
|
||||
expect(ctx.adapter.read).not.toHaveBeenCalled()
|
||||
expect(test.rejections).toEqual([
|
||||
{
|
||||
requestID: "notebook-1",
|
||||
directory: "/outside",
|
||||
error: {
|
||||
code: "invalid_path",
|
||||
message: "Notebook request directory is not an active VS Code workspace",
|
||||
},
|
||||
},
|
||||
])
|
||||
test.bridge.dispose()
|
||||
})
|
||||
|
||||
it("retries context initialization after a transient failure", async () => {
|
||||
const ctx = context()
|
||||
const test = harness(ctx.value)
|
||||
test.create.mockImplementationOnce(async () => {
|
||||
throw new Error("unreadable ignore file")
|
||||
})
|
||||
|
||||
test.request()
|
||||
await flush()
|
||||
expect(test.rejections).toHaveLength(1)
|
||||
|
||||
test.request({ ...read, id: "notebook-2" })
|
||||
await flush()
|
||||
expect(test.create).toHaveBeenCalledTimes(2)
|
||||
expect(ctx.adapter.read).toHaveBeenCalledTimes(1)
|
||||
expect(test.replies).toHaveLength(1)
|
||||
test.bridge.dispose()
|
||||
})
|
||||
|
||||
it("retries a failed reply without repeating the adapter operation", async () => {
|
||||
const ctx = context()
|
||||
const test = harness(ctx.value)
|
||||
test.state.failReply = true
|
||||
|
||||
test.request()
|
||||
await flush()
|
||||
test.state.failReply = false
|
||||
test.request()
|
||||
await flush()
|
||||
|
||||
expect(ctx.adapter.read).toHaveBeenCalledTimes(1)
|
||||
expect(test.replies).toHaveLength(2)
|
||||
test.bridge.dispose()
|
||||
})
|
||||
|
||||
it("aborts cancelled execution without posting a late completion", async () => {
|
||||
const pending = deferred<never>()
|
||||
const signals: AbortSignal[] = []
|
||||
const execute = mock((request: { signal?: AbortSignal }) => {
|
||||
if (request.signal) {
|
||||
signals.push(request.signal)
|
||||
request.signal.addEventListener("abort", () => pending.reject(new NotebookError("cancelled", "cancelled")))
|
||||
}
|
||||
return pending.promise
|
||||
})
|
||||
const ctx = context({ execute: execute as never })
|
||||
const test = harness(ctx.value)
|
||||
test.request({
|
||||
id: "execute-1",
|
||||
sessionID: "session-1",
|
||||
operation: "execute",
|
||||
path: "book.ipynb",
|
||||
expectedRevision: "content:1",
|
||||
index: 0,
|
||||
})
|
||||
await flush()
|
||||
|
||||
test.cancel("execute-1")
|
||||
await flush()
|
||||
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
expect(test.replies).toEqual([])
|
||||
expect(test.rejections).toEqual([])
|
||||
test.bridge.dispose()
|
||||
})
|
||||
|
||||
it("bounds unexpected adapter failures for the protocol", async () => {
|
||||
const ctx = context({
|
||||
read: mock(async () => {
|
||||
throw new Error("x".repeat(20_000))
|
||||
}),
|
||||
})
|
||||
const test = harness(ctx.value)
|
||||
test.request()
|
||||
await flush()
|
||||
expect((test.rejections[0] as { error: { code: string; message: string } }).error).toMatchObject({
|
||||
code: "execution_failed",
|
||||
message: "x".repeat(10_000),
|
||||
})
|
||||
test.bridge.dispose()
|
||||
|
||||
const empty = context({
|
||||
read: mock(async () => {
|
||||
throw new Error("")
|
||||
}),
|
||||
})
|
||||
const fallback = harness(empty.value)
|
||||
fallback.request()
|
||||
await flush()
|
||||
expect((fallback.rejections[0] as { error: { message: string } }).error.message).toBe(
|
||||
"Notebook operation failed without an error message",
|
||||
)
|
||||
fallback.bridge.dispose()
|
||||
})
|
||||
|
||||
it("recovers pending requests for known directories and maps adapter failures", async () => {
|
||||
const ctx = context({
|
||||
read: mock(async () => {
|
||||
throw new NotebookError("stale_revision", "Notebook changed", {
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
currentRevision: "content:current",
|
||||
})
|
||||
}),
|
||||
})
|
||||
const test = harness(ctx.value, ["/root", "/worktree"])
|
||||
test.lists.set("/worktree", [read])
|
||||
|
||||
test.handlers.state?.("connected")
|
||||
await flush()
|
||||
|
||||
expect(test.rejections).toEqual([
|
||||
{
|
||||
requestID: "notebook-1",
|
||||
directory: "/worktree",
|
||||
error: {
|
||||
code: "stale_revision",
|
||||
message: "Notebook changed",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
currentRevision: "content:current",
|
||||
},
|
||||
},
|
||||
])
|
||||
test.bridge.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService notebook directories", () => {
|
||||
it("tracks the workspace root, current request directory, and provider directories", async () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", {
|
||||
configurable: true,
|
||||
value: [{ uri: { fsPath: "/root" } }],
|
||||
})
|
||||
const service = new KiloConnectionService({} as vscode.ExtensionContext)
|
||||
const internals = service as unknown as { client: object; state: string }
|
||||
internals.client = {}
|
||||
internals.state = "connected"
|
||||
const unregister = service.registerDirectoryProvider(() => ["/worktree", "/root"])
|
||||
|
||||
await service.getClientAsync("/current")
|
||||
|
||||
expect(service.getKnownDirectories()).toEqual(["/root", "/current", "/worktree"])
|
||||
unregister()
|
||||
expect(service.getKnownDirectories()).toEqual(["/root", "/current"])
|
||||
service.dispose()
|
||||
if (descriptor) Object.defineProperty(vscode.workspace, "workspaceFolders", descriptor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,469 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import * as vscode from "vscode"
|
||||
import { NotebookAdapter } from "../../src/services/notebook/adapter"
|
||||
import { normalizeOutputs, normalizeSource } from "../../src/services/notebook/output"
|
||||
import { NotebookError, resolveNotebookPath } from "../../src/services/notebook/path"
|
||||
import type { NotebookAdapterDeps, NotebookCellInput } from "../../src/services/notebook/types"
|
||||
|
||||
interface CellState {
|
||||
source: string
|
||||
kind: vscode.NotebookCellKind
|
||||
language: string
|
||||
outputs: vscode.NotebookCellOutput[]
|
||||
execution?: vscode.NotebookCellExecutionSummary
|
||||
}
|
||||
|
||||
function uri(path: string): vscode.Uri {
|
||||
return { scheme: "file", fsPath: path, path, toString: () => `file://${path}` } as vscode.Uri
|
||||
}
|
||||
|
||||
function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code, language?: string) {
|
||||
const state: CellState = {
|
||||
source,
|
||||
kind,
|
||||
language: language ?? (kind === vscode.NotebookCellKind.Code ? "python" : "markdown"),
|
||||
outputs: [],
|
||||
}
|
||||
const getText = mock((range?: vscode.Range) => {
|
||||
const offset = (range?.end as (vscode.Position & { offset?: number }) | undefined)?.offset
|
||||
return state.source.slice(0, offset ?? state.source.length)
|
||||
})
|
||||
const value = {
|
||||
get kind() {
|
||||
return state.kind
|
||||
},
|
||||
document: {
|
||||
getText,
|
||||
positionAt: (offset: number) => ({ line: 0, character: offset, offset }) as vscode.Position,
|
||||
get languageId() {
|
||||
return state.language
|
||||
},
|
||||
},
|
||||
get outputs() {
|
||||
return state.outputs
|
||||
},
|
||||
get executionSummary() {
|
||||
return state.execution
|
||||
},
|
||||
} as unknown as vscode.NotebookCell
|
||||
return { value, state, getText }
|
||||
}
|
||||
|
||||
function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vscode.NotebookDocument {
|
||||
return {
|
||||
uri: uri(file),
|
||||
version: 1,
|
||||
isClosed: false,
|
||||
get cellCount() {
|
||||
return cells.length
|
||||
},
|
||||
getCells: () => cells,
|
||||
cellAt: (index: number) => cells[index]!,
|
||||
} as unknown as vscode.NotebookDocument
|
||||
}
|
||||
|
||||
function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[]) {
|
||||
const changes = new Set<(event: vscode.NotebookDocumentChangeEvent) => void>()
|
||||
const closes = new Set<(document: vscode.NotebookDocument) => void>()
|
||||
const calls = { open: 0, apply: 0, command: 0, commandArgs: [] as unknown[], edit: undefined as unknown }
|
||||
const deps: NotebookAdapterDeps = {
|
||||
documents: () => [document],
|
||||
open: async () => {
|
||||
calls.open++
|
||||
return document
|
||||
},
|
||||
apply: async (edit) => {
|
||||
calls.apply++
|
||||
const item = (
|
||||
edit as unknown as { edits: Array<{ type: string; index: number; cells?: vscode.NotebookCellData[] }> }
|
||||
).edits[0]!
|
||||
if (item.type === "delete") cells.splice(item.index, 1)
|
||||
if (item.type !== "delete") {
|
||||
const input = (item.cells?.[0] as unknown as { input: NotebookCellInput }).input
|
||||
const next = cell(
|
||||
input.source,
|
||||
input.kind === "code" ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup,
|
||||
input.language ?? (input.kind === "code" ? "plaintext" : "markdown"),
|
||||
).value
|
||||
if (item.type === "insert") cells.splice(item.index, 0, next)
|
||||
if (item.type === "replace") cells.splice(item.index, 1, next)
|
||||
}
|
||||
Object.assign(document, { version: document.version + 1 })
|
||||
return true
|
||||
},
|
||||
execute: async (...args) => {
|
||||
calls.command++
|
||||
calls.commandArgs = args
|
||||
},
|
||||
change: (listener) => {
|
||||
changes.add(listener)
|
||||
return { dispose: () => changes.delete(listener) }
|
||||
},
|
||||
close: (listener) => {
|
||||
closes.add(listener)
|
||||
return { dispose: () => closes.delete(listener) }
|
||||
},
|
||||
uri,
|
||||
edit: (_uri, edits) => {
|
||||
calls.edit = edits
|
||||
return { edits } as unknown as vscode.WorkspaceEdit
|
||||
},
|
||||
insert: (index, items) => ({ type: "insert", index, cells: items }) as unknown as vscode.NotebookEdit,
|
||||
replace: (index, items) => ({ type: "replace", index, cells: items }) as unknown as vscode.NotebookEdit,
|
||||
delete: (index) => ({ type: "delete", index }) as unknown as vscode.NotebookEdit,
|
||||
cell: (input: NotebookCellInput) => ({ input }) as unknown as vscode.NotebookCellData,
|
||||
}
|
||||
return { deps, changes, closes, calls }
|
||||
}
|
||||
|
||||
const paths = { realpath: async (value: string) => value }
|
||||
const access = { validateAccess: mock(() => true) }
|
||||
|
||||
function adapter(items: ReturnType<typeof cell>[], file = "/repo/book.ipynb") {
|
||||
const cells = items.map((item) => item.value)
|
||||
const document = notebook(cells, file)
|
||||
const ctx = harness(document, cells)
|
||||
return {
|
||||
adapter: new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 }),
|
||||
document,
|
||||
cells,
|
||||
...ctx,
|
||||
}
|
||||
}
|
||||
|
||||
async function revision(core: NotebookAdapter, path = "book.ipynb") {
|
||||
return (await core.read({ directory: "/repo", path, includeOutputs: false })).revision
|
||||
}
|
||||
|
||||
function event(document: vscode.NotebookDocument, cell: vscode.NotebookCell, change: object) {
|
||||
return {
|
||||
notebook: document,
|
||||
contentChanges: [],
|
||||
cellChanges: [{ cell, ...change }],
|
||||
} as unknown as vscode.NotebookDocumentChangeEvent
|
||||
}
|
||||
|
||||
describe("notebook path security", () => {
|
||||
it("accepts relative and contained absolute paths and normalizes results", async () => {
|
||||
await expect(resolveNotebookPath("/repo", "nested/book.ipynb", access, paths)).resolves.toEqual({
|
||||
target: "/repo/nested/book.ipynb",
|
||||
relative: "nested/book.ipynb",
|
||||
})
|
||||
await expect(resolveNotebookPath("/repo", "/repo/nested/book.ipynb", access, paths)).resolves.toEqual({
|
||||
target: "/repo/nested/book.ipynb",
|
||||
relative: "nested/book.ipynb",
|
||||
})
|
||||
const ctx = adapter([cell()], "/repo/nested/book.ipynb")
|
||||
expect(
|
||||
(await ctx.adapter.read({ directory: "/repo", path: "/repo/nested/book.ipynb", includeOutputs: false })).path,
|
||||
).toBe("nested/book.ipynb")
|
||||
})
|
||||
|
||||
it("rejects outside absolute paths and relative or absolute symlink escapes", async () => {
|
||||
const guard = { validateAccess: mock(() => true) }
|
||||
await expect(resolveNotebookPath("/repo", "/outside/secret.ipynb", guard, paths)).rejects.toMatchObject({
|
||||
code: "invalid_path",
|
||||
message: expect.stringContaining("/outside/secret.ipynb"),
|
||||
})
|
||||
for (const input of ["linked.ipynb", "/repo/linked.ipynb"]) {
|
||||
await expect(
|
||||
resolveNotebookPath("/repo", input, guard, {
|
||||
realpath: async (value) => (value.endsWith("linked.ipynb") ? "/outside/secret.ipynb" : value),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "invalid_path", message: expect.stringContaining(input) })
|
||||
}
|
||||
expect(guard.validateAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("enforces ignore access on the canonical target", async () => {
|
||||
const guard = { validateAccess: mock(() => false) }
|
||||
await expect(resolveNotebookPath("/repo", "book.ipynb", guard, paths)).rejects.toMatchObject({
|
||||
code: "invalid_path",
|
||||
})
|
||||
expect(guard.validateAccess).toHaveBeenCalledWith("/repo/book.ipynb")
|
||||
})
|
||||
})
|
||||
|
||||
describe("notebook normalization", () => {
|
||||
it("bounds UTF-8 source and text outputs and omits rich bodies", () => {
|
||||
expect(normalizeSource("abcdef", 3)).toEqual({ text: "abc", bytes: 6, truncated: true })
|
||||
expect(normalizeSource("a€b", 3)).toEqual({ text: "a", bytes: 5, truncated: true })
|
||||
const normalized = normalizeOutputs(
|
||||
[
|
||||
{
|
||||
items: [
|
||||
{ mime: "text/plain", data: new TextEncoder().encode("abcdef") },
|
||||
{ mime: "image/png", data: new Uint8Array(40) },
|
||||
],
|
||||
} as vscode.NotebookCellOutput,
|
||||
],
|
||||
3,
|
||||
)
|
||||
expect(normalized).toEqual({
|
||||
outputs: [
|
||||
{ mime: "text/plain", text: "abc", truncated: true },
|
||||
{ mime: "image/png", omitted: true },
|
||||
],
|
||||
truncated: true,
|
||||
bytes: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it("extracts and bounds standard notebook errors", () => {
|
||||
const data = new TextEncoder().encode(
|
||||
JSON.stringify({ name: "N".repeat(600), message: "M".repeat(11_000), stack: "trace" }),
|
||||
)
|
||||
const normalized = normalizeOutputs([
|
||||
{ items: [{ mime: "application/vnd.code.notebook.error", data }] } as vscode.NotebookCellOutput,
|
||||
])
|
||||
expect(normalized.outputs[0]).toMatchObject({ stack: "trace" })
|
||||
expect(normalized.outputs[0]?.name).toHaveLength(500)
|
||||
expect(normalized.outputs[0]?.message).toHaveLength(10_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("notebook content revisions", () => {
|
||||
it("ignores outputs and execution summaries", async () => {
|
||||
const item = cell("one")
|
||||
const ctx = adapter([item])
|
||||
const before = await revision(ctx.adapter)
|
||||
item.state.outputs = [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("result") }] } as never]
|
||||
item.state.execution = { success: true, executionOrder: 1 }
|
||||
expect(await revision(ctx.adapter)).toBe(before)
|
||||
})
|
||||
|
||||
it("changes for source, language, kind, insertion, deletion, replacement, and reordering", async () => {
|
||||
const first = cell("one")
|
||||
const second = cell("two")
|
||||
const ctx = adapter([first, second])
|
||||
const revisions = [await revision(ctx.adapter)]
|
||||
first.state.source = "changed"
|
||||
revisions.push(await revision(ctx.adapter))
|
||||
first.state.language = "javascript"
|
||||
revisions.push(await revision(ctx.adapter))
|
||||
first.state.kind = vscode.NotebookCellKind.Markup
|
||||
revisions.push(await revision(ctx.adapter))
|
||||
const added = cell("added")
|
||||
ctx.cells.splice(1, 0, added.value)
|
||||
revisions.push(await revision(ctx.adapter))
|
||||
ctx.cells.splice(1, 1)
|
||||
revisions.push(await revision(ctx.adapter))
|
||||
ctx.cells.splice(0, 1, cell("replacement").value)
|
||||
revisions.push(await revision(ctx.adapter))
|
||||
ctx.cells.reverse()
|
||||
revisions.push(await revision(ctx.adapter))
|
||||
for (const [index, value] of revisions.entries()) {
|
||||
if (index > 0) expect(value).not.toBe(revisions[index - 1])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("notebook adapter", () => {
|
||||
it("prefers an open document and opens missing notebooks in the background", async () => {
|
||||
const ctx = adapter([cell("unsaved"), cell("# title", vscode.NotebookCellKind.Markup)])
|
||||
const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false })
|
||||
expect(ctx.calls.open).toBe(0)
|
||||
expect(result).toMatchObject({
|
||||
path: "book.ipynb",
|
||||
cells: [
|
||||
{ index: 0, kind: "code", language: "python", source: "unsaved" },
|
||||
{ index: 1, kind: "markdown", language: "markdown", source: "# title" },
|
||||
],
|
||||
})
|
||||
ctx.deps.documents = () => []
|
||||
await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false })
|
||||
expect(ctx.calls.open).toBe(1)
|
||||
})
|
||||
|
||||
it("bounds source reads and skips extraction after the aggregate budget", async () => {
|
||||
const items = [
|
||||
cell("a".repeat(64 * 1024)),
|
||||
cell("b".repeat(64 * 1024)),
|
||||
cell("c".repeat(64 * 1024)),
|
||||
cell("d".repeat(64 * 1024)),
|
||||
cell("ignored"),
|
||||
]
|
||||
const ctx = adapter(items)
|
||||
const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false })
|
||||
|
||||
expect(items[0]!.getText).toHaveBeenCalledTimes(2)
|
||||
expect(items[0]!.getText.mock.calls[1]?.[0]).toBeInstanceOf(vscode.Range)
|
||||
expect(items[4]!.getText).toHaveBeenCalledTimes(1)
|
||||
expect(result.cells[4]?.source).toBe("")
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it("chains sequential edits with returned revisions and affected cells", async () => {
|
||||
const ctx = adapter([cell("one")])
|
||||
const initial = await revision(ctx.adapter)
|
||||
const first = await ctx.adapter.edit({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
expectedRevision: initial,
|
||||
edit: { action: "replace", kind: "code", language: "python", source: "two" },
|
||||
})
|
||||
expect(first.cell).toMatchObject({ index: 0, source: "two" })
|
||||
const second = await ctx.adapter.edit({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 1,
|
||||
expectedRevision: first.revision,
|
||||
edit: { action: "insert", kind: "markdown", language: "markdown", source: "three" },
|
||||
})
|
||||
expect(second.revision).not.toBe(first.revision)
|
||||
expect(second.cell).toMatchObject({ index: 1, kind: "markdown", source: "three" })
|
||||
})
|
||||
|
||||
it("returns a structured stale-revision error for genuine content conflicts", async () => {
|
||||
const item = cell("one")
|
||||
const ctx = adapter([item])
|
||||
const expectedRevision = await revision(ctx.adapter)
|
||||
item.state.source = "user edit"
|
||||
await expect(
|
||||
ctx.adapter.edit({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
expectedRevision,
|
||||
edit: { action: "delete" },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "stale_revision",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
currentRevision: expect.stringContaining("content:"),
|
||||
message: expect.stringContaining("Re-read"),
|
||||
})
|
||||
expect(ctx.calls.apply).toBe(0)
|
||||
})
|
||||
|
||||
it("serializes concurrent edits and rejects a stale queued mutation", async () => {
|
||||
const ctx = adapter([cell("one")])
|
||||
const expectedRevision = await revision(ctx.adapter)
|
||||
const first = ctx.adapter.edit({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
expectedRevision,
|
||||
edit: { action: "replace", kind: "code", source: "first" },
|
||||
})
|
||||
const other = new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 })
|
||||
const second = other.edit({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
expectedRevision,
|
||||
edit: { action: "replace", kind: "code", source: "second" },
|
||||
})
|
||||
const results = await Promise.allSettled([first, second])
|
||||
expect(results[0]).toMatchObject({ status: "fulfilled", value: { action: "replace" } })
|
||||
expect(results[1]).toMatchObject({ status: "rejected", reason: { code: "stale_revision" } })
|
||||
expect(ctx.cells[0]?.document.getText()).toBe("first")
|
||||
})
|
||||
|
||||
it("executes the same target when unrelated cell content or outputs change", async () => {
|
||||
const target = cell("target")
|
||||
const other = cell("other")
|
||||
const ctx = adapter([target, other])
|
||||
const expectedRevision = await revision(ctx.adapter)
|
||||
other.state.source = "changed elsewhere"
|
||||
other.state.outputs = [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("noise") }] } as never]
|
||||
ctx.deps.execute = async (...args) => {
|
||||
ctx.calls.command++
|
||||
ctx.calls.commandArgs = args
|
||||
target.state.outputs = [
|
||||
{ items: [{ mime: "text/plain", data: new TextEncoder().encode("done") }] } as vscode.NotebookCellOutput,
|
||||
]
|
||||
target.state.execution = { success: true, executionOrder: 2, timing: { startTime: 10, endTime: 20 } }
|
||||
for (const listener of ctx.changes)
|
||||
listener(event(ctx.document, target.value, { executionSummary: target.state.execution }))
|
||||
}
|
||||
const result = await ctx.adapter.execute({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
expectedRevision,
|
||||
})
|
||||
expect(result).toMatchObject({ operation: "execute", index: 0, status: "success", outputs: [{ text: "done" }] })
|
||||
expect(result.revision).not.toBe(expectedRevision)
|
||||
expect(ctx.changes.size).toBe(0)
|
||||
expect(ctx.closes.size).toBe(0)
|
||||
})
|
||||
|
||||
it("does not authorize another notebook with a cached revision", async () => {
|
||||
const first = adapter([cell("target"), cell("first")], "/repo/first.ipynb")
|
||||
const expectedRevision = await revision(first.adapter, "first.ipynb")
|
||||
const second = adapter([cell("target"), cell("second")], "/repo/second.ipynb")
|
||||
await expect(
|
||||
second.adapter.execute({
|
||||
directory: "/repo",
|
||||
path: "second.ipynb",
|
||||
index: 0,
|
||||
expectedRevision,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "stale_revision", path: "second.ipynb" })
|
||||
expect(second.calls.command).toBe(0)
|
||||
})
|
||||
|
||||
it("reports an unobserved startup as execution_failed rather than no_kernel", async () => {
|
||||
const ctx = adapter([cell()])
|
||||
const expectedRevision = await revision(ctx.adapter)
|
||||
await expect(
|
||||
ctx.adapter.execute({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
expectedRevision,
|
||||
timeout: 5,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "execution_failed",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
message: expect.stringContaining("command was dispatched"),
|
||||
})
|
||||
expect(ctx.calls.commandArgs).toEqual([
|
||||
"notebook.cell.cancelExecution",
|
||||
{ ranges: [{ start: 0, end: 1 }], document: ctx.document.uri },
|
||||
])
|
||||
})
|
||||
|
||||
it("cancels execution and disposes listeners", async () => {
|
||||
const ctx = adapter([cell()])
|
||||
const expectedRevision = await revision(ctx.adapter)
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.adapter.execute({
|
||||
directory: "/repo",
|
||||
path: "book.ipynb",
|
||||
index: 0,
|
||||
expectedRevision,
|
||||
signal: controller.signal,
|
||||
})
|
||||
controller.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: "cancelled", path: "book.ipynb", index: 0 })
|
||||
expect(ctx.changes.size).toBe(0)
|
||||
expect(ctx.closes.size).toBe(0)
|
||||
})
|
||||
|
||||
it("rejects execution if the targeted cell changes", async () => {
|
||||
const target = cell("before")
|
||||
const ctx = adapter([target])
|
||||
const expectedRevision = await revision(ctx.adapter)
|
||||
ctx.deps.execute = async (...args) => {
|
||||
ctx.calls.command++
|
||||
ctx.calls.commandArgs = args
|
||||
if (args[0] !== "notebook.cell.execute") return
|
||||
target.state.source = "after"
|
||||
for (const listener of ctx.changes)
|
||||
listener(event(ctx.document, target.value, { document: target.value.document }))
|
||||
}
|
||||
await expect(
|
||||
ctx.adapter.execute({ directory: "/repo", path: "book.ipynb", index: 0, expectedRevision }),
|
||||
).rejects.toMatchObject({ code: "stale_revision" })
|
||||
expect(ctx.calls.commandArgs).toEqual([
|
||||
"notebook.cell.cancelExecution",
|
||||
{ ranges: [{ start: 0, end: 1 }], document: ctx.document.uri },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,16 @@ import { TextField } from "@kilocode/kilo-ui/text-field"
|
||||
import { Select } from "@kilocode/kilo-ui/select"
|
||||
import { Tag } from "@kilocode/kilo-ui/tag"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Checkbox } from "@kilocode/kilo-ui/checkbox"
|
||||
import type {
|
||||
MarketplaceItem,
|
||||
McpMarketplaceItem,
|
||||
SkillMarketplaceItem,
|
||||
MarketplaceInstalledMetadata,
|
||||
MarketplaceRelevanceMetadata,
|
||||
} from "../../types/marketplace"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { filterItems, retain } from "./utils"
|
||||
import { filterItems, hasRelevantItems, retain } from "./utils"
|
||||
import { ItemCard } from "./ItemCard"
|
||||
import { MarketplaceContribute } from "./MarketplaceContribute"
|
||||
|
||||
@@ -22,9 +24,12 @@ interface StatusOption {
|
||||
interface Props {
|
||||
items: MarketplaceItem[]
|
||||
metadata: MarketplaceInstalledMetadata
|
||||
relevance: MarketplaceRelevanceMetadata
|
||||
fetching: boolean
|
||||
searchPlaceholder: string
|
||||
emptyMessage: string
|
||||
relevantEmptyMessage: string
|
||||
initialRelevant?: boolean
|
||||
onInstall: (item: MarketplaceItem) => void
|
||||
onRemove: (item: MarketplaceItem, scope: "project" | "global") => void
|
||||
}
|
||||
@@ -35,6 +40,7 @@ export const MarketplaceListView = (props: Props) => {
|
||||
const [status, setStatus] = createSignal<StatusOption>({ value: "all", label: t("marketplace.filter.all") })
|
||||
const [types, setTypes] = createSignal<MarketplaceItem["type"][]>([])
|
||||
const [categories, setCategories] = createSignal<string[]>([])
|
||||
const [relevant, setRelevant] = createSignal(props.initialRelevant ?? false)
|
||||
|
||||
const options = (): StatusOption[] => [
|
||||
{ value: "all", label: t("marketplace.filter.all") },
|
||||
@@ -84,11 +90,21 @@ export const MarketplaceListView = (props: Props) => {
|
||||
}
|
||||
|
||||
const filtered = createMemo(() =>
|
||||
filterItems(props.items, props.metadata, search(), status().value, categories(), types(), {
|
||||
agent: typeLabel("agent"),
|
||||
mcp: typeLabel("mcp"),
|
||||
skill: typeLabel("skill"),
|
||||
}),
|
||||
filterItems(
|
||||
props.items,
|
||||
props.metadata,
|
||||
search(),
|
||||
status().value,
|
||||
categories(),
|
||||
types(),
|
||||
{
|
||||
agent: typeLabel("agent"),
|
||||
mcp: typeLabel("mcp"),
|
||||
skill: typeLabel("skill"),
|
||||
},
|
||||
relevant(),
|
||||
props.relevance,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -105,6 +121,11 @@ export const MarketplaceListView = (props: Props) => {
|
||||
onSelect={(v: StatusOption | undefined) => v && setStatus(v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="marketplace-relevance-filter">
|
||||
<Checkbox checked={relevant()} onChange={setRelevant}>
|
||||
{t("marketplace.filter.relevant")}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Show when={allTypes().length > 1}>
|
||||
<div class="marketplace-types">
|
||||
<For each={allTypes()}>
|
||||
@@ -149,7 +170,11 @@ export const MarketplaceListView = (props: Props) => {
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="marketplace-empty">
|
||||
<span class="marketplace-empty-message">{props.emptyMessage}</span>
|
||||
<span class="marketplace-empty-message">
|
||||
{relevant() && !hasRelevantItems(props.items, props.relevance)
|
||||
? props.relevantEmptyMessage
|
||||
: props.emptyMessage}
|
||||
</span>
|
||||
<MarketplaceContribute />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useVSCode } from "../../context/vscode"
|
||||
import { useServer } from "../../context/server"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../../types/marketplace"
|
||||
import type {
|
||||
MarketplaceItem,
|
||||
MarketplaceInstalledMetadata,
|
||||
MarketplaceRelevanceMetadata,
|
||||
} from "../../types/marketplace"
|
||||
import { TelemetryEventName } from "../../../../src/services/telemetry/types"
|
||||
import { MarketplaceListView } from "./MarketplaceListView"
|
||||
import { InstallModal } from "./InstallModal"
|
||||
@@ -13,6 +17,7 @@ import { RemoveDialog } from "./RemoveDialog"
|
||||
import "./marketplace.css"
|
||||
|
||||
const EMPTY_METADATA: MarketplaceInstalledMetadata = { project: {}, global: {} }
|
||||
const EMPTY_RELEVANCE: MarketplaceRelevanceMetadata = {}
|
||||
|
||||
export const MarketplaceView = () => {
|
||||
const vscode = useVSCode()
|
||||
@@ -22,6 +27,7 @@ export const MarketplaceView = () => {
|
||||
|
||||
const [items, setItems] = createSignal<MarketplaceItem[]>([])
|
||||
const [metadata, setMetadata] = createSignal<MarketplaceInstalledMetadata>(EMPTY_METADATA)
|
||||
const [relevance, setRelevance] = createSignal<MarketplaceRelevanceMetadata>(EMPTY_RELEVANCE)
|
||||
const [fetching, setFetching] = createSignal(true)
|
||||
const [errors, setErrors] = createSignal<string[]>([])
|
||||
const [pending, setPending] = createSignal<{ item: MarketplaceItem; scope: "project" | "global" } | null>(null)
|
||||
@@ -38,6 +44,7 @@ export const MarketplaceView = () => {
|
||||
if (msg.type === "marketplaceData") {
|
||||
setItems(msg.marketplaceItems ?? [])
|
||||
setMetadata(msg.marketplaceInstalledMetadata ?? EMPTY_METADATA)
|
||||
setRelevance(msg.marketplaceRelevance ?? EMPTY_RELEVANCE)
|
||||
setErrors(msg.errors ?? [])
|
||||
setFetching(false)
|
||||
setShowMigrationBanner(msg.showAgentMigrationBanner ?? false)
|
||||
@@ -157,9 +164,11 @@ export const MarketplaceView = () => {
|
||||
<MarketplaceListView
|
||||
items={items()}
|
||||
metadata={metadata()}
|
||||
relevance={relevance()}
|
||||
fetching={fetching()}
|
||||
searchPlaceholder={t("marketplace.search")}
|
||||
emptyMessage={t("marketplace.empty")}
|
||||
relevantEmptyMessage={t("marketplace.empty.relevant")}
|
||||
onInstall={handleInstall}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.marketplace-relevance-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Type and category filters */
|
||||
|
||||
.marketplace-types,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { MarketplaceInstalledMetadata, MarketplaceItem } from "../../types/marketplace"
|
||||
import type {
|
||||
MarketplaceInstalledMetadata,
|
||||
MarketplaceItem,
|
||||
MarketplaceRelevanceMetadata,
|
||||
} from "../../types/marketplace"
|
||||
|
||||
export function hasRelevantItems(items: MarketplaceItem[], relevance: MarketplaceRelevanceMetadata): boolean {
|
||||
return items.some((item) => !!relevance[`${item.type}:${item.id}`])
|
||||
}
|
||||
|
||||
export function retain<T>(selected: T[], available: T[]): T[] {
|
||||
const values = new Set(available)
|
||||
@@ -48,6 +56,8 @@ export function filterItems(
|
||||
categories: string[],
|
||||
types: MarketplaceItem["type"][],
|
||||
labels: Partial<Record<MarketplaceItem["type"], string>> = {},
|
||||
relevant = false,
|
||||
relevance: MarketplaceRelevanceMetadata = {},
|
||||
): MarketplaceItem[] {
|
||||
const query = search.trim().toLowerCase()
|
||||
return items
|
||||
@@ -56,6 +66,7 @@ export function filterItems(
|
||||
if (status === "notInstalled" && isInstalled(item.id, item.type, metadata)) return false
|
||||
if (types.length > 0 && !types.includes(item.type)) return false
|
||||
if (categories.length > 0 && !categories.includes(item.category)) return false
|
||||
if (relevant && !relevance[`${item.type}:${item.id}`]) return false
|
||||
if (!query) return true
|
||||
return matches(item, query, labels)
|
||||
})
|
||||
|
||||
@@ -154,6 +154,19 @@ const ExperimentalTab: Component = () => {
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.experimental.nativeNotebookTools.title")}
|
||||
description={language.t("settings.experimental.nativeNotebookTools.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={experimental().native_notebook_tools ?? false}
|
||||
onChange={(checked) => updateExperimental("native_notebook_tools", checked)}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.experimental.nativeNotebookTools.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.experimental.continueOnDeny.title")}
|
||||
description={language.t("settings.experimental.continueOnDeny.description")}
|
||||
|
||||
+3
@@ -1313,6 +1313,9 @@ export const dict = {
|
||||
"قم بتمكين وتسجيل الدخول إلى مزود Kilo لاستخدام Speech to Text. ميزة Speech to Text مدعومة حاليًا فقط مع Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "نموذج تحويل الصوت إلى نص",
|
||||
"settings.models.speechToTextModel.description": "اختر نموذج نسخ Kilo Gateway للإدخال الصوتي.",
|
||||
"settings.experimental.nativeNotebookTools.title": "أدوات الدفاتر الأصلية",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"تمكين الأدوات التجريبية لقراءة دفاتر VS Code وتحريرها وتنفيذها",
|
||||
"settings.experimental.continueOnDeny.title": "المتابعة عند الرفض",
|
||||
"settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن",
|
||||
"settings.sandboxing.title": "العزل",
|
||||
|
||||
+3
@@ -1348,6 +1348,9 @@ export const dict = {
|
||||
"settings.models.speechToTextModel.title": "Modelo de fala para texto",
|
||||
"settings.models.speechToTextModel.description":
|
||||
"Escolha o modelo de transcrição do Kilo Gateway para entrada de voz.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Ferramentas nativas de notebook",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Ativar ferramentas experimentais para ler, editar e executar notebooks do VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "Continuar ao negar",
|
||||
"settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada",
|
||||
"settings.sandboxing.title": "Isolamento em sandbox",
|
||||
|
||||
+3
@@ -1344,6 +1344,9 @@ export const dict = {
|
||||
"Omogućite i prijavite se na Kilo provajder da biste koristili Speech to Text. Speech to Text je trenutno podržan samo uz Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "Model govora u tekst",
|
||||
"settings.models.speechToTextModel.description": "Odaberite Kilo Gateway model za transkripciju za glasovni unos.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Izvorni alati za bilježnice",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Omogući eksperimentalne alate za čitanje, uređivanje i izvršavanje VS Code bilježnica",
|
||||
"settings.experimental.continueOnDeny.title": "Nastavi pri odbijanju",
|
||||
"settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena",
|
||||
"settings.sandboxing.title": "Rad u izoliranom okruženju",
|
||||
|
||||
+3
@@ -1338,6 +1338,9 @@ export const dict = {
|
||||
"Aktivér og log ind på Kilo-udbyderen for at bruge Speech to Text. Speech to Text understøttes i øjeblikket kun med Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "Model til tale til tekst",
|
||||
"settings.models.speechToTextModel.description": "Vælg Kilo Gateway-transskriptionsmodellen til stemmeinput.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Indbyggede notebook-værktøjer",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Aktivér eksperimentelle værktøjer til at læse, redigere og køre VS Code-notebooks",
|
||||
"settings.experimental.continueOnDeny.title": "Fortsæt ved afvisning",
|
||||
"settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises",
|
||||
"settings.sandboxing.title": "Sandboxing",
|
||||
|
||||
+3
@@ -1364,6 +1364,9 @@ export const dict = {
|
||||
"settings.models.speechToTextModel.title": "Sprache-zu-Text-Modell",
|
||||
"settings.models.speechToTextModel.description":
|
||||
"Wählen Sie das Kilo Gateway-Transkriptionsmodell für die Spracheingabe.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Native Notebook-Tools",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Experimentelle Tools zum Lesen, Bearbeiten und Ausführen von VS Code-Notebooks aktivieren",
|
||||
"settings.experimental.continueOnDeny.title": "Bei Ablehnung fortfahren",
|
||||
"settings.experimental.continueOnDeny.description":
|
||||
"Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird",
|
||||
|
||||
@@ -1321,6 +1321,9 @@ export const dict = {
|
||||
"Enable and sign in to the Kilo provider to use Speech to Text. Speech to Text is currently only supported through Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "Speech to Text Model",
|
||||
"settings.models.speechToTextModel.description": "Choose the Kilo Gateway transcription model for voice input.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Native Notebook Tools",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Enable experimental tools for reading, editing, and executing VS Code notebooks",
|
||||
"settings.experimental.continueOnDeny.title": "Continue on Deny",
|
||||
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
|
||||
+3
@@ -1355,6 +1355,9 @@ export const dict = {
|
||||
"settings.models.speechToTextModel.title": "Modelo de voz a texto",
|
||||
"settings.models.speechToTextModel.description":
|
||||
"Elige el modelo de transcripción de Kilo Gateway para la entrada de voz.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Herramientas nativas de notebook",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Habilitar herramientas experimentales para leer, editar y ejecutar notebooks de VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "Continuar al denegar",
|
||||
"settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso",
|
||||
"settings.sandboxing.title": "Sandbox",
|
||||
|
||||
+3
@@ -1370,6 +1370,9 @@ export const dict = {
|
||||
"settings.models.speechToTextModel.title": "Modèle de transcription vocale",
|
||||
"settings.models.speechToTextModel.description":
|
||||
"Choisissez le modèle de transcription Kilo Gateway pour la saisie vocale.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Outils de notebook natifs",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Activer les outils expérimentaux pour lire, modifier et exécuter les notebooks VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "Continuer en cas de refus",
|
||||
"settings.experimental.continueOnDeny.description":
|
||||
"Continuer la boucle de l'agent lorsqu'une autorisation est refusée",
|
||||
|
||||
+3
@@ -1159,6 +1159,9 @@ export const dict = {
|
||||
"Abilita l'indicizzazione semantica del codebase e il tool semantic_search. Richiede configurazione indicizzazione.",
|
||||
"settings.experimental.codebaseSearch.title": "Ricerca codebase",
|
||||
"settings.experimental.codebaseSearch.description": "Abilita ricerca in linguaggio naturale con AI nel codebase",
|
||||
"settings.experimental.nativeNotebookTools.title": "Strumenti nativi per notebook",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Abilita strumenti sperimentali per leggere, modificare ed eseguire i notebook di VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "Continua dopo rifiuto",
|
||||
"settings.experimental.continueOnDeny.description": "Continua il loop agente quando un'autorizzazione viene negata",
|
||||
"settings.sandboxing.title": "Sandbox",
|
||||
|
||||
+3
@@ -1333,6 +1333,9 @@ export const dict = {
|
||||
"Speech to Text を使用するには、Kilo プロバイダーを有効にしてサインインしてください。現在、Speech to Text は Kilo Gateway でのみサポートされています。",
|
||||
"settings.models.speechToTextModel.title": "音声認識モデル",
|
||||
"settings.models.speechToTextModel.description": "音声入力に使用するKilo Gateway文字起こしモデルを選択します。",
|
||||
"settings.experimental.nativeNotebookTools.title": "ネイティブノートブックツール",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"VS Codeノートブックの読み取り、編集、実行を行う実験的なツールを有効にします",
|
||||
"settings.experimental.continueOnDeny.title": "拒否時に続行",
|
||||
"settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行",
|
||||
"settings.sandboxing.title": "サンドボックス化",
|
||||
|
||||
+3
@@ -1326,6 +1326,9 @@ export const dict = {
|
||||
"Speech to Text를 사용하려면 Kilo 제공자를 활성화하고 로그인하세요. 현재 Speech to Text는 Kilo Gateway에서만 지원됩니다.",
|
||||
"settings.models.speechToTextModel.title": "음성 텍스트 변환 모델",
|
||||
"settings.models.speechToTextModel.description": "음성 입력에 사용할 Kilo Gateway 변환 모델을 선택하세요.",
|
||||
"settings.experimental.nativeNotebookTools.title": "네이티브 노트북 도구",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"VS Code 노트북을 읽고, 편집하고, 실행하는 실험적 도구를 활성화합니다",
|
||||
"settings.experimental.continueOnDeny.title": "거부 시 계속",
|
||||
"settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속",
|
||||
"settings.sandboxing.title": "샌드박스",
|
||||
|
||||
+3
@@ -1340,6 +1340,9 @@ export const dict = {
|
||||
"Schakel de Kilo-provider in en meld u aan om Speech to Text te gebruiken. Speech to Text wordt momenteel alleen ondersteund met Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "Spraak-naar-tekst-model",
|
||||
"settings.models.speechToTextModel.description": "Kies het Kilo Gateway-transcriptiemodel voor spraakinvoer.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Native notebooktools",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Experimentele tools inschakelen voor het lezen, bewerken en uitvoeren van VS Code-notebooks",
|
||||
"settings.experimental.continueOnDeny.title": "Doorgaan bij weigering",
|
||||
"settings.experimental.continueOnDeny.description":
|
||||
"Ga door met de agent loop wanneer een toestemming wordt geweigerd",
|
||||
|
||||
+3
@@ -1301,6 +1301,9 @@ export const dict = {
|
||||
"Aktiver og logg på Kilo-leverandøren for å bruke Speech to Text. Speech to Text støttes for øyeblikket bare med Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "Tale-til-tekst-modell",
|
||||
"settings.models.speechToTextModel.description": "Velg Kilo Gateway-transkripsjonsmodellen for taleinndata.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Innebygde notatbok-verktøy",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Aktiver eksperimentelle verktøy for å lese, redigere og kjøre VS Code-notatbøker",
|
||||
"settings.experimental.continueOnDeny.title": "Fortsett ved avvisning",
|
||||
"settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises",
|
||||
"settings.sandboxing.title": "Kjøring i sandkasse",
|
||||
|
||||
+3
@@ -1301,6 +1301,9 @@ export const dict = {
|
||||
"settings.models.speechToTextModel.title": "Model mowy na tekst",
|
||||
"settings.models.speechToTextModel.description":
|
||||
"Wybierz model transkrypcji Kilo Gateway dla wprowadzania głosowego.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Natywne narzędzia notatników",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Włącz eksperymentalne narzędzia do odczytu, edycji i uruchamiania notatników VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "Kontynuuj przy odmowie",
|
||||
"settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia",
|
||||
"settings.sandboxing.title": "Izolacja w piaskownicy",
|
||||
|
||||
+3
@@ -1342,6 +1342,9 @@ export const dict = {
|
||||
"Включите провайдер Kilo и выполните вход, чтобы использовать Speech to Text. В настоящее время Speech to Text поддерживается только с Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "Модель речи в текст",
|
||||
"settings.models.speechToTextModel.description": "Выберите модель транскрипции Kilo Gateway для голосового ввода.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Нативные инструменты блокнотов",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Включить экспериментальные инструменты для чтения, редактирования и выполнения блокнотов VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "Продолжить при отказе",
|
||||
"settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении",
|
||||
"settings.sandboxing.title": "Изоляция в песочнице",
|
||||
|
||||
+3
@@ -1322,6 +1322,9 @@ export const dict = {
|
||||
"เปิดใช้งานและลงชื่อเข้าใช้ผู้ให้บริการ Kilo เพื่อใช้ Speech to Text ปัจจุบัน Speech to Text รองรับเฉพาะกับ Kilo Gateway เท่านั้น",
|
||||
"settings.models.speechToTextModel.title": "โมเดลแปลงเสียงเป็นข้อความ",
|
||||
"settings.models.speechToTextModel.description": "เลือกโมเดลการถอดเสียง Kilo Gateway สำหรับการป้อนข้อมูลด้วยเสียง",
|
||||
"settings.experimental.nativeNotebookTools.title": "เครื่องมือโน้ตบุ๊กดั้งเดิม",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"เปิดใช้งานเครื่องมือทดลองสำหรับการอ่าน แก้ไข และเรียกใช้โน้ตบุ๊ก VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "ดำเนินต่อเมื่อถูกปฏิเสธ",
|
||||
"settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ",
|
||||
"settings.sandboxing.title": "การทำงานในแซนด์บ็อกซ์",
|
||||
|
||||
+3
@@ -1332,6 +1332,9 @@ export const dict = {
|
||||
"Speech to Text kullanmak için Kilo sağlayıcısını etkinleştirin ve giriş yapın. Speech to Text şu anda yalnızca Kilo Gateway ile desteklenmektedir.",
|
||||
"settings.models.speechToTextModel.title": "Sesten metne modeli",
|
||||
"settings.models.speechToTextModel.description": "Sesli giriş için Kilo Gateway transkripsiyon modelini seçin.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Yerel Not Defteri Araçları",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"VS Code not defterlerini okumak, düzenlemek ve çalıştırmak için deneysel araçları etkinleştir",
|
||||
"settings.experimental.continueOnDeny.title": "Reddetme Durumunda Devam Et",
|
||||
"settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et",
|
||||
"settings.sandboxing.title": "Sandbox",
|
||||
|
||||
+3
@@ -1330,6 +1330,9 @@ export const dict = {
|
||||
"Увімкніть провайдер Kilo та виконайте вхід, щоб використовувати Speech to Text. Наразі Speech to Text підтримується лише з Kilo Gateway.",
|
||||
"settings.models.speechToTextModel.title": "Модель мовлення в текст",
|
||||
"settings.models.speechToTextModel.description": "Виберіть модель транскрипції Kilo Gateway для голосового введення.",
|
||||
"settings.experimental.nativeNotebookTools.title": "Власні інструменти для блокнотів",
|
||||
"settings.experimental.nativeNotebookTools.description":
|
||||
"Увімкнути експериментальні інструменти для читання, редагування та виконання блокнотів VS Code",
|
||||
"settings.experimental.continueOnDeny.title": "Продовжувати при відхиленні",
|
||||
"settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено",
|
||||
"settings.sandboxing.title": "Пісочниця",
|
||||
|
||||
+2
@@ -1300,6 +1300,8 @@ export const dict = {
|
||||
"启用并登录 Kilo 提供商以使用 Speech to Text。Speech to Text 目前仅支持通过 Kilo Gateway 使用。",
|
||||
"settings.models.speechToTextModel.title": "语音转文本模型",
|
||||
"settings.models.speechToTextModel.description": "选择用于语音输入的 Kilo Gateway 转录模型。",
|
||||
"settings.experimental.nativeNotebookTools.title": "原生笔记本工具",
|
||||
"settings.experimental.nativeNotebookTools.description": "启用用于读取、编辑和执行 VS Code 笔记本的实验性工具",
|
||||
"settings.experimental.continueOnDeny.title": "拒绝后继续",
|
||||
"settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环",
|
||||
"settings.sandboxing.title": "沙盒",
|
||||
|
||||
+2
@@ -1264,6 +1264,8 @@ export const dict = {
|
||||
"啟用並登入 Kilo 供應商以使用 Speech to Text。Speech to Text 目前僅支援透過 Kilo Gateway 使用。",
|
||||
"settings.models.speechToTextModel.title": "語音轉文字模型",
|
||||
"settings.models.speechToTextModel.description": "選擇用於語音輸入的 Kilo Gateway 轉錄模型。",
|
||||
"settings.experimental.nativeNotebookTools.title": "原生筆記本工具",
|
||||
"settings.experimental.nativeNotebookTools.description": "啟用用於讀取、編輯和執行 VS Code 筆記本的實驗性工具",
|
||||
"settings.experimental.continueOnDeny.title": "拒絕後繼續",
|
||||
"settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈",
|
||||
"settings.sandboxing.title": "沙盒",
|
||||
|
||||
@@ -114,6 +114,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [
|
||||
content:
|
||||
'{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } }',
|
||||
parameters: [{ name: "GitHub Token", key: "GITHUB_TOKEN", placeholder: "ghp_xxxxxxxxxxxx" }],
|
||||
suggest_for: { vscode_extension: ["github.vscode-pull-request-github"] },
|
||||
author: "Anthropic",
|
||||
category: "development",
|
||||
},
|
||||
@@ -197,6 +198,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [
|
||||
name: "Architect",
|
||||
description:
|
||||
"High-level system design and planning. Focuses on architecture decisions, component boundaries, and technical specifications without writing implementation code.",
|
||||
suggest_for: { filename: ["*.architecture.md"] },
|
||||
content: {
|
||||
mode: "primary",
|
||||
description: "Stress-test technical designs and produce implementation-ready plans",
|
||||
@@ -270,6 +272,10 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [
|
||||
]
|
||||
|
||||
const EMPTY_METADATA: MarketplaceInstalledMetadata = { project: {}, global: {} }
|
||||
const RELEVANCE = {
|
||||
"agent:architect": { filename: ["*.architecture.md"] },
|
||||
"mcp:github-mcp": { vscodeExtension: ["github.vscode-pull-request-github"] },
|
||||
}
|
||||
|
||||
const PARTIAL_INSTALLED_SKILLS: MarketplaceInstalledMetadata = {
|
||||
project: { "skill:nextjs-developer": { type: "skill" } },
|
||||
@@ -308,9 +314,33 @@ export const MixedListWithItems: Story = {
|
||||
<MarketplaceListView
|
||||
items={[...MOCK_AGENTS, ...MOCK_MCPS, ...MOCK_SKILLS]}
|
||||
metadata={PARTIAL_INSTALLED_MIXED}
|
||||
relevance={RELEVANCE}
|
||||
fetching={false}
|
||||
searchPlaceholder="Search marketplace..."
|
||||
emptyMessage="No items found"
|
||||
relevantEmptyMessage="No relevant marketplace items found for this workspace."
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const RelevantItems: Story = {
|
||||
name: "Mixed list — relevant to workspace",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ "max-height": "700px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={[...MOCK_AGENTS, ...MOCK_MCPS, ...MOCK_SKILLS]}
|
||||
metadata={PARTIAL_INSTALLED_MIXED}
|
||||
relevance={RELEVANCE}
|
||||
fetching={false}
|
||||
searchPlaceholder="Search marketplace..."
|
||||
emptyMessage="No items found"
|
||||
relevantEmptyMessage="No relevant marketplace items found for this workspace."
|
||||
initialRelevant
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
@@ -327,9 +357,11 @@ export const EmptyList: Story = {
|
||||
<MarketplaceListView
|
||||
items={[]}
|
||||
metadata={EMPTY_METADATA}
|
||||
relevance={{}}
|
||||
fetching={false}
|
||||
searchPlaceholder="Search marketplace..."
|
||||
emptyMessage="No items found"
|
||||
relevantEmptyMessage="No relevant marketplace items found for this workspace."
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,11 @@ export interface McpInstallationMethod {
|
||||
prerequisites?: string[]
|
||||
}
|
||||
|
||||
export interface MarketplaceSuggestFor {
|
||||
filename?: string[]
|
||||
vscode_extension?: string[]
|
||||
}
|
||||
|
||||
export interface MarketplaceItemBase {
|
||||
id: string
|
||||
name: string
|
||||
@@ -20,6 +25,7 @@ export interface MarketplaceItemBase {
|
||||
author?: string
|
||||
authorUrl?: string
|
||||
prerequisites?: string[]
|
||||
suggest_for?: MarketplaceSuggestFor
|
||||
}
|
||||
|
||||
export interface McpMarketplaceItem extends MarketplaceItemBase {
|
||||
@@ -62,6 +68,13 @@ export interface MarketplaceInstalledMetadata {
|
||||
global: Record<string, { type: string }>
|
||||
}
|
||||
|
||||
export interface MarketplaceRelevance {
|
||||
filename?: string[]
|
||||
vscodeExtension?: string[]
|
||||
}
|
||||
|
||||
export type MarketplaceRelevanceMetadata = Record<string, MarketplaceRelevance>
|
||||
|
||||
export interface MarketplaceFilters {
|
||||
type?: string
|
||||
search?: string
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface WatcherConfig {
|
||||
export interface ExperimentalConfig {
|
||||
batch_tool?: boolean
|
||||
codebase_search?: boolean
|
||||
native_notebook_tools?: boolean
|
||||
speech_to_text_model?: string
|
||||
primary_tools?: string[]
|
||||
continue_loop_on_deny?: boolean
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sd
|
||||
import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../../../src/diff/sources/types"
|
||||
import type { PartBatch, PartRemove, PartUpdate } from "../../../../src/shared/stream-messages"
|
||||
import type { SessionMode } from "../../context/worktree-mode"
|
||||
import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../marketplace"
|
||||
import type { MarketplaceItem, MarketplaceInstalledMetadata, MarketplaceRelevanceMetadata } from "../marketplace"
|
||||
import type { ConnectionState, ServerInfo, SessionStatus } from "./connection"
|
||||
import type { FileAttachment, Part } from "./parts"
|
||||
import type {
|
||||
@@ -919,6 +919,7 @@ export interface MarketplaceDataMessage {
|
||||
type: "marketplaceData"
|
||||
marketplaceItems: MarketplaceItem[]
|
||||
marketplaceInstalledMetadata: MarketplaceInstalledMetadata
|
||||
marketplaceRelevance: MarketplaceRelevanceMetadata
|
||||
errors?: string[]
|
||||
showAgentMigrationBanner?: boolean
|
||||
}
|
||||
|
||||
@@ -390,6 +390,9 @@ export const Info = Schema.Struct({
|
||||
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
|
||||
// kilocode_change start
|
||||
codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }),
|
||||
native_notebook_tools: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable native tools for reading, editing, and executing VS Code notebooks",
|
||||
}),
|
||||
speech_to_text_model: Schema.optional(Schema.String).annotate({
|
||||
description: "Speech-to-text transcription model ID to use for voice input",
|
||||
}),
|
||||
|
||||
@@ -34,6 +34,11 @@ const InputObject = Schema.StructWithRest(
|
||||
doom_loop: Schema.optional(Action),
|
||||
skill: Schema.optional(Rule),
|
||||
agent_manager: Schema.optional(Rule), // kilocode_change
|
||||
// kilocode_change start
|
||||
notebook_read: Schema.optional(Rule),
|
||||
notebook_edit: Schema.optional(Rule),
|
||||
notebook_execute: Schema.optional(Rule),
|
||||
// kilocode_change end
|
||||
}),
|
||||
[Schema.Record(Schema.String, Rule)],
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ import { DataMigration } from "@/data-migration"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
|
||||
|
||||
const CoreLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
@@ -85,6 +86,7 @@ const CoreLayer = Layer.mergeAll(
|
||||
|
||||
const SessionLayer = Layer.mergeAll(
|
||||
Question.defaultLayer,
|
||||
Notebook.defaultLayer, // kilocode_change
|
||||
Permission.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Info as AgentInfo } from "../../agent/agent"
|
||||
import { Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
import PROMPT_DEBUG from "../../agent/prompt/debug.txt"
|
||||
import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt"
|
||||
@@ -228,7 +229,13 @@ export interface KiloData {
|
||||
// Prepare kilo-specific data derived from config. Call once per state initialization.
|
||||
export function prepare(cfg: Config.Info): KiloData {
|
||||
const mcpRules = getMcpRules(cfg)
|
||||
const defaultsPatch = Permission.fromConfig({ bash, recall: "ask" })
|
||||
const defaultsPatch = Permission.fromConfig({
|
||||
bash,
|
||||
recall: "ask",
|
||||
...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.native_notebook_tools === true
|
||||
? { notebook_read: "ask" as const, notebook_edit: "ask" as const, notebook_execute: "ask" as const }
|
||||
: {}),
|
||||
})
|
||||
return { mcpRules, defaultsPatch }
|
||||
}
|
||||
|
||||
@@ -239,6 +246,7 @@ export function cacheKey(cfg: Config.Info) {
|
||||
mcp: cfg.mcp,
|
||||
mode: cfg.mode,
|
||||
permission: cfg.permission,
|
||||
native_notebook_tools: cfg.experimental?.native_notebook_tools,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const RequestID = Schema.String.pipe(Schema.brand("NotebookRequestID")).annotate({
|
||||
identifier: "NotebookRequestID",
|
||||
})
|
||||
export type RequestID = Schema.Schema.Type<typeof RequestID>
|
||||
|
||||
export const Path = Schema.String.check(
|
||||
Schema.isMinLength(1),
|
||||
Schema.isMaxLength(4096),
|
||||
Schema.makeFilter((value: string) =>
|
||||
value.includes("\0")
|
||||
? "Notebook path must be a request-directory-relative path or an absolute path inside the request directory"
|
||||
: undefined,
|
||||
),
|
||||
).annotate({
|
||||
description:
|
||||
"Notebook path relative to the request directory, or an absolute path that resolves inside the request directory",
|
||||
})
|
||||
const Source = Schema.String.check(Schema.isMaxLength(200_000))
|
||||
const Text = Schema.String.check(Schema.isMaxLength(100_000))
|
||||
const Revision = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)).annotate({
|
||||
description: "Opaque notebook content revision; pass it back unchanged and do not parse or increment it",
|
||||
})
|
||||
const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" })
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
mime: Schema.String.check(Schema.isMaxLength(200)),
|
||||
text: Schema.optional(Text),
|
||||
name: Schema.optional(Schema.String.check(Schema.isMaxLength(500))),
|
||||
message: Schema.optional(Schema.String.check(Schema.isMaxLength(10_000))),
|
||||
stack: Schema.optional(Schema.String.check(Schema.isMaxLength(50_000))),
|
||||
omitted: Schema.optional(Schema.Boolean),
|
||||
truncated: Schema.optional(Schema.Boolean),
|
||||
}).annotate({ identifier: "NotebookOutput" })
|
||||
export type Output = Schema.Schema.Type<typeof Output>
|
||||
|
||||
export const Cell = Schema.Struct({
|
||||
index: Index,
|
||||
kind: Schema.Literals(["code", "markdown"]),
|
||||
language: Schema.String.check(Schema.isMaxLength(200)),
|
||||
source: Source,
|
||||
execution: Schema.optional(
|
||||
Schema.Struct({
|
||||
order: Schema.optional(NonNegativeInt),
|
||||
success: Schema.optional(Schema.Boolean),
|
||||
started: Schema.optional(NonNegativeInt),
|
||||
ended: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
),
|
||||
outputs: Schema.optional(Schema.Array(Output).check(Schema.isMaxLength(100))),
|
||||
}).annotate({ identifier: "NotebookCell" })
|
||||
export type Cell = Schema.Schema.Type<typeof Cell>
|
||||
|
||||
const Base = { id: RequestID, sessionID: SessionID, path: Path }
|
||||
|
||||
export const ReadRequest = Schema.Struct({
|
||||
...Base,
|
||||
operation: Schema.Literal("read"),
|
||||
includeOutputs: Schema.Boolean,
|
||||
}).annotate({ identifier: "NotebookReadRequest" })
|
||||
|
||||
const CellEdit = {
|
||||
kind: Schema.Literals(["code", "markdown"]),
|
||||
language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))),
|
||||
source: Source,
|
||||
}
|
||||
|
||||
export const EditRequest = Schema.Struct({
|
||||
...Base,
|
||||
operation: Schema.Literal("edit"),
|
||||
expectedRevision: Revision,
|
||||
index: Index,
|
||||
edit: Schema.Union([
|
||||
Schema.Struct({ action: Schema.Literal("insert"), ...CellEdit }),
|
||||
Schema.Struct({ action: Schema.Literal("replace"), ...CellEdit }),
|
||||
Schema.Struct({ action: Schema.Literal("delete") }),
|
||||
]),
|
||||
}).annotate({ identifier: "NotebookEditRequest" })
|
||||
|
||||
export const ExecuteRequest = Schema.Struct({
|
||||
...Base,
|
||||
operation: Schema.Literal("execute"),
|
||||
expectedRevision: Revision,
|
||||
index: Index,
|
||||
}).annotate({ identifier: "NotebookExecuteRequest" })
|
||||
|
||||
export const Request = Schema.Union([ReadRequest, EditRequest, ExecuteRequest]).annotate({
|
||||
identifier: "NotebookRequest",
|
||||
})
|
||||
export type Request = Schema.Schema.Type<typeof Request>
|
||||
|
||||
export const ReadResult = Schema.Struct({
|
||||
operation: Schema.Literal("read"),
|
||||
path: Path,
|
||||
requestPath: Path,
|
||||
revision: Revision,
|
||||
cells: Schema.Array(Cell).check(Schema.isMaxLength(2_000)),
|
||||
truncated: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
.check(
|
||||
Schema.makeFilter((value) =>
|
||||
JSON.stringify(value).length <= 2_000_000 ? undefined : "Notebook read result exceeds the aggregate output limit",
|
||||
),
|
||||
)
|
||||
.annotate({ identifier: "NotebookReadResult" })
|
||||
|
||||
export const EditResult = Schema.Struct({
|
||||
operation: Schema.Literal("edit"),
|
||||
path: Path,
|
||||
requestPath: Path,
|
||||
revision: Revision,
|
||||
index: Index,
|
||||
action: Schema.Literals(["insert", "replace", "delete"]),
|
||||
cell: Schema.optional(Cell),
|
||||
}).annotate({ identifier: "NotebookEditResult" })
|
||||
|
||||
export const ExecuteResult = Schema.Struct({
|
||||
operation: Schema.Literal("execute"),
|
||||
path: Path,
|
||||
requestPath: Path,
|
||||
revision: Revision,
|
||||
index: Index,
|
||||
status: Schema.Literals(["success", "error"]),
|
||||
outputs: Schema.Array(Output).check(Schema.isMaxLength(100)),
|
||||
truncated: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
.check(
|
||||
Schema.makeFilter((value) =>
|
||||
JSON.stringify(value).length <= 2_000_000
|
||||
? undefined
|
||||
: "Notebook execution result exceeds the aggregate output limit",
|
||||
),
|
||||
)
|
||||
.annotate({ identifier: "NotebookExecuteResult" })
|
||||
|
||||
export const Result = Schema.Union([ReadResult, EditResult, ExecuteResult]).annotate({ identifier: "NotebookResult" })
|
||||
export type Result = Schema.Schema.Type<typeof Result>
|
||||
|
||||
export const ErrorCode = Schema.Literals([
|
||||
"cancelled",
|
||||
"closed",
|
||||
"disconnected",
|
||||
"execution_failed",
|
||||
"invalid_cell",
|
||||
"invalid_path",
|
||||
"no_kernel",
|
||||
"not_found",
|
||||
"stale_revision",
|
||||
"timeout",
|
||||
"unsupported",
|
||||
])
|
||||
export type ErrorCode = Schema.Schema.Type<typeof ErrorCode>
|
||||
|
||||
export const Failure = Schema.Struct({
|
||||
code: ErrorCode,
|
||||
message: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10_000)),
|
||||
path: Schema.optional(Path),
|
||||
index: Schema.optional(Index),
|
||||
currentRevision: Schema.optional(Revision),
|
||||
}).annotate({ identifier: "NotebookFailure" })
|
||||
export type Failure = Schema.Schema.Type<typeof Failure>
|
||||
|
||||
export const Event = {
|
||||
Requested: BusEvent.define("kilocode.notebook.requested", Request),
|
||||
Cancelled: BusEvent.define(
|
||||
"kilocode.notebook.cancelled",
|
||||
Schema.Struct({
|
||||
requestID: RequestID,
|
||||
sessionID: SessionID,
|
||||
reason: Schema.Literals(["cancelled", "disposed", "timeout"]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Deferred, Duration, Effect, Layer, Schema, Context } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ErrorCode, Event, type Failure, type Request, RequestID, type Result } from "./protocol"
|
||||
|
||||
const log = Log.create({ service: "notebook-host" })
|
||||
type WithoutID<T> = T extends unknown ? Omit<T, "id"> : never
|
||||
export type Input = WithoutID<Request>
|
||||
|
||||
export class HostError extends Schema.TaggedErrorClass<HostError>()("NotebookHostError", {
|
||||
code: ErrorCode,
|
||||
detail: Schema.String,
|
||||
path: Schema.optional(Schema.String),
|
||||
index: Schema.optional(Schema.Number),
|
||||
currentRevision: Schema.optional(Schema.String),
|
||||
}) {
|
||||
override get message() {
|
||||
return this.detail
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Notebook.NotFoundError", {
|
||||
requestID: RequestID,
|
||||
}) {}
|
||||
|
||||
export class InvalidReplyError extends Schema.TaggedErrorClass<InvalidReplyError>()("Notebook.InvalidReplyError", {
|
||||
requestID: RequestID,
|
||||
}) {}
|
||||
|
||||
interface Entry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Result, HostError>
|
||||
}
|
||||
interface State {
|
||||
pending: Map<RequestID, Entry>
|
||||
}
|
||||
|
||||
function matches(request: Request, result: Result) {
|
||||
if (request.path !== result.requestPath) return false
|
||||
if (request.operation === "read") return result.operation === "read"
|
||||
if (request.operation === "execute") return result.operation === "execute" && request.index === result.index
|
||||
return result.operation === "edit" && request.index === result.index && request.edit.action === result.action
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly request: (input: Input) => Effect.Effect<Result, HostError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
readonly reply: (input: {
|
||||
requestID: RequestID
|
||||
result: Result
|
||||
}) => Effect.Effect<void, NotFoundError | InvalidReplyError>
|
||||
readonly reject: (input: { requestID: RequestID; error: Failure }) => Effect.Effect<void, NotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/Notebook") {}
|
||||
|
||||
export function layer(timeout: Duration.Input = "10 minutes") {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Notebook.state")(function* () {
|
||||
const state = { pending: new Map<RequestID, Entry>() }
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const entry of state.pending.values()) {
|
||||
yield* bus.publish(Event.Cancelled, {
|
||||
requestID: entry.info.id,
|
||||
sessionID: entry.info.sessionID,
|
||||
reason: "disposed",
|
||||
})
|
||||
yield* Deferred.fail(
|
||||
entry.deferred,
|
||||
new HostError({ code: "disconnected", detail: "The notebook host disconnected" }),
|
||||
)
|
||||
}
|
||||
state.pending.clear()
|
||||
}),
|
||||
)
|
||||
return state
|
||||
}),
|
||||
)
|
||||
|
||||
const cancel = Effect.fn("Notebook.cancel")(function* (id: RequestID, reason: "cancelled" | "timeout") {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const entry = pending.get(id)
|
||||
if (!entry) return
|
||||
pending.delete(id)
|
||||
yield* bus.publish(Event.Cancelled, { requestID: id, sessionID: entry.info.sessionID, reason })
|
||||
yield* Deferred.fail(
|
||||
entry.deferred,
|
||||
new HostError({
|
||||
code: reason,
|
||||
detail:
|
||||
reason === "timeout" ? "The notebook host request timed out" : "The notebook host request was cancelled",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const request: Interface["request"] = Effect.fn("Notebook.request")(function* (input) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const id = RequestID.make(Identifier.create("nbr", "ascending"))
|
||||
const deferred = yield* Deferred.make<Result, HostError>()
|
||||
const info = { ...input, id } as Request
|
||||
pending.set(id, { info, deferred })
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* bus.publish(Event.Requested, info)
|
||||
return yield* Deferred.await(deferred).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeout,
|
||||
orElse: () => cancel(id, "timeout").pipe(Effect.andThen(Deferred.await(deferred))),
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.ensuring(cancel(id, "cancelled")))
|
||||
})
|
||||
|
||||
const list: Interface["list"] = Effect.fn("Notebook.list")(function* () {
|
||||
return Array.from((yield* InstanceState.get(state)).pending.values(), (entry) => entry.info)
|
||||
})
|
||||
|
||||
const reply: Interface["reply"] = Effect.fn("Notebook.reply")(function* (input) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const entry = pending.get(input.requestID)
|
||||
if (!entry) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return yield* new NotFoundError({ requestID: input.requestID })
|
||||
}
|
||||
if (!matches(entry.info, input.result)) return yield* new InvalidReplyError({ requestID: input.requestID })
|
||||
pending.delete(input.requestID)
|
||||
yield* Deferred.succeed(entry.deferred, input.result)
|
||||
})
|
||||
|
||||
const reject: Interface["reject"] = Effect.fn("Notebook.reject")(function* (input) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const entry = pending.get(input.requestID)
|
||||
if (!entry) {
|
||||
log.warn("rejection for unknown request", { requestID: input.requestID })
|
||||
return yield* new NotFoundError({ requestID: input.requestID })
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
yield* Deferred.fail(
|
||||
entry.deferred,
|
||||
new HostError({
|
||||
code: input.error.code,
|
||||
detail: input.error.message,
|
||||
path: input.error.path,
|
||||
index: input.error.index,
|
||||
currentRevision: input.error.currentRevision,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ request, list, reply, reject })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const defaultLayer = layer().pipe(Layer.provide(Bus.layer))
|
||||
export * as Notebook from "./service"
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
WorkspaceRoutingQuery,
|
||||
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
|
||||
import {
|
||||
Failure as NotebookFailure,
|
||||
Request as NotebookRequest,
|
||||
RequestID as NotebookRequestID,
|
||||
Result as NotebookResult,
|
||||
} from "@/kilocode/notebook/protocol"
|
||||
|
||||
const root = "/kilocode"
|
||||
|
||||
@@ -18,10 +24,16 @@ export const RemoveAgentPayload = Schema.Struct({
|
||||
name: Schema.String,
|
||||
})
|
||||
|
||||
export const NotebookReplyPayload = Schema.Struct({ result: NotebookResult })
|
||||
export const NotebookRejectPayload = Schema.Struct({ error: NotebookFailure })
|
||||
|
||||
export const KilocodePaths = {
|
||||
heapSnapshot: `${root}/heap/snapshot`,
|
||||
removeSkill: `${root}/skill/remove`,
|
||||
removeAgent: `${root}/agent/remove`,
|
||||
notebookList: `${root}/notebook`,
|
||||
notebookReply: `${root}/notebook/:requestID/reply`,
|
||||
notebookReject: `${root}/notebook/:requestID/reject`,
|
||||
} as const
|
||||
|
||||
export const KilocodeApi = HttpApi.make("kilocode")
|
||||
@@ -64,6 +76,42 @@ export const KilocodeApi = HttpApi.make("kilocode")
|
||||
"Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("notebookList", KilocodePaths.notebookList, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(NotebookRequest), "Pending notebook host requests"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.notebook.list",
|
||||
summary: "List pending notebook requests",
|
||||
description: "List pending native notebook requests for the routed workspace.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("notebookReply", KilocodePaths.notebookReply, {
|
||||
params: { requestID: NotebookRequestID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: NotebookReplyPayload,
|
||||
success: described(Schema.Boolean, "Notebook reply accepted"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.notebook.reply",
|
||||
summary: "Reply to a notebook request",
|
||||
description: "Complete a pending native notebook request with a structured result.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("notebookReject", KilocodePaths.notebookReject, {
|
||||
params: { requestID: NotebookRequestID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: NotebookRejectPayload,
|
||||
success: described(Schema.Boolean, "Notebook rejection accepted"),
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.notebook.reject",
|
||||
summary: "Reject a notebook request",
|
||||
description: "Complete a pending native notebook request with a structured host error.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -7,10 +7,12 @@ import { Config } from "@/config/config"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
|
||||
import { Notebook } from "@/kilocode/notebook/service"
|
||||
import type { RequestID as NotebookRequestID } from "@/kilocode/notebook/protocol"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
|
||||
import { Skill } from "@/skill"
|
||||
import { RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode"
|
||||
import { NotebookRejectPayload, NotebookReplyPayload, RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode"
|
||||
|
||||
export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -18,6 +20,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
const skills = yield* Skill.Service
|
||||
const config = yield* Config.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
const notebook = yield* Notebook.Service
|
||||
|
||||
const heapSnapshot = Effect.fn("KilocodeHttpApi.heapSnapshot")(function* () {
|
||||
return yield* Effect.sync(() => HeapSnapshot.write())
|
||||
@@ -49,9 +52,37 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return true
|
||||
})
|
||||
|
||||
const notebookList = Effect.fn("KilocodeHttpApi.notebookList")(function* () {
|
||||
return yield* notebook.list()
|
||||
})
|
||||
|
||||
const notebookReply = Effect.fn("KilocodeHttpApi.notebookReply")(function* (ctx: {
|
||||
params: { requestID: NotebookRequestID }
|
||||
payload: typeof NotebookReplyPayload.Type
|
||||
}) {
|
||||
yield* notebook.reply({ requestID: ctx.params.requestID, result: ctx.payload.result }).pipe(
|
||||
Effect.catchTag("Notebook.NotFoundError", () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||
Effect.catchTag("Notebook.InvalidReplyError", () => Effect.fail(new HttpApiError.BadRequest({}))),
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
const notebookReject = Effect.fn("KilocodeHttpApi.notebookReject")(function* (ctx: {
|
||||
params: { requestID: NotebookRequestID }
|
||||
payload: typeof NotebookRejectPayload.Type
|
||||
}) {
|
||||
yield* notebook
|
||||
.reject({ requestID: ctx.params.requestID, error: ctx.payload.error })
|
||||
.pipe(Effect.catchTag("Notebook.NotFoundError", () => Effect.fail(new HttpApiError.NotFound({}))))
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("heapSnapshot", heapSnapshot)
|
||||
.handle("removeSkill", removeSkill)
|
||||
.handle("removeAgent", removeAgent)
|
||||
.handle("notebookList", notebookList)
|
||||
.handle("notebookReply", notebookReply)
|
||||
.handle("notebookReject", notebookReject)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Notebook, HostError } from "@/kilocode/notebook/service"
|
||||
import { Path, type Result } from "@/kilocode/notebook/protocol"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import * as Tool from "@/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const Source = Schema.String.check(Schema.isMaxLength(200_000))
|
||||
const Revision = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)).annotate({
|
||||
description: "Opaque content revision returned by notebook_read or the previous successful notebook_edit",
|
||||
})
|
||||
const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" })
|
||||
const LIMIT = 20_000
|
||||
|
||||
function render(value: unknown) {
|
||||
const text = JSON.stringify(value, (key, item) => (key === "requestPath" ? undefined : item), 2)
|
||||
if (text.length <= LIMIT) return text
|
||||
const preview = text.slice(0, 3_000)
|
||||
return JSON.stringify(
|
||||
{
|
||||
truncated: true,
|
||||
omittedCharacters: text.length - preview.length,
|
||||
preview,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
|
||||
function abort(signal: AbortSignal) {
|
||||
return Effect.callback<never, HostError>((resume) => {
|
||||
const err = () => new HostError({ code: "cancelled", detail: "The notebook tool call was cancelled" })
|
||||
if (signal.aborted) return resume(Effect.fail(err()))
|
||||
const handler = () => resume(Effect.fail(err()))
|
||||
signal.addEventListener("abort", handler, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", handler))
|
||||
})
|
||||
}
|
||||
|
||||
function run(effect: Effect.Effect<Result, HostError>, signal: AbortSignal) {
|
||||
return effect.pipe(Effect.raceFirst(abort(signal)), Effect.orDie)
|
||||
}
|
||||
|
||||
const ReadParams = Schema.Struct({
|
||||
path: Path,
|
||||
include_outputs: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Include bounded text and error outputs. Defaults to false.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const NotebookReadTool = Tool.define<
|
||||
typeof ReadParams,
|
||||
{ path: string; revision: string },
|
||||
Notebook.Service,
|
||||
"notebook_read"
|
||||
>(
|
||||
"notebook_read",
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
return {
|
||||
description:
|
||||
"Read a live, possibly unsaved VS Code notebook using a request-directory-relative path or a safe absolute path inside that directory. Returns an opaque content revision for edits; outputs are omitted unless include_outputs is true.",
|
||||
parameters: ReadParams,
|
||||
execute: (params, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "notebook_read",
|
||||
patterns: [params.path],
|
||||
always: [params.path],
|
||||
metadata: { path: params.path, includeOutputs: params.include_outputs === true },
|
||||
})
|
||||
const result = yield* run(
|
||||
notebook.request({
|
||||
operation: "read",
|
||||
sessionID: ctx.sessionID,
|
||||
path: params.path,
|
||||
includeOutputs: params.include_outputs === true,
|
||||
}),
|
||||
ctx.abort,
|
||||
)
|
||||
if (result.operation !== "read")
|
||||
return yield* Effect.die(new Error("Notebook host returned the wrong result type"))
|
||||
return {
|
||||
title: `Notebook: ${params.path}`,
|
||||
output: render(result),
|
||||
metadata: { path: result.path, revision: result.revision },
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const EditParams = Schema.Struct({
|
||||
path: Path,
|
||||
expected_revision: Revision,
|
||||
index: Index,
|
||||
action: Schema.Literals(["insert", "replace", "delete"]).annotate({
|
||||
description: "insert and replace require kind and source; delete ignores cell fields",
|
||||
}),
|
||||
kind: Schema.optional(Schema.Literals(["code", "markdown"])).annotate({
|
||||
description: "Cell kind. Required for insert and replace.",
|
||||
}),
|
||||
language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))),
|
||||
source: Schema.optional(Source).annotate({ description: "Cell source. Required for insert and replace." }),
|
||||
})
|
||||
type EditInput = Schema.Schema.Type<typeof EditParams>
|
||||
|
||||
function cellEdit(params: EditInput) {
|
||||
if (params.action === "delete") return Effect.succeed({ action: params.action } as const)
|
||||
if (params.kind === undefined || params.source === undefined)
|
||||
return Effect.die(
|
||||
new Tool.InvalidArgumentsError({
|
||||
tool: "notebook_edit",
|
||||
detail: `the "${params.action}" action requires both "kind" and "source"`,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed({
|
||||
action: params.action,
|
||||
kind: params.kind,
|
||||
language: params.language,
|
||||
source: params.source,
|
||||
})
|
||||
}
|
||||
|
||||
export const NotebookEditTool = Tool.define<
|
||||
typeof EditParams,
|
||||
{ path: string; revision: string; index: number },
|
||||
Notebook.Service,
|
||||
"notebook_edit"
|
||||
>(
|
||||
"notebook_edit",
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
return {
|
||||
description:
|
||||
"Insert, replace, or delete one cell in a live VS Code notebook. Paths may be request-directory-relative or safe absolute paths. Pass the latest opaque revision from notebook_read or the previous successful edit unchanged. A stale_revision error requires a fresh read; never blindly retry an index-based edit. Leaves the document dirty.",
|
||||
parameters: EditParams,
|
||||
execute: (params, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const edit = yield* cellEdit(params)
|
||||
yield* ctx.ask({
|
||||
permission: "notebook_edit",
|
||||
patterns: [params.path],
|
||||
always: [params.path],
|
||||
metadata: {
|
||||
path: params.path,
|
||||
action: params.action,
|
||||
index: params.index,
|
||||
expectedRevision: params.expected_revision,
|
||||
},
|
||||
})
|
||||
const result = yield* run(
|
||||
notebook.request({
|
||||
operation: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
path: params.path,
|
||||
expectedRevision: params.expected_revision,
|
||||
index: params.index,
|
||||
edit,
|
||||
}),
|
||||
ctx.abort,
|
||||
)
|
||||
if (result.operation !== "edit")
|
||||
return yield* Effect.die(new Error("Notebook host returned the wrong result type"))
|
||||
return {
|
||||
title: `${result.action} notebook cell ${result.index}`,
|
||||
output: render(result),
|
||||
metadata: { path: result.path, revision: result.revision, index: result.index },
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const ExecuteParams = Schema.Struct({ path: Path, expected_revision: Revision, index: Index })
|
||||
|
||||
export const NotebookExecuteTool = Tool.define<
|
||||
typeof ExecuteParams,
|
||||
{ path: string; revision: string; index: number },
|
||||
Notebook.Service,
|
||||
"notebook_execute"
|
||||
>(
|
||||
"notebook_execute",
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
return {
|
||||
description:
|
||||
"Execute one explicit code cell in a live VS Code notebook using a request-directory-relative or safe absolute path. Pass the latest opaque content revision unchanged. Execution requires a kernel already selected by the user; the tool never reveals the notebook or opens a kernel picker.",
|
||||
parameters: ExecuteParams,
|
||||
execute: (params, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "notebook_execute",
|
||||
patterns: [params.path],
|
||||
always: [params.path],
|
||||
metadata: { path: params.path, index: params.index, expectedRevision: params.expected_revision },
|
||||
})
|
||||
const result = yield* run(
|
||||
notebook.request({
|
||||
operation: "execute",
|
||||
sessionID: ctx.sessionID,
|
||||
path: params.path,
|
||||
expectedRevision: params.expected_revision,
|
||||
index: params.index,
|
||||
}),
|
||||
ctx.abort,
|
||||
)
|
||||
if (result.operation !== "execute")
|
||||
return yield* Effect.die(new Error("Notebook host returned the wrong result type"))
|
||||
return {
|
||||
title: `Executed notebook cell ${result.index}`,
|
||||
output: render(result),
|
||||
metadata: { path: result.path, revision: result.revision, index: result.index },
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -3,9 +3,11 @@ import { CodebaseSearchTool } from "../../tool/warpgrep"
|
||||
import { RecallTool } from "../../tool/recall"
|
||||
import { AgentManagerTool } from "./agent-manager"
|
||||
import { BackgroundProcessTool } from "./background-process"
|
||||
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host"
|
||||
import * as Tool from "../../tool/tool"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Effect } from "effect"
|
||||
import { Notebook } from "@/kilocode/notebook/service"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
@@ -31,20 +33,34 @@ export namespace KiloToolRegistry {
|
||||
|
||||
/** Resolve Kilo-specific tool Infos outside any InstanceState, so their Truncate/Agent deps are
|
||||
* satisfied at the outer registry scope instead of leaking into InstanceState's Effect. */
|
||||
export function infos() {
|
||||
export function infos(notebook?: Notebook.Interface) {
|
||||
return Effect.gen(function* () {
|
||||
const codebase = yield* CodebaseSearchTool
|
||||
const recall = yield* RecallTool
|
||||
const manager = yield* AgentManagerTool
|
||||
const process = yield* BackgroundProcessTool
|
||||
return { codebase, recall, manager, process }
|
||||
if (!notebook) return { codebase, recall, manager, process }
|
||||
const tools = yield* Effect.all({
|
||||
notebookRead: NotebookReadTool,
|
||||
notebookEdit: NotebookEditTool,
|
||||
notebookExecute: NotebookExecuteTool,
|
||||
}).pipe(Effect.provideService(Notebook.Service, notebook))
|
||||
return { codebase, recall, manager, process, ...tools }
|
||||
})
|
||||
}
|
||||
|
||||
/** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect —
|
||||
* it has no Service deps beyond what Tool.init itself needs. */
|
||||
export function build(
|
||||
tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info },
|
||||
tools: {
|
||||
codebase: Tool.Info
|
||||
recall: Tool.Info
|
||||
manager: Tool.Info
|
||||
process: Tool.Info
|
||||
notebookRead?: Tool.Info
|
||||
notebookEdit?: Tool.Info
|
||||
notebookExecute?: Tool.Info
|
||||
},
|
||||
deps: Deps,
|
||||
loaders: Loaders = {},
|
||||
) {
|
||||
@@ -55,8 +71,16 @@ export namespace KiloToolRegistry {
|
||||
manager: Tool.init(tools.manager),
|
||||
process: Tool.init(tools.process),
|
||||
})
|
||||
const notebooks =
|
||||
tools.notebookRead && tools.notebookEdit && tools.notebookExecute
|
||||
? yield* Effect.all({
|
||||
notebookRead: Tool.init(tools.notebookRead),
|
||||
notebookEdit: Tool.init(tools.notebookEdit),
|
||||
notebookExecute: Tool.init(tools.notebookExecute),
|
||||
})
|
||||
: {}
|
||||
const semantic = yield* semanticTool(deps, loaders)
|
||||
return { ...base, semantic }
|
||||
return { ...base, ...notebooks, semantic }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,8 +123,17 @@ export namespace KiloToolRegistry {
|
||||
|
||||
/** Kilo-specific tools to append to the builtin list */
|
||||
export function extra(
|
||||
tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def },
|
||||
cfg: { experimental?: { codebase_search?: boolean } },
|
||||
tools: {
|
||||
codebase: Tool.Def
|
||||
semantic?: Tool.Def
|
||||
recall: Tool.Def
|
||||
manager: Tool.Def
|
||||
process: Tool.Def
|
||||
notebookRead?: Tool.Def
|
||||
notebookEdit?: Tool.Def
|
||||
notebookExecute?: Tool.Def
|
||||
},
|
||||
cfg: { experimental?: { codebase_search?: boolean; native_notebook_tools?: boolean } },
|
||||
): Tool.Def[] {
|
||||
return [
|
||||
...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []),
|
||||
@@ -109,6 +142,13 @@ export namespace KiloToolRegistry {
|
||||
...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []),
|
||||
// The extension is the only client that can consume the Agent Manager start event.
|
||||
...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []),
|
||||
...(Flag.KILO_CLIENT === "vscode" &&
|
||||
cfg.experimental?.native_notebook_tools === true &&
|
||||
tools.notebookRead &&
|
||||
tools.notebookEdit &&
|
||||
tools.notebookExecute
|
||||
? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Question } from "@/question"
|
||||
import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
@@ -225,6 +226,7 @@ export function createRoutes(
|
||||
Pty.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Notebook.defaultLayer, // kilocode_change
|
||||
Ripgrep.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { KiloToolRegistry } from "../kilocode/tool/registry" // kilocode_change
|
||||
import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
|
||||
import { RepoCloneTool } from "./repo_clone"
|
||||
import { RepoOverviewTool } from "./repo_overview"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change
|
||||
@@ -38,7 +39,7 @@ import { ApplyPatchTool } from "./apply_patch"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Option } from "effect" // kilocode_change
|
||||
import { HttpClient } from "effect/unstable/http" // kilocode_change
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -150,7 +151,8 @@ export const layer: Layer.Layer<
|
||||
const agent = yield* Agent.Service
|
||||
// kilocode_change start
|
||||
const suggesttool = yield* SuggestTool
|
||||
const kiloToolInfos = yield* KiloToolRegistry.infos()
|
||||
const notebook = Option.getOrUndefined(yield* Effect.serviceOption(Notebook.Service))
|
||||
const kiloToolInfos = yield* KiloToolRegistry.infos(notebook)
|
||||
// kilocode_change end
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
@@ -450,6 +452,7 @@ export const defaultLayer = Layer.suspend(
|
||||
// kilocode_change start - provide Kilo-owned registry dependencies
|
||||
.pipe(
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Notebook.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Notebook, HostError } from "@/kilocode/notebook/service"
|
||||
import { EditRequest, Event, ReadRequest, ReadResult, type Request } from "@/kilocode/notebook/protocol"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Effect, Fiber, Layer, Queue, Schema } from "effect"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Notebook.layer("20 millis").pipe(Layer.provideMerge(Bus.layer)))
|
||||
const sessionID = SessionID.make("ses_notebook_test")
|
||||
|
||||
function request(notebook: Notebook.Interface) {
|
||||
return notebook.request({ operation: "read", sessionID, path: "analysis.ipynb", includeOutputs: false })
|
||||
}
|
||||
|
||||
it.instance(
|
||||
"publishes, lists, and completes a correlated request",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
const bus = yield* Bus.Service
|
||||
const instance = yield* TestInstance
|
||||
const events = yield* Queue.unbounded<{ properties: Request }>()
|
||||
const global = yield* Queue.unbounded<GlobalEvent>()
|
||||
const off = yield* bus.subscribeCallback(Event.Requested, (event) => Queue.offerUnsafe(events, event))
|
||||
const handler = (event: GlobalEvent) => {
|
||||
if (event.payload?.type === Event.Requested.type) Queue.offerUnsafe(global, event)
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
off()
|
||||
GlobalBus.off("event", handler)
|
||||
}),
|
||||
)
|
||||
|
||||
const fiber = yield* request(notebook).pipe(Effect.forkChild)
|
||||
const event = yield* Queue.take(events).pipe(Effect.timeout("2 seconds"))
|
||||
expect(event.properties.sessionID).toBe(sessionID)
|
||||
expect(event.properties.path).toBe("analysis.ipynb")
|
||||
expect((yield* Queue.take(global).pipe(Effect.timeout("2 seconds"))).directory).toBe(instance.directory)
|
||||
expect(yield* notebook.list()).toEqual([event.properties])
|
||||
|
||||
yield* notebook.reply({
|
||||
requestID: event.properties.id,
|
||||
result: { operation: "read", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:3", cells: [] },
|
||||
})
|
||||
expect(yield* Fiber.join(fiber)).toEqual({
|
||||
operation: "read",
|
||||
path: "analysis.ipynb",
|
||||
requestPath: "analysis.ipynb",
|
||||
revision: "content:3",
|
||||
cells: [],
|
||||
})
|
||||
expect(yield* notebook.list()).toEqual([])
|
||||
|
||||
const late = yield* notebook
|
||||
.reply({
|
||||
requestID: event.properties.id,
|
||||
result: { operation: "read", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:3", cells: [] },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(late._tag).toBe("Notebook.NotFoundError")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"propagates structured host rejection and removes pending state",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
const fiber = yield* request(notebook).pipe(Effect.forkChild)
|
||||
const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 }))
|
||||
yield* notebook.reject({
|
||||
requestID: pending[0].id,
|
||||
error: {
|
||||
code: "stale_revision",
|
||||
message: "Notebook content changed; re-read before retrying",
|
||||
path: "analysis.ipynb",
|
||||
index: 0,
|
||||
currentRevision: "content:current",
|
||||
},
|
||||
})
|
||||
const err = yield* Fiber.join(fiber).pipe(Effect.flip)
|
||||
expect(err).toBeInstanceOf(HostError)
|
||||
expect(err.code).toBe("stale_revision")
|
||||
expect(err.message).toContain("re-read")
|
||||
expect(yield* notebook.list()).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"cancels interrupted requests and rejects operation-mismatched replies",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
const bus = yield* Bus.Service
|
||||
const cancelled = yield* Queue.unbounded<string>()
|
||||
const off = yield* bus.subscribeCallback(Event.Cancelled, (event) =>
|
||||
Queue.offerUnsafe(cancelled, event.properties.reason),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
const fiber = yield* request(notebook).pipe(Effect.forkChild)
|
||||
const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 }))
|
||||
const mismatch = yield* notebook
|
||||
.reply({
|
||||
requestID: pending[0].id,
|
||||
result: { operation: "edit", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:2", index: 0, action: "delete" },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(mismatch._tag).toBe("Notebook.InvalidReplyError")
|
||||
const wrongPath = yield* notebook
|
||||
.reply({
|
||||
requestID: pending[0].id,
|
||||
result: {
|
||||
operation: "read",
|
||||
path: "other.ipynb",
|
||||
requestPath: "other.ipynb",
|
||||
revision: "content:2",
|
||||
cells: [],
|
||||
},
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(wrongPath._tag).toBe("Notebook.InvalidReplyError")
|
||||
yield* Fiber.interrupt(fiber)
|
||||
expect(yield* Queue.take(cancelled).pipe(Effect.timeout("2 seconds"))).toBe("cancelled")
|
||||
expect(yield* notebook.list()).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"times out pending requests",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
const err = yield* request(notebook).pipe(Effect.flip)
|
||||
expect(err.code).toBe("timeout")
|
||||
expect(yield* notebook.list()).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"accepts both path forms, requires opaque revisions, and bounds aggregate results",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const absolute = yield* Schema.decodeUnknownEffect(ReadRequest)({
|
||||
id: "nbr_test",
|
||||
sessionID,
|
||||
operation: "read",
|
||||
path: "/workspace/analysis.ipynb",
|
||||
includeOutputs: false,
|
||||
})
|
||||
expect(absolute.path).toBe("/workspace/analysis.ipynb")
|
||||
const relative = yield* Schema.decodeUnknownEffect(ReadRequest)({
|
||||
...absolute,
|
||||
path: "nested/analysis.ipynb",
|
||||
})
|
||||
expect(relative.path).toBe("nested/analysis.ipynb")
|
||||
const invalid = yield* Schema.decodeUnknownEffect(EditRequest)({
|
||||
id: "nbr_edit",
|
||||
sessionID,
|
||||
operation: "edit",
|
||||
path: "analysis.ipynb",
|
||||
expectedRevision: 4,
|
||||
index: 0,
|
||||
edit: { action: "delete" },
|
||||
}).pipe(Effect.flip)
|
||||
expect(String(invalid)).toContain("string")
|
||||
|
||||
const cells = Array.from({ length: 11 }, (_, index) => ({
|
||||
index,
|
||||
kind: "code" as const,
|
||||
language: "python",
|
||||
source: "x".repeat(200_000),
|
||||
}))
|
||||
const output = yield* Schema.decodeUnknownEffect(ReadResult)({
|
||||
operation: "read",
|
||||
path: "analysis.ipynb",
|
||||
requestPath: "analysis.ipynb",
|
||||
revision: "content:1",
|
||||
cells,
|
||||
}).pipe(Effect.flip)
|
||||
expect(String(output)).toContain("aggregate output limit")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"fails pending requests when the instance is disposed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const notebook = yield* Notebook.Service
|
||||
const instance = yield* TestInstance
|
||||
const fiber = yield* request(notebook).pipe(Effect.forkChild)
|
||||
yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 }))
|
||||
yield* Effect.promise(() => disposeInstance(instance.directory))
|
||||
const err = yield* Fiber.join(fiber).pipe(Effect.flip)
|
||||
expect(err.code).toBe("disconnected")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Notebook } from "@/kilocode/notebook/service"
|
||||
import * as KiloAgent from "@/kilocode/agent"
|
||||
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "@/kilocode/tool/notebook-host"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import * as Tool from "@/tool/tool"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const calls: Notebook.Input[] = []
|
||||
const notebook = Layer.mock(Notebook.Service, {
|
||||
request: (input) => {
|
||||
calls.push(input)
|
||||
if (input.operation === "read")
|
||||
return Effect.succeed({
|
||||
operation: "read" as const,
|
||||
path: input.path,
|
||||
requestPath: input.path,
|
||||
revision: "content:read",
|
||||
cells: [{ index: 0, kind: "code" as const, language: "python", source: "x".repeat(200_000) }],
|
||||
})
|
||||
if (input.operation === "edit")
|
||||
return Effect.succeed({
|
||||
operation: "edit" as const,
|
||||
path: input.path,
|
||||
requestPath: input.path,
|
||||
revision: "content:edit",
|
||||
index: input.index,
|
||||
action: input.edit.action,
|
||||
})
|
||||
return Effect.succeed({
|
||||
operation: "execute" as const,
|
||||
path: input.path,
|
||||
requestPath: input.path,
|
||||
revision: "content:execute",
|
||||
index: input.index,
|
||||
status: "success" as const,
|
||||
outputs: [],
|
||||
})
|
||||
},
|
||||
})
|
||||
const it = testEffect(Layer.mergeAll(notebook, Agent.defaultLayer, Truncate.defaultLayer))
|
||||
|
||||
function context(asks: Parameters<Tool.Context["ask"]>[0][]): Tool.Context {
|
||||
return {
|
||||
sessionID: SessionID.make("ses_notebook_tools"),
|
||||
messageID: MessageID.make("msg_notebook_tools"),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: (input) => Effect.sync(() => asks.push(input)),
|
||||
}
|
||||
}
|
||||
|
||||
describe("native notebook tools", () => {
|
||||
it.instance(
|
||||
"uses dedicated permissions and bounded structured output",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
calls.length = 0
|
||||
const asks: Parameters<Tool.Context["ask"]>[0][] = []
|
||||
const read = yield* NotebookReadTool.pipe(Effect.flatMap(Tool.init))
|
||||
const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init))
|
||||
const execute = yield* NotebookExecuteTool.pipe(Effect.flatMap(Tool.init))
|
||||
const ctx = context(asks)
|
||||
|
||||
const readResult = yield* read.execute({ path: "analysis.ipynb", include_outputs: true }, ctx)
|
||||
const editResult = yield* edit.execute(
|
||||
{
|
||||
path: "analysis.ipynb",
|
||||
expected_revision: "content:read",
|
||||
index: 0,
|
||||
action: "replace",
|
||||
kind: "code",
|
||||
language: "python",
|
||||
source: "print(42)",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
const executeResult = yield* execute.execute(
|
||||
{ path: "/workspace/analysis.ipynb", expected_revision: "content:edit", index: 0 },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(asks.map((item) => item.permission)).toEqual(["notebook_read", "notebook_edit", "notebook_execute"])
|
||||
expect(asks.map((item) => item.patterns[0])).toEqual([
|
||||
"analysis.ipynb",
|
||||
"analysis.ipynb",
|
||||
"/workspace/analysis.ipynb",
|
||||
])
|
||||
expect(calls.map((item) => item.operation)).toEqual(["read", "edit", "execute"])
|
||||
expect(calls[1]).toMatchObject({ expectedRevision: "content:read" })
|
||||
expect(calls[2]).toMatchObject({ expectedRevision: "content:edit" })
|
||||
expect(readResult.output.length).toBeLessThanOrEqual(20_000)
|
||||
const rendered = JSON.parse(readResult.output)
|
||||
expect(rendered).toMatchObject({ truncated: true, omittedCharacters: expect.any(Number) })
|
||||
expect(rendered.preview).toContain('"revision"')
|
||||
expect(rendered.preview).not.toContain("requestPath")
|
||||
expect(editResult.metadata.revision).toBe("content:edit")
|
||||
expect(executeResult.metadata.index).toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"exposes notebook_edit without a top-level schema union",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init))
|
||||
const schema = ToolJsonSchema.fromTool(edit)
|
||||
expect(schema.type).toBe("object")
|
||||
expect(schema.anyOf).toBeUndefined()
|
||||
expect(schema.oneOf).toBeUndefined()
|
||||
expect(schema.allOf).toBeUndefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"rejects insert without kind and source before asking for permission",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init))
|
||||
const asks: Parameters<Tool.Context["ask"]>[0][] = []
|
||||
const ctx = context(asks)
|
||||
const exit = yield* edit
|
||||
.execute({ path: "analysis.ipynb", expected_revision: "content:read", index: 0, action: "insert" }, ctx)
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(asks).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
test("uses dedicated VS Code notebook permission defaults only when enabled", () => {
|
||||
const prev = process.env.KILO_CLIENT
|
||||
try {
|
||||
process.env.KILO_CLIENT = "vscode"
|
||||
const disabled = KiloAgent.prepare({}).defaultsPatch
|
||||
expect(disabled.some((rule) => rule.permission.startsWith("notebook_"))).toBe(false)
|
||||
|
||||
const rules = KiloAgent.prepare({ experimental: { native_notebook_tools: true } }).defaultsPatch
|
||||
expect(rules.findLast((rule) => rule.permission === "notebook_read")?.action).toBe("ask")
|
||||
expect(rules.findLast((rule) => rule.permission === "notebook_edit")?.action).toBe("ask")
|
||||
expect(rules.findLast((rule) => rule.permission === "notebook_execute")?.action).toBe("ask")
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.KILO_CLIENT
|
||||
if (prev !== undefined) process.env.KILO_CLIENT = prev
|
||||
}
|
||||
})
|
||||
@@ -39,6 +39,9 @@ function infos() {
|
||||
recall: info("recall"),
|
||||
manager: info("agent_manager"),
|
||||
process: info("background_process"),
|
||||
notebookRead: info("notebook_read"),
|
||||
notebookEdit: info("notebook_edit"),
|
||||
notebookExecute: info("notebook_execute"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,9 @@ describe("kilocode tool registry indexing", () => {
|
||||
recall: def("recall"),
|
||||
manager: def("agent_manager"),
|
||||
process: def("background_process"),
|
||||
notebookRead: def("notebook_read"),
|
||||
notebookEdit: def("notebook_edit"),
|
||||
notebookExecute: def("notebook_execute"),
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -220,6 +223,20 @@ describe("kilocode tool registry indexing", () => {
|
||||
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
|
||||
["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"],
|
||||
)
|
||||
expect(
|
||||
KiloToolRegistry.extra(tools, {
|
||||
experimental: { codebase_search: true, native_notebook_tools: true },
|
||||
}).map((tool) => tool.id),
|
||||
).toEqual([
|
||||
"codebase_search",
|
||||
"semantic_search",
|
||||
"recall",
|
||||
"background_process",
|
||||
"agent_manager",
|
||||
"notebook_read",
|
||||
"notebook_edit",
|
||||
"notebook_execute",
|
||||
])
|
||||
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
|
||||
"recall",
|
||||
"background_process",
|
||||
|
||||
@@ -51,6 +51,9 @@ function infos() {
|
||||
recall: info("recall"),
|
||||
manager: info("agent_manager"),
|
||||
process: info("background_process"),
|
||||
notebookRead: info("notebook_read"),
|
||||
notebookEdit: info("notebook_edit"),
|
||||
notebookExecute: info("notebook_execute"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,33 @@ const scenarios: Scenario[] = [
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/question", "question.list").json(200, array),
|
||||
// kilocode_change start
|
||||
http.protected.get("/kilocode/notebook", "kilocode.notebook.list").json(200, array),
|
||||
http.protected
|
||||
.post("/kilocode/notebook/{requestID}/reply", "kilocode.notebook.reply")
|
||||
.at((ctx) => ({
|
||||
path: route("/kilocode/notebook/{requestID}/reply", { requestID: "nbr_httpapi_reply" }),
|
||||
headers: ctx.headers(),
|
||||
body: {
|
||||
result: {
|
||||
operation: "read",
|
||||
path: "notebook.ipynb",
|
||||
requestPath: "notebook.ipynb",
|
||||
revision: "content:1",
|
||||
cells: [],
|
||||
},
|
||||
},
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/kilocode/notebook/{requestID}/reject", "kilocode.notebook.reject")
|
||||
.at((ctx) => ({
|
||||
path: route("/kilocode/notebook/{requestID}/reject", { requestID: "nbr_httpapi_reject" }),
|
||||
headers: ctx.headers(),
|
||||
body: { error: { code: "not_found", message: "Notebook not found" } },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
// kilocode_change end
|
||||
http.protected
|
||||
.post("/question/{requestID}/reply", "question.reply.invalid")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -142,6 +142,12 @@ import type {
|
||||
KiloCloudSessionsResponses,
|
||||
KilocodeHeapSnapshotErrors,
|
||||
KilocodeHeapSnapshotResponses,
|
||||
KilocodeNotebookListErrors,
|
||||
KilocodeNotebookListResponses,
|
||||
KilocodeNotebookRejectErrors,
|
||||
KilocodeNotebookRejectResponses,
|
||||
KilocodeNotebookReplyErrors,
|
||||
KilocodeNotebookReplyResponses,
|
||||
KilocodeRemoveAgentErrors,
|
||||
KilocodeRemoveAgentResponses,
|
||||
KilocodeRemoveSkillErrors,
|
||||
@@ -192,6 +198,9 @@ import type {
|
||||
NetworkRejectResponses,
|
||||
NetworkReplyErrors,
|
||||
NetworkReplyResponses,
|
||||
NotebookFailure,
|
||||
NotebookRequestId,
|
||||
NotebookResult,
|
||||
OutputFormat,
|
||||
Part as Part2,
|
||||
PartDeleteErrors,
|
||||
@@ -6964,6 +6973,128 @@ export class Heap extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Notebook extends HeyApiClient {
|
||||
/**
|
||||
* List pending notebook requests
|
||||
*
|
||||
* List pending native notebook requests for the routed workspace.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<
|
||||
KilocodeNotebookListResponses,
|
||||
KilocodeNotebookListErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/notebook",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a notebook request
|
||||
*
|
||||
* Complete a pending native notebook request with a structured result.
|
||||
*/
|
||||
public reply<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
requestID: NotebookRequestId
|
||||
directory?: string
|
||||
workspace?: string
|
||||
result?: NotebookResult
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "requestID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "result" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
KilocodeNotebookReplyResponses,
|
||||
KilocodeNotebookReplyErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/notebook/{requestID}/reply",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a notebook request
|
||||
*
|
||||
* Complete a pending native notebook request with a structured host error.
|
||||
*/
|
||||
public reject<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
requestID: NotebookRequestId
|
||||
directory?: string
|
||||
workspace?: string
|
||||
error?: NotebookFailure
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "requestID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "error" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
KilocodeNotebookRejectResponses,
|
||||
KilocodeNotebookRejectErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/notebook/{requestID}/reject",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionImport extends HeyApiClient {
|
||||
/**
|
||||
* Insert project for session import
|
||||
@@ -7432,6 +7563,11 @@ export class Kilocode extends HeyApiClient {
|
||||
return (this._heap ??= new Heap({ client: this.client }))
|
||||
}
|
||||
|
||||
private _notebook?: Notebook
|
||||
get notebook(): Notebook {
|
||||
return (this._notebook ??= new Notebook({ client: this.client }))
|
||||
}
|
||||
|
||||
private _sessionImport?: SessionImport
|
||||
get sessionImport(): SessionImport {
|
||||
return (this._sessionImport ??= new SessionImport({ client: this.client }))
|
||||
|
||||
@@ -14,6 +14,8 @@ export type Event =
|
||||
| EventTuiSessionSelect
|
||||
| EventSandboxStatusChanged
|
||||
| EventKilocodeAgentManagerStart
|
||||
| EventKilocodeNotebookRequested
|
||||
| EventKilocodeNotebookCancelled
|
||||
| EventIndexingStatus
|
||||
| EventIndexingWarning
|
||||
| EventServerInstanceDisposed
|
||||
@@ -192,6 +194,64 @@ export type EventTuiSessionSelect = {
|
||||
}
|
||||
}
|
||||
|
||||
export type NotebookRequestId = string
|
||||
|
||||
export type NotebookReadRequest = {
|
||||
id: NotebookRequestId
|
||||
sessionID: string
|
||||
path: string
|
||||
operation: "read"
|
||||
includeOutputs: boolean
|
||||
}
|
||||
|
||||
export type NotebookEditRequest = {
|
||||
id: NotebookRequestId
|
||||
sessionID: string
|
||||
path: string
|
||||
operation: "edit"
|
||||
/**
|
||||
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
|
||||
*/
|
||||
expectedRevision: string
|
||||
/**
|
||||
* Zero-based cell index
|
||||
*/
|
||||
index: number
|
||||
edit:
|
||||
| {
|
||||
action: "insert"
|
||||
kind: "code" | "markdown"
|
||||
language?: string
|
||||
source: string
|
||||
}
|
||||
| {
|
||||
action: "replace"
|
||||
kind: "code" | "markdown"
|
||||
language?: string
|
||||
source: string
|
||||
}
|
||||
| {
|
||||
action: "delete"
|
||||
}
|
||||
}
|
||||
|
||||
export type NotebookExecuteRequest = {
|
||||
id: NotebookRequestId
|
||||
sessionID: string
|
||||
path: string
|
||||
operation: "execute"
|
||||
/**
|
||||
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
|
||||
*/
|
||||
expectedRevision: string
|
||||
/**
|
||||
* Zero-based cell index
|
||||
*/
|
||||
index: number
|
||||
}
|
||||
|
||||
export type NotebookRequest = NotebookReadRequest | NotebookEditRequest | NotebookExecuteRequest
|
||||
|
||||
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
|
||||
|
||||
export type IndexingStatus = {
|
||||
@@ -933,6 +993,8 @@ export type GlobalEvent = {
|
||||
| EventTuiSessionSelect
|
||||
| EventSandboxStatusChanged
|
||||
| EventKilocodeAgentManagerStart
|
||||
| EventKilocodeNotebookRequested
|
||||
| EventKilocodeNotebookCancelled
|
||||
| EventIndexingStatus
|
||||
| EventIndexingWarning
|
||||
| EventServerInstanceDisposed
|
||||
@@ -1183,6 +1245,9 @@ export type PermissionConfig =
|
||||
doom_loop?: PermissionActionConfig
|
||||
skill?: PermissionRuleConfig
|
||||
agent_manager?: PermissionRuleConfig
|
||||
notebook_read?: PermissionRuleConfig
|
||||
notebook_edit?: PermissionRuleConfig
|
||||
notebook_execute?: PermissionRuleConfig
|
||||
[key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
|
||||
}
|
||||
|
||||
@@ -1542,6 +1607,7 @@ export type Config = {
|
||||
disable_paste_summary?: boolean
|
||||
batch_tool?: boolean
|
||||
codebase_search?: boolean
|
||||
native_notebook_tools?: boolean
|
||||
speech_to_text_model?: string
|
||||
openTelemetry?: boolean
|
||||
primary_tools?: Array<string>
|
||||
@@ -2422,6 +2488,105 @@ export type EffectHttpApiErrorServiceUnavailable = {
|
||||
_tag: "ServiceUnavailable"
|
||||
}
|
||||
|
||||
export type NotebookOutput = {
|
||||
mime: string
|
||||
text?: string
|
||||
name?: string
|
||||
message?: string
|
||||
stack?: string
|
||||
omitted?: boolean
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export type NotebookCell = {
|
||||
/**
|
||||
* Zero-based cell index
|
||||
*/
|
||||
index: number
|
||||
kind: "code" | "markdown"
|
||||
language: string
|
||||
source: string
|
||||
execution?: {
|
||||
order?: number
|
||||
success?: boolean
|
||||
started?: number
|
||||
ended?: number
|
||||
}
|
||||
outputs?: Array<NotebookOutput>
|
||||
}
|
||||
|
||||
export type NotebookReadResult = {
|
||||
operation: "read"
|
||||
path: string
|
||||
requestPath: string
|
||||
/**
|
||||
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
|
||||
*/
|
||||
revision: string
|
||||
cells: Array<NotebookCell>
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export type NotebookEditResult = {
|
||||
operation: "edit"
|
||||
path: string
|
||||
requestPath: string
|
||||
/**
|
||||
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
|
||||
*/
|
||||
revision: string
|
||||
/**
|
||||
* Zero-based cell index
|
||||
*/
|
||||
index: number
|
||||
action: "insert" | "replace" | "delete"
|
||||
cell?: NotebookCell
|
||||
}
|
||||
|
||||
export type NotebookExecuteResult = {
|
||||
operation: "execute"
|
||||
path: string
|
||||
requestPath: string
|
||||
/**
|
||||
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
|
||||
*/
|
||||
revision: string
|
||||
/**
|
||||
* Zero-based cell index
|
||||
*/
|
||||
index: number
|
||||
status: "success" | "error"
|
||||
outputs: Array<NotebookOutput>
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export type NotebookResult = NotebookReadResult | NotebookEditResult | NotebookExecuteResult
|
||||
|
||||
export type NotebookFailure = {
|
||||
code:
|
||||
| "cancelled"
|
||||
| "closed"
|
||||
| "disconnected"
|
||||
| "execution_failed"
|
||||
| "invalid_cell"
|
||||
| "invalid_path"
|
||||
| "no_kernel"
|
||||
| "not_found"
|
||||
| "stale_revision"
|
||||
| "timeout"
|
||||
| "unsupported"
|
||||
message: string
|
||||
path?: string
|
||||
/**
|
||||
* Zero-based cell index
|
||||
*/
|
||||
index?: number
|
||||
/**
|
||||
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
|
||||
*/
|
||||
currentRevision?: string
|
||||
}
|
||||
|
||||
export type KilocodeSessionImportResult = {
|
||||
ok: boolean
|
||||
id: string
|
||||
@@ -3020,6 +3185,22 @@ export type EventKilocodeAgentManagerStart = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventKilocodeNotebookRequested = {
|
||||
id: string
|
||||
type: "kilocode.notebook.requested"
|
||||
properties: NotebookRequest
|
||||
}
|
||||
|
||||
export type EventKilocodeNotebookCancelled = {
|
||||
id: string
|
||||
type: "kilocode.notebook.cancelled"
|
||||
properties: {
|
||||
requestID: NotebookRequestId
|
||||
sessionID: string
|
||||
reason: "cancelled" | "disposed" | "timeout"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventIndexingStatus = {
|
||||
id: string
|
||||
type: "indexing.status"
|
||||
@@ -10546,6 +10727,106 @@ export type KilocodeRemoveAgentResponses = {
|
||||
|
||||
export type KilocodeRemoveAgentResponse = KilocodeRemoveAgentResponses[keyof KilocodeRemoveAgentResponses]
|
||||
|
||||
export type KilocodeNotebookListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/notebook"
|
||||
}
|
||||
|
||||
export type KilocodeNotebookListErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type KilocodeNotebookListError = KilocodeNotebookListErrors[keyof KilocodeNotebookListErrors]
|
||||
|
||||
export type KilocodeNotebookListResponses = {
|
||||
/**
|
||||
* Pending notebook host requests
|
||||
*/
|
||||
200: Array<NotebookRequest>
|
||||
}
|
||||
|
||||
export type KilocodeNotebookListResponse = KilocodeNotebookListResponses[keyof KilocodeNotebookListResponses]
|
||||
|
||||
export type KilocodeNotebookReplyData = {
|
||||
body?: {
|
||||
result: NotebookResult
|
||||
}
|
||||
path: {
|
||||
requestID: NotebookRequestId
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/notebook/{requestID}/reply"
|
||||
}
|
||||
|
||||
export type KilocodeNotebookReplyErrors = {
|
||||
/**
|
||||
* BadRequest | InvalidRequestError
|
||||
*/
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type KilocodeNotebookReplyError = KilocodeNotebookReplyErrors[keyof KilocodeNotebookReplyErrors]
|
||||
|
||||
export type KilocodeNotebookReplyResponses = {
|
||||
/**
|
||||
* Notebook reply accepted
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type KilocodeNotebookReplyResponse = KilocodeNotebookReplyResponses[keyof KilocodeNotebookReplyResponses]
|
||||
|
||||
export type KilocodeNotebookRejectData = {
|
||||
body?: {
|
||||
error: NotebookFailure
|
||||
}
|
||||
path: {
|
||||
requestID: NotebookRequestId
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/notebook/{requestID}/reject"
|
||||
}
|
||||
|
||||
export type KilocodeNotebookRejectErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type KilocodeNotebookRejectError = KilocodeNotebookRejectErrors[keyof KilocodeNotebookRejectErrors]
|
||||
|
||||
export type KilocodeNotebookRejectResponses = {
|
||||
/**
|
||||
* Notebook rejection accepted
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type KilocodeNotebookRejectResponse = KilocodeNotebookRejectResponses[keyof KilocodeNotebookRejectResponses]
|
||||
|
||||
export type NetworkListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
+963
-22
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user