mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat(vscode): create empty notebooks via notebook_edit
Add a create action to the notebook_edit tool so an agent can make a new empty .ipynb on disk, then insert cells. The VS Code notebook API has no way to create a notebook file at a path (openNotebookDocument only makes an unsaved untitled notebook), so the bridge writes a minimal valid empty .ipynb and opens it. Reuses the existing notebook_edit permission and native_notebook_tools experiment; .ipynb only for now, parent directory must already exist.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Let the notebook tools create a new empty notebook.
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path"
|
||||
import * as vscode from "vscode"
|
||||
import { normalizeOutputs, normalizeSource } from "./output"
|
||||
import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path"
|
||||
import { NotebookError, resolveNotebookCreatePath, resolveNotebookPath, type NotebookPathDeps } from "./path"
|
||||
import { cellFingerprint, fingerprint, notebookState, sameCell, type NotebookState } from "./revision"
|
||||
import {
|
||||
NOTEBOOK_LIMITS,
|
||||
@@ -21,6 +21,18 @@ const RETAINED_REVISIONS = 1_000
|
||||
const revisions = new Map<string, NotebookState>()
|
||||
const locks = new Map<string, Promise<void>>()
|
||||
|
||||
// Minimal valid empty Jupyter notebook accepted by the built-in ipynb serializer.
|
||||
const EMPTY_IPYNB = JSON.stringify(
|
||||
{
|
||||
cells: [],
|
||||
metadata: {},
|
||||
nbformat: 4,
|
||||
nbformat_minor: 5,
|
||||
},
|
||||
null,
|
||||
1,
|
||||
)
|
||||
|
||||
function revisionKey(target: string, revision: string): string {
|
||||
return `${target}\0${revision}`
|
||||
}
|
||||
@@ -60,6 +72,7 @@ function defaults(): NotebookAdapterDeps {
|
||||
return {
|
||||
documents: () => vscode.workspace.notebookDocuments,
|
||||
open: (uri) => Promise.resolve(vscode.workspace.openNotebookDocument(uri)),
|
||||
write: (uri, content) => Promise.resolve(vscode.workspace.fs.writeFile(uri, content)),
|
||||
apply: (edit) => Promise.resolve(vscode.workspace.applyEdit(edit)),
|
||||
execute: (command, ...args) => Promise.resolve(vscode.commands.executeCommand(command, ...args)),
|
||||
change: (listener) => vscode.workspace.onDidChangeNotebookDocument(listener),
|
||||
@@ -167,12 +180,23 @@ export class NotebookAdapter {
|
||||
}
|
||||
|
||||
async edit(request: NotebookEditRequest): Promise<NotebookEditResult> {
|
||||
const edit = request.edit
|
||||
if (edit.action === "create") {
|
||||
return this.create(request)
|
||||
}
|
||||
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 expectedRevision = request.expectedRevision
|
||||
if (expectedRevision === undefined) {
|
||||
throw new NotebookError("invalid_cell", "An expected revision is required for this edit", {
|
||||
path: loaded.path,
|
||||
index: request.index,
|
||||
})
|
||||
}
|
||||
this.revision(before, expectedRevision, loaded.path, request.index)
|
||||
const count = loaded.document.cellCount
|
||||
const max = request.edit.action === "insert" ? count : count - 1
|
||||
const max = 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,
|
||||
@@ -182,25 +206,25 @@ export class NotebookAdapter {
|
||||
|
||||
const expected = [...before.cells]
|
||||
const edits = (() => {
|
||||
if (request.edit.action === "delete") {
|
||||
if (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 language = edit.language ?? (edit.kind === "code" ? "plaintext" : "markdown")
|
||||
const cell = this.deps.cell({
|
||||
kind: request.edit.kind,
|
||||
language: request.edit.language,
|
||||
source: request.edit.source,
|
||||
kind: edit.kind,
|
||||
language: edit.language,
|
||||
source: edit.source,
|
||||
})
|
||||
const value = fingerprint(request.edit.kind, language, request.edit.source)
|
||||
if (request.edit.action === "insert") {
|
||||
const value = fingerprint(edit.kind, language, edit.source)
|
||||
if (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)
|
||||
this.revision(this.remember(loaded.document, loaded.target), 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,
|
||||
@@ -217,15 +241,37 @@ export class NotebookAdapter {
|
||||
requestPath: request.path,
|
||||
revision: after.revision,
|
||||
index: request.index,
|
||||
action: request.edit.action,
|
||||
action: edit.action,
|
||||
}
|
||||
if (request.edit.action !== "delete" && request.index < loaded.document.cellCount) {
|
||||
if (edit.action !== "delete" && request.index < loaded.document.cellCount) {
|
||||
result.cell = this.cell(loaded.document.cellAt(request.index), request.index)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
private async create(request: NotebookEditRequest): Promise<NotebookEditResult> {
|
||||
const resolved = await resolveNotebookCreatePath(request.directory, request.path, this.access, this.options.paths)
|
||||
return this.lock(resolved.target, async () => {
|
||||
await this.deps.write(this.deps.uri(resolved.target), new TextEncoder().encode(EMPTY_IPYNB))
|
||||
const document = 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,
|
||||
})
|
||||
}
|
||||
const state = this.remember(document, resolved.target)
|
||||
return {
|
||||
operation: "edit",
|
||||
path: resolved.relative,
|
||||
requestPath: request.path,
|
||||
revision: state.revision,
|
||||
index: 0,
|
||||
action: "create",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async execute(request: NotebookExecuteRequest): Promise<NotebookExecuteResult> {
|
||||
const loaded = await this.document(request.directory, request.path)
|
||||
const state = this.remember(loaded.document, loaded.target)
|
||||
|
||||
@@ -264,7 +264,7 @@ export class NotebookBridge {
|
||||
return adapter.edit({
|
||||
path: request.path,
|
||||
directory,
|
||||
expectedRevision: request.expectedRevision,
|
||||
...(request.expectedRevision !== undefined ? { expectedRevision: request.expectedRevision } : {}),
|
||||
index: request.index,
|
||||
edit: request.edit,
|
||||
})
|
||||
|
||||
@@ -84,3 +84,53 @@ export async function resolveNotebookPath(
|
||||
}
|
||||
return { target, relative: path.relative(root, target).split(path.sep).join("/") }
|
||||
}
|
||||
|
||||
// Resolve a path for a notebook that does not exist yet. The file itself is not
|
||||
// realpathed (it must not exist); instead the parent directory is realpathed and
|
||||
// must be contained in the request root and pass access checks.
|
||||
export async function resolveNotebookCreatePath(
|
||||
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")
|
||||
}
|
||||
if (!input.toLowerCase().endsWith(".ipynb")) {
|
||||
throw invalid(input, "only .ipynb notebooks can be created")
|
||||
}
|
||||
|
||||
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 parent = await deps.realpath(path.dirname(candidate)).catch((error: unknown) => {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new NotebookError("not_found", `Cannot resolve the parent directory of ${JSON.stringify(input)}: ${detail}`, {
|
||||
path: input,
|
||||
})
|
||||
})
|
||||
if (!contained(root, parent)) {
|
||||
throw invalid(input, "its parent directory resolves outside the request directory")
|
||||
}
|
||||
|
||||
const target = path.join(parent, path.basename(candidate))
|
||||
const existing = await deps.realpath(target).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (existing) {
|
||||
throw new NotebookError("already_exists", `Notebook ${JSON.stringify(input)} already exists`, { path: input })
|
||||
}
|
||||
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("/") }
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface NotebookEditResult {
|
||||
requestPath: string
|
||||
revision: string
|
||||
index: number
|
||||
action: "insert" | "replace" | "delete"
|
||||
action: "insert" | "replace" | "delete" | "create"
|
||||
cell?: NotebookCell
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export type NotebookEdit =
|
||||
| ({ action: "insert" } & NotebookCellInput)
|
||||
| ({ action: "replace" } & NotebookCellInput)
|
||||
| { action: "delete" }
|
||||
| { action: "create" }
|
||||
|
||||
export interface NotebookReadRequest {
|
||||
path: string
|
||||
@@ -92,7 +93,7 @@ export interface NotebookReadRequest {
|
||||
export interface NotebookEditRequest {
|
||||
path: string
|
||||
directory: string
|
||||
expectedRevision: string
|
||||
expectedRevision?: string
|
||||
index: number
|
||||
edit: NotebookEdit
|
||||
}
|
||||
@@ -113,6 +114,7 @@ export interface NotebookAccess {
|
||||
export interface NotebookAdapterDeps {
|
||||
documents(): readonly vscode.NotebookDocument[]
|
||||
open(uri: vscode.Uri): Promise<vscode.NotebookDocument>
|
||||
write(uri: vscode.Uri, content: Uint8Array): Promise<void>
|
||||
apply(edit: vscode.WorkspaceEdit): Promise<boolean>
|
||||
execute(command: string, ...args: unknown[]): Promise<unknown>
|
||||
change(listener: (event: vscode.NotebookDocumentChangeEvent) => void): vscode.Disposable
|
||||
|
||||
@@ -65,13 +65,23 @@ function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vsco
|
||||
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 calls = {
|
||||
open: 0,
|
||||
apply: 0,
|
||||
command: 0,
|
||||
commandArgs: [] as unknown[],
|
||||
edit: undefined as unknown,
|
||||
write: [] as Array<{ uri: vscode.Uri; content: Uint8Array }>,
|
||||
}
|
||||
const deps: NotebookAdapterDeps = {
|
||||
documents: () => [document],
|
||||
open: async () => {
|
||||
calls.open++
|
||||
return document
|
||||
},
|
||||
write: async (uri, content) => {
|
||||
calls.write.push({ uri, content })
|
||||
},
|
||||
apply: async (edit) => {
|
||||
calls.apply++
|
||||
const item = (
|
||||
@@ -119,12 +129,12 @@ function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[]
|
||||
const paths = { realpath: async (value: string) => value }
|
||||
const access = { validateAccess: mock(() => true) }
|
||||
|
||||
function adapter(items: ReturnType<typeof cell>[], file = "/repo/book.ipynb") {
|
||||
function adapter(items: ReturnType<typeof cell>[], file = "/repo/book.ipynb", resolver = paths) {
|
||||
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 }),
|
||||
adapter: new NotebookAdapter(access, { deps: ctx.deps, paths: resolver, timeout: 50 }),
|
||||
document,
|
||||
cells,
|
||||
...ctx,
|
||||
@@ -467,3 +477,64 @@ describe("notebook adapter", () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("notebook create", () => {
|
||||
// The new file must not resolve (it does not exist), but its parent directory must.
|
||||
const creating = {
|
||||
realpath: async (value: string) => (value.endsWith("fresh.ipynb") ? Promise.reject(new Error("ENOENT")) : value),
|
||||
}
|
||||
|
||||
it("writes a minimal empty .ipynb, opens it, and returns the initial revision", async () => {
|
||||
const ctx = adapter([], "/repo/fresh.ipynb", creating)
|
||||
const result = await ctx.adapter.edit({
|
||||
directory: "/repo",
|
||||
path: "fresh.ipynb",
|
||||
index: 0,
|
||||
edit: { action: "create" },
|
||||
})
|
||||
expect(result).toMatchObject({ operation: "edit", action: "create", path: "fresh.ipynb", index: 0 })
|
||||
expect(result.revision).toContain("content:")
|
||||
expect(ctx.calls.write).toHaveLength(1)
|
||||
expect(ctx.calls.open).toBe(1)
|
||||
const written = JSON.parse(new TextDecoder().decode(ctx.calls.write[0]!.content))
|
||||
expect(written).toMatchObject({ cells: [], nbformat: 4 })
|
||||
})
|
||||
|
||||
it("rejects creating a notebook that already exists", async () => {
|
||||
const ctx = adapter([cell()], "/repo/book.ipynb")
|
||||
await expect(
|
||||
ctx.adapter.edit({ directory: "/repo", path: "book.ipynb", index: 0, edit: { action: "create" } }),
|
||||
).rejects.toMatchObject({ code: "already_exists", path: "book.ipynb" })
|
||||
expect(ctx.calls.write).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rejects a missing parent directory with not_found", async () => {
|
||||
const missing = {
|
||||
realpath: async (value: string) => (value === "/repo" ? value : Promise.reject(new Error("ENOENT"))),
|
||||
}
|
||||
const ctx = adapter([], "/repo/missing/fresh.ipynb", missing)
|
||||
await expect(
|
||||
ctx.adapter.edit({ directory: "/repo", path: "missing/fresh.ipynb", index: 0, edit: { action: "create" } }),
|
||||
).rejects.toMatchObject({ code: "not_found" })
|
||||
expect(ctx.calls.write).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rejects non-.ipynb create targets", async () => {
|
||||
const ctx = adapter([], "/repo/notes.txt", creating)
|
||||
await expect(
|
||||
ctx.adapter.edit({ directory: "/repo", path: "notes.txt", index: 0, edit: { action: "create" } }),
|
||||
).rejects.toMatchObject({ code: "invalid_path" })
|
||||
expect(ctx.calls.write).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rejects create targets excluded by access rules", async () => {
|
||||
const guard = { validateAccess: mock(() => false) }
|
||||
const document = notebook([], "/repo/fresh.ipynb")
|
||||
const ctx = harness(document, [])
|
||||
const core = new NotebookAdapter(guard, { deps: ctx.deps, paths: creating, timeout: 50 })
|
||||
await expect(
|
||||
core.edit({ directory: "/repo", path: "fresh.ipynb", index: 0, edit: { action: "create" } }),
|
||||
).rejects.toMatchObject({ code: "invalid_path" })
|
||||
expect(ctx.calls.write).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,12 +72,15 @@ const CellEdit = {
|
||||
export const EditRequest = Schema.Struct({
|
||||
...Base,
|
||||
operation: Schema.Literal("edit"),
|
||||
expectedRevision: Revision,
|
||||
expectedRevision: Schema.optional(Revision).annotate({
|
||||
description: "Required for insert, replace, and delete; omitted for create, which has no prior 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") }),
|
||||
Schema.Struct({ action: Schema.Literal("create") }),
|
||||
]),
|
||||
}).annotate({ identifier: "NotebookEditRequest" })
|
||||
|
||||
@@ -114,7 +117,7 @@ export const EditResult = Schema.Struct({
|
||||
requestPath: Path,
|
||||
revision: Revision,
|
||||
index: Index,
|
||||
action: Schema.Literals(["insert", "replace", "delete"]),
|
||||
action: Schema.Literals(["insert", "replace", "delete", "create"]),
|
||||
cell: Schema.optional(Cell),
|
||||
}).annotate({ identifier: "NotebookEditResult" })
|
||||
|
||||
@@ -141,6 +144,7 @@ export const Result = Schema.Union([ReadResult, EditResult, ExecuteResult]).anno
|
||||
export type Result = Schema.Schema.Type<typeof Result>
|
||||
|
||||
export const ErrorCode = Schema.Literals([
|
||||
"already_exists",
|
||||
"cancelled",
|
||||
"closed",
|
||||
"disconnected",
|
||||
|
||||
@@ -91,10 +91,15 @@ export const NotebookReadTool = Tool.define<
|
||||
|
||||
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",
|
||||
expected_revision: Schema.optional(Revision).annotate({
|
||||
description: "Required for insert, replace, and delete. Omit for create, which has no prior revision.",
|
||||
}),
|
||||
index: Schema.optional(Index).annotate({
|
||||
description: "Zero-based cell index. Required for insert, replace, and delete. Ignored for create.",
|
||||
}),
|
||||
action: Schema.Literals(["insert", "replace", "delete", "create"]).annotate({
|
||||
description:
|
||||
"insert and replace require kind and source; delete ignores cell fields; create makes a new empty .ipynb at path and ignores cell fields, index, and expected_revision",
|
||||
}),
|
||||
kind: Schema.optional(Schema.Literals(["code", "markdown"])).annotate({
|
||||
description: "Cell kind. Required for insert and replace.",
|
||||
@@ -105,12 +110,27 @@ const EditParams = Schema.Struct({
|
||||
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)
|
||||
if (params.action === "create") return Effect.succeed({ action: params.action } as const)
|
||||
if (params.action === "delete") {
|
||||
if (params.expected_revision === undefined || params.index === undefined)
|
||||
return Effect.die(
|
||||
new Tool.InvalidArgumentsError({
|
||||
tool: "notebook_edit",
|
||||
detail: `the "delete" action requires both "expected_revision" and "index"`,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed({ action: params.action } as const)
|
||||
}
|
||||
if (
|
||||
params.kind === undefined ||
|
||||
params.source === undefined ||
|
||||
params.expected_revision === undefined ||
|
||||
params.index === undefined
|
||||
)
|
||||
return Effect.die(
|
||||
new Tool.InvalidArgumentsError({
|
||||
tool: "notebook_edit",
|
||||
detail: `the "${params.action}" action requires both "kind" and "source"`,
|
||||
detail: `the "${params.action}" action requires "kind", "source", "expected_revision", and "index"`,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed({
|
||||
@@ -132,11 +152,12 @@ export const NotebookEditTool = Tool.define<
|
||||
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.",
|
||||
"Insert, replace, delete, or create. insert/replace/delete operate on one cell in a live VS Code notebook and require expected_revision and index. create makes a new empty .ipynb at path (the parent directory must exist) and returns its initial revision so you can then insert cells. 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)
|
||||
const index = params.action === "create" ? 0 : params.index!
|
||||
yield* ctx.ask({
|
||||
permission: "notebook_edit",
|
||||
patterns: [params.path],
|
||||
@@ -144,7 +165,7 @@ export const NotebookEditTool = Tool.define<
|
||||
metadata: {
|
||||
path: params.path,
|
||||
action: params.action,
|
||||
index: params.index,
|
||||
index,
|
||||
expectedRevision: params.expected_revision,
|
||||
},
|
||||
})
|
||||
@@ -153,8 +174,8 @@ export const NotebookEditTool = Tool.define<
|
||||
operation: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
path: params.path,
|
||||
expectedRevision: params.expected_revision,
|
||||
index: params.index,
|
||||
...(params.expected_revision !== undefined ? { expectedRevision: params.expected_revision } : {}),
|
||||
index,
|
||||
edit,
|
||||
}),
|
||||
ctx.abort,
|
||||
@@ -162,7 +183,10 @@ export const NotebookEditTool = Tool.define<
|
||||
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}`,
|
||||
title:
|
||||
result.action === "create"
|
||||
? `created notebook ${result.path}`
|
||||
: `${result.action} notebook cell ${result.index}`,
|
||||
output: render(result),
|
||||
metadata: { path: result.path, revision: result.revision, index: result.index },
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const notebook = Layer.mock(Notebook.Service, {
|
||||
operation: "edit" as const,
|
||||
path: input.path,
|
||||
requestPath: input.path,
|
||||
revision: "content:edit",
|
||||
revision: input.edit.action === "create" ? "content:create" : "content:edit",
|
||||
index: input.index,
|
||||
action: input.edit.action,
|
||||
})
|
||||
@@ -135,6 +135,26 @@ describe("native notebook tools", () => {
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"creates a new notebook through notebook_edit without a revision or cell fields",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
calls.length = 0
|
||||
const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init))
|
||||
const asks: Parameters<Tool.Context["ask"]>[0][] = []
|
||||
const ctx = context(asks)
|
||||
const result = yield* edit.execute({ path: "fresh.ipynb", action: "create" }, ctx)
|
||||
|
||||
expect(asks.map((item) => item.permission)).toEqual(["notebook_edit"])
|
||||
expect(calls).toEqual([
|
||||
{ operation: "edit", sessionID: ctx.sessionID, path: "fresh.ipynb", index: 0, edit: { action: "create" } },
|
||||
])
|
||||
expect(result.title).toContain("created notebook")
|
||||
expect(result.metadata).toMatchObject({ path: "fresh.ipynb", revision: "content:create", index: 0 })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
test("uses dedicated VS Code notebook permission defaults only when enabled", () => {
|
||||
|
||||
@@ -212,7 +212,7 @@ export type NotebookEditRequest = {
|
||||
/**
|
||||
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
|
||||
*/
|
||||
expectedRevision: string
|
||||
expectedRevision?: string
|
||||
/**
|
||||
* Zero-based cell index
|
||||
*/
|
||||
@@ -233,6 +233,9 @@ export type NotebookEditRequest = {
|
||||
| {
|
||||
action: "delete"
|
||||
}
|
||||
| {
|
||||
action: "create"
|
||||
}
|
||||
}
|
||||
|
||||
export type NotebookExecuteRequest = {
|
||||
@@ -2539,7 +2542,7 @@ export type NotebookEditResult = {
|
||||
* Zero-based cell index
|
||||
*/
|
||||
index: number
|
||||
action: "insert" | "replace" | "delete"
|
||||
action: "insert" | "replace" | "delete" | "create"
|
||||
cell?: NotebookCell
|
||||
}
|
||||
|
||||
@@ -2564,6 +2567,7 @@ export type NotebookResult = NotebookReadResult | NotebookEditResult | NotebookE
|
||||
|
||||
export type NotebookFailure = {
|
||||
code:
|
||||
| "already_exists"
|
||||
| "cancelled"
|
||||
| "closed"
|
||||
| "disconnected"
|
||||
|
||||
@@ -17647,11 +17647,22 @@
|
||||
},
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create"]
|
||||
}
|
||||
},
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "path", "operation", "expectedRevision", "index", "edit"],
|
||||
"required": ["id", "sessionID", "path", "operation", "index", "edit"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NotebookExecuteRequest": {
|
||||
@@ -24518,7 +24529,7 @@
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["insert", "replace", "delete"]
|
||||
"enum": ["insert", "replace", "delete", "create"]
|
||||
},
|
||||
"cell": {
|
||||
"$ref": "#/components/schemas/NotebookCell"
|
||||
@@ -24592,6 +24603,7 @@
|
||||
"code": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"already_exists",
|
||||
"cancelled",
|
||||
"closed",
|
||||
"disconnected",
|
||||
|
||||
Reference in New Issue
Block a user