feat(core): add NotebookEdit tool for Jupyter notebooks
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

Adds NotebookEdit as the structured write counterpart to existing notebook read support.

Summary:
- Add `notebook_edit` for safe cell-level `.ipynb` replace/insert/delete operations.
- Integrate notebook editing with tool registration, permissions, Claude conversion, prior-read enforcement, IDE/inline modify flow, commit attribution, docs, and SDK permission docs.
- Harden notebook read/edit behavior for truncated notebook renders, ambiguous fallback cell IDs, internal modify metadata, compact JSON, UTF-8 BOM notebooks, and cache behavior after structural edits.
- Add unit and integration coverage for notebook read/edit behavior.

Follow-up work remains for tab-indented notebook formatting preservation, a few low-risk unit-test additions, and non-blocking hardening suggestions from review.
This commit is contained in:
zhangxy-zju 2026-05-21 00:06:15 +08:00 committed by GitHub
parent a552df8998
commit ed14a33064
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2863 additions and 113 deletions

View file

@ -144,7 +144,7 @@ The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`auto-edit`**: Auto-approve edit tools (`edit`, `write_file`, `notebook_edit`) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Session Event Consumers and Assistant Content Consumers

View file

@ -158,7 +158,7 @@ The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`auto-edit`**: Auto-approve edit tools (`edit`, `write_file`, `notebook_edit`) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Permission Priority Chain

View file

@ -44,7 +44,72 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
- For other binary files: A message like `Cannot display content of binary file: /path/to/data.bin`.
- **Confirmation:** No.
## 3. `write_file` (WriteFile)
### Jupyter notebook reads
For Jupyter notebooks (`.ipynb`), `read_file` parses the notebook JSON and returns a structured, model-readable notebook view instead of raw JSON. The rendered output includes the notebook language, ordered cells, cell IDs, source, and summarized outputs.
Notebook cells can then be edited with `notebook_edit`. The model should use the cell IDs shown by `read_file` when targeting a cell.
`offset` and `limit` are not supported for `.ipynb` files. Notebook reads are treated as structured full-file reads; if the rendered notebook output is internally truncated because it is too large, `notebook_edit` will reject cell-level edits and ask you to reduce outputs or split the notebook before editing.
## 3. `notebook_edit` (NotebookEdit)
`notebook_edit` edits Jupyter notebook (`.ipynb`) files safely at the cell level. Use it instead of `edit` or `write_file` when changing notebook cells.
- **Tool name:** `notebook_edit`
- **Display name:** NotebookEdit
- **File:** `notebook-edit.ts`
- **Parameters:**
- `notebook_path` (string, required): The absolute path to the `.ipynb` file.
- `cell_id` (string, optional): The target cell ID shown by `read_file`. Required for `replace` and `delete`. For `insert`, the new cell is inserted after this cell; if omitted, the new cell is inserted at the beginning.
- `new_source` (string, optional): The new cell source for `replace` and `insert`. Not required for `delete`.
- `cell_type` (`code` or `markdown`, optional): The cell type for inserted cells, or the target type when replacing a cell.
- `edit_mode` (`replace`, `insert`, or `delete`, optional): The edit operation. Defaults to `replace`.
- **Behavior:**
- Requires the notebook to have been read first with `read_file` in the current session.
- Targets cells using the IDs rendered by `read_file`, including real notebook cell IDs and displayed `cell-N` fallback IDs.
- Rejects ambiguous rendered cell IDs instead of guessing.
- For code cells, clears stale outputs and resets `execution_count` when source changes.
- Preserves notebook JSON formatting, line endings, encoding, and BOM where possible.
- Invalidates the prior-read state after structural edits when displayed fallback IDs can shift, so the next notebook edit requires a fresh `read_file`.
- **Output (`llmContent`):** A success message describing the edited notebook cell and, for non-delete operations, the updated source.
- **Confirmation:** Yes. Shows a notebook JSON diff and asks for user approval before writing, unless the current permission mode or rules auto-approve edit tools.
### `notebook_edit` examples
Replace a code cell:
```
notebook_edit(
notebook_path="/path/to/analysis.ipynb",
cell_id="load-data",
new_source="result = 41 + 1\nprint(result)"
)
```
Insert a markdown cell after an existing cell:
```
notebook_edit(
notebook_path="/path/to/analysis.ipynb",
edit_mode="insert",
cell_id="summary",
cell_type="markdown",
new_source="## Findings\n\nThe cleaned data is ready for modeling."
)
```
Delete a cell:
```
notebook_edit(
notebook_path="/path/to/analysis.ipynb",
edit_mode="delete",
cell_id="old-experiment"
)
```
## 4. `write_file` (WriteFile)
`write_file` writes content to a specified file. If the file exists, it will be overwritten. If the file doesn't exist, it (and any necessary parent directories) will be created.
@ -56,11 +121,12 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
- `content` (string, required): The content to write into the file.
- **Behavior:**
- Writes the provided `content` to the `file_path`.
- Does not write raw Jupyter notebook JSON. Use `notebook_edit` for `.ipynb` cell edits.
- Creates parent directories if they don't exist.
- **Output (`llmContent`):** A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` or `Successfully created and wrote to new file: /path/to/new/file.txt`.
- **Confirmation:** Yes. Shows a diff of changes and asks for user approval before writing.
## 4. `glob` (Glob)
## 5. `glob` (Glob)
`glob` finds files matching specific glob patterns (e.g., `src/**/*.ts`, `*.md`), returning absolute paths sorted by modification time (newest first).
@ -78,7 +144,7 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...`
- **Confirmation:** No.
## 5. `grep_search` (Grep)
## 6. `grep_search` (Grep)
`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
@ -131,7 +197,7 @@ Search for a pattern with file filtering and custom result limiting:
grep_search(pattern="function", glob="*.js", limit=10)
```
## 6. `edit` (Edit)
## 7. `edit` (Edit)
`edit` replaces text within a file. By default it requires `old_string` to match a single unique location; set `replace_all` to `true` when you intentionally want to change every occurrence. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
@ -148,6 +214,7 @@ grep_search(pattern="function", glob="*.js", limit=10)
- `replace_all` (boolean, optional): Replace all occurrences of `old_string`. Defaults to `false`.
- **Behavior:**
- Does not edit raw Jupyter notebook JSON. Use `notebook_edit` for `.ipynb` cell edits.
- If `old_string` is empty and `file_path` does not exist, creates a new file with `new_string` as content.
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence unless `replace_all` is true.
- If the match is unique (or `replace_all` is true), it replaces the text with `new_string`.

View file

