From 1038059e9c85b2405f2108cac92a12bfa8261318 Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Wed, 29 Jul 2026 19:26:37 -0300 Subject: [PATCH] =?UTF-8?q?feat(backend):=20split=20file=20authoring=20fro?= =?UTF-8?q?m=20code=20execution=20=E2=80=94=20write-file=20/=20edit-file?= =?UTF-8?q?=20/=20run-file=20+=20notebook=20cell=20edit/delete/rerun=20(cl?= =?UTF-8?q?oses=20#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New skills (purely additive; no signature changes to execute-code or the existing notebook skills): - write-file(path, content, overwrite=true) — wraps volume.write_to_volume; emits file_created / file_updated; does NOT write into scripts/ (authoring is not an execution step). - edit-file(path, mode=replace|overwrite, old/new|content) — exact-anchor replace (must match exactly once) or whole-file overwrite; emits file_updated, never file_created. - run-file(path, heavy=false) — runpy.run_path(path, run_name='__main__') through services.sandbox.run_code under the existing preamble (src/ on sys.path, session cwd); auto-saves a one-line runpy audit entry to scripts/step_NN_run_.py; heavy picks the training sandbox profile with the owner max_timeout cap (same as execute-code post-#160). - edit-notebook-cell / delete-notebook-cell — wrap new notebook_store.edit_cell / delete_cell helpers (outputs preserved on edit, same as apply_source_update; both persist under the existing _lock + save path); broadcast notebook.structure.changed for live UI. - rerun-notebook-cell — looks a cell up by cell_id, re-executes its current source against the persistent kernel via kernel_manager.execute_and_wait (kernel cell_started clears outputs; earlier-cell variables stay in scope); same result envelope as run-notebook-cell. Shared resolve_session_path() helper in services/skills/state.py (session-relative, volume-absolute, and /data/-prefixed paths; rejects escapes). All six skills registered in chat, eda, data_prep, feature_eng, trainer, orchestrator YAMLs, whose 'Workspace is a real Python repo' blocks are replaced by the prescriptive 'Authoring vs running' block (rule of three; edit don't repaste; import from src/ works in kernel cells) keeping the per-agent example bullets. execute-code SKILL.md now documents its one-shot contract. Frontend: file_updated added to the typed SSE union + handled in useSessionStream (tree update, no auto-open). Tests: 48 new (skill handlers happy/failure paths incl. invalid path, non-matching/ambiguous anchor, missing file, cell_id not found, markdown rerun, kernel timeout; notebook_store edit_cell output preservation + delete_cell volume persistence; path resolution). Full backend suite: 647 passed, 8 skipped. ruff 0.15.22 check + format clean. Frontend: vitest 20/20, tsc, next lint, prettier clean. --- backend/agents/chat.yaml | 38 ++- backend/agents/data_prep.yaml | 31 ++- backend/agents/eda.yaml | 34 ++- backend/agents/feature_eng.yaml | 31 ++- backend/agents/orchestrator.yaml | 36 ++- backend/agents/trainer.yaml | 32 ++- backend/services/notebook_store.py | 76 ++++++ backend/services/skills/state.py | 37 +++ backend/skills/delete-notebook-cell/SKILL.md | 34 +++ .../skills/delete-notebook-cell/handler.py | 108 ++++++++ .../skills/delete-notebook-cell/schema.yaml | 11 + backend/skills/edit-file/SKILL.md | 47 ++++ backend/skills/edit-file/handler.py | 132 ++++++++++ backend/skills/edit-file/schema.yaml | 25 ++ backend/skills/edit-notebook-cell/SKILL.md | 39 +++ backend/skills/edit-notebook-cell/handler.py | 120 +++++++++ backend/skills/edit-notebook-cell/schema.yaml | 20 ++ backend/skills/execute-code/SKILL.md | 5 + backend/skills/rerun-notebook-cell/SKILL.md | 44 ++++ backend/skills/rerun-notebook-cell/handler.py | 142 +++++++++++ .../skills/rerun-notebook-cell/schema.yaml | 15 ++ backend/skills/run-file/SKILL.md | 42 ++++ backend/skills/run-file/handler.py | 233 ++++++++++++++++++ backend/skills/run-file/schema.yaml | 17 ++ backend/skills/write-file/SKILL.md | 46 ++++ backend/skills/write-file/handler.py | 120 +++++++++ backend/skills/write-file/schema.yaml | 17 ++ backend/tests/test_delete_notebook_cell.py | 99 ++++++++ backend/tests/test_edit_file.py | 151 ++++++++++++ backend/tests/test_edit_notebook_cell.py | 114 +++++++++ backend/tests/test_notebook_store.py | 121 +++++++++ backend/tests/test_rerun_notebook_cell.py | 135 ++++++++++ backend/tests/test_run_file.py | 143 +++++++++++ backend/tests/test_write_file.py | 157 ++++++++++++ frontend/src/lib/types.ts | 4 + frontend/src/lib/useSessionStream.ts | 23 ++ 36 files changed, 2452 insertions(+), 27 deletions(-) create mode 100644 backend/skills/delete-notebook-cell/SKILL.md create mode 100644 backend/skills/delete-notebook-cell/handler.py create mode 100644 backend/skills/delete-notebook-cell/schema.yaml create mode 100644 backend/skills/edit-file/SKILL.md create mode 100644 backend/skills/edit-file/handler.py create mode 100644 backend/skills/edit-file/schema.yaml create mode 100644 backend/skills/edit-notebook-cell/SKILL.md create mode 100644 backend/skills/edit-notebook-cell/handler.py create mode 100644 backend/skills/edit-notebook-cell/schema.yaml create mode 100644 backend/skills/rerun-notebook-cell/SKILL.md create mode 100644 backend/skills/rerun-notebook-cell/handler.py create mode 100644 backend/skills/rerun-notebook-cell/schema.yaml create mode 100644 backend/skills/run-file/SKILL.md create mode 100644 backend/skills/run-file/handler.py create mode 100644 backend/skills/run-file/schema.yaml create mode 100644 backend/skills/write-file/SKILL.md create mode 100644 backend/skills/write-file/handler.py create mode 100644 backend/skills/write-file/schema.yaml create mode 100644 backend/tests/test_delete_notebook_cell.py create mode 100644 backend/tests/test_edit_file.py create mode 100644 backend/tests/test_edit_notebook_cell.py create mode 100644 backend/tests/test_notebook_store.py create mode 100644 backend/tests/test_rerun_notebook_cell.py create mode 100644 backend/tests/test_run_file.py create mode 100644 backend/tests/test_write_file.py diff --git a/backend/agents/chat.yaml b/backend/agents/chat.yaml index e807cc3..29d7bf5 100644 --- a/backend/agents/chat.yaml +++ b/backend/agents/chat.yaml @@ -10,6 +10,12 @@ skills: - name: execute-code - name: append-notebook-cell - name: run-notebook-cell + - name: write-file + - name: edit-file + - name: run-file + - name: edit-notebook-cell + - name: delete-notebook-cell + - name: rerun-notebook-cell - name: read-notebook - name: delegate-task - name: request-clarification @@ -174,12 +180,32 @@ system: | outputs freely. Soft conventions: `report.md` at the top level, `figures/` for plots, `data/` for datasets, `models/` for trained models, `scripts/` is managed by execute_code (auto-saved). - - The session workspace behaves like a real Python repo: it's the cwd of - every `execute-code` call and notebook cell, and `src/` is on `sys.path`. - Anything you write to `src/{name}.py` is importable from later calls - (`from features import build_X`). Use `src/` for reusable code; use - `scripts/` (auto-managed) as the one-shot audit log. Module names must - be plain Python identifiers — no hyphens, no step prefixes. + ## Authoring vs running — use the right verb + + Your session is a real Python repo. `/sessions/{sid}/src/` is on sys.path, + `/sessions/{sid}/` is your cwd. The skills split along the verbs you need: + + - write-file(path, content) create or replace a file at `path` + - edit-file(path, mode, ...) change part of an existing file + - run-file(path) execute a file in the sandbox + - execute-code(code) one-shot throwaway Python; auto-saved to scripts/ + - append-notebook-cell / run-notebook-cell / edit-notebook-cell / + delete-notebook-cell / rerun-notebook-cell for interactive work + + **Rule of three:** before pasting code into a third execute-code call on + the same theme, stop and refactor. write-file(`src/{name}.py`, ...) the + shared body, then run-file it (or `from {name} import ...` in your next + cell). This holds for notebook cells too — `import` from `src/` works + inside a kernel cell. + + **Edit, don't repaste.** If you'd be about to write-file a near-duplicate + of an existing module, edit-file the existing one. `scripts/` is the + audit log of what ran; `src/` is the library that survives. Keep `src/` + small and edited; let `scripts/` accumulate. + + - Anything you write to `src/{name}.py` is importable from later calls + (`from features import build_X`). Module names must be plain Python + identifiers — no hyphens, no step prefixes. ## Environment - `execute-code` runs Python in an isolated sandbox with pandas, numpy, matplotlib, diff --git a/backend/agents/data_prep.yaml b/backend/agents/data_prep.yaml index f2659d3..4b57e1c 100644 --- a/backend/agents/data_prep.yaml +++ b/backend/agents/data_prep.yaml @@ -9,6 +9,12 @@ skills: - name: execute-code - name: append-notebook-cell - name: run-notebook-cell + - name: write-file + - name: edit-file + - name: run-file + - name: edit-notebook-cell + - name: delete-notebook-cell + - name: rerun-notebook-cell - name: read-notebook - name: delegate-task - name: request-clarification @@ -65,11 +71,28 @@ system: | ALWAYS create directories before writing: `os.makedirs('/data/sessions/{session_id}/data/', exist_ok=True)`. - ## Workspace is a real Python repo + ## Authoring vs running — use the right verb - The session directory is your cwd in every `execute-code` and notebook - cell, and `src/` is on `sys.path` with an auto-created `__init__.py`. - Any `.py` file you write under `src/` can be imported by later calls. + Your session is a real Python repo. `/sessions/{sid}/src/` is on sys.path, + `/sessions/{sid}/` is your cwd. The skills split along the verbs you need: + + - write-file(path, content) create or replace a file at `path` + - edit-file(path, mode, ...) change part of an existing file + - run-file(path) execute a file in the sandbox + - execute-code(code) one-shot throwaway Python; auto-saved to scripts/ + - append-notebook-cell / run-notebook-cell / edit-notebook-cell / + delete-notebook-cell / rerun-notebook-cell for interactive work + + **Rule of three:** before pasting code into a third execute-code call on + the same theme, stop and refactor. write-file(`src/{name}.py`, ...) the + shared body, then run-file it (or `from {name} import ...` in your next + cell). This holds for notebook cells too — `import` from `src/` works + inside a kernel cell. + + **Edit, don't repaste.** If you'd be about to write-file a near-duplicate + of an existing module, edit-file the existing one. `scripts/` is the + audit log of what ran; `src/` is the library that survives. Keep `src/` + small and edited; let `scripts/` accumulate. - Factor reusable prep building blocks (raw loaders, cleaning passes, a fitted `Pipeline.transform` wrapper, the encoders / scalers diff --git a/backend/agents/eda.yaml b/backend/agents/eda.yaml index 9272716..76d5283 100644 --- a/backend/agents/eda.yaml +++ b/backend/agents/eda.yaml @@ -9,6 +9,12 @@ skills: - name: execute-code - name: append-notebook-cell - name: run-notebook-cell + - name: write-file + - name: edit-file + - name: run-file + - name: edit-notebook-cell + - name: delete-notebook-cell + - name: rerun-notebook-cell - name: read-notebook - name: delegate-task - name: request-clarification @@ -57,17 +63,37 @@ system: | ALWAYS create directories before writing: `os.makedirs('/data/sessions/{session_id}/figures/', exist_ok=True)`. - ## Workspace is a real Python repo + ## Authoring vs running — use the right verb - The session directory is your cwd in every `execute-code` and notebook - cell, and `src/` is on `sys.path` with an auto-created `__init__.py`. - Any `.py` file you write under `src/` can be imported by later cells. + Your session is a real Python repo. `/sessions/{sid}/src/` is on sys.path, + `/sessions/{sid}/` is your cwd. The skills split along the verbs you need: + + - write-file(path, content) create or replace a file at `path` + - edit-file(path, mode, ...) change part of an existing file + - run-file(path) execute a file in the sandbox + - execute-code(code) one-shot throwaway Python; auto-saved to scripts/ + - append-notebook-cell / run-notebook-cell / edit-notebook-cell / + delete-notebook-cell / rerun-notebook-cell for interactive work + + **Rule of three:** before pasting code into a third execute-code call on + the same theme, stop and refactor. write-file(`src/{name}.py`, ...) the + shared body, then run-file it (or `from {name} import ...` in your next + cell). This holds for notebook cells too — `import` from `src/` works + inside a kernel cell. + + **Edit, don't repaste.** If you'd be about to write-file a near-duplicate + of an existing module, edit-file the existing one. `scripts/` is the + audit log of what ran; `src/` is the library that survives. Keep `src/` + small and edited; let `scripts/` accumulate. - For EDA you'll mostly stay in notebooks (kernel state already persists across cells), but the `src/` convention is useful when you want to promote a reusable helper out of a notebook into a module — e.g. `src/loaders.py` with `load_raw()` — so data_prep can `from loaders import load_raw` instead of rewriting it. + - When you find yourself pasting the same helper into a third notebook + cell, write-file it to `src/{name}.py` and import it in the next + cell — the kernel will find it without restart. - Module names must be plain Python identifiers (no hyphens, no step prefixes). The auto-saved per-call scripts under `scripts/` are step-prefixed by design and are NOT importable. diff --git a/backend/agents/feature_eng.yaml b/backend/agents/feature_eng.yaml index 6723446..c552f63 100644 --- a/backend/agents/feature_eng.yaml +++ b/backend/agents/feature_eng.yaml @@ -9,6 +9,12 @@ skills: - name: execute-code - name: append-notebook-cell - name: run-notebook-cell + - name: write-file + - name: edit-file + - name: run-file + - name: edit-notebook-cell + - name: delete-notebook-cell + - name: rerun-notebook-cell - name: read-notebook - name: delegate-task - name: request-clarification @@ -55,11 +61,28 @@ system: | ALWAYS create directories first: `os.makedirs('/data/sessions/{session_id}/features/', exist_ok=True)`. - ## Workspace is a real Python repo + ## Authoring vs running — use the right verb - The session directory is your cwd in every `execute-code` and notebook - cell, and `src/` is on `sys.path` with an auto-created `__init__.py`. - Any `.py` file you write under `src/` can be imported by later calls. + Your session is a real Python repo. `/sessions/{sid}/src/` is on sys.path, + `/sessions/{sid}/` is your cwd. The skills split along the verbs you need: + + - write-file(path, content) create or replace a file at `path` + - edit-file(path, mode, ...) change part of an existing file + - run-file(path) execute a file in the sandbox + - execute-code(code) one-shot throwaway Python; auto-saved to scripts/ + - append-notebook-cell / run-notebook-cell / edit-notebook-cell / + delete-notebook-cell / rerun-notebook-cell for interactive work + + **Rule of three:** before pasting code into a third execute-code call on + the same theme, stop and refactor. write-file(`src/{name}.py`, ...) the + shared body, then run-file it (or `from {name} import ...` in your next + cell). This holds for notebook cells too — `import` from `src/` works + inside a kernel cell. + + **Edit, don't repaste.** If you'd be about to write-file a near-duplicate + of an existing module, edit-file the existing one. `scripts/` is the + audit log of what ran; `src/` is the library that survives. Keep `src/` + small and edited; let `scripts/` accumulate. - Factor reusable feature builders (date-part extractors, target encoders, interaction-feature helpers) into `src/features.py` and diff --git a/backend/agents/orchestrator.yaml b/backend/agents/orchestrator.yaml index c3e79a7..7e31479 100644 --- a/backend/agents/orchestrator.yaml +++ b/backend/agents/orchestrator.yaml @@ -9,6 +9,12 @@ skills: - name: execute-code - name: append-notebook-cell - name: run-notebook-cell + - name: write-file + - name: edit-file + - name: run-file + - name: edit-notebook-cell + - name: delete-notebook-cell + - name: rerun-notebook-cell - name: read-notebook - name: delegate-task - name: request-clarification @@ -215,10 +221,32 @@ system: | `report.md`, `data/`, `figures/`, `models/`, `features/`) - Reports from previous agents in this session are injected into each sub-agent's system prompt via `## Prior context` - - The workspace behaves like a Python repo: it's the cwd of every - `execute-code` call and notebook cell, and `src/` is on `sys.path`. - Sub-agents should factor reusable helpers into `src/{name}.py` and - `import` them across calls rather than re-pasting boilerplate. + ## Authoring vs running — use the right verb + + Your session is a real Python repo. `/sessions/{sid}/src/` is on sys.path, + `/sessions/{sid}/` is your cwd. The skills split along the verbs you need: + + - write-file(path, content) create or replace a file at `path` + - edit-file(path, mode, ...) change part of an existing file + - run-file(path) execute a file in the sandbox + - execute-code(code) one-shot throwaway Python; auto-saved to scripts/ + - append-notebook-cell / run-notebook-cell / edit-notebook-cell / + delete-notebook-cell / rerun-notebook-cell for interactive work + + **Rule of three:** before pasting code into a third execute-code call on + the same theme, stop and refactor. write-file(`src/{name}.py`, ...) the + shared body, then run-file it (or `from {name} import ...` in your next + cell). This holds for notebook cells too — `import` from `src/` works + inside a kernel cell. + + **Edit, don't repaste.** If you'd be about to write-file a near-duplicate + of an existing module, edit-file the existing one. `scripts/` is the + audit log of what ran; `src/` is the library that survives. Keep `src/` + small and edited; let `scripts/` accumulate. + + - Sub-agents share this same surface — when you delegate, they can + write-file reusable helpers into `src/{name}.py` and `import` them + across calls rather than re-pasting boilerplate. ## Collaboration & skepticism - Be skeptical. Do not assume — verify. If a user request is ambiguous, or if diff --git a/backend/agents/trainer.yaml b/backend/agents/trainer.yaml index 6b3144e..efe60a6 100644 --- a/backend/agents/trainer.yaml +++ b/backend/agents/trainer.yaml @@ -10,6 +10,12 @@ skills: - name: execute-code - name: append-notebook-cell - name: run-notebook-cell + - name: write-file + - name: edit-file + - name: run-file + - name: edit-notebook-cell + - name: delete-notebook-cell + - name: rerun-notebook-cell - name: read-notebook - name: delegate-task - name: request-clarification @@ -68,12 +74,28 @@ system: | ALWAYS create directories before writing: `os.makedirs('/data/sessions/{session_id}/models/', exist_ok=True)`. - ## Workspace is a real Python repo + ## Authoring vs running — use the right verb - The session directory is your cwd in every `execute-code` and notebook - cell. Any `.py` file you write under `src/` can be imported by later - calls — `src/` is on `sys.path` and `src/__init__.py` is created for - you on first boot. + Your session is a real Python repo. `/sessions/{sid}/src/` is on sys.path, + `/sessions/{sid}/` is your cwd. The skills split along the verbs you need: + + - write-file(path, content) create or replace a file at `path` + - edit-file(path, mode, ...) change part of an existing file + - run-file(path) execute a file in the sandbox + - execute-code(code) one-shot throwaway Python; auto-saved to scripts/ + - append-notebook-cell / run-notebook-cell / edit-notebook-cell / + delete-notebook-cell / rerun-notebook-cell for interactive work + + **Rule of three:** before pasting code into a third execute-code call on + the same theme, stop and refactor. write-file(`src/{name}.py`, ...) the + shared body, then run-file it (or `from {name} import ...` in your next + cell). This holds for notebook cells too — `import` from `src/` works + inside a kernel cell. + + **Edit, don't repaste.** If you'd be about to write-file a near-duplicate + of an existing module, edit-file the existing one. `scripts/` is the + audit log of what ran; `src/` is the library that survives. Keep `src/` + small and edited; let `scripts/` accumulate. - Factor reusable training-loop building blocks (data loading, evaluator helpers, the `objective(trial)` function for Optuna, diff --git a/backend/services/notebook_store.py b/backend/services/notebook_store.py index f83034f..8fdd1e5 100644 --- a/backend/services/notebook_store.py +++ b/backend/services/notebook_store.py @@ -239,6 +239,82 @@ async def append_cell( } +async def edit_cell( + session_id: str, + name: str, + cell_id: str, + source: str, + cell_type: Optional[str] = None, +) -> dict: + """Replace a cell's source in place. Outputs are preserved by default — + same behavior as `apply_source_update`'s output carry-over for the + frontend HTTP PUT path. This is the per-cell agent surface. + """ + if cell_type is not None and cell_type not in ("code", "markdown"): + raise ValueError(f"cell_type must be 'code' or 'markdown', got {cell_type!r}") + + name = sanitize_name(name) + key = (session_id, name) + nb = await load(session_id, name) + if nb is None: + raise ValueError(f"Notebook '{name}' not found") + + async with _lock(key): + cell = next((c for c in nb.cells if c.get("id") == cell_id), None) + if cell is None: + raise ValueError(f"Cell '{cell_id}' not found in notebook '{name}'") + cell["source"] = source + if cell_type is not None and cell_type != cell.get("cell_type"): + cell["cell_type"] = cell_type + if cell_type == "code": + cell.setdefault("outputs", []) + cell["execution_count"] = None + else: + cell.pop("outputs", None) + cell.pop("execution_count", None) + _cache[key] = nb + + await save(session_id, name) + return { + "notebook_name": name, + "notebook_path": notebook_path(session_id, name), + "cell_id": cell_id, + "source_len": len(source), + "total_cells": len(nb.cells), + } + + +async def delete_cell( + session_id: str, + name: str, + cell_id: str, +) -> dict: + """Remove a cell from the notebook and persist.""" + name = sanitize_name(name) + key = (session_id, name) + nb = await load(session_id, name) + if nb is None: + raise ValueError(f"Notebook '{name}' not found") + + async with _lock(key): + idx = next( + (i for i, c in enumerate(nb.cells) if c.get("id") == cell_id), + None, + ) + if idx is None: + raise ValueError(f"Cell '{cell_id}' not found in notebook '{name}'") + nb.cells.pop(idx) + _cache[key] = nb + + await save(session_id, name) + return { + "notebook_name": name, + "notebook_path": notebook_path(session_id, name), + "cell_id": cell_id, + "remaining_cells": len(nb.cells), + } + + async def on_cell_event( session_id: str, name: str, diff --git a/backend/services/skills/state.py b/backend/services/skills/state.py index 11fbce3..6225b08 100644 --- a/backend/services/skills/state.py +++ b/backend/services/skills/state.py @@ -90,6 +90,43 @@ def _max_existing_step(session_id: str) -> int: return highest +def resolve_session_path(session_id: str, raw: str) -> tuple[str, str]: + """Resolve a skill-supplied file path against the session workspace. + + Accepts volume-absolute paths (`/sessions/{sid}/...`), sandbox-absolute + paths (`/data/sessions/{sid}/...`), or paths relative to the session + workspace (`src/foo.py`). Returns `(volume_path, relative_path)` where + `volume_path` is what the volume API expects and `relative_path` is the + path as seen from the sandbox cwd (the session workspace). + + Raises ValueError for anything outside the session workspace. + """ + if not isinstance(raw, str) or not raw.strip(): + raise ValueError("`path` must be a non-empty string.") + p = raw.strip() + prefix = f"/sessions/{session_id}/" + sandbox_prefix = f"/data/sessions/{session_id}/" + if p.startswith(sandbox_prefix): + rel = p[len(sandbox_prefix) :] + elif p.startswith(prefix): + rel = p[len(prefix) :] + elif p.startswith("/"): + raise ValueError( + f"Path must live under the session workspace ({prefix}...), got {raw!r}." + ) + else: + rel = p[2:] if p.startswith("./") else p + if ( + not rel + or rel == ".." + or rel.startswith("../") + or "/../" in rel + or rel.endswith("/..") + ): + raise ValueError(f"Path escapes the session workspace: {raw!r}") + return prefix + rel, rel + + def activate_tools(session_id: str, agent_id: str, slugs: list[str]) -> list[str]: """Mark capability skills as active for this (session, agent). diff --git a/backend/skills/delete-notebook-cell/SKILL.md b/backend/skills/delete-notebook-cell/SKILL.md new file mode 100644 index 0000000..fc3ee35 --- /dev/null +++ b/backend/skills/delete-notebook-cell/SKILL.md @@ -0,0 +1,34 @@ +--- +name: delete-notebook-cell +description: Remove a cell from a notebook by cell_id. The UI updates live. Pair with edit-notebook-cell to keep notebooks curated instead of append-only. +when_to_use: Removing a dead-end cell — a failed experiment, a duplicated paste, a scratch check that doesn't belong in the final narrative. Use read-notebook to find the cell_id first. +version: '0.1' +kind: capability +--- + +# delete-notebook-cell + +Delete a single cell from a notebook, identified by `cell_id`. The +on-volume `.ipynb` is updated immediately and the UI reflects the removal +live. + +Deleting is permanent for that cell — if the cell's outputs fed later +cells, those later cells still hold their old outputs; rerun them with +rerun-notebook-cell if the notebook should be recomputed. + +## When to use +Pruning cells that shouldn't survive: failed attempts, duplicate pastes, +scratch checks. Notebooks are curated documents — append is not the only +verb. + +## Inputs +- `notebook_name` (required): target notebook (e.g. "data-overview"). +- `cell_id` (required): id of the cell to delete. + +## Returns +```json +{ "ok": true, "notebook_name": "data-overview", "cell_id": "abc123", "remaining_cells": 4 } +``` + +## Failure modes +- Returns error when the notebook or the `cell_id` does not exist. diff --git a/backend/skills/delete-notebook-cell/handler.py b/backend/skills/delete-notebook-cell/handler.py new file mode 100644 index 0000000..8c49658 --- /dev/null +++ b/backend/skills/delete-notebook-cell/handler.py @@ -0,0 +1,108 @@ +"""delete-notebook-cell skill — remove a cell from a notebook. + +Wraps notebook_store.delete_cell. Emits `notebook.structure.changed` so +the UI updates live. +""" + +from __future__ import annotations + +import json +import logging +import time + +from services import notebook_store +from services.broadcaster import broadcaster + +logger = logging.getLogger(__name__) + + +def create_handler(session_id: str, publish_fn, **kwargs): + async def handler(args: dict): + start = time.time() + notebook_name = notebook_store.sanitize_name(args.get("notebook_name")) + cell_id = (args.get("cell_id") or "").strip() + + await publish_fn( + session_id, + "tool_start", + { + "tool": "delete_notebook_cell", + "input": {"notebook_name": notebook_name, "cell_id": cell_id}, + }, + role="tool", + ) + + async def _fail(msg: str): + await publish_fn( + session_id, + "tool_end", + { + "tool": "delete_notebook_cell", + "output": msg, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": msg}], "is_error": True} + + if not cell_id: + return await _fail("`cell_id` must be a non-empty string.") + + try: + info = await notebook_store.delete_cell(session_id, notebook_name, cell_id) + except ValueError as e: + return await _fail(str(e)) + except Exception as e: + logger.exception("delete_notebook_cell failed") + return await _fail(f"Failed to delete cell {cell_id}: {e}") + + try: + await broadcaster.publish( + session_id, + { + "type": "notebook.structure.changed", + "data": { + "reason": "agent_delete", + "notebook_name": notebook_name, + "notebook_path": info["notebook_path"], + "cell_id": cell_id, + "total_cells": info["remaining_cells"], + }, + }, + ) + except Exception as e: + logger.debug("broadcast failed: %s", e) + + summary = ( + f"Deleted cell {cell_id} from {notebook_name}.ipynb " + f"({info['remaining_cells']} cells remaining)" + ) + await publish_fn( + session_id, + "tool_end", + { + "tool": "delete_notebook_cell", + "output": summary, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + + return { + "content": [ + { + "type": "text", + "text": json.dumps( + { + "ok": True, + "notebook_name": notebook_name, + "notebook_path": info["notebook_path"], + "cell_id": cell_id, + "remaining_cells": info["remaining_cells"], + } + ), + } + ] + } + + return handler diff --git a/backend/skills/delete-notebook-cell/schema.yaml b/backend/skills/delete-notebook-cell/schema.yaml new file mode 100644 index 0000000..8d4ef94 --- /dev/null +++ b/backend/skills/delete-notebook-cell/schema.yaml @@ -0,0 +1,11 @@ +type: object +properties: + notebook_name: + type: string + description: Target notebook (e.g. "data-overview"). Defaults to + "scratch" if omitted. + cell_id: + type: string + description: Id of the cell to delete (see read-notebook). +required: +- cell_id diff --git a/backend/skills/edit-file/SKILL.md b/backend/skills/edit-file/SKILL.md new file mode 100644 index 0000000..cf20acc --- /dev/null +++ b/backend/skills/edit-file/SKILL.md @@ -0,0 +1,47 @@ +--- +name: edit-file +description: Change part of an existing file in the session workspace — exact-anchor replace, or full overwrite as an escape hatch. Emits file_updated; never creates files and never writes into the scripts/ audit log. +when_to_use: Modifying an existing module (e.g. adding a function to src/loaders.py) instead of re-pasting the whole file. For new files use write-file; for one-shot Python use execute-code. +version: '0.1' +kind: capability +--- + +# edit-file + +Edit an existing file in place. Two modes: + +- `mode="replace"` (default): exact-anchor substitution. `old` must match + the current file content **exactly once** — zero matches or multiple + matches both return an error, so the edit can never land in the wrong + place. This is the same anchor model as the Edit tool you already know. +- `mode="overwrite"`: `content` replaces the whole file. Escape hatch for + when the file is too dirty to anchor — prefer write-file for planned + rewrites and replace-mode for surgical changes. + +Edit, don't repaste: if you are about to write-file a near-duplicate of +an existing module, edit-file the existing one instead. Emits a +`file_updated` event (never `file_created` — the file must already +exist). Does NOT write into `scripts/` — authoring is not an execution +step. + +## When to use +Changing part of an existing file — adding/removing/renaming a function +in `src/`, fixing a constant, updating a config. + +## Inputs +- `path` (required): file to edit. Same resolution rules as write-file. +- `mode` (optional, default `"replace"`): `"replace"` or `"overwrite"`. +- `old` (required for replace): exact string to find; must occur exactly once. +- `new` (required for replace): replacement string. +- `content` (required for overwrite): full new file content. + +## Returns +```json +{ "ok": true, "path": "/sessions/{sid}/src/loaders.py", "bytes_written": 540, "replacements": 1 } +``` + +## Failure modes +- Returns error when the file does not exist (use write-file to create it). +- Returns error in replace mode when `old` is not found, or matches more + than once — re-read the file and pick a longer, unique anchor. +- Returns error when `path` escapes the session workspace. diff --git a/backend/skills/edit-file/handler.py b/backend/skills/edit-file/handler.py new file mode 100644 index 0000000..4f32870 --- /dev/null +++ b/backend/skills/edit-file/handler.py @@ -0,0 +1,132 @@ +"""edit-file skill — change part of an existing file in place. + +Reads via services.volume.read_volume_file_async, applies the patch, +writes back via write_to_volume. v1 supports two modes: exact-anchor +`replace` (old must match exactly once) and whole-file `overwrite`. +Emits `file_updated`, never `file_created` — the file must already +exist. Does NOT write into `scripts/` — authoring is not an execution +step. +""" + +from __future__ import annotations + +import json +import logging +import time + +from services.skills.state import _known_files, resolve_session_path +from services.volume import read_volume_file_async, write_to_volume + +logger = logging.getLogger(__name__) + + +def create_handler(session_id: str, stage: str = None, publish_fn=None, **kwargs): + """Factory: create an edit_file handler bound to a session/stage.""" + + async def handler(args: dict): + start = time.time() + raw_path = args.get("path") if isinstance(args, dict) else None + mode = (args.get("mode") or "replace") if isinstance(args, dict) else "replace" + + try: + path, _ = resolve_session_path(session_id, raw_path) + except ValueError as e: + return {"content": [{"type": "text", "text": str(e)}], "is_error": True} + if mode not in ("replace", "overwrite"): + msg = f"`mode` must be 'replace' or 'overwrite', got {mode!r}." + return {"content": [{"type": "text", "text": msg}], "is_error": True} + + await publish_fn( + session_id, + "tool_start", + {"tool": "edit_file", "input": {"path": path, "mode": mode}}, + role="tool", + ) + + async def _fail(msg: str): + await publish_fn( + session_id, + "tool_end", + { + "tool": "edit_file", + "output": msg, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": msg}], "is_error": True} + + try: + raw = await read_volume_file_async(path) + except Exception: + return await _fail( + f"File not found: {path}. edit-file only modifies existing " + "files — use write-file to create one." + ) + try: + current = raw.decode("utf-8") + except UnicodeDecodeError: + return await _fail(f"File is not UTF-8 text: {path}.") + + if mode == "replace": + old = args.get("old") + new = args.get("new") + if not isinstance(old, str) or not old: + return await _fail("`old` must be a non-empty string (mode=replace).") + if not isinstance(new, str): + return await _fail("`new` must be a string (mode=replace).") + matches = current.count(old) + if matches == 0: + return await _fail( + f"Anchor not found in {path}. Re-read the file and " + "provide an exact `old` string." + ) + if matches > 1: + return await _fail( + f"Anchor matches {matches} times in {path} — the edit " + "must be unambiguous. Provide a longer, unique `old` " + "string." + ) + updated = current.replace(old, new, 1) + replacements = 1 + else: + content = args.get("content") + if not isinstance(content, str): + return await _fail("`content` must be a string (mode=overwrite).") + updated = content + replacements = 0 + + try: + await write_to_volume(updated, path) + except Exception as e: + logger.exception("edit_file failed for %s", path) + return await _fail(f"Failed to write {path}: {e}") + + name = path.rsplit("/", 1)[-1] + _known_files.setdefault(session_id, set()).add(path) + await publish_fn( + session_id, + "file_updated", + {"path": path, "name": name, "type": "file", "stage": stage}, + ) + + result = { + "ok": True, + "path": path, + "bytes_written": len(updated.encode("utf-8")), + "replacements": replacements, + } + await publish_fn( + session_id, + "tool_end", + { + "tool": "edit_file", + "output": f"Updated {path} ({result['bytes_written']} bytes, " + f"{replacements} replacement(s))", + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": json.dumps(result)}]} + + return handler diff --git a/backend/skills/edit-file/schema.yaml b/backend/skills/edit-file/schema.yaml new file mode 100644 index 0000000..4e52117 --- /dev/null +++ b/backend/skills/edit-file/schema.yaml @@ -0,0 +1,25 @@ +type: object +properties: + path: + type: string + description: File to edit. Absolute under /sessions/{sid}/ (or + /data/sessions/{sid}/), or relative to the session workspace. + mode: + type: string + enum: [replace, overwrite] + description: '"replace" = exact-anchor substitution (old must match + exactly once); "overwrite" = content replaces the whole file. + ' + default: replace + old: + type: string + description: Required for mode=replace. Exact string to find; must + occur exactly once in the file. + new: + type: string + description: Required for mode=replace. Replacement string. + content: + type: string + description: Required for mode=overwrite. Full new file content. +required: +- path diff --git a/backend/skills/edit-notebook-cell/SKILL.md b/backend/skills/edit-notebook-cell/SKILL.md new file mode 100644 index 0000000..bffb52e --- /dev/null +++ b/backend/skills/edit-notebook-cell/SKILL.md @@ -0,0 +1,39 @@ +--- +name: edit-notebook-cell +description: Replace the source of an existing notebook cell in place (outputs preserved). Notebooks are no longer append-only — fix a cell instead of appending a corrected copy. +when_to_use: Changing the code or markdown of a cell that already exists — fixing a bug in an earlier cell, tightening a query, rewriting a markdown summary. Use read-notebook to find the cell_id first. +version: '0.1' +kind: capability +--- + +# edit-notebook-cell + +Replace a cell's `source` in place, identified by `cell_id`. The cell's +outputs are preserved (same behavior as the frontend's editor), so the +notebook keeps its rendered results until you rerun the cell. + +Editing a code cell does NOT re-execute it — pair with rerun-notebook-cell +when the new source should run. The UI updates live via the same +notebook-structure event the other notebook skills emit. + +## When to use +Fixing or refining an existing cell instead of appending a corrected +duplicate. Get `cell_id` values from read-notebook or from the result of +append-notebook-cell / run-notebook-cell. + +## Inputs +- `notebook_name` (required): target notebook (e.g. "data-overview"). +- `cell_id` (required): id of the cell to edit. +- `source` (required): full new cell source (Python for code cells, + markdown for markdown cells). +- `cell_type` (optional): `"code"` or `"markdown"` — converts the cell + type when different from the current one. + +## Returns +```json +{ "ok": true, "notebook_name": "data-overview", "cell_id": "abc123", "source_len": 210 } +``` + +## Failure modes +- Returns error when the notebook or the `cell_id` does not exist. +- Returns error when `source` is not a string. diff --git a/backend/skills/edit-notebook-cell/handler.py b/backend/skills/edit-notebook-cell/handler.py new file mode 100644 index 0000000..f6e66e3 --- /dev/null +++ b/backend/skills/edit-notebook-cell/handler.py @@ -0,0 +1,120 @@ +"""edit-notebook-cell skill — replace a cell's source in place. + +Wraps notebook_store.edit_cell (outputs preserved, same as the frontend's +PUT path). Emits `notebook.structure.changed` so the UI updates live. +""" + +from __future__ import annotations + +import json +import logging +import time + +from services import notebook_store +from services.broadcaster import broadcaster + +logger = logging.getLogger(__name__) + + +def create_handler(session_id: str, publish_fn, **kwargs): + async def handler(args: dict): + start = time.time() + notebook_name = notebook_store.sanitize_name(args.get("notebook_name")) + cell_id = (args.get("cell_id") or "").strip() + source = args.get("source") + cell_type = (args.get("cell_type") or "").strip() or None + + preview = source if isinstance(source, str) else "" + await publish_fn( + session_id, + "tool_start", + { + "tool": "edit_notebook_cell", + "input": { + "code": preview[:500], + "notebook_name": notebook_name, + "cell_id": cell_id, + }, + }, + role="tool", + ) + + async def _fail(msg: str): + await publish_fn( + session_id, + "tool_end", + { + "tool": "edit_notebook_cell", + "output": msg, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": msg}], "is_error": True} + + if not cell_id: + return await _fail("`cell_id` must be a non-empty string.") + if not isinstance(source, str): + return await _fail("`source` must be a string.") + + try: + info = await notebook_store.edit_cell( + session_id, notebook_name, cell_id, source, cell_type=cell_type + ) + except ValueError as e: + return await _fail(str(e)) + except Exception as e: + logger.exception("edit_notebook_cell failed") + return await _fail(f"Failed to edit cell {cell_id}: {e}") + + try: + await broadcaster.publish( + session_id, + { + "type": "notebook.structure.changed", + "data": { + "reason": "agent_edit", + "notebook_name": notebook_name, + "notebook_path": info["notebook_path"], + "cell_id": cell_id, + "total_cells": info["total_cells"], + }, + }, + ) + except Exception as e: + logger.debug("broadcast failed: %s", e) + + summary = ( + f"Edited cell {cell_id} in {notebook_name}.ipynb " + f"({info['source_len']} chars; outputs preserved — " + "rerun-notebook-cell to re-execute)" + ) + await publish_fn( + session_id, + "tool_end", + { + "tool": "edit_notebook_cell", + "output": summary, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + + return { + "content": [ + { + "type": "text", + "text": json.dumps( + { + "ok": True, + "notebook_name": notebook_name, + "notebook_path": info["notebook_path"], + "cell_id": cell_id, + "source_len": info["source_len"], + } + ), + } + ] + } + + return handler diff --git a/backend/skills/edit-notebook-cell/schema.yaml b/backend/skills/edit-notebook-cell/schema.yaml new file mode 100644 index 0000000..5c62f4d --- /dev/null +++ b/backend/skills/edit-notebook-cell/schema.yaml @@ -0,0 +1,20 @@ +type: object +properties: + notebook_name: + type: string + description: Target notebook (e.g. "data-overview"). Defaults to + "scratch" if omitted. + cell_id: + type: string + description: Id of the cell to edit (see read-notebook). + source: + type: string + description: Full new cell source. Python for code cells, markdown for + markdown cells. + cell_type: + type: string + enum: [code, markdown] + description: Optional — convert the cell to this type. +required: +- cell_id +- source diff --git a/backend/skills/execute-code/SKILL.md b/backend/skills/execute-code/SKILL.md index a30bdea..41aafb0 100644 --- a/backend/skills/execute-code/SKILL.md +++ b/backend/skills/execute-code/SKILL.md @@ -9,6 +9,11 @@ kind: capability # execute-code Execute Python code in an isolated Modal sandbox. +For one-shot, throwaway Python — "what's the dtype of column X", "print +the head", a quick plot. Every call is auto-saved to `scripts/` as the +audit log. For anything you'd want to call again, author a file with +write-file and execute it with run-file; change existing files with +edit-file. Don't use execute-code to `.write_text()` modules into place. Pre-installed: pandas, numpy, matplotlib, seaborn, scikit-learn, xgboost, lightgbm, pyarrow, openpyxl, duckdb, imbalanced-learn, optuna, category_encoders, pandera, shap, statsmodels, diff --git a/backend/skills/rerun-notebook-cell/SKILL.md b/backend/skills/rerun-notebook-cell/SKILL.md new file mode 100644 index 0000000..4bbd53d --- /dev/null +++ b/backend/skills/rerun-notebook-cell/SKILL.md @@ -0,0 +1,44 @@ +--- +name: rerun-notebook-cell +description: Re-execute an existing notebook cell (by cell_id) against the session's persistent kernel — outputs are cleared and streamed back fresh; variables from earlier cells stay in scope. +when_to_use: Re-running a cell after edit-notebook-cell changed its source, or recomputing a cell after upstream data changed — without appending a duplicate cell. Only works on code cells. +version: '0.1' +kind: capability +--- + +# rerun-notebook-cell + +Re-execute an existing code cell, identified by `cell_id`, against the +session's long-lived kernel — the same kernel run-notebook-cell uses, so +variables, imports, and fitted objects from earlier cells remain in +scope. The cell's previous outputs are cleared and the new outputs +stream back into the notebook live. + +This is the missing half of edit-notebook-cell: edit changes the source, +rerun makes it real. Neither appends a new cell — the notebook stays +curated. + +Note that `import` from `src/` works inside a kernel cell — after +write-file(`src/loaders.py`, ...), a rerun of a cell containing +`from loaders import load` picks the module up with no kernel restart. + +## When to use +Recomputing an existing cell: after editing its source, after upstream +data changed, or to refresh stale outputs. + +## Inputs +- `notebook_name` (required): target notebook (e.g. "data-overview"). +- `cell_id` (required): id of the code cell to re-execute. +- `timeout_seconds` (optional, default 300, max 1800): max wall-clock time. + +## Returns +Same shape as run-notebook-cell: +```json +{ "ok": true, "notebook_name": "data-overview", "cell_id": "abc123", + "exec_count": 7, "duration_ms": 412, "had_error": false, "outputs": "..." } +``` + +## Failure modes +- Returns error when the notebook or the `cell_id` does not exist. +- Returns error when the cell is a markdown cell (nothing to execute). +- Returns error when execution exceeds `timeout_seconds`. diff --git a/backend/skills/rerun-notebook-cell/handler.py b/backend/skills/rerun-notebook-cell/handler.py new file mode 100644 index 0000000..3ca4564 --- /dev/null +++ b/backend/skills/rerun-notebook-cell/handler.py @@ -0,0 +1,142 @@ +"""rerun-notebook-cell skill — re-execute an existing cell in place. + +Looks the cell up by `cell_id`, clears its outputs (via the kernel's +cell_started event, which notebook_store.on_cell_event handles), and +re-executes against the session's persistent kernel — the same kernel +run-notebook-cell uses, so variables from earlier cells stay in scope. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time + +from services import notebook_store +from services.kernel_manager import kernel_manager + +logger = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT = 300 +_MAX_TIMEOUT = 1800 +_MAX_OUTPUT_CHARS_PER_CELL = 4000 + + +def create_handler(session_id: str, publish_fn, **kwargs): + async def handler(args: dict): + start = time.time() + notebook_name = notebook_store.sanitize_name(args.get("notebook_name")) + cell_id = (args.get("cell_id") or "").strip() + try: + timeout = int(args.get("timeout_seconds") or _DEFAULT_TIMEOUT) + except (TypeError, ValueError): + timeout = _DEFAULT_TIMEOUT + timeout = max(10, min(timeout, _MAX_TIMEOUT)) + + await publish_fn( + session_id, + "tool_start", + { + "tool": "rerun_notebook_cell", + "input": {"notebook_name": notebook_name, "cell_id": cell_id}, + }, + role="tool", + ) + + async def _fail(msg: str, payload: dict | None = None): + await publish_fn( + session_id, + "tool_end", + { + "tool": "rerun_notebook_cell", + "output": msg, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + text = json.dumps(payload) if payload is not None else msg + return {"content": [{"type": "text", "text": text}], "is_error": True} + + if not cell_id: + return await _fail("`cell_id` must be a non-empty string.") + + nb = await notebook_store.load(session_id, notebook_name) + if nb is None: + return await _fail(f"Notebook '{notebook_name}' not found.") + cell = next((c for c in nb.cells if c.get("id") == cell_id), None) + if cell is None: + return await _fail( + f"Cell '{cell_id}' not found in notebook '{notebook_name}'." + ) + if cell.get("cell_type") != "code": + return await _fail( + f"Cell '{cell_id}' is a {cell.get('cell_type')} cell — only " + "code cells can be re-executed." + ) + code = notebook_store._src(cell) + notebook_path = notebook_store.notebook_path(session_id, notebook_name) + + try: + result = await kernel_manager.execute_and_wait( + session_id, cell_id, code, notebook_name=notebook_name, timeout=timeout + ) + except asyncio.TimeoutError: + err = f"Cell execution exceeded {timeout}s timeout." + return await _fail( + err, + { + "ok": False, + "notebook_name": notebook_name, + "cell_id": cell_id, + "error": err, + }, + ) + except Exception as e: + logger.exception("rerun_notebook_cell: execute failed") + return await _fail(f"Failed to execute cell {cell_id}: {e}") + + rendered_outputs = "" + nb = await notebook_store.load(session_id, notebook_name) + if nb is not None: + cell = next((c for c in nb.cells if c.get("id") == cell_id), None) + if cell is not None: + outs = notebook_store._format_outputs(cell.get("outputs", [])) + if len(outs) > _MAX_OUTPUT_CHARS_PER_CELL: + outs = outs[:_MAX_OUTPUT_CHARS_PER_CELL] + "\n… (outputs truncated)" + rendered_outputs = outs + + envelope = { + "ok": not result.get("had_error", False), + "notebook_name": notebook_name, + "notebook_path": notebook_path, + "cell_id": cell_id, + "exec_count": result.get("exec_count"), + "duration_ms": result.get("duration_ms"), + "had_error": result.get("had_error", False), + "outputs": rendered_outputs, + } + + status = "✗ error" if envelope["had_error"] else "✓ ok" + preview = rendered_outputs if rendered_outputs else "(no output)" + if len(preview) > 500: + preview = preview[:500] + "\n… (truncated)" + chat_out = ( + f"{status} · {notebook_name}.ipynb " + f"[{envelope.get('exec_count')}]" + f" · {envelope.get('duration_ms') or 0} ms\n\n{preview}" + ) + await publish_fn( + session_id, + "tool_end", + { + "tool": "rerun_notebook_cell", + "output": chat_out, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + + return {"content": [{"type": "text", "text": json.dumps(envelope)}]} + + return handler diff --git a/backend/skills/rerun-notebook-cell/schema.yaml b/backend/skills/rerun-notebook-cell/schema.yaml new file mode 100644 index 0000000..fba1456 --- /dev/null +++ b/backend/skills/rerun-notebook-cell/schema.yaml @@ -0,0 +1,15 @@ +type: object +properties: + notebook_name: + type: string + description: Target notebook (e.g. "data-overview"). Defaults to + "scratch" if omitted. + cell_id: + type: string + description: Id of the existing code cell to re-execute (see + read-notebook). + timeout_seconds: + type: integer + description: Max wall-clock time (default 300, max 1800). +required: +- cell_id diff --git a/backend/skills/run-file/SKILL.md b/backend/skills/run-file/SKILL.md new file mode 100644 index 0000000..7980fa1 --- /dev/null +++ b/backend/skills/run-file/SKILL.md @@ -0,0 +1,42 @@ +--- +name: run-file +description: Execute an existing file from the session workspace in an isolated sandbox (runpy run_path, __main__ semantics). Records a one-line runpy call in the scripts/ audit log — the log says what ran, the body stays on disk. +when_to_use: Running a module you already wrote (e.g. run-file src/training.py) instead of re-pasting its body or its call site into execute-code. For one-shot throwaway Python use execute-code. +version: '0.1' +kind: capability +--- + +# run-file + +Execute a file that already exists in the session workspace. The file +runs in a fresh sandbox under the same preamble as execute-code — +`src/` is on `sys.path`, the session workspace is the cwd — via +`runpy.run_path(path, run_name="__main__")`, so `if __name__ == "__main__":` +blocks fire. + +Audit-log behavior: run-file auto-saves a ONE-LINE `runpy.run_path(...)` +call to `scripts/step_NN_run_.py`. The audit log records "we ran +X", not X's body — the body already lives on disk as `src/X.py`. This is +what keeps `scripts/` a meaningful replay log. + +## When to use +Running a module you authored with write-file / edit-file. Reuse looks +like `run-file`, authoring looks like `write-file`/`edit-file`, one-shot +exploration looks like `execute-code` — pick the verb that matches the +intent. + +## Inputs +- `path` (required): file to execute. Same resolution rules as + write-file (e.g. `src/training.py` or `/sessions/{sid}/src/training.py`). +- `heavy` (optional, default `false`): set true for heavy ML workloads — + uses the project's training sandbox profile (GPU + extended timeout), + same as execute-code's heavy flag. + +## Returns +Same shape as execute-code: stdout text (plus exit code / stderr when +the run fails). + +## Failure modes +- Returns error when the file does not exist (author it with write-file first). +- Returns error when `path` escapes the session workspace. +- Returns error output (exit code + stderr) when the module itself raises. diff --git a/backend/skills/run-file/handler.py b/backend/skills/run-file/handler.py new file mode 100644 index 0000000..f84ec7f --- /dev/null +++ b/backend/skills/run-file/handler.py @@ -0,0 +1,233 @@ +"""run-file skill — execute an existing workspace file in the sandbox. + +Runs the file via `runpy.run_path(path, run_name="__main__")` through +services.sandbox.run_code, so it gets the same preamble as execute-code +(`src/` on sys.path, session workspace as cwd). The audit log gets a +one-line `runpy.run_path(...)` script — it records WHAT ran; the body +already lives on disk. +""" + +from __future__ import annotations + +import logging +import time + +import modal.exception as modal_exc + +from config import settings +from services.compute.base import SandboxTimeoutError +from services.compute_allowance import resolve_compute_allowance +from services.sandbox import run_code +from services.skills.state import ( + _known_files, + _script_filename, + resolve_session_path, +) +from services.volume import read_volume_file_async, write_to_volume + +logger = logging.getLogger(__name__) + + +async def detect_new_files(session_id: str, stage: str, publish_fn): + """Scan the session workspace and emit file_created for any new files. + + Same contract as the execute-code post-run scan. + """ + from services.volume import listdir_async, reload_volume_async + + workspace = f"/sessions/{session_id}" + try: + await reload_volume_async() + current_files = set() + for entry in await listdir_async(workspace, recursive=True): + if entry.type.name == "FILE": + current_files.add(entry.path) + + known = _known_files.get(session_id, set()) + new_files = current_files - known + _known_files[session_id] = current_files + + for path in sorted(new_files): + name = path.split("/")[-1] + await publish_fn( + session_id, + "file_created", + {"path": path, "name": name, "type": "file", "stage": stage}, + ) + + if new_files: + logger.info( + "Detected %d new files in session %s", len(new_files), session_id + ) + except Exception as e: + logger.warning("File detection error: %s", e) + + +def create_handler( + session_id: str, + stage: str = None, + publish_fn=None, + sandbox_config: dict | None = None, + parent_agent_type: str | None = None, + parent_agent_id: str | None = None, + **kwargs, +): + """Factory: create a run_file handler bound to a session/stage.""" + + _sandbox_config = sandbox_config or {} + _agent_type = parent_agent_type or stage + _agent_id = parent_agent_id or "root" + + async def handler(args: dict): + raw_path = args.get("path") if isinstance(args, dict) else None + heavy = args.get("heavy", False) if isinstance(args, dict) else False + + try: + path, rel_path = resolve_session_path(session_id, raw_path) + except ValueError as e: + return {"content": [{"type": "text", "text": str(e)}], "is_error": True} + + # Compute selection: the heavy flag picks the training profile, + # same as execute-code. An owner-set max_timeout caps every run. + allowance = resolve_compute_allowance(_sandbox_config) + profile_key = "training" if heavy else "default" + profile = _sandbox_config.get(profile_key) or {} + gpu = profile.get("gpu") + timeout = min( + profile.get("timeout") or settings.sandbox_timeout, allowance.max_timeout + ) + + start = time.time() + + await publish_fn( + session_id, + "tool_start", + { + "tool": "run_file", + "input": {"path": rel_path, "heavy": heavy}, + "gpu": gpu or "cpu", + "timeout": timeout, + }, + role="tool", + ) + + try: + await read_volume_file_async(path) + except Exception: + msg = ( + f"File not found: {path}. Author it with write-file first, " + "then run-file it." + ) + await publish_fn( + session_id, + "tool_end", + { + "tool": "run_file", + "output": msg, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": msg}], "is_error": True} + + # The sandbox cwd is the session workspace, so the relative path is + # what runpy sees. The leading comment makes the auto-saved audit + # script self-describing (scripts/step_NN_run_.py). + code = ( + f"# run {rel_path}\n" + "import runpy\n\n" + f"runpy.run_path({rel_path!r}, run_name='__main__')\n" + ) + + # Audit log: one-line "we ran X" entry — the body stays on disk. + filename = _script_filename(code, session_id) + script_path = f"/sessions/{session_id}/scripts/{filename}" + try: + await write_to_volume(code, script_path) + _known_files.setdefault(session_id, set()).add(script_path) + await publish_fn( + session_id, + "file_created", + {"path": script_path, "name": filename, "type": "file", "stage": stage}, + ) + except Exception as e: + logger.error("Failed to save script %s: %s", filename, e) + + try: + result = await run_code( + code, + session_id, + stage=stage, + gpu=gpu, + timeout=timeout, + agent_type=_agent_type, + agent_id=_agent_id, + ) + except ( + modal_exc.SandboxTimeoutError, + modal_exc.TimeoutError, + SandboxTimeoutError, + ) as e: + elapsed = round(time.time() - start, 1) + error_msg = ( + f"Sandbox timed out after {elapsed}s running {rel_path} " + f"(profile={profile_key}, configured timeout={timeout}s). " + "The Python process was killed mid-execution and partial " + "output (if any) is lost. Options: (a) split the work into " + "smaller files/functions, (b) reduce data size or iterations, " + "(c) re-run with heavy=true for the GPU/training profile. " + f"Underlying error: {e.__class__.__name__}" + ) + logger.warning("Sandbox timeout (session=%s): %s", session_id, e) + await publish_fn( + session_id, + "tool_end", + { + "tool": "run_file", + "output": error_msg, + "duration": elapsed, + "timed_out": True, + }, + role="tool", + ) + return {"content": [{"type": "text", "text": error_msg}], "is_error": True} + except Exception as e: + error_msg = f"Sandbox error: {e}" + await publish_fn( + session_id, + "tool_end", + { + "tool": "run_file", + "output": error_msg, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": error_msg}], "is_error": True} + + output = result["stdout"] + if result["returncode"] != 0: + output = ( + f"Exit code {result['returncode']}.\n" + f"STDOUT:\n{result['stdout']}\nSTDERR:\n{result['stderr']}" + ) + elif result["stderr"]: + output += f"\n[stderr]: {result['stderr']}" + output = output or "(no output)" + + await detect_new_files(session_id, stage, publish_fn) + + await publish_fn( + session_id, + "tool_end", + { + "tool": "run_file", + "output": output[:2000], + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + + return {"content": [{"type": "text", "text": output}]} + + return handler diff --git a/backend/skills/run-file/schema.yaml b/backend/skills/run-file/schema.yaml new file mode 100644 index 0000000..394f3e0 --- /dev/null +++ b/backend/skills/run-file/schema.yaml @@ -0,0 +1,17 @@ +type: object +properties: + path: + type: string + description: File to execute. Absolute under /sessions/{sid}/ (or + /data/sessions/{sid}/), or relative to the session workspace (e.g. + "src/training.py"). + heavy: + type: boolean + description: 'Set to true for heavy ML workloads (training, tuning, large + transforms). Uses the project''s training sandbox profile with GPU and + extended timeout. Leave false for lightweight runs. + + ' + default: false +required: +- path diff --git a/backend/skills/write-file/SKILL.md b/backend/skills/write-file/SKILL.md new file mode 100644 index 0000000..4cd2805 --- /dev/null +++ b/backend/skills/write-file/SKILL.md @@ -0,0 +1,46 @@ +--- +name: write-file +description: Create or replace a file in the session workspace (e.g. a reusable module under src/). Emits file_created / file_updated for the UI; does NOT write into the scripts/ audit log — authoring is not an execution step. +when_to_use: Authoring a new file or replacing a whole file — reusable modules under src/, config files, reports. For changing part of an existing file use edit-file; for one-shot throwaway Python use execute-code. +version: '0.1' +kind: capability +--- + +# write-file + +Write `content` to `path` inside the session workspace. This is the +authoring verb: use it to create reusable modules (e.g. `src/loaders.py`) +instead of running `Path(...).write_text(...)` inside execute-code. + +Files written under `src/` are importable from later execute-code calls +and notebook cells (`src/` is on `sys.path`, the session workspace is the +cwd). Module names must be plain Python identifiers — no hyphens, no +step prefixes. + +Unlike execute-code, write-file does NOT auto-save anything into +`scripts/` — that directory is the audit log of what *ran*, and +authoring a file is not an execution step. The UI is notified via a +`file_created` event (new file) or `file_updated` event (overwrite). + +## When to use +Authoring a new file or wholesale replacing one. To change part of an +existing file, use edit-file. To execute a file, use run-file. + +## Inputs +- `path` (required): target file. Absolute under `/sessions/{sid}/` (or + the sandbox spelling `/data/sessions/{sid}/`), or relative to the + session workspace (e.g. `src/loaders.py`). Paths outside the session + workspace are rejected. +- `content` (required): full file content (text). +- `overwrite` (optional, default `true`): when false, writing over an + existing file returns an error instead of clobbering it. + +## Returns +```json +{ "ok": true, "path": "/sessions/{sid}/src/loaders.py", "bytes_written": 512, "created": true } +``` + +## Failure modes +- Returns error when `path` is empty, escapes the session workspace, or + points outside `/sessions/{sid}/`. +- Returns error when the file exists and `overwrite` is false. diff --git a/backend/skills/write-file/handler.py b/backend/skills/write-file/handler.py new file mode 100644 index 0000000..a5ee9f7 --- /dev/null +++ b/backend/skills/write-file/handler.py @@ -0,0 +1,120 @@ +"""write-file skill — author a file in the session workspace. + +Wraps services.volume.write_to_volume. Emits `file_created` (new file) or +`file_updated` (overwrite) so the UI can distinguish authoring from +execution. Deliberately does NOT write into `scripts/` — that directory +is the audit log of what ran, and authoring is not an execution step. +""" + +from __future__ import annotations + +import json +import logging +import time + +from services.skills.state import _known_files, resolve_session_path +from services.volume import read_volume_file_async, write_to_volume + +logger = logging.getLogger(__name__) + + +async def _file_exists(path: str) -> bool: + try: + await read_volume_file_async(path) + return True + except Exception: + return False + + +def create_handler(session_id: str, stage: str = None, publish_fn=None, **kwargs): + """Factory: create a write_file handler bound to a session/stage.""" + + async def handler(args: dict): + start = time.time() + raw_path = args.get("path") if isinstance(args, dict) else None + content = args.get("content") if isinstance(args, dict) else None + overwrite = args.get("overwrite", True) if isinstance(args, dict) else True + + try: + path, _ = resolve_session_path(session_id, raw_path) + except ValueError as e: + return {"content": [{"type": "text", "text": str(e)}], "is_error": True} + if not isinstance(content, str): + msg = "`content` must be a string." + return {"content": [{"type": "text", "text": msg}], "is_error": True} + + await publish_fn( + session_id, + "tool_start", + { + "tool": "write_file", + "input": {"path": path, "bytes": len(content.encode("utf-8"))}, + }, + role="tool", + ) + + try: + existed = await _file_exists(path) + if existed and not overwrite: + msg = ( + f"File already exists and overwrite=false: {path}. " + "Use edit-file to change part of it, or re-call with " + "overwrite=true." + ) + await publish_fn( + session_id, + "tool_end", + { + "tool": "write_file", + "output": msg, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": msg}], "is_error": True} + + await write_to_volume(content, path) + except Exception as e: + logger.exception("write_file failed for %s", path) + err = f"Failed to write {path}: {e}" + await publish_fn( + session_id, + "tool_end", + { + "tool": "write_file", + "output": err, + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": err}], "is_error": True} + + name = path.rsplit("/", 1)[-1] + event = "file_updated" if existed else "file_created" + _known_files.setdefault(session_id, set()).add(path) + await publish_fn( + session_id, + event, + {"path": path, "name": name, "type": "file", "stage": stage}, + ) + + result = { + "ok": True, + "path": path, + "bytes_written": len(content.encode("utf-8")), + "created": not existed, + } + await publish_fn( + session_id, + "tool_end", + { + "tool": "write_file", + "output": f"{'Updated' if existed else 'Created'} {path} " + f"({result['bytes_written']} bytes)", + "duration": round(time.time() - start, 1), + }, + role="tool", + ) + return {"content": [{"type": "text", "text": json.dumps(result)}]} + + return handler diff --git a/backend/skills/write-file/schema.yaml b/backend/skills/write-file/schema.yaml new file mode 100644 index 0000000..810c431 --- /dev/null +++ b/backend/skills/write-file/schema.yaml @@ -0,0 +1,17 @@ +type: object +properties: + path: + type: string + description: Target file path. Absolute under /sessions/{sid}/ (or + /data/sessions/{sid}/), or relative to the session workspace (e.g. + "src/loaders.py"). Must stay inside the session workspace. + content: + type: string + description: Full file content to write. + overwrite: + type: boolean + description: When false, fail instead of replacing an existing file. + default: true +required: +- path +- content diff --git a/backend/tests/test_delete_notebook_cell.py b/backend/tests/test_delete_notebook_cell.py new file mode 100644 index 0000000..0302e57 --- /dev/null +++ b/backend/tests/test_delete_notebook_cell.py @@ -0,0 +1,99 @@ +"""Tests for the delete-notebook-cell skill handler (issue #85).""" + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import nbformat +import pytest + +HANDLER_PATH = ( + Path(__file__).resolve().parents[1] + / "skills" + / "delete-notebook-cell" + / "handler.py" +) + + +def _load_handler_module(): + spec = importlib.util.spec_from_file_location( + "delete_notebook_cell_handler", HANDLER_PATH + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def handler_mod(): + mod = _load_handler_module() + mod.broadcaster = SimpleNamespace(publish=AsyncMock()) + return mod + + +@pytest.fixture +def seeded_store(monkeypatch): + """Real notebook_store with an isolated cache and captured saves.""" + from services import notebook_store + + notebook_store._cache.clear() + notebook_store._locks.clear() + monkeypatch.setattr(notebook_store, "upload_to_volume", AsyncMock()) + monkeypatch.setattr( + notebook_store, + "read_volume_file_async", + AsyncMock(side_effect=FileNotFoundError), + ) + nb = nbformat.v4.new_notebook() + keep = nbformat.v4.new_code_cell("x = 1") + drop = nbformat.v4.new_code_cell("print('dead end')") + nb.cells = [keep, drop] + notebook_store._cache[("sess-nc2", "scratch")] = nb + yield notebook_store, keep, drop + notebook_store._cache.clear() + notebook_store._locks.clear() + + +class _PublishRecorder: + def __init__(self): + self.events = [] + + async def __call__(self, session_id, event_type, data, role=None, **kwargs): + self.events.append({"type": event_type, "data": data, "role": role}) + + +@pytest.mark.asyncio +async def test_happy_path_deletes_and_broadcasts(handler_mod, seeded_store): + store, keep, drop = seeded_store + publish = _PublishRecorder() + handler = handler_mod.create_handler(session_id="sess-nc2", publish_fn=publish) + result = await handler({"cell_id": drop["id"]}) + assert not result.get("is_error") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert payload["cell_id"] == drop["id"] + assert payload["remaining_cells"] == 1 + + nb = store._cache[("sess-nc2", "scratch")] + assert [c["id"] for c in nb.cells] == [keep["id"]] + + # On-volume .ipynb updated. + store.upload_to_volume.assert_awaited_once() + + # UI live-update event. + _, event = handler_mod.broadcaster.publish.await_args.args + assert event["type"] == "notebook.structure.changed" + assert event["data"]["reason"] == "agent_delete" + assert event["data"]["total_cells"] == 1 + + +@pytest.mark.asyncio +async def test_unknown_cell_is_error(handler_mod, seeded_store): + publish = _PublishRecorder() + handler = handler_mod.create_handler(session_id="sess-nc2", publish_fn=publish) + result = await handler({"cell_id": "nope"}) + assert result["is_error"] is True + assert "not found" in result["content"][0]["text"] + handler_mod.broadcaster.publish.assert_not_awaited() diff --git a/backend/tests/test_edit_file.py b/backend/tests/test_edit_file.py new file mode 100644 index 0000000..2044a0d --- /dev/null +++ b/backend/tests/test_edit_file.py @@ -0,0 +1,151 @@ +"""Tests for the edit-file skill handler (issue #85).""" + +import importlib.util +import json +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +HANDLER_PATH = ( + Path(__file__).resolve().parents[1] / "skills" / "edit-file" / "handler.py" +) + + +def _load_handler_module(): + spec = importlib.util.spec_from_file_location("edit_file_handler", HANDLER_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def handler_mod(): + mod = _load_handler_module() + mod.write_to_volume = AsyncMock() + mod.read_volume_file_async = AsyncMock(return_value=b"def load():\n return 1\n") + return mod + + +class _PublishRecorder: + def __init__(self): + self.events = [] + + async def __call__(self, session_id, event_type, data, role=None, **kwargs): + self.events.append({"type": event_type, "data": data, "role": role}) + + def of_type(self, event_type): + return [e for e in self.events if e["type"] == event_type] + + +def _make_handler(mod, session_id="sess-e1"): + publish = _PublishRecorder() + handler = mod.create_handler( + session_id=session_id, stage="data_prep", publish_fn=publish + ) + return handler, publish + + +class TestReplaceMode: + @pytest.mark.asyncio + async def test_happy_path_mutates_and_emits_file_updated(self, handler_mod): + handler, publish = _make_handler(handler_mod) + result = await handler( + { + "path": "src/loaders.py", + "mode": "replace", + "old": "def load():", + "new": "def load(n=1):", + } + ) + assert not result.get("is_error") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert payload["replacements"] == 1 + + handler_mod.write_to_volume.assert_awaited_once_with( + "def load(n=1):\n return 1\n", "/sessions/sess-e1/src/loaders.py" + ) + updated = publish.of_type("file_updated") + assert len(updated) == 1 + assert updated[0]["data"]["path"] == "/sessions/sess-e1/src/loaders.py" + # edit-file never creates and never touches the audit log. + assert publish.of_type("file_created") == [] + for call in handler_mod.write_to_volume.await_args_list: + assert "/scripts/" not in call.args[1] + + @pytest.mark.asyncio + async def test_anchor_not_found(self, handler_mod): + handler, _ = _make_handler(handler_mod) + result = await handler( + {"path": "src/loaders.py", "old": "def missing():", "new": "x"} + ) + assert result["is_error"] is True + assert "Anchor not found" in result["content"][0]["text"] + handler_mod.write_to_volume.assert_not_awaited() + + @pytest.mark.asyncio + async def test_anchor_matching_twice_rejected(self, handler_mod): + handler_mod.read_volume_file_async = AsyncMock( + return_value=b"return 1\nreturn 1\n" + ) + handler, _ = _make_handler(handler_mod) + result = await handler( + {"path": "src/loaders.py", "old": "return 1", "new": "return 2"} + ) + assert result["is_error"] is True + assert "matches 2 times" in result["content"][0]["text"] + handler_mod.write_to_volume.assert_not_awaited() + + @pytest.mark.asyncio + async def test_replace_is_default_mode(self, handler_mod): + handler, _ = _make_handler(handler_mod) + result = await handler( + {"path": "src/loaders.py", "old": "return 1", "new": "return 2"} + ) + assert not result.get("is_error") + handler_mod.write_to_volume.assert_awaited_once_with( + "def load():\n return 2\n", "/sessions/sess-e1/src/loaders.py" + ) + + +class TestOverwriteMode: + @pytest.mark.asyncio + async def test_overwrite_replaces_whole_file(self, handler_mod): + handler, publish = _make_handler(handler_mod) + result = await handler( + {"path": "src/loaders.py", "mode": "overwrite", "content": "X = 1\n"} + ) + assert not result.get("is_error") + payload = json.loads(result["content"][0]["text"]) + assert payload["replacements"] == 0 + handler_mod.write_to_volume.assert_awaited_once_with( + "X = 1\n", "/sessions/sess-e1/src/loaders.py" + ) + assert len(publish.of_type("file_updated")) == 1 + + +class TestFailures: + @pytest.mark.asyncio + async def test_missing_file_is_error_never_creates(self, handler_mod): + handler_mod.read_volume_file_async = AsyncMock(side_effect=FileNotFoundError) + handler, publish = _make_handler(handler_mod) + result = await handler({"path": "src/nope.py", "old": "a", "new": "b"}) + assert result["is_error"] is True + assert "write-file" in result["content"][0]["text"] + handler_mod.write_to_volume.assert_not_awaited() + assert publish.of_type("file_created") == [] + assert publish.of_type("file_updated") == [] + + @pytest.mark.asyncio + async def test_invalid_path_rejected(self, handler_mod): + handler, _ = _make_handler(handler_mod) + result = await handler({"path": "/etc/passwd", "old": "a", "new": "b"}) + assert result["is_error"] is True + handler_mod.write_to_volume.assert_not_awaited() + + @pytest.mark.asyncio + async def test_unknown_mode_rejected(self, handler_mod): + handler, _ = _make_handler(handler_mod) + result = await handler({"path": "src/x.py", "mode": "fuzzy"}) + assert result["is_error"] is True diff --git a/backend/tests/test_edit_notebook_cell.py b/backend/tests/test_edit_notebook_cell.py new file mode 100644 index 0000000..d6e63d3 --- /dev/null +++ b/backend/tests/test_edit_notebook_cell.py @@ -0,0 +1,114 @@ +"""Tests for the edit-notebook-cell skill handler (issue #85).""" + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import nbformat +import pytest + +HANDLER_PATH = ( + Path(__file__).resolve().parents[1] / "skills" / "edit-notebook-cell" / "handler.py" +) + + +def _load_handler_module(): + spec = importlib.util.spec_from_file_location( + "edit_notebook_cell_handler", HANDLER_PATH + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def handler_mod(): + mod = _load_handler_module() + mod.broadcaster = SimpleNamespace(publish=AsyncMock()) + return mod + + +@pytest.fixture +def seeded_store(monkeypatch): + """Real notebook_store with an isolated cache and captured saves.""" + from services import notebook_store + + notebook_store._cache.clear() + notebook_store._locks.clear() + monkeypatch.setattr(notebook_store, "upload_to_volume", AsyncMock()) + monkeypatch.setattr( + notebook_store, + "read_volume_file_async", + AsyncMock(side_effect=FileNotFoundError), + ) + nb = nbformat.v4.new_notebook() + cell = nbformat.v4.new_code_cell("x = 1") + cell["outputs"] = [ + nbformat.v4.new_output(output_type="stream", name="stdout", text="1\n") + ] + nb.cells = [cell] + notebook_store._cache[("sess-nc1", "scratch")] = nb + yield notebook_store, cell + notebook_store._cache.clear() + notebook_store._locks.clear() + + +class _PublishRecorder: + def __init__(self): + self.events = [] + + async def __call__(self, session_id, event_type, data, role=None, **kwargs): + self.events.append({"type": event_type, "data": data, "role": role}) + + +def _make_handler(mod, session_id="sess-nc1"): + publish = _PublishRecorder() + handler = mod.create_handler(session_id=session_id, publish_fn=publish) + return handler, publish + + +@pytest.mark.asyncio +async def test_happy_path_edits_and_broadcasts(handler_mod, seeded_store): + store, cell = seeded_store + handler, publish = _make_handler(handler_mod) + result = await handler({"cell_id": cell["id"], "source": "x = 2"}) + assert not result.get("is_error") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert payload["cell_id"] == cell["id"] + assert payload["source_len"] == len("x = 2") + + # Source updated, outputs preserved. + assert cell["source"] == "x = 2" + assert cell["outputs"] + + # UI live-update event. + handler_mod.broadcaster.publish.assert_awaited_once() + _, event = handler_mod.broadcaster.publish.await_args.args + assert event["type"] == "notebook.structure.changed" + assert event["data"]["reason"] == "agent_edit" + assert event["data"]["cell_id"] == cell["id"] + + # tool_start/tool_end pair emitted in order. + kinds = [e["type"] for e in publish.events] + assert kinds[0] == "tool_start" + assert kinds[-1] == "tool_end" + + +@pytest.mark.asyncio +async def test_unknown_cell_is_error(handler_mod, seeded_store): + handler, _ = _make_handler(handler_mod) + result = await handler({"cell_id": "nope", "source": "x"}) + assert result["is_error"] is True + assert "not found" in result["content"][0]["text"] + handler_mod.broadcaster.publish.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_non_string_source_rejected(handler_mod, seeded_store): + _, cell = seeded_store + handler, _ = _make_handler(handler_mod) + result = await handler({"cell_id": cell["id"], "source": 5}) + assert result["is_error"] is True diff --git a/backend/tests/test_notebook_store.py b/backend/tests/test_notebook_store.py new file mode 100644 index 0000000..d713e8f --- /dev/null +++ b/backend/tests/test_notebook_store.py @@ -0,0 +1,121 @@ +"""Tests for notebook_store.edit_cell / delete_cell (issue #85). + +The agent-facing per-cell surface: edit_cell preserves outputs (same as +apply_source_update's carry-over for the frontend PUT path); delete_cell +persists the removal to the on-volume .ipynb. +""" + +import nbformat +import pytest +from unittest.mock import AsyncMock + +from services import notebook_store + + +@pytest.fixture(autouse=True) +def _clean_store(monkeypatch): + """Isolate the process-global notebook cache/locks; capture saves.""" + notebook_store._cache.clear() + notebook_store._locks.clear() + saves = [] + upload = AsyncMock() + monkeypatch.setattr(notebook_store, "upload_to_volume", upload) + monkeypatch.setattr( + notebook_store, + "read_volume_file_async", + AsyncMock(side_effect=FileNotFoundError), + ) + yield saves + notebook_store._cache.clear() + notebook_store._locks.clear() + + +def _seed_notebook(session_id="sess-n1", name="scratch"): + """Seed the cache with a notebook: one markdown + two code cells.""" + nb = nbformat.v4.new_notebook() + md = nbformat.v4.new_markdown_cell("# title") + c1 = nbformat.v4.new_code_cell("x = 1") + c2 = nbformat.v4.new_code_cell("print(x)") + c1["outputs"] = [ + nbformat.v4.new_output(output_type="stream", name="stdout", text="1\n") + ] + c1["execution_count"] = 3 + nb.cells = [md, c1, c2] + notebook_store._cache[(session_id, name)] = nb + return nb, md, c1, c2 + + +class TestEditCell: + @pytest.mark.asyncio + async def test_edit_preserves_outputs(self): + nb, _, c1, _ = _seed_notebook() + info = await notebook_store.edit_cell("sess-n1", "scratch", c1["id"], "x = 2") + assert info["cell_id"] == c1["id"] + assert info["source_len"] == len("x = 2") + cell = next(c for c in nb.cells if c["id"] == c1["id"]) + assert cell["source"] == "x = 2" + # Outputs + execution count carried over untouched. + assert cell["outputs"] == c1["outputs"] + assert cell["execution_count"] == 3 + + @pytest.mark.asyncio + async def test_edit_persists_to_volume(self): + _, _, c1, _ = _seed_notebook() + await notebook_store.edit_cell("sess-n1", "scratch", c1["id"], "x = 9") + notebook_store.upload_to_volume.assert_awaited_once() + _, vol_path = notebook_store.upload_to_volume.await_args.args + assert vol_path == "/sessions/sess-n1/notebooks/scratch.ipynb" + + @pytest.mark.asyncio + async def test_edit_unknown_cell_raises(self): + _seed_notebook() + with pytest.raises(ValueError, match="not found"): + await notebook_store.edit_cell("sess-n1", "scratch", "nope", "x") + + @pytest.mark.asyncio + async def test_edit_unknown_notebook_raises(self): + with pytest.raises(ValueError, match="Notebook 'ghost' not found"): + await notebook_store.edit_cell("sess-n1", "ghost", "c", "x") + + @pytest.mark.asyncio + async def test_edit_can_convert_cell_type(self): + nb, _, c1, _ = _seed_notebook() + await notebook_store.edit_cell( + "sess-n1", "scratch", c1["id"], "now prose", cell_type="markdown" + ) + cell = next(c for c in nb.cells if c["id"] == c1["id"]) + assert cell["cell_type"] == "markdown" + assert "outputs" not in cell + assert "execution_count" not in cell + + @pytest.mark.asyncio + async def test_edit_invalid_cell_type_raises(self): + _, _, c1, _ = _seed_notebook() + with pytest.raises(ValueError): + await notebook_store.edit_cell( + "sess-n1", "scratch", c1["id"], "x", cell_type="raw" + ) + + +class TestDeleteCell: + @pytest.mark.asyncio + async def test_delete_removes_and_persists(self): + nb, md, c1, c2 = _seed_notebook() + info = await notebook_store.delete_cell("sess-n1", "scratch", c1["id"]) + assert info["cell_id"] == c1["id"] + assert info["remaining_cells"] == 2 + assert [c["id"] for c in nb.cells] == [md["id"], c2["id"]] + notebook_store.upload_to_volume.assert_awaited_once() + _, vol_path = notebook_store.upload_to_volume.await_args.args + assert vol_path == "/sessions/sess-n1/notebooks/scratch.ipynb" + + @pytest.mark.asyncio + async def test_delete_unknown_cell_raises(self): + _seed_notebook() + with pytest.raises(ValueError, match="not found"): + await notebook_store.delete_cell("sess-n1", "scratch", "nope") + + @pytest.mark.asyncio + async def test_delete_unknown_notebook_raises(self): + with pytest.raises(ValueError, match="Notebook 'ghost' not found"): + await notebook_store.delete_cell("sess-n1", "ghost", "c") diff --git a/backend/tests/test_rerun_notebook_cell.py b/backend/tests/test_rerun_notebook_cell.py new file mode 100644 index 0000000..e2aeb16 --- /dev/null +++ b/backend/tests/test_rerun_notebook_cell.py @@ -0,0 +1,135 @@ +"""Tests for the rerun-notebook-cell skill handler (issue #85).""" + +import asyncio +import importlib.util +import json +from pathlib import Path +from unittest.mock import AsyncMock + +import nbformat +import pytest + +HANDLER_PATH = ( + Path(__file__).resolve().parents[1] + / "skills" + / "rerun-notebook-cell" + / "handler.py" +) + + +def _load_handler_module(): + spec = importlib.util.spec_from_file_location( + "rerun_notebook_cell_handler", HANDLER_PATH + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def handler_mod(): + mod = _load_handler_module() + mod.kernel_manager = AsyncMock() + mod.kernel_manager.execute_and_wait = AsyncMock( + return_value={"exec_count": 7, "duration_ms": 42, "had_error": False} + ) + return mod + + +@pytest.fixture +def seeded_store(monkeypatch): + """Real notebook_store with an isolated cache and captured saves.""" + from services import notebook_store + + notebook_store._cache.clear() + notebook_store._locks.clear() + monkeypatch.setattr(notebook_store, "upload_to_volume", AsyncMock()) + monkeypatch.setattr( + notebook_store, + "read_volume_file_async", + AsyncMock(side_effect=FileNotFoundError), + ) + nb = nbformat.v4.new_notebook() + md = nbformat.v4.new_markdown_cell("# notes") + code = nbformat.v4.new_code_cell("from loaders import load\ndf = load()") + code["outputs"] = [ + nbformat.v4.new_output(output_type="stream", name="stdout", text="stale\n") + ] + nb.cells = [md, code] + notebook_store._cache[("sess-nc3", "scratch")] = nb + yield notebook_store, md, code + notebook_store._cache.clear() + notebook_store._locks.clear() + + +class _PublishRecorder: + def __init__(self): + self.events = [] + + async def __call__(self, session_id, event_type, data, role=None, **kwargs): + self.events.append({"type": event_type, "data": data, "role": role}) + + +def _make_handler(mod, session_id="sess-nc3"): + publish = _PublishRecorder() + handler = mod.create_handler(session_id=session_id, publish_fn=publish) + return handler, publish + + +@pytest.mark.asyncio +async def test_happy_path_reexecutes_existing_cell(handler_mod, seeded_store): + _, _, code = seeded_store + handler, publish = _make_handler(handler_mod) + result = await handler({"cell_id": code["id"]}) + assert not result.get("is_error") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert payload["cell_id"] == code["id"] + assert payload["exec_count"] == 7 + assert payload["duration_ms"] == 42 + assert payload["had_error"] is False + + # Re-executed against the persistent kernel with the cell's CURRENT + # source and id — no new cell appended. + args = handler_mod.kernel_manager.execute_and_wait.await_args + assert args.args[0] == "sess-nc3" + assert args.args[1] == code["id"] + assert args.args[2] == "from loaders import load\ndf = load()" + assert args.kwargs["notebook_name"] == "scratch" + + kinds = [e["type"] for e in publish.events] + assert kinds[0] == "tool_start" + assert kinds[-1] == "tool_end" + + +@pytest.mark.asyncio +async def test_unknown_cell_is_error(handler_mod, seeded_store): + handler, _ = _make_handler(handler_mod) + result = await handler({"cell_id": "nope"}) + assert result["is_error"] is True + assert "not found" in result["content"][0]["text"] + handler_mod.kernel_manager.execute_and_wait.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_markdown_cell_rejected(handler_mod, seeded_store): + _, md, _ = seeded_store + handler, _ = _make_handler(handler_mod) + result = await handler({"cell_id": md["id"]}) + assert result["is_error"] is True + assert "markdown" in result["content"][0]["text"] + handler_mod.kernel_manager.execute_and_wait.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_timeout_is_error(handler_mod, seeded_store): + _, _, code = seeded_store + handler_mod.kernel_manager.execute_and_wait = AsyncMock( + side_effect=asyncio.TimeoutError + ) + handler, _ = _make_handler(handler_mod) + result = await handler({"cell_id": code["id"], "timeout_seconds": 10}) + assert result["is_error"] is True + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is False + assert "timeout" in payload["error"].lower() diff --git a/backend/tests/test_run_file.py b/backend/tests/test_run_file.py new file mode 100644 index 0000000..13c3757 --- /dev/null +++ b/backend/tests/test_run_file.py @@ -0,0 +1,143 @@ +"""Tests for the run-file skill handler (issue #85).""" + +import importlib.util +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +HANDLER_PATH = ( + Path(__file__).resolve().parents[1] / "skills" / "run-file" / "handler.py" +) + + +def _load_handler_module(): + spec = importlib.util.spec_from_file_location("run_file_handler", HANDLER_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def handler_mod(): + mod = _load_handler_module() + mod.run_code = AsyncMock( + return_value={"stdout": "ran ok", "stderr": "", "returncode": 0} + ) + mod.write_to_volume = AsyncMock() + mod.read_volume_file_async = AsyncMock(return_value=b"print('hi')\n") + mod.detect_new_files = AsyncMock() + return mod + + +class _PublishRecorder: + def __init__(self): + self.events = [] + + async def __call__(self, session_id, event_type, data, role=None, **kwargs): + self.events.append({"type": event_type, "data": data, "role": role}) + + def of_type(self, event_type): + return [e for e in self.events if e["type"] == event_type] + + +CONFIG = { + "default": {"gpu": None, "timeout": 600}, + "training": {"gpu": "A10G", "timeout": 1800}, + "max_timeout": 3600, +} + + +def _make_handler(mod, sandbox_config=None, session_id="sess-r1"): + publish = _PublishRecorder() + handler = mod.create_handler( + session_id=session_id, + stage="train", + publish_fn=publish, + sandbox_config=sandbox_config, + ) + return handler, publish + + +class TestRunFile: + @pytest.mark.asyncio + async def test_happy_path_runs_via_runpy(self, handler_mod): + handler, _ = _make_handler(handler_mod, CONFIG) + result = await handler({"path": "src/loaders.py"}) + assert not result.get("is_error") + assert result["content"][0]["text"] == "ran ok" + + code = handler_mod.run_code.await_args.args[0] + assert "runpy.run_path('src/loaders.py', run_name='__main__')" in code + kwargs = handler_mod.run_code.await_args.kwargs + assert kwargs["gpu"] is None + assert kwargs["timeout"] == 600 + + @pytest.mark.asyncio + async def test_audit_log_gets_one_liner_not_body(self, handler_mod): + handler, publish = _make_handler(handler_mod, CONFIG) + await handler({"path": "src/loaders.py"}) + + # Exactly one volume write: the audit script. The module body is + # never written by run-file (it already lives on disk). + handler_mod.write_to_volume.assert_awaited_once() + audit_code, audit_path = handler_mod.write_to_volume.await_args.args + assert audit_path.startswith("/sessions/sess-r1/scripts/step_") + assert "_run_src_loaders_py.py" in audit_path + assert "runpy.run_path('src/loaders.py'" in audit_code + assert "print('hi')" not in audit_code + + created = publish.of_type("file_created") + assert any(e["data"]["path"] == audit_path for e in created) + + @pytest.mark.asyncio + async def test_missing_file_errors_before_running(self, handler_mod): + handler_mod.read_volume_file_async = AsyncMock(side_effect=FileNotFoundError) + handler, _ = _make_handler(handler_mod, CONFIG) + result = await handler({"path": "src/nope.py"}) + assert result["is_error"] is True + assert "write-file" in result["content"][0]["text"] + handler_mod.run_code.assert_not_awaited() + handler_mod.write_to_volume.assert_not_awaited() + + @pytest.mark.asyncio + async def test_invalid_path_rejected(self, handler_mod): + handler, _ = _make_handler(handler_mod, CONFIG) + result = await handler({"path": "/etc/passwd"}) + assert result["is_error"] is True + handler_mod.run_code.assert_not_awaited() + + @pytest.mark.asyncio + async def test_heavy_uses_training_profile(self, handler_mod): + handler, _ = _make_handler(handler_mod, CONFIG) + await handler({"path": "src/training.py", "heavy": True}) + kwargs = handler_mod.run_code.await_args.kwargs + assert kwargs["gpu"] == "A10G" + assert kwargs["timeout"] == 1800 + + @pytest.mark.asyncio + async def test_owner_max_timeout_caps_profile(self, handler_mod): + cfg = {"training": {"gpu": "A10G", "timeout": 1800}, "max_timeout": 100} + handler, _ = _make_handler(handler_mod, cfg) + await handler({"path": "src/training.py", "heavy": True}) + assert handler_mod.run_code.await_args.kwargs["timeout"] == 100 + + @pytest.mark.asyncio + async def test_nonzero_exit_surfaces_stderr(self, handler_mod): + handler_mod.run_code = AsyncMock( + return_value={"stdout": "", "stderr": "boom", "returncode": 1} + ) + handler, _ = _make_handler(handler_mod, CONFIG) + result = await handler({"path": "src/loaders.py"}) + text = result["content"][0]["text"] + assert "Exit code 1" in text + assert "boom" in text + + @pytest.mark.asyncio + async def test_sandbox_exception_returns_tool_error(self, handler_mod): + handler_mod.run_code = AsyncMock(side_effect=RuntimeError("kaput")) + handler, publish = _make_handler(handler_mod, CONFIG) + result = await handler({"path": "src/loaders.py"}) + assert result["is_error"] is True + assert "kaput" in result["content"][0]["text"] + assert len(publish.of_type("tool_end")) == 1 diff --git a/backend/tests/test_write_file.py b/backend/tests/test_write_file.py new file mode 100644 index 0000000..315d537 --- /dev/null +++ b/backend/tests/test_write_file.py @@ -0,0 +1,157 @@ +"""Tests for the write-file skill handler + session path resolution (issue #85).""" + +import importlib.util +import json +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from services.skills.state import resolve_session_path + +HANDLER_PATH = ( + Path(__file__).resolve().parents[1] / "skills" / "write-file" / "handler.py" +) + + +def _load_handler_module(): + spec = importlib.util.spec_from_file_location("write_file_handler", HANDLER_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def handler_mod(monkeypatch): + mod = _load_handler_module() + mod.write_to_volume = AsyncMock() + # Default: nothing exists on the volume. + mod.read_volume_file_async = AsyncMock(side_effect=FileNotFoundError) + return mod + + +class _PublishRecorder: + def __init__(self): + self.events = [] + + async def __call__(self, session_id, event_type, data, role=None, **kwargs): + self.events.append({"type": event_type, "data": data, "role": role}) + + def of_type(self, event_type): + return [e for e in self.events if e["type"] == event_type] + + +def _make_handler(mod, session_id="sess-w1"): + publish = _PublishRecorder() + handler = mod.create_handler(session_id=session_id, stage="eda", publish_fn=publish) + return handler, publish + + +class TestPathResolution: + def test_relative_path(self): + vol, rel = resolve_session_path("s1", "src/loaders.py") + assert vol == "/sessions/s1/src/loaders.py" + assert rel == "src/loaders.py" + + def test_volume_absolute_path(self): + vol, rel = resolve_session_path("s1", "/sessions/s1/src/loaders.py") + assert vol == "/sessions/s1/src/loaders.py" + assert rel == "src/loaders.py" + + def test_sandbox_absolute_path(self): + vol, rel = resolve_session_path("s1", "/data/sessions/s1/src/loaders.py") + assert vol == "/sessions/s1/src/loaders.py" + assert rel == "src/loaders.py" + + def test_dot_slash_relative(self): + vol, _ = resolve_session_path("s1", "./src/loaders.py") + assert vol == "/sessions/s1/src/loaders.py" + + def test_other_absolute_path_rejected(self): + with pytest.raises(ValueError): + resolve_session_path("s1", "/etc/passwd") + + def test_other_session_rejected(self): + with pytest.raises(ValueError): + resolve_session_path("s1", "/sessions/other/src/x.py") + + def test_parent_escape_rejected(self): + for raw in ("../x.py", "src/../../x.py", "src/..", ".."): + with pytest.raises(ValueError): + resolve_session_path("s1", raw) + + def test_empty_rejected(self): + with pytest.raises(ValueError): + resolve_session_path("s1", " ") + + +class TestWriteFile: + @pytest.mark.asyncio + async def test_new_file_emits_file_created_and_writes(self, handler_mod): + handler, publish = _make_handler(handler_mod) + result = await handler({"path": "src/loaders.py", "content": "def load(): ..."}) + assert not result.get("is_error") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert payload["path"] == "/sessions/sess-w1/src/loaders.py" + assert payload["created"] is True + assert payload["bytes_written"] == len("def load(): ...") + + handler_mod.write_to_volume.assert_awaited_once_with( + "def load(): ...", "/sessions/sess-w1/src/loaders.py" + ) + created = publish.of_type("file_created") + assert len(created) == 1 + assert created[0]["data"]["path"] == "/sessions/sess-w1/src/loaders.py" + # Authoring is not an execution step — nothing under scripts/. + assert "/scripts/" not in payload["path"] + for call in handler_mod.write_to_volume.await_args_list: + assert "/scripts/" not in call.args[1] + + @pytest.mark.asyncio + async def test_existing_file_emits_file_updated(self, handler_mod): + handler_mod.read_volume_file_async = AsyncMock(return_value=b"old") + handler, publish = _make_handler(handler_mod) + result = await handler({"path": "src/loaders.py", "content": "new"}) + assert not result.get("is_error") + payload = json.loads(result["content"][0]["text"]) + assert payload["created"] is False + assert len(publish.of_type("file_updated")) == 1 + assert len(publish.of_type("file_created")) == 0 + + @pytest.mark.asyncio + async def test_overwrite_false_refuses_clobber(self, handler_mod): + handler_mod.read_volume_file_async = AsyncMock(return_value=b"old") + handler, _ = _make_handler(handler_mod) + result = await handler( + {"path": "src/loaders.py", "content": "new", "overwrite": False} + ) + assert result["is_error"] is True + assert "overwrite=false" in result["content"][0]["text"] + handler_mod.write_to_volume.assert_not_awaited() + + @pytest.mark.asyncio + async def test_invalid_path_rejected(self, handler_mod): + handler, publish = _make_handler(handler_mod) + result = await handler({"path": "/etc/passwd", "content": "x"}) + assert result["is_error"] is True + handler_mod.write_to_volume.assert_not_awaited() + assert publish.of_type("tool_start") == [] + + @pytest.mark.asyncio + async def test_non_string_content_rejected(self, handler_mod): + handler, _ = _make_handler(handler_mod) + result = await handler({"path": "src/x.py", "content": 42}) + assert result["is_error"] is True + handler_mod.write_to_volume.assert_not_awaited() + + @pytest.mark.asyncio + async def test_absolute_session_path_accepted(self, handler_mod): + handler, _ = _make_handler(handler_mod) + result = await handler( + {"path": "/sessions/sess-w1/src/loaders.py", "content": "x"} + ) + assert not result.get("is_error") + handler_mod.write_to_volume.assert_awaited_once_with( + "x", "/sessions/sess-w1/src/loaders.py" + ) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c2216d9..808018b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -351,6 +351,9 @@ export interface FileCreatedData { name: string; stage?: string; } +// Same wire shape as FileCreatedData — the difference is semantic (the +// file already existed). Emitted by write-file / edit-file overwrites. +export type FileUpdatedData = FileCreatedData; export interface MetricEventData { step: number; name: string; @@ -511,6 +514,7 @@ export type SSEEvent = | { type: 'report_ready'; data: ReportReadyData } | { type: 'files_ready'; data: FilesReadyData } | { type: 'file_created'; data: FileCreatedData } + | { type: 'file_updated'; data: FileUpdatedData } | { type: 'agent_aborted'; data: Record } | { type: 'session_resumed'; data: SessionResumedData } | { type: 'metrics_batch'; data: MetricsBatchData } diff --git a/frontend/src/lib/useSessionStream.ts b/frontend/src/lib/useSessionStream.ts index fdac0bb..2e03d2c 100644 --- a/frontend/src/lib/useSessionStream.ts +++ b/frontend/src/lib/useSessionStream.ts @@ -519,6 +519,28 @@ export function useSessionStream( openCanvas(); break; } + case 'file_updated': { + // write-file/edit-file overwrite of an existing file. The + // node is already in the tree (insertNodeIntoTree is a no-op + // for known paths — this only back-fills if the create event + // was missed), and unlike file_created an update does NOT + // auto-open the canvas. + const data = event.data; + const stage = data.stage || ''; + setFileTree((prev) => + insertNodeIntoTree( + prev, + { + name: data.name, + path: data.path, + type: 'file', + }, + `/sessions/${sid}`, + stage, + ), + ); + break; + } case 'agent_aborted': streamingItemIdRef.current = null; addItem({ type: 'status', content: 'Agent stopped' }); @@ -1066,6 +1088,7 @@ export function useSessionStream( const NON_VISIBLE_EVENTS = new Set([ 'agent_thought', 'file_created', + 'file_updated', 'files_ready', 'metric', 'metrics_batch',