mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-22 03:03:56 +00:00
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
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:
parent
a552df8998
commit
ed14a33064
37 changed files with 2863 additions and 113 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
239
integration-tests/cli/notebook-edit.test.ts
Normal file
239
integration-tests/cli/notebook-edit.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -468,6 +468,7 @@ export class PermissionManager {
|
|||
'read_file',
|
||||
'write_file',
|
||||
'edit',
|
||||
'notebook_edit',
|
||||
'glob',
|
||||
'grep_search',
|
||||
'run_shell_command',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
800
packages/core/src/tools/notebook-edit.test.ts
Normal file
800
packages/core/src/tools/notebook-edit.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
931
packages/core/src/tools/notebook-edit.ts
Normal file
931
packages/core/src/tools/notebook-edit.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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\./);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue