mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-25 08:54:45 +00:00
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.
239 lines
6.7 KiB
TypeScript
239 lines
6.7 KiB
TypeScript
/**
|
|
* @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');
|
|
});
|
|
});
|