# Sprint 5b — Editor Exec + Save Scene

## Objective

Implement 2 critical capabilities that close the agent's editing loop:

1. **Editor-side exec** — Execute arbitrary GDScript in the editor context with full access to `EditorInterface`, `get_tree()`, scene nodes. This is what GDAI's `execute_editor_script` does. Currently our exec only works game-side (RefCounted sandbox, no get_node).

2. **Save scene** — Dedicated tool to save the current scene. After node_ops makes changes, the agent can save without manual intervention.

After this sprint: **29 MCP tools** total (27 + save_scene + exec upgrade counts as enhancement not new tool).

---

## CRITICAL: Known Godot Bug with Dynamic Scripts in @tool Mode

**Godot Issue #87046**: `GDScript.new()` + `source_code` + `reload()` + `set_script()` does NOT work reliably in `@tool` mode (editor context). The method exists but calls fail at runtime.

**This affects our editor exec implementation directly.** We planned to use dynamic script compilation in the editor — this bug means we need a safer approach.

### Two approaches — implement PRIMARY, fall back to SECONDARY:

**PRIMARY (Approach B): Direct instantiation via script.new()**
```gdscript
var script := GDScript.new()
script.source_code = "@tool\nextends Node\n\n" + code
var err := script.reload()
if err != OK:
    # compilation error
    return

var obj = script.new()  # Direct instantiation — different from set_script()
add_child(obj)           # Temporary child of server → has get_tree()
var result = obj.run()   # Call the user's run() function
remove_child(obj)
obj.queue_free()
```