@ -298,6 +298,8 @@ The first matching rule wins. Rules use the format `"ToolName"` or `"ToolName(sp
| `Read`, `ReadFile` | `read_file` | Meta-category — see below |
| `Edit`, `EditFile` | `edit` | Meta-category — see below |
| `Write`, `WriteFile` | `write_file` | |
| `NotebookEdit` | `notebook_edit` | |
| `NotebookEditTool` | `notebook_edit` | |
| `Grep`, `SearchFiles` | `grep_search` | |
| `Glob`, `FindFiles` | `glob` | |
| `ListFiles` | `list_directory` | |
@ -312,7 +314,7 @@ Some rule names automatically cover multiple tools:
| Rule name | Tools covered |
| --------- | ---------------------------------------------------- |
| `Read` | `read_file`, `grep_search`, `glob`, `list_directory` |
| `Edit` | `edit`, `write_file` |
| `Edit` | `edit`, `write_file`, `notebook_edit` |
> [!important]
> `Read(/path/**)` matches **all four** read tools (file read, grep, glob, and directory listing).
@ -603,42 +605,42 @@ For sandbox image selection, precedence is:
### Command-Line Arguments Table
| Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| `--system-prompt` | | Overrides the built-in main session system prompt for this run. | Your prompt text | Loaded context files such as `QWEN.md` are still appended after this override. Can be combined with `--append-system-prompt`. |
| `--append-system-prompt` | | Appends extra instructions to the main session system prompt for this run. | Your prompt text | Applied after the built-in prompt and loaded context files. Can be combined with `--system-prompt`. See [Headless Mode](../features/headless) for examples. |
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
| `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--disabled-slash-commands` | | Slash command names to hide/disable (comma-separated or repeated). Unioned with the `slashCommands.disabled` setting and the `QWEN_DISABLED_SLASH_COMMANDS` environment variable. Matched case-insensitively against the final command name. | Command names | Example: `qwen --disabled-slash-commands "auth,mcp,extensions"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
| `--experimental-lsp` | | Enables experimental [LSP (Language Server Protocol)](../features/lsp) feature for code intelligence (go-to-definition, find references, diagnostics, etc.). | | Experimental. Requires language servers to be installed. |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
| `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
| Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| `--system-prompt` | | Overrides the built-in main session system prompt for this run. | Your prompt text | Loaded context files such as `QWEN.md` are still appended after this override. Can be combined with `--append-system-prompt`. |
| `--append-system-prompt` | | Appends extra instructions to the main session system prompt for this run. | Your prompt text | Applied after the built-in prompt and loaded context files. Can be combined with `--system-prompt`. See [Headless Mode](../features/headless) for examples. |
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
| `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (`edit`, `write_file`, `notebook_edit`) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--disabled-slash-commands` | | Slash command names to hide/disable (comma-separated or repeated). Unioned with the `slashCommands.disabled` setting and the `QWEN_DISABLED_SLASH_COMMANDS` environment variable. Matched case-insensitively against the final command name. | Command names | Example: `qwen --disabled-slash-commands "auth,mcp,extensions"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
| `--experimental-lsp` | | Enables experimental [LSP (Language Server Protocol)](../features/lsp) feature for code intelligence (go-to-definition, find references, diagnostics, etc.). | | Experimental. Requires language servers to be installed. |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
| `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
## Context Files (Hierarchical Instructional Context)

View file

@ -163,6 +163,8 @@ You can review each proposed change and approve or reject it individually.
Auto-Edit Mode instructs Qwen Code to automatically approve file edits while requiring manual approval for shell commands, ideal for accelerating development workflows while maintaining system safety.
Auto-approved edit tools include `edit`, `write_file`, and `notebook_edit`.
### When to use Auto-Accept Edits Mode
- **Daily development**: Ideal for most coding tasks

View file

@ -0,0 +1,239 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
TestRig,
createToolCallErrorMessage,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
type NotebookCell = {
id?: string;
cell_type: 'code' | 'markdown' | 'raw';
metadata: Record<string, unknown>;
source: string | string[];
execution_count?: number | null;
outputs?: unknown[];
};
type NotebookContent = {
cells: NotebookCell[];
metadata: Record<string, unknown>;
nbformat: number;
nbformat_minor: number;
};
const sourceText = (source: string | string[]) =>
Array.isArray(source) ? source.join('') : source;
const promptPath = (filePath: string) => filePath.split(path.sep).join('/');
const readNotebook = (rig: TestRig, fileName: string): NotebookContent =>
JSON.parse(rig.readFile(fileName)) as NotebookContent;
const baseNotebook = (cells: NotebookCell[]): NotebookContent => ({
cells,
metadata: {
kernelspec: {
display_name: 'Python 3',
language: 'python',
name: 'python3',
},
language_info: {
name: 'python',
},
},
nbformat: 4,
nbformat_minor: 5,
});
const expectReadThenNotebookEdit = (rig: TestRig, result: string) => {
const logs = rig.readToolLogs();
const foundTools = logs.map((t) => t.toolRequest.name);
const readIndex = foundTools.findIndex((name) => name === 'read_file');
const notebookEditIndex = foundTools.findIndex(
(name) => name === 'notebook_edit',
);
if (readIndex === -1 || notebookEditIndex === -1) {
printDebugInfo(rig, result, { foundTools });
}
expect(
readIndex,
createToolCallErrorMessage('read_file', foundTools, result),
).toBeGreaterThanOrEqual(0);
expect(
notebookEditIndex,
createToolCallErrorMessage('notebook_edit', foundTools, result),
).toBeGreaterThan(readIndex);
};
const expectNoSuccessfulRawNotebookWrites = (
rig: TestRig,
notebookFileName: string,
) => {
const rawNotebookWrites = rig
.readToolLogs()
.filter(
(log) =>
['edit', 'write_file'].includes(log.toolRequest.name) &&
log.toolRequest.success &&
log.toolRequest.args.includes(notebookFileName),
);
expect(rawNotebookWrites).toEqual([]);
};
describe('notebook_edit integration', () => {
let rig: TestRig;
afterEach(async () => {
await rig?.cleanup();
});
it('replaces a code cell after reading the notebook and clears stale outputs', async () => {
rig = new TestRig();
await rig.setup('notebook edit replace code cell clears outputs');
const fileName = 'analysis.ipynb';
const notebookPath = rig.createFile(
fileName,
JSON.stringify(
baseNotebook([
{
id: 'intro',
cell_type: 'markdown',
metadata: {},
source: ['# Analysis\n'],
},
{
id: 'load-data',
cell_type: 'code',
metadata: {},
source: ['old_value = 1\n', 'print(old_value)\n'],
execution_count: 7,
outputs: [
{
output_type: 'stream',
name: 'stdout',
text: ['1\n'],
},
],
},
]),
null,
1,
),
);
const prompt = `Read the notebook at ${promptPath(notebookPath)} with read_file first.
Then use notebook_edit, not edit or write_file, to replace the code cell whose id is load-data.
Set the new source exactly to:
result = 41 + 1
print(result)
Do not change any other cell.`;
const result = await rig.run(prompt);
expectReadThenNotebookEdit(rig, result);
expectNoSuccessfulRawNotebookWrites(rig, fileName);
validateModelOutput(result, null, 'Notebook replace');
const notebook = readNotebook(rig, fileName);
const target = notebook.cells.find((cell) => cell.id === 'load-data');
expect(target).toBeDefined();
expect(target?.cell_type).toBe('code');
expect(sourceText(target!.source).trimEnd()).toBe(
'result = 41 + 1\nprint(result)',
);
expect(target?.execution_count).toBeNull();
expect(target?.outputs).toEqual([]);
expect(sourceText(notebook.cells[0]!.source)).toBe('# Analysis\n');
});
it('inserts a markdown cell and deletes a target cell using notebook_edit', async () => {
rig = new TestRig();
await rig.setup('notebook edit insert and delete cells');
const fileName = 'workflow.ipynb';
const notebookPath = rig.createFile(
fileName,
JSON.stringify(
baseNotebook([
{
id: 'intro',
cell_type: 'markdown',
metadata: {},
source: ['# Workflow\n'],
},
{
id: 'remove-me',
cell_type: 'markdown',
metadata: {},
source: ['This temporary cell should be deleted.\n'],
},
{
id: 'calculate',
cell_type: 'code',
metadata: {},
source: ['value = 10\n'],
execution_count: null,
outputs: [],
},
]),
null,
1,
),
);
const insertedMarkdown =
'## Inserted Note\nThis cell was inserted by NotebookEdit.';
const prompt = `Read the notebook at ${promptPath(notebookPath)} with read_file first.
Then use notebook_edit, not edit or write_file, for both changes:
1. Insert a markdown cell after the cell whose id is intro. Its source must be exactly:
${insertedMarkdown}
2. Delete the cell whose id is remove-me.
Do not change the calculate code cell.`;
const result = await rig.run(prompt);
expectReadThenNotebookEdit(rig, result);
expectNoSuccessfulRawNotebookWrites(rig, fileName);
validateModelOutput(result, null, 'Notebook insert/delete');
const successfulNotebookEdits = rig
.readToolLogs()
.filter(
(log) =>
log.toolRequest.name === 'notebook_edit' && log.toolRequest.success,
);
expect(successfulNotebookEdits.length).toBeGreaterThanOrEqual(2);
const notebook = readNotebook(rig, fileName);
const cellIds = notebook.cells.map((cell) => cell.id);
const insertedCell = notebook.cells.find((cell) =>
sourceText(cell.source).includes('Inserted Note'),
);
expect(cellIds).toHaveLength(3);
expect(cellIds).not.toContain('remove-me');
expect(notebook.cells[0]?.id).toBe('intro');
expect(insertedCell).toBeDefined();
expect(insertedCell?.cell_type).toBe('markdown');
expect(sourceText(insertedCell!.source).trimEnd()).toBe(insertedMarkdown);
expect(notebook.cells.at(-1)?.id).toBe('calculate');
expect(sourceText(notebook.cells.at(-1)!.source)).toBe('value = 10\n');
});
});

View file

@ -2388,6 +2388,7 @@ describe('loadCliConfig with includeDirectories', () => {
expect(config.getCoreTools()).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
]);
expect(config.getDisableAllHooks()).toBe(true);
@ -2406,6 +2407,7 @@ describe('loadCliConfig with includeDirectories', () => {
expect(config.getCoreTools()).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
]);
});

View file

@ -624,7 +624,12 @@ describe('Server Config (config.ts)', () => {
(ToolRegistry.prototype.registerFactory as Mock).mock.calls.map(
(call) => call[0],
),
).toEqual([ToolNames.READ_FILE, ToolNames.EDIT, ToolNames.SHELL]);
).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
]);
});
it('skips inline MCP discovery by default (progressive availability)', async () => {
@ -1736,20 +1741,26 @@ describe('Server Config (config.ts)', () => {
expect(config.getCoreTools()).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
]);
expect(
(registerToolMock as Mock).mock.calls.map((call) => call[0]),
).toEqual([ToolNames.READ_FILE, ToolNames.EDIT, ToolNames.SHELL]);
).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
]);
});
it('registers structured_output in bare mode when jsonSchema is set', async () => {
// Bare mode strips the toolset to READ_FILE/EDIT/SHELL, but the
// Bare mode strips the toolset to READ_FILE/EDIT/NOTEBOOK_EDIT/SHELL, but the
// synthetic structured_output tool is the terminal contract for
// --json-schema runs. Without it the model loops until
// maxSessionTurns and exits via the "plain text" failure path —
// expensive in tokens for what's almost always a CI use case. The
// synthetic tool must be registered alongside the bare three.
// synthetic tool must be registered alongside the bare toolset.
const config = new Config({
...baseParams,
bareMode: true,
@ -1768,6 +1779,7 @@ describe('Server Config (config.ts)', () => {
).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
ToolNames.STRUCTURED_OUTPUT,
]);
@ -1796,8 +1808,8 @@ describe('Server Config (config.ts)', () => {
ToolRegistry: { prototype: { registerFactory: Mock } };
}
).ToolRegistry.prototype.registerFactory;
// Initial bare init registers READ_FILE / EDIT / SHELL /
// STRUCTURED_OUTPUT (asserted by the test above). Reset so we can
// Initial bare init registers READ_FILE / EDIT / NOTEBOOK_EDIT /
// SHELL / STRUCTURED_OUTPUT (asserted by the test above). Reset so we can
// observe ONLY the forSubAgent rebuild's calls.
(registerToolMock as Mock).mockClear();
@ -1811,10 +1823,11 @@ describe('Server Config (config.ts)', () => {
(call) => call[0],
);
expect(registeredNames).not.toContain(ToolNames.STRUCTURED_OUTPUT);
// The bare three still register so the subagent has its toolset.
// The bare tools still register so the subagent has its toolset.
expect(registeredNames).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
]);
});

View file

@ -736,6 +736,7 @@ export interface ConfigInitializeOptions {
const DEFAULT_BARE_CORE_TOOLS = [
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.NOTEBOOK_EDIT,
ToolNames.SHELL,
];
@ -3593,6 +3594,10 @@ export class Config {
const { EditTool } = await import('../tools/edit.js');
return new EditTool(this);
});
await registerLazy(ToolNames.NOTEBOOK_EDIT, async () => {
const { NotebookEditTool } = await import('../tools/notebook-edit.js');
return new NotebookEditTool(this);
});
await registerLazy(ToolNames.SHELL, async () => {
const { ShellTool } = await import('../tools/shell.js');
return new ShellTool(this);
@ -3677,6 +3682,10 @@ export class Config {
const { EditTool } = await import('../tools/edit.js');
return new EditTool(this);
});
await registerLazy(ToolNames.NOTEBOOK_EDIT, async () => {
const { NotebookEditTool } = await import('../tools/notebook-edit.js');
return new NotebookEditTool(this);
});
await registerLazy(ToolNames.WRITE_FILE, async () => {
const { WriteFileTool } = await import('../tools/write-file.js');
return new WriteFileTool(this);

View file

@ -5552,6 +5552,14 @@ describe('extractToolFilePaths', () => {
]);
});
it('extracts notebook_path for notebook_edit', () => {
expect(
extractToolFilePaths('notebook_edit', {
notebook_path: '/proj/analysis.ipynb',
}),
).toEqual(['/proj/analysis.ipynb']);
});
it('extracts filePath for lsp (camelCase convention)', () => {
expect(extractToolFilePaths('lsp', { filePath: '/proj/b.ts' })).toEqual([
'/proj/b.ts',

View file

@ -314,6 +314,7 @@ const FS_PATH_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
ToolNames.GLOB,
ToolNames.LS,
ToolNames.LSP,
ToolNames.NOTEBOOK_EDIT,
]);
function canonicalToolName(toolName: string): string {
@ -383,6 +384,7 @@ function pushLspPathCandidate(out: string[], v: unknown): void {
* Per-tool dispatcher because the field name and shape differ:
*
* - read_file / edit / write_file `file_path`
* - notebook_edit `notebook_path`
* - list_directory `path` (search root)
* - glob `path` (search root, optional) + `pattern` (path-shaped
* selector); `<path>/<pattern>` is the effective glob walked
@ -502,6 +504,13 @@ export function extractToolFilePaths(
case ToolNames.READ_FILE:
case ToolNames.EDIT:
case ToolNames.WRITE_FILE:
push(obj['file_path']);
return out;
case ToolNames.NOTEBOOK_EDIT:
push(obj['notebook_path']);
return out;
default:
push(obj['file_path']);
return out;

View file

@ -74,4 +74,19 @@ describe('buildPermissionCheckContext', () => {
command: `/bin/bash -c 'tail -f ./app.log' && rm -rf /tmp/owned`,
});
});
it('uses notebook_path as the file path for notebook_edit', () => {
expect(
buildPermissionCheckContext(
'notebook_edit',
{
notebook_path: '/project/analysis.ipynb',
},
'/project',
),
).toMatchObject({
toolName: 'notebook_edit',
filePath: '/project/analysis.ipynb',
});
});
});

View file

@ -49,11 +49,18 @@ export function buildPermissionCheckContext(
: path.resolve(targetDir, toolParams['directory'])
: undefined;
// Extract file path — tools use 'file_path' or 'path' (LS / grep / glob).
// Extract file path — tools use 'file_path', 'notebook_path', or
// 'path' (LS / grep / glob).
let filePath =
typeof toolParams['file_path'] === 'string'
? toolParams['file_path']
: undefined;
if (
filePath === undefined &&
typeof toolParams['notebook_path'] === 'string'
) {
filePath = toolParams['notebook_path'];
}
if (filePath === undefined && typeof toolParams['path'] === 'string') {
// LS uses absolute paths; grep/glob may be relative to targetDir.
filePath = path.isAbsolute(toolParams['path'])

View file

@ -10,6 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import {
convertClaudeToQwenConfig,
convertClaudeAgentConfig,
mergeClaudeConfigs,
isClaudePluginConfig,
convertClaudePluginPackage,
@ -79,6 +80,18 @@ describe('convertClaudeToQwenConfig', () => {
});
});
describe('convertClaudeAgentConfig', () => {
it('should map Claude NotebookEdit to Qwen NotebookEdit', () => {
const result = convertClaudeAgentConfig({
name: 'notebook-agent',
description: 'Works on notebooks',
tools: ['Read', 'NotebookEdit', 'Edit'],
});
expect(result['tools']).toEqual(['ReadFile', 'NotebookEdit', 'Edit']);
});
});
describe('mergeClaudeConfigs', () => {
it('should merge marketplace and plugin configs', () => {
const marketplacePlugin: ClaudeMarketplacePluginConfig = {

View file

@ -102,7 +102,7 @@ const CLAUDE_TOOLS_MAPPING: Record<string, string | string[]> = {
Glob: 'Glob',
Grep: 'Grep',
KillShell: 'None',
NotebookEdit: 'None',
NotebookEdit: 'NotebookEdit',
Read: 'ReadFile',
Skill: 'Skill',
Task: 'Task',

View file

@ -40,6 +40,8 @@ describe('resolveToolName', () => {
expect(resolveToolName('ReadFile')).toBe('read_file');
expect(resolveToolName('ReadFileTool')).toBe('read_file');
expect(resolveToolName('EditTool')).toBe('edit');
expect(resolveToolName('NotebookEdit')).toBe('notebook_edit');
expect(resolveToolName('NotebookEditTool')).toBe('notebook_edit');
expect(resolveToolName('WriteFileTool')).toBe('write_file');
});
@ -77,6 +79,7 @@ describe('getSpecifierKind', () => {
it('returns "path" for file read/edit tools', async () => {
expect(getSpecifierKind('read_file')).toBe('path');
expect(getSpecifierKind('edit')).toBe('path');
expect(getSpecifierKind('notebook_edit')).toBe('path');
expect(getSpecifierKind('write_file')).toBe('path');
expect(getSpecifierKind('grep_search')).toBe('path');
expect(getSpecifierKind('glob')).toBe('path');
@ -108,8 +111,9 @@ describe('toolMatchesRuleToolName', () => {
expect(toolMatchesRuleToolName('read_file', 'list_directory')).toBe(true);
});
it('"Edit" (edit) covers write_file', async () => {
it('"Edit" (edit) covers write_file and notebook_edit', async () => {
expect(toolMatchesRuleToolName('edit', 'write_file')).toBe(true);
expect(toolMatchesRuleToolName('edit', 'notebook_edit')).toBe(true);
});
it('"Bash" (run_shell_command) covers monitor', async () => {
@ -704,10 +708,11 @@ describe('matchesRule', () => {
});
// Meta-category matching: Edit
it('Edit rule matches edit and write_file', async () => {
it('Edit rule matches edit, write_file, and notebook_edit', async () => {
const rule = parseRule('Edit');
expect(matchesRule(rule, 'edit')).toBe(true);
expect(matchesRule(rule, 'write_file')).toBe(true);
expect(matchesRule(rule, 'notebook_edit')).toBe(true);
expect(matchesRule(rule, 'read_file')).toBe(false); // not an edit tool
});
@ -765,6 +770,31 @@ describe('matchesRule', () => {
).toBe(false);
});
it('Edit path specifier matches notebook_edit too', async () => {
const rule = parseRule('Edit(/src/**/*.ipynb)');
const pathCtx = { projectRoot: '/project', cwd: '/project' };
expect(
matchesRule(
rule,
'notebook_edit',
undefined,
'/project/src/analysis.ipynb',
undefined,
pathCtx,
),
).toBe(true);
expect(
matchesRule(
rule,
'notebook_edit',
undefined,
'/project/docs/analysis.ipynb',
undefined,
pathCtx,
),
).toBe(false);
});
// WebFetch domain matching
it('WebFetch domain specifier', async () => {
const rule = parseRule('WebFetch(domain:example.com)');
@ -1504,6 +1534,12 @@ describe('PermissionManager', () => {
expect(await pm.isToolEnabled('run_shell_command')).toBe(false);
});
it('Edit deny rule disables notebook_edit', async () => {
pm = new PermissionManager(makeConfig({ permissionsDeny: ['Edit'] }));
pm.initialize();
expect(await pm.isToolEnabled('notebook_edit')).toBe(false);
});
it('coreTools allowlist: listed tool is enabled', async () => {
pm = new PermissionManager(
makeConfig({ coreTools: ['read_file', 'Bash'] }),
@ -1519,6 +1555,14 @@ describe('PermissionManager', () => {
expect(await pm.isToolEnabled('read_file')).toBe(true);
expect(await pm.isToolEnabled('run_shell_command')).toBe(false);
expect(await pm.isToolEnabled('edit')).toBe(false);
expect(await pm.isToolEnabled('notebook_edit')).toBe(false);
});
it('coreTools allowlist: NotebookEdit alias enables notebook_edit', async () => {
pm = new PermissionManager(makeConfig({ coreTools: ['NotebookEdit'] }));
pm.initialize();
expect(await pm.isToolEnabled('notebook_edit')).toBe(true);
expect(await pm.isToolEnabled('edit')).toBe(false);
});
it('coreTools with specifier: tool-level check strips specifier', async () => {
@ -1773,6 +1817,7 @@ describe('getRuleDisplayName', () => {
it('maps edit tools to "Edit" meta-category', async () => {
expect(getRuleDisplayName('edit')).toBe('Edit');
expect(getRuleDisplayName('write_file')).toBe('Edit');
expect(getRuleDisplayName('notebook_edit')).toBe('Edit');
});
it('maps shell to "Bash"', async () => {
@ -1848,6 +1893,14 @@ describe('buildPermissionRules', () => {
expect(rules).toEqual(['Edit(//tmp/**)']);
});
it('generates Edit rule scoped to parent directory for notebook_edit', async () => {
const rules = buildPermissionRules({
toolName: 'notebook_edit',
filePath: '/tmp/analysis.ipynb',
});
expect(rules).toEqual(['Edit(//tmp/**)']);
});
it('falls back to bare display name when no filePath', async () => {
const rules = buildPermissionRules({ toolName: 'read_file' });
expect(rules).toEqual(['Read']);

View file

@ -468,6 +468,7 @@ export class PermissionManager {
'read_file',
'write_file',
'edit',
'notebook_edit',
'glob',
'grep_search',
'run_shell_command',

View file

@ -52,6 +52,11 @@ export const TOOL_NAME_ALIASES: Readonly<Record<string, string>> = {
Edit: 'edit',
EditTool: 'edit',
// Notebook Edit tool — also matched by "Edit" meta-category rules
notebook_edit: 'notebook_edit',
NotebookEdit: 'notebook_edit',
NotebookEditTool: 'notebook_edit',
// Write File tool — also matched by "Edit" meta-category rules
write_file: 'write_file',
WriteFile: 'write_file',
@ -153,7 +158,7 @@ const READ_TOOLS = new Set([
*
* Per Claude Code docs: "Edit rules apply to all built-in tools that edit files."
*/
const EDIT_TOOLS = new Set(['edit', 'write_file']);
const EDIT_TOOLS = new Set(['edit', 'write_file', 'notebook_edit']);
/**
* WebFetch tools.
@ -319,6 +324,7 @@ const CANONICAL_TO_RULE_DISPLAY: Readonly<Record<string, string>> = {
// Edit meta-category
edit: 'Edit',
write_file: 'Edit',
notebook_edit: 'Edit',
// Shell
run_shell_command: 'Bash',
// Monitor
@ -355,7 +361,12 @@ export function getRuleDisplayName(canonicalToolName: string): string {
* Directory-targeted tools (list_directory, grep_search, glob) already receive
* a directory path, so they use it as-is.
*/
const FILE_TARGETED_TOOLS = new Set(['read_file', 'edit', 'write_file']);
const FILE_TARGETED_TOOLS = new Set([
'read_file',
'edit',
'write_file',
'notebook_edit',
]);
/**
* Build minimum-scope permission rule strings from a permission check context.

View file

@ -332,6 +332,17 @@ describe('FileReadCache', () => {
expect(afterWrite.entry.lastReadCacheable).toBe(true);
}
});
it('can record structured writes as non-cacheable', () => {
const cache = new FileReadCache();
const entry = cache.recordWrite('/x/notebook.ipynb', makeStats(), {
cacheable: false,
});
expect(entry.lastReadWasFull).toBe(true);
expect(entry.lastReadCacheable).toBe(false);
expect(entry.lastReadAt).toBe(entry.lastWriteAt);
});
});
describe('read-then-write-then-read ordering', () => {

View file

@ -149,7 +149,8 @@ export class FileReadCache {
* output was not truncated. Pass `false` for ranged reads OR
* for full-request reads whose content was truncated by the
* truncate-tool-output limit; both leave the model without
* sight of every current byte.
* sight of every current byte. This gates the `file_unchanged`
* fast-path and notebook-specific prior-read checks.
* - `cacheable` the produced content is plain text (vs. binary /
* image / audio / video / PDF / notebook). This flag is purely
* about content type, not about whether the read was complete:
@ -230,22 +231,24 @@ export class FileReadCache {
* see its own write as a "stale" external change.
*
* Read metadata is **always** refreshed alongside the write, not
* just for brand-new entries: the model authored the entire current
* content, so for prior-read enforcement purposes it has now "seen"
* all bytes regardless of whether the prior recordRead happened
* to be partial (`lastReadWasFull=false`) or non-cacheable
* (`lastReadCacheable=false`). Without this, a sequence such as
* `ReadFile(limit=10)` `WriteFile` (full content) `Edit` would
* be rejected on the Edit because `lastReadWasFull=false` from the
* earlier partial read would persist through the write.
* just for brand-new entries: the model authored the current content
* produced by the mutating tool, so for prior-read enforcement purposes
* it has now "seen" the bytes that tool wrote. Plain text writers use
* the default `cacheable: true`; structured writers such as notebook cell
* editors can set `cacheable: false` so regular Edit / WriteFile still
* reject the file as a non-text payload.
*/
recordWrite(absPath: string, stats: Stats): FileReadEntry {
recordWrite(
absPath: string,
stats: Stats,
opts: { cacheable?: boolean } = {},
): FileReadEntry {
const entry = this.upsert(absPath, stats);
const now = Date.now();
entry.lastWriteAt = now;
entry.lastReadAt = now;
entry.lastReadWasFull = true;
entry.lastReadCacheable = true;
entry.lastReadCacheable = opts.cacheable ?? true;
// The model authored the current bytes and that result is in
// history, so the fast-path may serve a placeholder again.
entry.readResidentInHistory = true;

View file

@ -1216,6 +1216,7 @@ describe('EditTool', () => {
expect(result.error?.message).toMatch(
/binary \/ image \/ audio \/ video \/ PDF \/ notebook payload/,
);
expect(result.error?.message).toContain('notebook_edit');
expect(result.error?.message).not.toMatch(/Use the read_file tool first/);
// EditTool's verb is "edit", not "overwrite" — using the
// wrong one here would be confusing for in-place edits.

View file

@ -0,0 +1,800 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { FileReadCache } from '../services/fileReadCache.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { CommitAttributionService } from '../services/commitAttribution.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolErrorType } from './tool-error.js';
import type { ToolInvocation, ToolResult } from './tools.js';
import { applyNotebookEdit, NotebookEditTool } from './notebook-edit.js';
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
describe('NotebookEditTool', () => {
let tempDir: string;
let fileReadCache: FileReadCache;
let config: Config;
let tool: NotebookEditTool;
let mockFileHistoryService: { trackEdit: ReturnType<typeof vi.fn> };
const abortSignal = new AbortController().signal;
beforeEach(() => {
CommitAttributionService.resetInstance();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notebook-edit-test-'));
fileReadCache = new FileReadCache();
mockFileHistoryService = { trackEdit: vi.fn() };
config = {
getTargetDir: () => tempDir,
getProjectRoot: () => tempDir,
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
getWorkspaceContext: () => createMockWorkspaceContext(tempDir),
getFileService: () => new FileDiscoveryService(tempDir),
getFileSystemService: () => new StandardFileSystemService(),
getDefaultFileEncoding: () => 'utf-8',
getFileReadCache: () => fileReadCache,
getFileHistoryService: () => mockFileHistoryService,
getFileReadCacheDisabled: () => false,
getGeminiClient: vi.fn(),
getBaseLlmClient: vi.fn(),
getIdeMode: () => false,
getApiKey: () => 'test-api-key',
getModel: () => 'test-model',
getSandbox: () => false,
getDebugMode: () => false,
getQuestion: () => undefined,
getFullContext: () => false,
getToolDiscoveryCommand: () => undefined,
getToolCallCommand: () => undefined,
getMcpServerCommand: () => undefined,
getMcpServers: () => undefined,
getUserAgent: () => 'test-agent',
getUserMemory: () => '',
setUserMemory: vi.fn(),
getGeminiMdFileCount: () => 0,
setGeminiMdFileCount: vi.fn(),
getToolRegistry: () => ({}) as never,
} as unknown as Config;
tool = new NotebookEditTool(config);
});
afterEach(() => {
CommitAttributionService.resetInstance();
fs.rmSync(tempDir, { recursive: true, force: true });
});
function writeNotebook(name: string, notebook: Record<string, unknown>) {
const filePath = path.join(tempDir, name);
fs.writeFileSync(filePath, JSON.stringify(notebook, null, 1), 'utf-8');
return filePath;
}
function seedNotebookRead(filePath: string) {
fileReadCache.recordRead(filePath, fs.statSync(filePath), {
full: true,
cacheable: false,
});
}
function buildInvocation(params: Parameters<NotebookEditTool['build']>[0]) {
return tool.build(params) as ToolInvocation<
Parameters<NotebookEditTool['build']>[0],
ToolResult
>;
}
it('replaces a code cell by real ID and clears stale outputs', async () => {
const filePath = writeNotebook('analysis.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [
{
cell_type: 'code',
id: 'load-data',
source: ['x = 1\n'],
execution_count: 7,
outputs: [{ output_type: 'stream', text: ['old\n'] }],
metadata: {},
},
],
metadata: { language_info: { name: 'python' } },
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'load-data',
new_source: 'x = 2\nprint(x)',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const updated = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(updated.cells[0].source).toEqual(['x = 2\n', 'print(x)']);
expect(updated.cells[0].execution_count).toBeNull();
expect(updated.cells[0].outputs).toEqual([]);
expect(result.llmContent).toContain('replace cell load-data');
const cacheState = fileReadCache.check(fs.statSync(filePath));
expect(cacheState.state).toBe('fresh');
if (cacheState.state === 'fresh') {
expect(cacheState.entry.lastReadWasFull).toBe(true);
expect(cacheState.entry.lastReadCacheable).toBe(false);
}
});
it('replaces a code cell in a UTF-8 BOM notebook and preserves the BOM', async () => {
const filePath = path.join(tempDir, 'bom-replace.ipynb');
fs.writeFileSync(
filePath,
`\ufeff${JSON.stringify(
{
nbformat: 4,
nbformat_minor: 5,
cells: [
{
cell_type: 'code',
id: 'load-data',
source: ['x = 1\n'],
execution_count: 7,
outputs: [{ output_type: 'stream', text: ['old\n'] }],
metadata: {},
},
],
metadata: { language_info: { name: 'python' } },
},
null,
1,
)}`,
'utf-8',
);
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'load-data',
new_source: 'x = 2\nprint(x)',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const updatedBuffer = fs.readFileSync(filePath);
expect([...updatedBuffer.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf]);
const updated = JSON.parse(updatedBuffer.toString('utf-8').slice(1));
expect(updated.cells[0].source).toEqual(['x = 2\n', 'print(x)']);
expect(updated.cells[0].execution_count).toBeNull();
expect(updated.cells[0].outputs).toEqual([]);
});
it('replaces by cell-N fallback and converts code to markdown cleanly', async () => {
const filePath = writeNotebook('convert.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [
{
cell_type: 'code',
source: 'print("old")',
execution_count: 1,
outputs: [{ output_type: 'stream', text: 'old\n' }],
metadata: {},
},
],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'cell-0',
cell_type: 'markdown',
new_source: '# Notes',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const updated = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(updated.cells[0].cell_type).toBe('markdown');
expect(updated.cells[0].source).toBe('# Notes');
expect(updated.cells[0]).not.toHaveProperty('outputs');
expect(updated.cells[0]).not.toHaveProperty('execution_count');
});
it('converts markdown to code with code-only fields', async () => {
const filePath = writeNotebook('convert-to-code.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [
{
cell_type: 'markdown',
id: 'intro',
source: ['# Intro'],
metadata: {},
},
],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'intro',
cell_type: 'code',
new_source: 'print("hi")',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const updated = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(updated.cells[0].cell_type).toBe('code');
expect(updated.cells[0].source).toEqual(['print("hi")']);
expect(updated.cells[0].execution_count).toBeNull();
expect(updated.cells[0].outputs).toEqual([]);
});
it('inserts after a target cell and generates an nbformat 4.5 cell ID', async () => {
const raw = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'markdown', id: 'cell-1', source: ['# A'], metadata: {} },
{ cell_type: 'code', id: 'cell-2', source: ['a = 1'], metadata: {} },
],
metadata: {},
});
const result = applyNotebookEdit(raw, {
notebook_path: '/tmp/insert.ipynb',
edit_mode: 'insert',
cell_id: 'cell-1',
cell_type: 'markdown',
new_source: '## Inserted',
});
const updated = JSON.parse(result.updatedContent);
expect(updated.cells).toHaveLength(3);
expect(updated.cells[1].cell_type).toBe('markdown');
expect(updated.cells[1].source).toEqual(['## Inserted']);
expect(updated.cells[1].id).toBe('qwen-cell-1');
expect(result.editedCellId).toBe('qwen-cell-1');
});
it('preserves adjacent source style for inserted cells in mixed-format notebooks', async () => {
const raw = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'markdown', id: 'intro', source: '# Intro', metadata: {} },
{
cell_type: 'code',
id: 'code',
source: ['value = 1\n'],
metadata: {},
},
],
metadata: {},
});
const result = applyNotebookEdit(raw, {
notebook_path: '/tmp/insert.ipynb',
edit_mode: 'insert',
cell_id: 'intro',
cell_type: 'markdown',
new_source: '## Inserted',
});
const updated = JSON.parse(result.updatedContent);
expect(updated.cells[1].source).toBe('## Inserted');
});
it('preserves notebook JSON indentation and trailing newline style on edit', () => {
const raw = JSON.stringify(
{
nbformat: 4,
nbformat_minor: 5,
cells: [
{
cell_type: 'markdown',
id: 'intro',
source: '# Intro',
metadata: {},
},
],
metadata: {},
},
null,
2,
);
const result = applyNotebookEdit(raw, {
notebook_path: '/tmp/format.ipynb',
cell_id: 'intro',
new_source: '# Updated',
});
expect(result.updatedContent).toContain('\n "cells"');
expect(result.updatedContent.endsWith('\n')).toBe(false);
});
it('rejects ambiguous fallback-like cell IDs', async () => {
const filePath = writeNotebook('ambiguous.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [
{
cell_type: 'markdown',
id: 'cell-1',
source: ['real id'],
metadata: {},
},
{
cell_type: 'markdown',
source: ['fallback id'],
metadata: {},
},
],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'cell-1',
new_source: 'updated',
}).execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
expect(result.llmContent).toContain('ambiguous');
});
it('inserts at the beginning when no cell_id is provided', async () => {
const filePath = writeNotebook('insert-start.ipynb', {
nbformat: 4,
nbformat_minor: 4,
cells: [{ cell_type: 'code', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
edit_mode: 'insert',
cell_type: 'code',
new_source: 'print("first")',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const updated = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(updated.cells[0].source).toEqual(['print("first")']);
expect(updated.cells[0]).not.toHaveProperty('id');
});
it('deletes a cell without requiring new_source', async () => {
const filePath = writeNotebook('delete.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'markdown', id: 'keep', source: ['keep'], metadata: {} },
{ cell_type: 'markdown', id: 'drop', source: ['drop'], metadata: {} },
],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
edit_mode: 'delete',
cell_id: 'drop',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const updated = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(updated.cells.map((cell: { id: string }) => cell.id)).toEqual([
'keep',
]);
});
it('requires a fresh read after structural edits when fallback IDs can shift', async () => {
const filePath = writeNotebook('fallback-shift.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'markdown', source: ['A'], metadata: {} },
{ cell_type: 'markdown', source: ['B'], metadata: {} },
],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
edit_mode: 'insert',
cell_type: 'markdown',
new_source: 'inserted',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
expect(fileReadCache.check(fs.statSync(filePath)).state).toBe('unknown');
});
it('preserves fresh read state after structural edits when all IDs are stable', async () => {
const filePath = writeNotebook('stable-ids.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'markdown', id: 'a', source: ['A'], metadata: {} },
{ cell_type: 'markdown', id: 'b', source: ['B'], metadata: {} },
],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
edit_mode: 'insert',
cell_id: 'a',
cell_type: 'markdown',
new_source: 'inserted',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const cacheState = fileReadCache.check(fs.statSync(filePath));
expect(cacheState.state).toBe('fresh');
if (cacheState.state === 'fresh') {
expect(cacheState.entry.lastReadWasFull).toBe(true);
}
});
it('requires a fresh full notebook read before editing', async () => {
const filePath = writeNotebook('unread.ipynb', {
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
}).execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ);
expect(result.llmContent).toContain('has not been fully read');
});
it('rejects edits after a truncated notebook read', async () => {
const filePath = writeNotebook('truncated-read.ipynb', {
cells: [
{ cell_type: 'code', id: 'visible', source: ['x = 1'], metadata: {} },
{ cell_type: 'code', id: 'tail', source: ['x = 2'], metadata: {} },
],
metadata: {},
});
fileReadCache.recordRead(filePath, fs.statSync(filePath), {
full: false,
cacheable: false,
});
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'tail',
new_source: 'x = 3',
}).execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ);
expect(result.llmContent).toContain('too large for cell-level editing');
expect(result.llmContent).not.toContain('without offset or limit');
});
it.skipIf(process.platform === 'win32')(
'rejects non-regular notebook paths with a dedicated error type',
async () => {
const fifoPath = path.join(tempDir, 'notebook-fifo.ipynb');
execFileSync('mkfifo', [fifoPath]);
const result = await buildInvocation({
notebook_path: fifoPath,
cell_id: 'a',
new_source: 'x = 2',
}).execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.TARGET_NOT_REGULAR_FILE);
expect(result.llmContent).toContain('not a regular file');
},
);
it('rejects stale notebook edits after an external change', async () => {
const filePath = writeNotebook('stale.ipynb', {
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(filePath);
fs.writeFileSync(
filePath,
JSON.stringify({
cells: [
{ cell_type: 'code', id: 'a', source: ['x = 100'], metadata: {} },
],
metadata: {},
}),
'utf-8',
);
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
}).execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.FILE_CHANGED_SINCE_READ);
});
it('returns structured errors for missing cells and invalid JSON', async () => {
const missingCellPath = writeNotebook('missing-cell.ipynb', {
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(missingCellPath);
const missingCellResult = await buildInvocation({
notebook_path: missingCellPath,
cell_id: 'missing',
new_source: 'x = 2',
}).execute(abortSignal);
expect(missingCellResult.error?.type).toBe(
ToolErrorType.NOTEBOOK_CELL_NOT_FOUND,
);
const invalidPath = path.join(tempDir, 'bad.ipynb');
fs.writeFileSync(invalidPath, 'not json', 'utf-8');
seedNotebookRead(invalidPath);
const invalidResult = await buildInvocation({
notebook_path: invalidPath,
edit_mode: 'insert',
new_source: 'x = 1',
}).execute(abortSignal);
expect(invalidResult.error?.type).toBe(ToolErrorType.NOTEBOOK_INVALID_JSON);
});
it('keeps invalid original notebook errors structured for user-modified content', async () => {
const invalidPath = writeNotebook('bad-original.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(invalidPath);
const originalParams = {
notebook_path: invalidPath,
cell_id: 'a',
new_source: 'x = 2',
};
const modifyContext = tool.getModifyContext(abortSignal);
const currentContent =
await modifyContext.getCurrentContent(originalParams);
const proposedContent =
await modifyContext.getProposedContent(originalParams);
const updatedParams = modifyContext.createUpdatedParams(
currentContent,
proposedContent,
originalParams,
);
fs.writeFileSync(invalidPath, 'not json', 'utf-8');
seedNotebookRead(invalidPath);
const result = await buildInvocation(
structuredClone(updatedParams),
).execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.NOTEBOOK_INVALID_JSON);
});
it('rejects direct attempts to set internal modified notebook content params', () => {
const filePath = writeNotebook('injected-modified-content.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
expect(() =>
tool.build({
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
modified_notebook_content: JSON.stringify({
cells: [],
metadata: {},
}),
} as Parameters<NotebookEditTool['build']>[0]),
).toThrow(/additional properties|modified_notebook_content/i);
});
it('rejects qwenignored notebooks during validation', () => {
fs.writeFileSync(path.join(tempDir, '.qwenignore'), '*.ipynb\n', 'utf-8');
const filePath = writeNotebook('ignored.ipynb', {
cells: [],
metadata: {},
});
expect(() =>
tool.build({
notebook_path: filePath,
edit_mode: 'insert',
new_source: 'x = 1',
}),
).toThrow(/ignored by \.qwenignore/);
});
it('returns a notebook diff for confirmation', async () => {
const filePath = writeNotebook('confirm.ipynb', {
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(filePath);
const details = await buildInvocation({
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
}).getConfirmationDetails(abortSignal);
const editDetails = details as Extract<typeof details, { type: 'edit' }>;
expect(editDetails.fileDiff).toContain('- "x = 1"');
expect(editDetails.fileDiff).toContain('+ "x = 2"');
expect((editDetails as { originalContent: string }).originalContent).toBe(
fs.readFileSync(filePath, 'utf-8'),
);
});
it('applies IDE or inline modified full-notebook content instead of the original cell proposal', async () => {
const filePath = writeNotebook('modified-content.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(filePath);
const originalParams = {
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
};
const modifyContext = tool.getModifyContext(abortSignal);
const currentContent =
await modifyContext.getCurrentContent(originalParams);
const proposedContent =
await modifyContext.getProposedContent(originalParams);
const modifiedContent = proposedContent.replace('x = 2', 'x = 99');
const updatedParams = modifyContext.createUpdatedParams(
currentContent,
modifiedContent,
originalParams,
);
const result = await buildInvocation(
structuredClone(updatedParams),
).execute(abortSignal);
expect(result.error).toBeUndefined();
const updated = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(updated.cells[0].source).toEqual(['x = 99']);
expect(result.llmContent).toContain('modified by the user');
expect(
CommitAttributionService.getInstance().getFileAttribution(filePath),
).toBeUndefined();
});
it('uses one current-content snapshot for notebook modify previews', async () => {
const filePath = writeNotebook('modify-snapshot.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
const params = {
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
};
const modifyContext = tool.getModifyContext(abortSignal);
const currentContent = await modifyContext.getCurrentContent(params);
fs.writeFileSync(
filePath,
JSON.stringify(
{
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'code', id: 'a', source: ['x = 999'], metadata: {} },
],
metadata: {},
},
null,
1,
),
'utf-8',
);
const proposedContent = await modifyContext.getProposedContent(params);
expect(currentContent).toContain('x = 1');
expect(proposedContent).toContain('x = 2');
expect(proposedContent).not.toContain('x = 999');
});
it('records AI-originated notebook writes for commit attribution', async () => {
const filePath = writeNotebook('attribution.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(filePath);
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
}).execute(abortSignal);
expect(result.error).toBeUndefined();
const attribution =
CommitAttributionService.getInstance().getFileAttribution(filePath);
expect(attribution).toBeDefined();
expect(attribution!.aiContribution).toBeGreaterThan(0);
expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath);
});
it('tracks file history before the final freshness check', async () => {
const filePath = writeNotebook('history-before-check.ipynb', {
nbformat: 4,
nbformat_minor: 5,
cells: [{ cell_type: 'code', id: 'a', source: ['x = 1'], metadata: {} }],
metadata: {},
});
seedNotebookRead(filePath);
mockFileHistoryService.trackEdit.mockImplementation(async () => {
fs.writeFileSync(
filePath,
JSON.stringify(
{
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'code', id: 'a', source: ['x = 100'], metadata: {} },
],
metadata: {},
},
null,
1,
),
'utf-8',
);
});
const result = await buildInvocation({
notebook_path: filePath,
cell_id: 'a',
new_source: 'x = 2',
}).execute(abortSignal);
expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath);
expect(result.error?.type).toBe(ToolErrorType.FILE_CHANGED_SINCE_READ);
const updated = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(updated.cells[0].source).toEqual(['x = 100']);
});
});

View file

@ -0,0 +1,931 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as Diff from 'diff';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { detectLineEnding } from '../services/fileSystemService.js';
import type { LineEnding } from '../services/fileSystemService.js';
import type {
ToolCallConfirmationDetails,
ToolEditConfirmationDetails,
ToolInvocation,
ToolLocation,
ToolResult,
} from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
} from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
import { FileOperation } from '../telemetry/metrics.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { getSpecificMimeType } from '../utils/fileUtils.js';
import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js';
import {
findCellIndex,
getCellDisplayId,
getNotebookLanguage,
hasStableCellIds,
inferInsertedCellSourceArrayStyle,
inferNotebookJsonFormat,
isAmbiguousCellId,
makeCellId,
normalizeEditedCell,
normalizeSource,
parseNotebook,
serializeNotebook,
toNotebookSource,
type EditableNotebookCellType,
type NotebookCell,
type NotebookCellType,
} from '../utils/notebook.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import { ToolErrorType } from './tool-error.js';
import { StructuredToolError } from './priorReadEnforcement.js';
import type {
ModifiableDeclarativeTool,
ModifyContext,
} from './modifiable-tool.js';
import { CommitAttributionService } from '../services/commitAttribution.js';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('NOTEBOOK_EDIT');
export type NotebookEditMode = 'replace' | 'insert' | 'delete';
export interface NotebookEditToolParams {
notebook_path: string;
cell_id?: string;
new_source?: string;
cell_type?: EditableNotebookCellType;
edit_mode?: NotebookEditMode;
}
interface NotebookEditModifyMetadata {
modifiedByUser?: boolean;
aiProposedContent?: string;
modifiedNotebookContent?: string;
}
interface NotebookEditResult {
updatedContent: string;
editedCellId: string;
editedCellType?: NotebookCellType;
language: string;
mode: NotebookEditMode;
requiresReadAfterWrite: boolean;
}
interface PreparedNotebookEdit extends NotebookEditResult {
originalContent: string;
bom: boolean;
encoding: string | undefined;
lineEnding: LineEnding;
}
type NotebookPriorReadDecision =
| { ok: true }
| {
ok: false;
type: ToolErrorType;
rawMessage: string;
displayMessage: string;
};
function rejectNotebookPriorRead(
notebookPath: string,
reason: string,
decision: Exclude<NotebookPriorReadDecision, { ok: true }>,
): NotebookPriorReadDecision {
debugLogger.debug('prior-read-rejected', {
path: notebookPath,
reason,
type: decision.type,
displayMessage: decision.displayMessage,
});
return decision;
}
class NotebookEditError extends Error {
constructor(
message: string,
readonly type: ToolErrorType,
) {
super(message);
this.name = 'NotebookEditError';
}
}
function requireNotebookSource(
source: string | undefined,
mode: NotebookEditMode,
): string {
if (mode === 'delete') {
return '';
}
if (typeof source !== 'string') {
throw new NotebookEditError(
`new_source is required when edit_mode is "${mode}".`,
ToolErrorType.INVALID_TOOL_PARAMS,
);
}
return source;
}
function displayCellId(cell: NotebookCell | undefined, index: number): string {
return cell ? getCellDisplayId(cell, index) : `cell-${index}`;
}
function resolveTargetIndex(
notebook: ReturnType<typeof parseNotebook>,
cellId: string | undefined,
mode: NotebookEditMode,
): number {
if (!cellId) {
if (mode === 'insert') {
return -1;
}
throw new NotebookEditError(
'cell_id is required for replace and delete operations.',
ToolErrorType.INVALID_TOOL_PARAMS,
);
}
if (isAmbiguousCellId(notebook, cellId)) {
throw new NotebookEditError(
`Cell ID "${cellId}" is ambiguous in the rendered notebook. Re-read the notebook and target a stable real cell ID before editing.`,
ToolErrorType.INVALID_TOOL_PARAMS,
);
}
const index = findCellIndex(notebook, cellId);
if (index === -1) {
throw new NotebookEditError(
`Cell with ID "${cellId}" not found in notebook.`,
ToolErrorType.NOTEBOOK_CELL_NOT_FOUND,
);
}
return index;
}
function createNotebookCell(
notebook: ReturnType<typeof parseNotebook>,
cellType: EditableNotebookCellType,
source: string,
preferSourceArray: boolean,
): NotebookCell {
const cell: NotebookCell = {
cell_type: cellType,
metadata: {},
source: toNotebookSource(source, preferSourceArray),
};
const id = makeCellId(notebook);
if (id) {
cell.id = id;
}
normalizeEditedCell(cell, cellType);
return cell;
}
export function applyNotebookEdit(
rawContent: string,
params: NotebookEditToolParams,
): NotebookEditResult {
let notebook: ReturnType<typeof parseNotebook>;
try {
notebook = parseNotebook(rawContent);
} catch (error) {
throw new NotebookEditError(
error instanceof Error ? error.message : String(error),
ToolErrorType.NOTEBOOK_INVALID_JSON,
);
}
const mode = params.edit_mode ?? 'replace';
const source = requireNotebookSource(params.new_source, mode);
const originalHasStableCellIds = hasStableCellIds(notebook);
const targetIndex = resolveTargetIndex(notebook, params.cell_id, mode);
const language = getNotebookLanguage(notebook);
const jsonFormat = inferNotebookJsonFormat(rawContent);
const buildResult = (
updatedNotebook: ReturnType<typeof parseNotebook>,
result: Omit<
NotebookEditResult,
'updatedContent' | 'requiresReadAfterWrite'
>,
): NotebookEditResult => {
const structuralEdit = mode === 'insert' || mode === 'delete';
return {
...result,
updatedContent: serializeNotebook(updatedNotebook, jsonFormat),
requiresReadAfterWrite:
structuralEdit &&
!(originalHasStableCellIds && hasStableCellIds(updatedNotebook)),
};
};
switch (mode) {
case 'insert': {
const cellType = params.cell_type ?? 'code';
const insertAt = targetIndex === -1 ? 0 : targetIndex + 1;
const newCell = createNotebookCell(
notebook,
cellType,
source,
inferInsertedCellSourceArrayStyle(notebook, insertAt),
);
notebook.cells.splice(insertAt, 0, newCell);
return buildResult(notebook, {
editedCellId: displayCellId(newCell, insertAt),
editedCellType: cellType,
language,
mode,
});
}
case 'delete': {
const [removed] = notebook.cells.splice(targetIndex, 1);
return buildResult(notebook, {
editedCellId: displayCellId(removed, targetIndex),
editedCellType: removed?.cell_type,
language,
mode,
});
}
case 'replace': {
const target = notebook.cells[targetIndex];
if (!target) {
throw new NotebookEditError(
`Cell index ${targetIndex} is out of range.`,
ToolErrorType.NOTEBOOK_CELL_NOT_FOUND,
);
}
const finalType = params.cell_type ?? target.cell_type;
target.source = toNotebookSource(source, Array.isArray(target.source));
normalizeEditedCell(target, finalType);
return buildResult(notebook, {
editedCellId: displayCellId(target, targetIndex),
editedCellType: finalType,
language,
mode,
});
}
default:
throw new NotebookEditError(
`Unsupported notebook edit mode: ${mode}`,
ToolErrorType.INVALID_TOOL_PARAMS,
);
}
}
function prepareModifiedNotebookContent(
originalContent: string,
modifiedContent: string,
params: NotebookEditToolParams,
): NotebookEditResult {
let notebook: ReturnType<typeof parseNotebook>;
let originalNotebook: ReturnType<typeof parseNotebook>;
try {
notebook = parseNotebook(modifiedContent);
originalNotebook = parseNotebook(originalContent);
} catch (error) {
throw new NotebookEditError(
error instanceof Error ? error.message : String(error),
ToolErrorType.NOTEBOOK_INVALID_JSON,
);
}
return {
updatedContent: modifiedContent,
editedCellId: params.cell_id ?? 'user-modified-notebook',
editedCellType: undefined,
language: getNotebookLanguage(notebook),
mode: params.edit_mode ?? 'replace',
requiresReadAfterWrite:
!hasStableCellIds(originalNotebook) || !hasStableCellIds(notebook),
};
}
async function checkPriorNotebookRead(
config: Config,
notebookPath: string,
options: { expectExisting?: boolean } = {},
): Promise<NotebookPriorReadDecision> {
if (config.getFileReadCacheDisabled()) {
return { ok: true };
}
let stats: fs.Stats;
try {
stats = await fs.promises.stat(notebookPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === 'ENOENT') {
if (options.expectExisting) {
return rejectNotebookPriorRead(notebookPath, 'missing-after-read', {
ok: false,
type: ToolErrorType.FILE_CHANGED_SINCE_READ,
rawMessage: `Notebook ${notebookPath} disappeared after it was read. Re-read it with the ${ToolNames.READ_FILE} tool before editing it.`,
displayMessage: `notebook disappeared after last read; re-run ${ToolNames.READ_FILE} first.`,
});
}
return { ok: true };
}
return rejectNotebookPriorRead(notebookPath, 'stat-failed', {
ok: false,
type: ToolErrorType.PRIOR_READ_VERIFICATION_FAILED,
rawMessage: `Could not stat ${notebookPath} to verify prior notebook read (${code ?? 'unknown error'}). Re-read it with the ${ToolNames.READ_FILE} tool before editing it.`,
displayMessage: `cannot verify prior read of ${notebookPath}; re-run ${ToolNames.READ_FILE} before editing this notebook.`,
});
}
if (stats.isDirectory()) {
return rejectNotebookPriorRead(notebookPath, 'target-is-directory', {
ok: false,
type: ToolErrorType.TARGET_IS_DIRECTORY,
rawMessage: `${notebookPath} is a directory. The NotebookEdit tool only operates on .ipynb files.`,
displayMessage: 'path is a directory; cannot edit as a notebook.',
});
}
if (!stats.isFile()) {
return rejectNotebookPriorRead(notebookPath, 'target-not-regular-file', {
ok: false,
type: ToolErrorType.TARGET_NOT_REGULAR_FILE,
rawMessage: `${notebookPath} is not a regular file. The NotebookEdit tool only operates on .ipynb files.`,
displayMessage: 'special file; cannot edit as a notebook.',
});
}
const status = config.getFileReadCache().check(stats);
if (
status.state === 'fresh' &&
status.entry.lastReadAt !== undefined &&
status.entry.lastReadWasFull
) {
return { ok: true };
}
if (
status.state === 'fresh' &&
status.entry.lastReadAt !== undefined &&
!status.entry.lastReadWasFull
) {
return rejectNotebookPriorRead(notebookPath, 'truncated-cache-entry', {
ok: false,
type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ,
rawMessage: `Notebook ${notebookPath} is too large for cell-level editing because its rendered output was truncated when read. Reduce the notebook output size or split the notebook before editing cells.`,
displayMessage: 'notebook too large for cell-level editing.',
});
}
if (status.state === 'stale') {
return rejectNotebookPriorRead(notebookPath, 'stale-cache-entry', {
ok: false,
type: ToolErrorType.FILE_CHANGED_SINCE_READ,
rawMessage: `Notebook ${notebookPath} has been modified since you last read it. Re-read it with the ${ToolNames.READ_FILE} tool before editing it.`,
displayMessage: `notebook changed since last read; re-run ${ToolNames.READ_FILE} first.`,
});
}
return rejectNotebookPriorRead(notebookPath, `cache-${status.state}`, {
ok: false,
type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ,
rawMessage: `Notebook ${notebookPath} has not been fully read in this session. Use the ${ToolNames.READ_FILE} tool first, without offset or limit, before editing cells.`,
displayMessage: `${ToolNames.READ_FILE} required before editing this notebook.`,
});
}
class NotebookEditInvocation extends BaseToolInvocation<
NotebookEditToolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: NotebookEditToolParams,
private readonly modifyMetadata?: NotebookEditModifyMetadata,
) {
super(params);
}
override toolLocations(): ToolLocation[] {
return [{ path: this.params.notebook_path }];
}
override getDescription(): string {
const relativePath = makeRelative(
this.params.notebook_path,
this.config.getTargetDir(),
);
const mode = this.params.edit_mode ?? 'replace';
const cell = this.params.cell_id ?? 'beginning';
return `${mode} notebook cell ${cell} in ${shortenPath(relativePath)}`;
}
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
override async getConfirmationDetails(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails> {
const prepared = await this.prepareEdit(abortSignal);
const fileName = path.basename(this.params.notebook_path);
const fileDiff = Diff.createPatch(
fileName,
prepared.originalContent,
prepared.updatedContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Confirm Notebook Edit: ${shortenPath(makeRelative(this.params.notebook_path, this.config.getTargetDir()))}`,
fileName,
filePath: this.params.notebook_path,
fileDiff,
originalContent: prepared.originalContent,
newContent: prepared.updatedContent,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
}
private async prepareEdit(
abortSignal: AbortSignal,
): Promise<PreparedNotebookEdit> {
const preDecision = await checkPriorNotebookRead(
this.config,
this.params.notebook_path,
);
if (!preDecision.ok) {
throw new StructuredToolError(preDecision.rawMessage, preDecision.type);
}
let originalContent: string;
let bom = false;
let encoding: string | undefined;
let lineEnding: LineEnding = 'lf';
try {
const fileInfo = await this.config.getFileSystemService().readTextFile({
path: this.params.notebook_path,
});
originalContent = fileInfo.content;
bom = fileInfo._meta?.bom ?? false;
encoding = fileInfo._meta?.encoding;
lineEnding =
fileInfo._meta?.lineEnding ?? detectLineEnding(fileInfo.content);
} catch (error) {
if (abortSignal.aborted) {
throw error;
}
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === 'ENOENT') {
throw new StructuredToolError(
`Notebook file not found: ${this.params.notebook_path}`,
ToolErrorType.FILE_NOT_FOUND,
);
}
throw new StructuredToolError(
`Error reading notebook: ${
error instanceof Error ? error.message : String(error)
}`,
ToolErrorType.READ_CONTENT_FAILURE,
);
}
const postDecision = await checkPriorNotebookRead(
this.config,
this.params.notebook_path,
{ expectExisting: true },
);
if (!postDecision.ok) {
throw new StructuredToolError(postDecision.rawMessage, postDecision.type);
}
try {
if (this.modifyMetadata?.modifiedNotebookContent !== undefined) {
return {
...prepareModifiedNotebookContent(
originalContent,
this.modifyMetadata.modifiedNotebookContent,
this.params,
),
originalContent,
bom,
encoding,
lineEnding,
};
}
return {
...applyNotebookEdit(originalContent, this.params),
originalContent,
bom,
encoding,
lineEnding,
};
} catch (error) {
if (error instanceof NotebookEditError) {
throw new StructuredToolError(error.message, error.type);
}
throw error;
}
}
override async execute(signal: AbortSignal): Promise<ToolResult> {
let prepared: PreparedNotebookEdit;
try {
prepared = await this.prepareEdit(signal);
} catch (error) {
if (signal.aborted) {
throw error;
}
const errorType =
error instanceof StructuredToolError
? error.errorType
: ToolErrorType.NOTEBOOK_EDIT_FAILURE;
const message = error instanceof Error ? error.message : String(error);
return {
llmContent: message,
returnDisplay: `Error: ${message}`,
error: {
message,
type: errorType,
},
};
}
try {
try {
await this.config
.getFileHistoryService()
.trackEdit(this.params.notebook_path);
} catch {
// File history is best-effort; never block core tool operations.
}
const writeDecision = await checkPriorNotebookRead(
this.config,
this.params.notebook_path,
{ expectExisting: true },
);
if (!writeDecision.ok) {
return {
llmContent: writeDecision.rawMessage,
returnDisplay: `Error: ${writeDecision.displayMessage}`,
error: {
message: writeDecision.rawMessage,
type: writeDecision.type,
},
};
}
await this.config.getFileSystemService().writeTextFile({
path: this.params.notebook_path,
content: prepared.updatedContent,
_meta: {
bom: prepared.bom,
encoding: prepared.encoding,
lineEnding: prepared.lineEnding,
},
});
if (!this.modifyMetadata?.modifiedByUser) {
CommitAttributionService.getInstance().recordEdit(
this.params.notebook_path,
prepared.originalContent,
prepared.updatedContent,
);
}
try {
const postWriteStats = fs.statSync(this.params.notebook_path);
const cache = this.config.getFileReadCache();
if (prepared.requiresReadAfterWrite) {
cache.invalidate(postWriteStats);
} else {
cache.recordWrite(this.params.notebook_path, postWriteStats, {
cacheable: false,
});
}
} catch (error) {
// Non-fatal: the write succeeded. A subsequent read will re-stat and
// refresh the cache if this best-effort cache update failed.
debugLogger.warn(
`[NotebookEdit] post-write cache update failed for ${this.params.notebook_path}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const fileName = path.basename(this.params.notebook_path);
const fileDiff = Diff.createPatch(
fileName,
prepared.originalContent,
prepared.updatedContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
const diffStat = getDiffStat(
fileName,
prepared.originalContent,
this.modifyMetadata?.aiProposedContent ?? prepared.updatedContent,
prepared.updatedContent,
);
logFileOperation(
this.config,
new FileOperationEvent(
NotebookEditTool.Name,
FileOperation.UPDATE,
prepared.updatedContent.split('\n').length,
getSpecificMimeType(this.params.notebook_path),
'.ipynb',
prepared.language,
),
);
const displayResult = {
fileDiff,
fileName,
originalContent: prepared.originalContent,
newContent: prepared.updatedContent,
diffStat,
};
const llmContent =
this.modifyMetadata?.modifiedNotebookContent !== undefined
? `Notebook ${this.params.notebook_path} has been updated. Notebook content was modified by the user before approval; the final saved notebook may differ from the original ${prepared.mode} cell ${prepared.editedCellId} proposal.`
: `Notebook ${this.params.notebook_path} has been updated. ${prepared.mode} cell ${prepared.editedCellId}.${
prepared.mode === 'delete'
? ''
: `\n\nUpdated source:\n\n---\n\n${normalizeSource(this.params.new_source ?? '')}`
}`;
return {
llmContent,
returnDisplay: displayResult,
resultFilePaths: [this.params.notebook_path],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
llmContent: `Error writing notebook: ${message}`,
returnDisplay: `Error writing notebook: ${message}`,
error: {
message,
type: ToolErrorType.FILE_WRITE_FAILURE,
},
};
}
}
}
export class NotebookEditTool
extends BaseDeclarativeTool<NotebookEditToolParams, ToolResult>
implements ModifiableDeclarativeTool<NotebookEditToolParams>
{
static readonly Name = ToolNames.NOTEBOOK_EDIT;
private readonly modifyMetadataByParams = new WeakMap<
NotebookEditToolParams,
NotebookEditModifyMetadata
>();
private readonly modifyMetadataByKey = new Map<
string,
NotebookEditModifyMetadata[]
>();
constructor(private readonly config: Config) {
super(
NotebookEditTool.Name,
ToolDisplayNames.NOTEBOOK_EDIT,
`Edits a Jupyter notebook (.ipynb) safely at the cell level. Use this instead of ${ToolNames.EDIT} or ${ToolNames.WRITE_FILE} for notebook cells. Supports replacing, inserting, and deleting cells. Always read the notebook first with ${ToolNames.READ_FILE}; then use the cell IDs shown in that output.`,
Kind.Edit,
{
properties: {
notebook_path: {
description:
'Absolute path to the Jupyter notebook file to edit. Must end with .ipynb.',
type: 'string',
},
cell_id: {
description:
'Target cell ID from read_file output, or cell-N 0-based fallback. Required for replace and delete. For insert, the new cell is inserted after this cell; if omitted, inserted at the beginning.',
type: 'string',
},
new_source: {
description:
'New source content for replace and insert operations. Not required for delete.',
type: 'string',
},
cell_type: {
description:
'Cell type for inserted cells or type conversion on replace.',
type: 'string',
enum: ['code', 'markdown'],
},
edit_mode: {
description: 'Notebook edit operation. Defaults to replace.',
type: 'string',
enum: ['replace', 'insert', 'delete'],
},
},
required: ['notebook_path'],
additionalProperties: false,
type: 'object',
},
);
}
protected override validateToolParamValues(
params: NotebookEditToolParams,
): string | null {
params.notebook_path = unescapePath(params.notebook_path.trim());
if (!params.notebook_path) {
return "The 'notebook_path' parameter must be non-empty.";
}
if (!path.isAbsolute(params.notebook_path)) {
return `Notebook path must be absolute: ${params.notebook_path}`;
}
if (path.extname(params.notebook_path).toLowerCase() !== '.ipynb') {
return 'File must be a Jupyter notebook (.ipynb). Use the edit tool for other file types.';
}
const mode = params.edit_mode ?? 'replace';
if (!['replace', 'insert', 'delete'].includes(mode)) {
return "edit_mode must be 'replace', 'insert', or 'delete'.";
}
if (params.cell_type && !['code', 'markdown'].includes(params.cell_type)) {
return "cell_type must be 'code' or 'markdown'.";
}
if (mode !== 'insert' && !params.cell_id) {
return 'cell_id is required for replace and delete operations.';
}
if (mode !== 'delete' && typeof params.new_source !== 'string') {
return `new_source is required when edit_mode is "${mode}".`;
}
const fileService = this.config.getFileService();
if (fileService.shouldQwenIgnoreFile(params.notebook_path)) {
return `File path '${params.notebook_path}' is ignored by .qwenignore pattern(s).`;
}
return null;
}
protected createInvocation(
params: NotebookEditToolParams,
): ToolInvocation<NotebookEditToolParams, ToolResult> {
return new NotebookEditInvocation(
this.config,
params,
this.consumeModifyMetadata(params),
);
}
private getModifyMetadataKey(params: NotebookEditToolParams): string {
return JSON.stringify({
notebook_path: unescapePath(params.notebook_path.trim()),
cell_id: params.cell_id,
new_source: params.new_source,
cell_type: params.cell_type,
edit_mode: params.edit_mode,
});
}
private rememberModifyMetadata(
params: NotebookEditToolParams,
metadata: NotebookEditModifyMetadata,
): void {
this.modifyMetadataByParams.set(params, metadata);
const key = this.getModifyMetadataKey(params);
const queue = this.modifyMetadataByKey.get(key) ?? [];
queue.push(metadata);
this.modifyMetadataByKey.set(key, queue);
}
private consumeModifyMetadata(
params: NotebookEditToolParams,
): NotebookEditModifyMetadata | undefined {
const metadata = this.modifyMetadataByParams.get(params);
if (metadata) {
this.modifyMetadataByParams.delete(params);
this.removeQueuedModifyMetadata(params, metadata);
return metadata;
}
const key = this.getModifyMetadataKey(params);
const queue = this.modifyMetadataByKey.get(key);
const queuedMetadata = queue?.shift();
if (queue && queue.length === 0) {
this.modifyMetadataByKey.delete(key);
}
return queuedMetadata;
}
private removeQueuedModifyMetadata(
params: NotebookEditToolParams,
metadata: NotebookEditModifyMetadata,
): void {
const key = this.getModifyMetadataKey(params);
const queue = this.modifyMetadataByKey.get(key);
if (!queue) {
return;
}
const index = queue.indexOf(metadata);
if (index !== -1) {
queue.splice(index, 1);
}
if (queue.length === 0) {
this.modifyMetadataByKey.delete(key);
}
}
getModifyContext(
_abortSignal: AbortSignal,
): ModifyContext<NotebookEditToolParams> {
const currentContentSnapshots = new Map<string, string>();
const readCurrentContentSnapshot = async (
params: NotebookEditToolParams,
): Promise<string> => {
const cached = currentContentSnapshots.get(params.notebook_path);
if (cached !== undefined) {
return cached;
}
const { content } = await this.config
.getFileSystemService()
.readTextFile({ path: params.notebook_path });
currentContentSnapshots.set(params.notebook_path, content);
return content;
};
return {
getFilePath: (params: NotebookEditToolParams) => params.notebook_path,
getCurrentContent: async (
params: NotebookEditToolParams,
): Promise<string> => readCurrentContentSnapshot(params),
getProposedContent: async (
params: NotebookEditToolParams,
): Promise<string> => {
const content = await readCurrentContentSnapshot(params);
return applyNotebookEdit(content, params).updatedContent;
},
createUpdatedParams: (
oldContent: string,
modifiedProposedContent: string,
originalParams: NotebookEditToolParams,
): NotebookEditToolParams => {
let aiProposedContent =
this.modifyMetadataByParams.get(originalParams)?.aiProposedContent;
if (aiProposedContent === undefined) {
try {
aiProposedContent = applyNotebookEdit(
oldContent,
originalParams,
).updatedContent;
} catch {
aiProposedContent = modifiedProposedContent;
}
}
const updatedParams: NotebookEditToolParams = {
...originalParams,
};
this.rememberModifyMetadata(updatedParams, {
aiProposedContent,
modifiedByUser: true,
modifiedNotebookContent: modifiedProposedContent,
});
return updatedParams;
},
};
}
}

View file

@ -289,8 +289,11 @@ export async function checkPriorRead(
`notebook payload that the ${ToolNames.READ_FILE} tool returns ` +
`as a structured value rather than as plain text. The Edit / ` +
`WriteFile tools cannot mutate that payload safely — re-reading ` +
`it would not change this. Use a different mechanism (e.g. shell ` +
`tool with a binary-aware writer) if you need to ${verbBare} it.`;
`it would not change this. If this is a Jupyter notebook (.ipynb), ` +
`use the ${ToolNames.NOTEBOOK_EDIT} tool for cell-level edits after ` +
`reading it. For other non-text files, use a different mechanism ` +
`(e.g. shell tool with an appropriate writer) if you need to ` +
`${verbBare} it.`;
return {
ok: false,
type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ,

View file

@ -158,6 +158,29 @@ describe('ReadFileTool', () => {
'Limit must be a positive number',
);
});
it('should reject offset or limit for notebook files', () => {
const params: ReadFileToolParams = {
file_path: path.join(tempRootDir, 'test.ipynb'),
offset: 0,
limit: 10,
};
expect(() => tool.build(params)).toThrow(
'offset and limit are not supported for Jupyter notebook (.ipynb) files',
);
});
it('should reject pages for notebook files', () => {
const params: ReadFileToolParams = {
file_path: path.join(tempRootDir, 'test.ipynb'),
pages: '1',
};
expect(() => tool.build(params)).toThrow(
'pages is not supported for Jupyter notebook (.ipynb) files',
);
});
});
describe('getDefaultPermission', () => {
@ -505,6 +528,36 @@ describe('ReadFileTool', () => {
expect(result.returnDisplay).toBe('Read notebook: test.ipynb');
});
it('records truncated notebook reads as not full', async () => {
const nbPath = path.join(tempRootDir, 'large.ipynb');
const notebook = {
cells: Array.from({ length: 200 }, (_, i) => ({
cell_type: 'code',
source: ['x = ' + 'a'.repeat(600) + '\n'],
execution_count: i + 1,
outputs: [{ output_type: 'stream', text: ['result '.repeat(100)] }],
metadata: {},
})),
metadata: { language_info: { name: 'python' } },
};
await fsp.writeFile(nbPath, JSON.stringify(notebook), 'utf-8');
const invocation = tool.build({
file_path: nbPath,
}) as ToolInvocation<ReadFileToolParams, ToolResult>;
const result = await invocation.execute(abortSignal);
expect(typeof result.llmContent).toBe('string');
expect(result.llmContent).toContain('remaining cells truncated');
expect(result.llmContent).not.toContain('Showing lines');
const status = fileReadCache.check(fs.statSync(nbPath));
expect(status.state).toBe('fresh');
if (status.state === 'fresh') {
expect(status.entry.lastReadWasFull).toBe(false);
expect(status.entry.lastReadCacheable).toBe(false);
}
});
it('should reject invalid pages parameter', () => {
const params: ReadFileToolParams = {
file_path: '/tmp/test.pdf',
@ -544,7 +597,9 @@ describe('ReadFileTool', () => {
file_path: path.join(tempRootDir, 'test.txt'),
pages: '',
};
expect(() => tool.build(params)).not.toThrow();
const invocation = tool.build(params);
expect(invocation.params.pages).toBeUndefined();
});
it('should support offset and limit for text files', async () => {

View file

@ -156,10 +156,9 @@ class ReadFileToolInvocation extends BaseToolInvocation<
const cacheEnabled = !this.config.getFileReadCacheDisabled();
const useFastPath = cacheEnabled && !isAutoMem;
const cache = this.config.getFileReadCache();
// A "full" Read consumes the whole file: no offset, no limit, no PDF
// page range. Only full Reads are eligible for the file_unchanged
// fast-path; range-scoped Reads always go through, since the model
// may legitimately ask for a different slice next time.
// A request-level "full" Read asks for the whole file: no offset,
// no limit, no PDF page range. The cache entry is only marked as
// full later if the produced content was not truncated.
const isFullRead =
this.params.offset === undefined &&
this.params.limit === undefined &&
@ -245,7 +244,9 @@ class ReadFileToolInvocation extends BaseToolInvocation<
// level if the produced content was not truncated, otherwise
// the model only saw the head and a follow-up `file_unchanged`
// placeholder would falsely imply "you've already seen
// everything".
// everything". NotebookEdit also requires this flag so a
// truncated notebook render does not authorize structured writes
// against unseen cells.
//
// The stat we record is the one taken inside `processSingleFileContent`
// and surfaced via `result.stats`. The internal stat happens
@ -274,7 +275,11 @@ class ReadFileToolInvocation extends BaseToolInvocation<
}
let llmContent: PartUnion;
if (result.isTruncated) {
if (
result.isTruncated &&
result.linesShown &&
result.originalLineCount !== undefined
) {
const [start, end] = result.linesShown!;
const total = result.originalLineCount!;
llmContent = `Showing lines ${start}-${end} of ${total} total lines.\n\n---\n\n${result.llmContent}`;
@ -432,6 +437,23 @@ export class ReadFileTool extends BaseDeclarativeTool<
return 'Limit must be a positive number';
}
if (params.pages !== undefined) {
const pages = params.pages.trim();
params.pages = pages.length > 0 ? pages : undefined;
}
const ext = path.extname(filePath).toLowerCase();
if (
(params.offset !== undefined || params.limit !== undefined) &&
ext === '.ipynb'
) {
return 'offset and limit are not supported for Jupyter notebook (.ipynb) files. Notebooks are always read in full with structured cell output.';
}
if (params.pages !== undefined && ext === '.ipynb') {
return 'pages is not supported for Jupyter notebook (.ipynb) files. Notebooks are always read in full with structured cell output.';
}
if (params.pages) {
const parsed = parsePDFPageRange(params.pages);
if (!parsed) {

View file

@ -46,17 +46,11 @@ export enum ToolErrorType {
// view, so the model has not seen the full text content the
// mutation could touch.
// 3. The file is a structural dead end that no amount of
// re-reading can change:
// - non-text payloads (binary / image / audio / video /
// PDF / notebook) — read_file returns these as
// structured values that Edit / WriteFile cannot mutate
// safely; the rejection message tells the model to use
// a different tool (shell with a binary-aware writer).
// - special files (FIFO / socket / character or block
// device) — read_file rejects these as "not a regular
// file", so an enforcement loop on read_file would
// never terminate; the rejection points the model at
// shell instead.
// re-reading can change: non-text payloads (binary / image /
// audio / video / PDF / notebook). read_file returns these as
// structured values that Edit / WriteFile cannot mutate safely;
// the rejection message tells the model to use a different tool
// (shell with a binary-aware writer).
//
// Despite the `EDIT_` prefix this code is shared between EditTool
// and WriteFileTool: the boundary it guards is "the model is about
@ -88,6 +82,15 @@ export enum ToolErrorType {
// cannot verify. Operators monitoring on error codes can route this
// separately.
PRIOR_READ_VERIFICATION_FAILED = 'prior_read_verification_failed',
// Returned when a path resolves but is not a regular file (FIFO / socket /
// character or block device). Re-reading cannot make these editable, so this
// is distinct from EDIT_REQUIRES_PRIOR_READ to avoid read/edit retry loops.
TARGET_NOT_REGULAR_FILE = 'target_not_regular_file',
// Notebook-specific Errors
NOTEBOOK_EDIT_FAILURE = 'notebook_edit_failure',
NOTEBOOK_INVALID_JSON = 'notebook_invalid_json',
NOTEBOOK_CELL_NOT_FOUND = 'notebook_cell_not_found',
// Glob-specific Errors
GLOB_EXECUTION_ERROR = 'glob_execution_error',

View file

@ -40,6 +40,7 @@ export const ToolNames = {
SEND_MESSAGE: 'send_message',
STRUCTURED_OUTPUT: 'structured_output',
MONITOR: 'monitor',
NOTEBOOK_EDIT: 'notebook_edit',
TOOL_SEARCH: 'tool_search',
ENTER_WORKTREE: 'enter_worktree',
EXIT_WORKTREE: 'exit_worktree',
@ -73,6 +74,7 @@ export const ToolDisplayNames = {
SEND_MESSAGE: 'SendMessage',
STRUCTURED_OUTPUT: 'StructuredOutput',
MONITOR: 'Monitor',
NOTEBOOK_EDIT: 'NotebookEdit',
TOOL_SEARCH: 'ToolSearch',
ENTER_WORKTREE: 'EnterWorktree',
EXIT_WORKTREE: 'ExitWorktree',

View file

@ -1116,6 +1116,7 @@ describe('WriteFileTool', () => {
.build({ file_path: filePath, content: 'clobber' })
.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ);
expect(result.error?.message).toContain('notebook_edit');
// Verb in the dead-end guidance must read correctly for
// overwrite (the WriteFile path), not "edit".
expect(result.error?.message).toMatch(/if you need to overwrite it\./);

View file

@ -22,7 +22,7 @@ import { isNodeError } from './errors.js';
import type { InputModalities } from '../core/contentGenerator.js';
import { detectEncodingFromBuffer } from './systemEncoding.js';
import { extractPDFText, parsePDFPageRange } from './pdf.js';
import { readNotebook } from './notebook.js';
import { readNotebookWithMetadata } from './notebook.js';
const debugLogger = createDebugLogger('FILE_UTILS');
@ -818,7 +818,7 @@ export interface ProcessedFileReadResult {
error?: string; // Optional error message for the LLM if file processing failed
errorType?: ToolErrorType; // Structured error type
originalLineCount?: number; // For text files, the total number of lines in the original file
isTruncated?: boolean; // For text files, indicates if content was truncated
isTruncated?: boolean; // Indicates if displayed content was truncated
linesShown?: [number, number]; // For text files [startLine, endLine] (1-based for display)
/**
* The Stats taken at the start of the read pipeline, before the
@ -1172,10 +1172,13 @@ export async function processSingleFileContent(
}
case 'notebook': {
try {
const content = await readNotebook(filePath);
const { content, isTruncated } =
await readNotebookWithMetadata(filePath);
return {
llmContent: content,
returnDisplay: `Read notebook: ${relativePathForDisplay}`,
isTruncated,
stats,
};
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);

View file

@ -5,7 +5,21 @@
*/
import { describe, it, expect, afterEach } from 'vitest';
import { readNotebook } from './notebook.js';
import {
findCellIndex,
getCellDisplayId,
hasStableCellIds,
inferInsertedCellSourceArrayStyle,
inferNotebookJsonFormat,
isAmbiguousCellId,
makeCellId,
parseCellId,
parseNotebook,
readNotebook,
readNotebookWithMetadata,
serializeNotebook,
toNotebookSource,
} from './notebook.js';
import path from 'node:path';
import os from 'node:os';
import fsp from 'node:fs/promises';
@ -369,6 +383,26 @@ describe('notebook utilities', () => {
expect(result.length).toBeLessThan(120000);
});
it('reports when notebook cell rendering is truncated', async () => {
const cells = Array.from({ length: 200 }, (_, i) => ({
cell_type: 'code' as const,
source: ['x = ' + 'a'.repeat(600) + '\n'],
execution_count: i + 1,
outputs: [
{ output_type: 'stream' as const, text: ['result '.repeat(100)] },
],
metadata: {},
}));
const filePath = await writeNotebook('big-metadata.ipynb', {
cells,
metadata: { language_info: { name: 'python' } },
});
const result = await readNotebookWithMetadata(filePath);
expect(result.isTruncated).toBe(true);
expect(result.content).toContain('remaining cells truncated');
});
it('should throw on invalid JSON', async () => {
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'notebook-test-'));
const filePath = path.join(tempDir, 'bad.ipynb');
@ -376,4 +410,184 @@ describe('notebook utilities', () => {
await expect(readNotebook(filePath)).rejects.toThrow();
});
it('should parse notebooks with a leading UTF-8 BOM', async () => {
const notebook = {
cells: [
{
cell_type: 'code',
source: ['print("bom")'],
execution_count: null,
outputs: [],
metadata: {},
},
],
metadata: { language_info: { name: 'python' } },
};
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'notebook-test-'));
const filePath = path.join(tempDir, 'bom.ipynb');
await fsp.writeFile(filePath, `\ufeff${JSON.stringify(notebook)}`, 'utf-8');
expect(
parseNotebook(`\ufeff${JSON.stringify(notebook)}`).cells,
).toHaveLength(1);
await expect(readNotebookWithMetadata(filePath)).resolves.toMatchObject({
isTruncated: false,
});
});
it('should parse cell-N IDs as zero-based indexes', () => {
expect(parseCellId('cell-0')).toBe(0);
expect(parseCellId('cell-12')).toBe(12);
expect(parseCellId('abc-12')).toBeUndefined();
expect(parseCellId('cell-nope')).toBeUndefined();
});
it('should find cells by the same IDs read_file displays', () => {
const notebook = parseNotebook(
JSON.stringify({
cells: [
{ cell_type: 'code', id: 'real-id', source: '', metadata: {} },
{ cell_type: 'code', source: '', metadata: {} },
],
metadata: {},
}),
);
expect(getCellDisplayId(notebook.cells[0]!, 0)).toBe('real-id');
expect(getCellDisplayId(notebook.cells[1]!, 1)).toBe('cell-1');
expect(findCellIndex(notebook, 'real-id')).toBe(0);
expect(findCellIndex(notebook, 'cell-1')).toBe(1);
expect(findCellIndex(notebook, 'cell-0')).toBe(-1);
expect(findCellIndex(notebook, 'missing')).toBe(-1);
});
it('should reject ambiguous displayed cell IDs', () => {
const notebook = parseNotebook(
JSON.stringify({
cells: [
{ cell_type: 'code', id: 'cell-1', source: '', metadata: {} },
{ cell_type: 'code', source: '', metadata: {} },
],
metadata: {},
}),
);
expect(isAmbiguousCellId(notebook, 'cell-1')).toBe(true);
expect(findCellIndex(notebook, 'cell-1')).toBe(-1);
});
it('should preserve newline boundaries when converting source to arrays', () => {
expect(toNotebookSource('a\nb\n', true)).toEqual(['a\n', 'b\n']);
expect(toNotebookSource('a\nb', true)).toEqual(['a\n', 'b']);
expect(toNotebookSource('', true)).toEqual([]);
expect(toNotebookSource('a\nb\n', false)).toBe('a\nb\n');
});
it('should preserve notebook JSON indentation and trailing newline style', () => {
const raw = JSON.stringify(
{
cells: [{ cell_type: 'markdown', source: '# Title', metadata: {} }],
metadata: {},
},
null,
2,
);
const notebook = parseNotebook(raw);
notebook.cells[0]!.source = '# Updated';
const format = inferNotebookJsonFormat(raw);
const serialized = serializeNotebook(notebook, format);
expect(format).toEqual({ indent: 2, trailingNewline: false });
expect(serialized).toContain('\n "cells"');
expect(serialized.endsWith('\n')).toBe(false);
});
it('should preserve compact notebook JSON when serializing after edits', () => {
const raw = JSON.stringify({
cells: [{ cell_type: 'markdown', source: '# Title', metadata: {} }],
metadata: {},
});
const notebook = parseNotebook(raw);
notebook.cells[0]!.source = '# Updated';
const format = inferNotebookJsonFormat(raw);
const serialized = serializeNotebook(notebook, format);
expect(format).toEqual({ indent: undefined, trailingNewline: false });
expect(serialized).toBe(JSON.stringify(notebook));
});
it('should infer inserted source style from adjacent cells', () => {
const notebook = parseNotebook(
JSON.stringify({
cells: [
{ cell_type: 'markdown', source: '# string source', metadata: {} },
{
cell_type: 'code',
source: ['print("array source")'],
metadata: {},
},
],
metadata: {},
}),
);
expect(inferInsertedCellSourceArrayStyle(notebook, 1)).toBe(false);
expect(inferInsertedCellSourceArrayStyle(notebook, 0)).toBe(false);
expect(inferInsertedCellSourceArrayStyle(notebook, 2)).toBe(true);
});
it('should generate deterministic cell IDs that cannot collide with cell-N fallbacks', () => {
const notebook = parseNotebook(
JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
cells: [
{ cell_type: 'code', id: 'qwen-cell-1', source: '', metadata: {} },
{ cell_type: 'code', source: '', metadata: {} },
],
metadata: {},
}),
);
expect(hasStableCellIds(notebook)).toBe(false);
expect(makeCellId(notebook)).toBe('qwen-cell-2');
notebook.cells.push({
cell_type: 'code',
id: 'qwen-cell-2',
source: '',
metadata: {},
});
expect(makeCellId(notebook)).toBe('qwen-cell-3');
});
it('should not generate cell IDs for old notebook formats', () => {
const notebook = parseNotebook(
JSON.stringify({
nbformat: 4,
nbformat_minor: 4,
cells: [],
metadata: {},
}),
);
expect(makeCellId(notebook)).toBeUndefined();
});
it('should reject notebook JSON without a cells array', () => {
expect(() => parseNotebook(JSON.stringify({ metadata: {} }))).toThrow(
'missing cells array',
);
});
it('should reject non-object notebook cells', () => {
expect(() =>
parseNotebook(JSON.stringify({ cells: [null], metadata: {} })),
).toThrow('cell at index 0 is not an object');
expect(() =>
parseNotebook(JSON.stringify({ cells: ['not a cell'], metadata: {} })),
).toThrow('cell at index 0 is not an object');
});
});

View file

@ -42,7 +42,7 @@ function sanitizeMimeTypes(keys: string[]): string[] {
/**
* Jupyter Notebook cell output types.
*/
interface NotebookCellOutput {
export interface NotebookCellOutput {
output_type: 'stream' | 'execute_result' | 'display_data' | 'error';
text?: string | string[];
data?: Record<string, unknown>;
@ -54,9 +54,13 @@ interface NotebookCellOutput {
/**
* Jupyter Notebook cell.
*/
interface NotebookCell {
cell_type: 'code' | 'markdown' | 'raw';
export type NotebookCellType = 'code' | 'markdown' | 'raw';
export type EditableNotebookCellType = 'code' | 'markdown';
export interface NotebookCell {
cell_type: NotebookCellType;
source: string | string[];
metadata?: Record<string, unknown>;
outputs?: NotebookCellOutput[];
execution_count?: number | null;
id?: string;
@ -65,16 +69,218 @@ interface NotebookCell {
/**
* Jupyter Notebook top-level structure.
*/
interface NotebookContent {
export interface NotebookContent {
cells: NotebookCell[];
metadata: {
metadata?: {
language_info?: { name?: string };
kernelspec?: { language?: string; display_name?: string };
[key: string]: unknown;
};
nbformat?: number;
nbformat_minor?: number;
}
export interface NotebookReadResult {
content: string;
isTruncated: boolean;
}
export interface NotebookJsonFormat {
indent?: number;
trailingNewline: boolean;
}
export function normalizeSource(source: string | string[]): string {
return Array.isArray(source) ? source.join('') : source;
}
export function parseNotebook(content: string): NotebookContent {
const jsonContent =
content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
const parsed = JSON.parse(jsonContent) as unknown;
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid notebook: expected a JSON object');
}
const notebook = parsed as NotebookContent;
if (!Array.isArray(notebook.cells)) {
throw new Error('Invalid notebook: missing cells array');
}
for (let i = 0; i < notebook.cells.length; i++) {
const cell = notebook.cells[i];
if (!cell || typeof cell !== 'object' || Array.isArray(cell)) {
throw new Error(`Invalid notebook: cell at index ${i} is not an object`);
}
}
return notebook;
}
export function inferNotebookJsonFormat(content: string): NotebookJsonFormat {
const lineMatch = content.match(/\n( +)"/);
return {
indent: lineMatch?.[1]?.length,
trailingNewline: content.endsWith('\n'),
};
}
function normalizeSource(source: string | string[]): string {
return Array.isArray(source) ? source.join('') : source;
export function serializeNotebook(
notebook: NotebookContent,
format: NotebookJsonFormat = { indent: 1, trailingNewline: true },
): string {
const serialized = JSON.stringify(notebook, null, format.indent);
return format.trailingNewline ? `${serialized}\n` : serialized;
}
export function parseCellId(cellId: string): number | undefined {
const match = cellId.match(/^cell-(\d+)$/);
if (!match?.[1]) {
return undefined;
}
const index = Number.parseInt(match[1], 10);
return Number.isNaN(index) ? undefined : index;
}
export function getCellDisplayId(cell: NotebookCell, index: number): string {
return typeof cell.id === 'string' && cell.id.length > 0
? cell.id
: `cell-${index}`;
}
export function hasStableCellIds(notebook: NotebookContent): boolean {
return notebook.cells.every(
(cell) => typeof cell.id === 'string' && cell.id.length > 0,
);
}
export function findCellIndexesByDisplayId(
notebook: NotebookContent,
cellId: string,
): number[] {
const indexes: number[] = [];
notebook.cells.forEach((cell, index) => {
if (getCellDisplayId(cell, index) === cellId) {
indexes.push(index);
}
});
return indexes;
}
export function isAmbiguousCellId(
notebook: NotebookContent,
cellId: string,
): boolean {
return findCellIndexesByDisplayId(notebook, cellId).length > 1;
}
export function findCellIndex(
notebook: NotebookContent,
cellId: string,
): number {
const indexes = findCellIndexesByDisplayId(notebook, cellId);
return indexes.length === 1 ? indexes[0]! : -1;
}
export function getNotebookLanguage(notebook: NotebookContent): string {
return (
notebook.metadata?.language_info?.name ??
notebook.metadata?.kernelspec?.language ??
'python'
);
}
export function shouldGenerateCellIds(notebook: NotebookContent): boolean {
return (
(notebook.nbformat ?? 0) > 4 ||
((notebook.nbformat ?? 0) === 4 && (notebook.nbformat_minor ?? 0) >= 5)
);
}
export function makeCellId(notebook: NotebookContent): string | undefined {
if (!shouldGenerateCellIds(notebook)) {
return undefined;
}
const existingDisplayIds = new Set(
notebook.cells.map((cell, index) => getCellDisplayId(cell, index)),
);
let fallbackIndex = 1;
let fallback = `qwen-cell-${fallbackIndex}`;
while (existingDisplayIds.has(fallback)) {
fallbackIndex++;
fallback = `qwen-cell-${fallbackIndex}`;
}
return fallback;
}
export function inferNotebookSourceArrayStyle(
notebook: NotebookContent,
): boolean {
const sourceCell = notebook.cells.find((cell) => cell.source !== undefined);
return sourceCell ? Array.isArray(sourceCell.source) : true;
}
export function inferInsertedCellSourceArrayStyle(
notebook: NotebookContent,
insertAt: number,
): boolean {
const previousCell = notebook.cells[insertAt - 1];
if (previousCell?.source !== undefined) {
return Array.isArray(previousCell.source);
}
const nextCell = notebook.cells[insertAt];
if (nextCell?.source !== undefined) {
return Array.isArray(nextCell.source);
}
return inferNotebookSourceArrayStyle(notebook);
}
export function toNotebookSource(
source: string,
preferArray: boolean,
): string | string[] {
if (!preferArray) {
return source;
}
if (source.length === 0) {
return [];
}
const lines: string[] = [];
let start = 0;
for (let i = 0; i < source.length; i++) {
if (source[i] === '\n') {
lines.push(source.slice(start, i + 1));
start = i + 1;
}
}
if (start < source.length) {
lines.push(source.slice(start));
}
return lines;
}
export function normalizeEditedCell(
cell: NotebookCell,
finalType: NotebookCellType,
): void {
cell.cell_type = finalType;
cell.metadata ??= {};
if (finalType === 'code') {
cell.execution_count = null;
cell.outputs = [];
return;
}
delete cell.execution_count;
delete cell.outputs;
}
function processOutputText(text: string | string[] | undefined): string {
@ -131,7 +337,7 @@ function processCell(
index: number,
language: string,
): string {
const cellId = cell.id ?? `cell-${index}`;
const cellId = getCellDisplayId(cell, index);
const source = normalizeSource(cell.source);
const parts: string[] = [];
@ -181,29 +387,29 @@ function processCell(
/**
* Read and parse a Jupyter notebook file (.ipynb) into a structured text
* representation. Returns a formatted string with all cells and their outputs.
* representation, plus whether the cell listing was truncated.
*/
export async function readNotebook(filePath: string): Promise<string> {
export async function readNotebookWithMetadata(
filePath: string,
): Promise<NotebookReadResult> {
const raw = await fs.promises.readFile(filePath, 'utf-8');
const notebook: NotebookContent = JSON.parse(raw);
const language =
notebook.metadata?.language_info?.name ??
notebook.metadata?.kernelspec?.language ??
'python';
const notebook = parseNotebook(raw);
const language = getNotebookLanguage(notebook);
if (!notebook.cells || notebook.cells.length === 0) {
return '(empty notebook)';
return { content: '(empty notebook)', isTruncated: false };
}
const header = `Jupyter Notebook (${language}, ${notebook.cells.length} cells)`;
const cellTexts: string[] = [];
let totalLength = header.length;
let isTruncated = false;
for (let i = 0; i < notebook.cells.length; i++) {
const cellText = processCell(notebook.cells[i]!, i, language);
totalLength += cellText.length + 2; // +2 for "\n\n" separator
if (totalLength > MAX_NOTEBOOK_OUTPUT_CHARS) {
isTruncated = true;
cellTexts.push(
`... [${notebook.cells.length - i} remaining cells truncated, total ${notebook.cells.length} cells. Use shell to inspect: cat <path> | jq '.cells[${i}:]']`,
);
@ -212,5 +418,16 @@ export async function readNotebook(filePath: string): Promise<string> {
cellTexts.push(cellText);
}
return `${header}\n\n${cellTexts.join('\n\n')}`;
return {
content: `${header}\n\n${cellTexts.join('\n\n')}`,
isTruncated,
};
}
/**
* Read and parse a Jupyter notebook file (.ipynb) into a structured text
* representation. Returns a formatted string with all cells and their outputs.
*/
export async function readNotebook(filePath: string): Promise<string> {
return (await readNotebookWithMetadata(filePath)).content;
}

View file

@ -105,6 +105,35 @@ describe('readManyFiles', () => {
expect(content).toContain('--- End of content ---');
});
it('should include truncated notebooks that do not expose text line ranges', async () => {
const relativePath = 'large.ipynb';
const absolutePath = path.join(tempRootDir, relativePath);
const cells = Array.from({ length: 30 }, (_, index) => ({
cell_type: 'code',
id: `large-cell-${index}`,
source: [`value_${index} = "${'x'.repeat(4500)}"`],
metadata: {},
outputs: [],
}));
await fs.writeFile(
absolutePath,
JSON.stringify({ cells, metadata: {} }),
'utf-8',
);
const mockConfig = createMockConfig(tempRootDir);
const result = await readManyFiles(mockConfig, { paths: [relativePath] });
const content = contentToString(result.contentParts);
expect(content).toContain('Jupyter Notebook');
expect(content).toContain('remaining cells truncated');
expect(content).not.toContain(
'No files matching the criteria were found',
);
expect(result.files).toHaveLength(1);
expect(result.files[0]!.filePath).toBe(absolutePath);
});
it('should return message when no files found', async () => {
const mockConfig = createMockConfig(tempRootDir);

View file

@ -201,7 +201,11 @@ async function readFileContent(
if (typeof fileReadResult.llmContent === 'string') {
let fileContentForLlm = '';
if (fileReadResult.isTruncated) {
if (
fileReadResult.isTruncated &&
fileReadResult.linesShown &&
fileReadResult.originalLineCount !== undefined
) {
const [start, end] = fileReadResult.linesShown!;
const total = fileReadResult.originalLineCount!;
fileContentForLlm = `Showing lines ${start}-${end} of ${total} total lines.\n---\n${fileReadResult.llmContent}`;

View file

@ -135,7 +135,7 @@ The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`auto-edit`**: Auto-approve edit tools (`edit`, `write_file`, `notebook_edit`) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Transport Options

View file

@ -144,7 +144,7 @@ The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`auto-edit`**: Auto-approve edit tools (`edit`, `write_file`, `notebook_edit`) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Session Event Consumers and Assistant Content Consumers

View file

@ -227,7 +227,7 @@ The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`auto-edit`**: Auto-approve edit tools (`edit`, `write_file`, `notebook_edit`) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Permission Priority Chain