The bug report (#87046) was specifically about `RefCounted.new()` + `set_script()`. Our pattern uses `extends Node` + `script.new()` (direct instantiation from the compiled script). This is a different code path and may work. **Test this first.**

**SECONDARY (Approach A): File-based execution — guaranteed to work**
```gdscript
var tmp_path := "res://addons/godotiq/_temp_exec.gd"
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
file.store_string("@tool\nextends Node\n\n" + code)
file.close()

# Force reimport so editor recognizes the file
EditorInterface.get_resource_filesystem().scan()
await get_tree().create_timer(0.1).timeout  # wait for scan

var script := load(tmp_path) as GDScript
var obj = script.new()
add_child(obj)
var result = obj.run()
remove_child(obj)
obj.queue_free()

# Cleanup
DirAccess.remove_absolute(tmp_path)
```

`load()` always works in @tool mode. Downside: touches filesystem, needs async wait for scan. But guaranteed reliable.

**Implementation strategy**: Try PRIMARY first. If it works in live testing → done. If `script.new()` returns null or `obj.run()` fails → switch to SECONDARY. Claude Code can test both during implementation.

---

## CRITICAL LESSONS FROM SPRINTS 4a-4c (still apply)

1. **NO Python ternaries** in GDScript: `x if cond else y` → use if/else blocks
2. **`snapped()` NOT `snapf()`**
3. **NO f-strings**: use `%` formatting
4. **PREFIX STRIPPING**: EngineDebugger handlers receive messages WITHOUT `"godotiq:"` prefix
5. **Session persistence**: NEVER set `_session_id = -1` in `_on_game_stopped()`
6. **Test live on PFT** after implementation — GDScript compilation bugs only show up at runtime

---

## DO NOT TOUCH:
- `src/godotiq/parsers/`, `tools/code/`, `tools/spatial/`, `tools/animation/`, `tools/assets/`, `tools/memory/` — Phase 1
- `src/godotiq/resolvers/`, `src/godotiq/session.py` — Phase 1
- `godot-addon/addons/godotiq/godotiq_plugin.gd`
- `godot-addon/addons/godotiq/godotiq_runtime.gd` — game-side exec stays as-is
- `godot-addon/addons/godotiq/godotiq_debugger.gd`
- Existing test files — only ADD new ones

## Files to MODIFY:
- `godot-addon/addons/godotiq/godotiq_server.gd` — add `_handle_exec_editor()`, `_handle_save_scene()`, update dispatch
- `src/godotiq/tools/bridge/exec_code.py` — add `context` parameter
- `src/godotiq/__main__.py` (or tool registration) — register `godotiq_save_scene`

## Files to CREATE:
- `src/godotiq/tools/bridge/save_scene.py` — new MCP tool
- `tests/test_bridge/test_save_scene.py` — tests
- `tests/test_bridge/test_exec_editor.py` — tests for editor context

---

## TOOL 1: `godotiq_exec` upgrade — `context` parameter

### Current behavior (keep unchanged):
- `context="game"` → forwards to game runtime via EngineDebugger → RefCounted sandbox
- This is the existing Sprint 4c implementation — DO NOT modify it

### New behavior:
- `context="editor"` (NEW DEFAULT) → executes directly in `godotiq_server.gd`
- Code runs as a temporary child Node of the server → has `get_tree()`, can access `EditorInterface`, scene nodes, everything
- Same safety checks as game-side: `func run():` required, blocked patterns, timeout

### Python: Modify `src/godotiq/tools/bridge/exec_code.py`

```python
"""A08 — godotiq_exec: Execute arbitrary GDScript.

Supports two contexts:
- "editor" (default): Runs in editor process. Full access to EditorInterface,
  get_tree(), edited scene root, all editor nodes. Like GDAI's execute_editor_script.
- "game": Runs in game process via EngineDebugger. RefCounted sandbox,
  no get_node/get_tree. Only Engine.* and static APIs. Game must be running.
"""

from __future__ import annotations
from ...bridge.session import get_bridge

TOOL_NAME = "godotiq_exec"
TOOL_DESCRIPTION = (
    "Execute arbitrary GDScript code. "
    "context='editor' (default): runs in editor with full access to EditorInterface, "
    "get_tree(), scene nodes. Use for editor queries and modifications. "
    "context='game': runs in game sandbox with limited access. Game must be running. "
    "Code MUST start with 'func run():'. Return value is stringified."
)


async def exec_code(
    code: str = "",
    context: str = "editor",
    timeout_ms: int = 5000,
) -> dict:
    """Execute GDScript code.

    Args:
        code: GDScript code starting with 'func run():'.
        context: "editor" (default) — full editor access, or "game" — sandbox.
        timeout_ms: Max execution time in ms (default 5000).

    Editor context examples:
        # Get scene root info
        code="func run():\\n\\tvar root = get_tree().edited_scene_root\\n\\treturn root.name"

        # Count nodes
        code="func run():\\n\\treturn get_tree().edited_scene_root.get_child_count()"

        # Save scene
        code="func run():\\n\\tEditorInterface.save_scene()\\n\\treturn 'saved'"

        # Read node position
        code="func run():\\n\\tvar n = get_tree().edited_scene_root.get_node('Entities/Worker_1')\\n\\treturn str(n.position)"

    Game context examples:
        code="func run():\\n\\treturn Engine.get_version_info()"
        code="func run():\\n\\treturn Engine.get_frames_per_second()"

    Returns:
        Dict with status (OK/BLOCKED/COMPILE_ERROR/ERROR), result (string), error.
    """
    if not code:
        return {"status": "ERROR", "result": "", "error": "No code provided"}

    if context not in ("editor", "game"):
        return {"status": "ERROR", "result": "", "error": f"Invalid context: {context}. Use 'editor' or 'game'."}

    bridge = await get_bridge()

    if context == "editor":
        return await bridge.request("exec_editor", params={
            "code": code,
            "timeout_ms": timeout_ms,
        }, timeout=10.0)
    else:
        # Game-side exec — existing Sprint 4c behavior
        return await bridge.request("exec", params={
            "code": code,
            "timeout_ms": timeout_ms,
        }, timeout=10.0)
```

### GDScript: Add to `godotiq_server.gd`

Add to dispatch:
```gdscript
"exec_editor":
    _handle_exec_editor(peer_id, id, params)
```

**Also keep the existing `"exec"` dispatch entry unchanged** (forwards to game).

Implementation — add to `godotiq_server.gd`:

```gdscript
func _handle_exec_editor(peer_id: int, id: String, params: Dictionary) -> void:
    var code: String = params.get("code", "")
    var timeout_ms: int = params.get("timeout_ms", 5000)

    if code.is_empty():
        _send_error(peer_id, id, "EXEC_ERROR", "No code provided")
        return

    # Safety: must start with func run():
    var trimmed: String = code.strip_edges()
    if not trimmed.begins_with("func run():") and not trimmed.begins_with("func run() ->"):
        send_response(peer_id, id, {
            "status": "BLOCKED",
            "result": "",
            "error": "Code must start with 'func run():' or 'func run() -> Type:'",
        })
        return

    # Safety: blocked patterns (same as game-side)
    var blocked_patterns: Array = [
        "DirAccess.remove",
        "OS.execute",
        "OS.kill",
        "OS.shell_open",
    ]
    # NOTE: We do NOT block FileAccess.open in editor context — the editor
    # legitimately uses file operations. We block destructive OS operations only.
    for pattern in blocked_patterns:
        if code.find(pattern) != -1:
            send_response(peer_id, id, {
                "status": "BLOCKED",
                "result": "",
                "error": "Blocked pattern found: %s" % pattern,
            })
            return

    # Compile the script
    var script := GDScript.new()
    # @tool + extends Node → gives access to get_tree(), add_child, etc.
    script.source_code = "@tool\nextends Node\n\n" + code
    var err: int = script.reload()
    if err != OK:
        send_response(peer_id, id, {
            "status": "COMPILE_ERROR",
            "result": "",
            "error": "Compilation failed (error %d). Check GDScript syntax." % err,
        })
        return

    # Instantiate and execute
    var obj = script.new()
    if obj == null:
        # Approach B failed — try file-based approach (Approach A)
        var file_result: Dictionary = _exec_editor_via_file(code)
        send_response(peer_id, id, file_result)
        return

    # Add as temporary child → obj now has get_tree(), get_parent(), etc.
    add_child(obj)

    var result = null
    var exec_error: String = ""
    # Execute run()
    if obj.has_method("run"):
        result = obj.run()
    else:
        exec_error = "Script compiled but has no run() method"

    # Cleanup
    remove_child(obj)
    obj.queue_free()

    if not exec_error.is_empty():
        send_response(peer_id, id, {
            "status": "ERROR",
            "result": "",
            "error": exec_error,
        })
        return

    var result_str: String
    if result != null:
        result_str = str(result)
    else:
        result_str = "null"

    send_response(peer_id, id, {
        "status": "OK",
        "result": result_str,
        "error": "",
    })


func _exec_editor_via_file(code: String) -> Dictionary:
    ## Fallback: write script to temp file, load(), execute, cleanup.
    var tmp_path: String = "res://addons/godotiq/_temp_exec.gd"

    # Write temp file
    var file := FileAccess.open(tmp_path, FileAccess.WRITE)
    if file == null:
        return {
            "status": "ERROR",
            "result": "",
            "error": "Cannot create temp file: %s" % str(FileAccess.get_open_error()),
        }
    file.store_string("@tool\nextends Node\n\n" + code)
    file.close()

    # Small delay for filesystem
    # NOTE: This is synchronous — we can't easily await here.
    # If this doesn't work, the caller should retry.

    var script = load(tmp_path)
    if script == null:
        # Cleanup and fail
        DirAccess.remove_absolute(tmp_path)
        return {
            "status": "ERROR",
            "result": "",
            "error": "Failed to load temp script. Godot may need a filesystem scan.",
        }

    var obj = script.new()
    if obj == null:
        DirAccess.remove_absolute(tmp_path)
        return {
            "status": "ERROR",
            "result": "",
            "error": "Failed to instantiate temp script",
        }

    add_child(obj)
    var result = null
    if obj.has_method("run"):
        result = obj.run()
    remove_child(obj)
    obj.queue_free()

    # Cleanup temp file
    DirAccess.remove_absolute(tmp_path)

    var result_str: String
    if result != null:
        result_str = str(result)
    else:
        result_str = "null"

    return {
        "status": "OK",
        "result": result_str,
        "error": "",
    }
```

**Key decisions in this implementation:**
1. `extends Node` (not `EditorScript` or `RefCounted`) — Node can be `add_child()`'d which gives `get_tree()`
2. `@tool` prefix — required for editor execution
3. FileAccess.open NOT blocked in editor context — editor legitimately needs file access
4. Fallback to file-based approach if `script.new()` returns null (Godot bug #87046)
5. `has_method("run")` check — defensive, in case compilation succeeds but method not found

---

## TOOL 2: `godotiq_save_scene` — Save Current Scene

### GDScript: Add to `godotiq_server.gd`

Add to dispatch:
```gdscript
"save_scene":
    _handle_save_scene(peer_id, id, params)
```

Implementation:

```gdscript
func _handle_save_scene(peer_id: int, id: String, params: Dictionary) -> void:
    var scene_root := EditorInterface.get_edited_scene_root()
    if scene_root == null:
        _send_error(peer_id, id, "NO_SCENE", "No scene is currently open in the editor")
        return

    var save_path: String = params.get("path", "")

    if save_path.is_empty():
        # Save in place
        EditorInterface.save_scene()
    else:
        # Save as new path
        EditorInterface.save_scene_as(save_path)

    # Get the actual saved path
    var scene_path: String = scene_root.scene_file_path
    if scene_path.is_empty():
        scene_path = save_path

    send_response(peer_id, id, {
        "saved": true,
        "scene_path": scene_path,
        "scene_name": scene_root.name,
    })
```

### Python: Create `src/godotiq/tools/bridge/save_scene.py`

```python
"""godotiq_save_scene: Save the current scene in the editor.

Closes the editing loop: node_ops modifies → save_scene persists.
Editor-side — no game needed.
"""

from __future__ import annotations
from ...bridge.session import get_bridge

TOOL_NAME = "godotiq_save_scene"
TOOL_DESCRIPTION = (
    "Save the currently open scene in the Godot editor. "
    "Call after node_ops or other modifications to persist changes to disk. "
    "Optionally provide a path to 'Save As' to a new location. "
    "Editor-side — no game needed."
)


async def save_scene(
    path: str = "",
) -> dict:
    """Save the current scene.

    Args:
        path: Optional new file path for 'Save As'. Empty = save in place.

    Returns:
        Dict with saved (bool), scene_path, scene_name.
    """
    bridge = await get_bridge()
    params = {}
    if path:
        params["path"] = path
    return await bridge.request("save_scene", params=params, timeout=5.0)
```

### Register: Add to MCP server

Register `godotiq_save_scene` → `save_scene()` from `src/godotiq/tools/bridge/save_scene.py`.

Standard bridge error handling pattern.

---

## PART 3: Update dispatch table

After Sprint 5b, `godotiq_server.gd` dispatch should include:

```gdscript
# Sprint 4a-4d (existing — DO NOT change)
"ping": # ...
"editor_context": # ...
"run": # ...
"stop": # ...
"screenshot": # forward to game
"perf_snapshot": # forward to game
"scene_tree": # editor handler
"node_ops": # editor handler
"input": # forward to game
"exec": # forward to game (KEEP — game-side exec)
"state_inspect": # forward to game
"nav_query": # forward to game
"watch": # forward to game
"undo_history": # editor handler

# Sprint 5b (NEW)
"exec_editor":
    _handle_exec_editor(peer_id, id, params)
"save_scene":
    _handle_save_scene(peer_id, id, params)
```

**IMPORTANT**: `"exec"` dispatch (game-side) stays UNCHANGED. The new `"exec_editor"` is a separate dispatch entry. The Python tool routes to one or the other based on the `context` parameter.

---

## PART 4: Tests

### `tests/test_bridge/test_exec_editor.py`

```python
"""Tests for exec tool with editor context."""
import pytest
from unittest.mock import AsyncMock, patch
from godotiq.tools.bridge.exec_code import exec_code


class TestExecEditorContext:
    @pytest.mark.asyncio
    async def test_default_context_is_editor(self):
        """Default context should be 'editor', not 'game'."""
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "status": "OK", "result": "42", "error": "",
        })
        with patch("godotiq.tools.bridge.exec_code.get_bridge", return_value=mock_bridge):
            await exec_code(code="func run():\n\treturn 42")
            # Should send to "exec_editor", not "exec"
            call_args = mock_bridge.request.call_args
            assert call_args[0][0] == "exec_editor" or call_args.kwargs.get("method") == "exec_editor"

    @pytest.mark.asyncio
    async def test_editor_context_explicit(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "status": "OK", "result": "Main", "error": "",
        })
        with patch("godotiq.tools.bridge.exec_code.get_bridge", return_value=mock_bridge):
            result = await exec_code(
                code="func run():\n\treturn get_tree().edited_scene_root.name",
                context="editor",
            )
            assert result["status"] == "OK"
            assert result["result"] == "Main"

    @pytest.mark.asyncio
    async def test_game_context_uses_old_method(self):
        """Game context should still use 'exec' method (game-side)."""
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "status": "OK", "result": "60", "error": "",
        })
        with patch("godotiq.tools.bridge.exec_code.get_bridge", return_value=mock_bridge):
            await exec_code(
                code="func run():\n\treturn Engine.get_frames_per_second()",
                context="game",
            )
            call_args = mock_bridge.request.call_args
            assert call_args[0][0] == "exec" or call_args.kwargs.get("method") == "exec"

    @pytest.mark.asyncio
    async def test_invalid_context(self):
        result = await exec_code(code="func run():\n\treturn 1", context="invalid")
        assert result["status"] == "ERROR"
        assert "Invalid context" in result["error"]

    @pytest.mark.asyncio
    async def test_empty_code_still_errors(self):
        result = await exec_code(code="", context="editor")
        assert result["status"] == "ERROR"

    @pytest.mark.asyncio
    async def test_blocked_pattern_editor(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "status": "BLOCKED", "result": "", "error": "Blocked pattern found: OS.execute",
        })
        with patch("godotiq.tools.bridge.exec_code.get_bridge", return_value=mock_bridge):
            result = await exec_code(
                code="func run():\n\tOS.execute('rm', ['-rf', '/'])",
                context="editor",
            )
            assert result["status"] == "BLOCKED"

    @pytest.mark.asyncio
    async def test_compile_error(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "status": "COMPILE_ERROR", "result": "", "error": "Compilation failed",
        })
        with patch("godotiq.tools.bridge.exec_code.get_bridge", return_value=mock_bridge):
            result = await exec_code(
                code="func run():\n\tvar x = :",
                context="editor",
            )
            assert result["status"] == "COMPILE_ERROR"
```

### `tests/test_bridge/test_save_scene.py`

```python
"""Tests for save_scene tool."""
import pytest
from unittest.mock import AsyncMock, patch
from godotiq.tools.bridge.save_scene import save_scene


class TestSaveSceneTool:
    @pytest.mark.asyncio
    async def test_save_in_place(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "saved": True,
            "scene_path": "res://scenes/main.tscn",
            "scene_name": "Main",
        })
        with patch("godotiq.tools.bridge.save_scene.get_bridge", return_value=mock_bridge):
            result = await save_scene()
            assert result["saved"] is True
            assert result["scene_path"] == "res://scenes/main.tscn"
            # Verify no path param sent
            params = mock_bridge.request.call_args[1].get("params", {})
            assert "path" not in params or params.get("path") == ""

    @pytest.mark.asyncio
    async def test_save_as(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "saved": True,
            "scene_path": "res://scenes/main_v2.tscn",
            "scene_name": "Main",
        })
        with patch("godotiq.tools.bridge.save_scene.get_bridge", return_value=mock_bridge):
            result = await save_scene(path="res://scenes/main_v2.tscn")
            assert result["saved"] is True
            params = mock_bridge.request.call_args[1].get("params", {})
            assert params["path"] == "res://scenes/main_v2.tscn"

    @pytest.mark.asyncio
    async def test_timeout_value(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={"saved": True})
        with patch("godotiq.tools.bridge.save_scene.get_bridge", return_value=mock_bridge):
            await save_scene()
            call_kwargs = mock_bridge.request.call_args
            timeout = call_kwargs.kwargs.get("timeout") or call_kwargs[1].get("timeout")
            assert timeout == 5.0

    @pytest.mark.asyncio
    async def test_no_scene_open(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(side_effect=Exception("NO_SCENE"))
        with patch("godotiq.tools.bridge.save_scene.get_bridge", return_value=mock_bridge):
            try:
                result = await save_scene()
            except Exception:
                pass  # Expected — error handling is in the registration layer
```

---

## PART 5: Verification Checklist

1. **Regression**: `pytest tests/ -v` → ALL 622+ tests still pass
2. **New tests**: `pytest tests/test_bridge/test_exec_editor.py tests/test_bridge/test_save_scene.py -v` → ALL pass
3. **Import checks**:
   ```
   python -c "from godotiq.tools.bridge.exec_code import exec_code; print('OK')"
   python -c "from godotiq.tools.bridge.save_scene import save_scene; print('OK')"
   ```
4. **GDScript audit**: grep `godotiq_server.gd` for Python ternaries, `snapf(`, `f"` → ZERO matches
5. **Dispatch check**: Verify `_dispatch()` has both `"exec"` (game) AND `"exec_editor"` (editor) entries
6. **Total test count**: 622 + ~11 new = 633+

### Manual Godot testing (critical for this sprint):
1. Copy updated addon → PFT, reload plugin
2. **Test editor exec (the risky one)**:
   ```python
   # Test 1: Basic — does script.new() work in @tool?
   exec_editor with code="func run():\n\treturn 'hello from editor'"

   # Test 2: get_tree() access
   exec_editor with code="func run():\n\treturn get_tree().edited_scene_root.name"

   # Test 3: Node access
   exec_editor with code="func run():\n\tvar root = get_tree().edited_scene_root\n\treturn root.get_child_count()"

   # Test 4: EditorInterface access
   exec_editor with code="func run():\n\treturn str(EditorInterface.get_edited_scene_root().scene_file_path)"
   ```
3. **If Test 1 fails** (script.new() returns null): The fallback `_exec_editor_via_file()` should kick in. Check if IT works. If neither works, we need to debug.
4. **Test save_scene**:
   ```python
   # First make a change
   node_ops with set_property on some node
   # Then save
   save_scene
   # Verify: no asterisk (*) in the Godot title bar (unsaved indicator gone)
   ```

---

## Summary of deliverables

### Files to CREATE:
| # | Path | Purpose |
|---|------|---------|
| 1 | `src/godotiq/tools/bridge/save_scene.py` | MCP tool |
| 2 | `tests/test_bridge/test_save_scene.py` | Tests |
| 3 | `tests/test_bridge/test_exec_editor.py` | Tests |

### Files to MODIFY:
| Path | Change |
|------|--------|
| `godot-addon/addons/godotiq/godotiq_server.gd` | Add `exec_editor` + `save_scene` dispatch, `_handle_exec_editor()` with fallback, `_handle_save_scene()` |
| `src/godotiq/tools/bridge/exec_code.py` | Add `context` parameter, route to `exec_editor` or `exec` |
| `src/godotiq/__main__.py` (or registration) | Register `godotiq_save_scene` |

### After Sprint 5b:
- `exec` with `context="editor"` → full editor access (EditorInterface, get_tree, scene nodes)
- `exec` with `context="game"` → existing sandbox (unchanged)
- `save_scene` → persists changes to disk
- The agent can now: modify scene → verify → save → all without human intervention
