# Section 1: Editor State in Every Response

## Overview

This section adds ambient editor context (`_editor_state`) to every response the GodotIQ WebSocket server sends back to the Python MCP server. The goal is to eliminate redundant tool calls -- the agent no longer needs to explicitly poll for which scene is open, whether the game is running, or whether errors occurred. Every bridge response carries this information automatically.

The implementation spans:
- **GDScript side** (addon): New member variable, helper functions, and injection into `send_response()` and `_send_error()`
- **GDScript debugger**: Wiring error capture to the new ring buffer
- **Python side**: Backward compatibility defaults and `apply_output_limit()` exclusion

## Files to Modify

| File | Action |
|------|--------|
| `godot-addon/addons/godotiq/godotiq_server.gd` | Add `_recent_errors`, `_get_editor_state()`, `_record_error()`. Modify `send_response()` and `_send_error()` to inject `_editor_state`. |
| `godot-addon/addons/godotiq/godotiq_debugger.gd` | Wire `_record_error()` call in `_capture()` for `godotiq:error` messages. |
| `src/godotiq/tools/output_limit.py` | Modify `apply_output_limit()` to exclude `_editor_state` from truncation. |
| `tests/test_tools/test_output_limits.py` | Add tests for `_editor_state` preservation during output limiting. |
| `tests/test_tools/test_editor_state.py` | New test file for `_editor_state` structure and backward compatibility. |

## Tests (Write First)

All tests are pure Python unit tests. No live Godot connection required.

### New test file: `tests/test_tools/test_editor_state.py`

This file tests that `_editor_state` has the correct structure and that Python-side backward compatibility works.

```python
"""Tests for _editor_state ambient context in bridge responses."""

from __future__ import annotations

from godotiq.tools.output_limit import apply_output_limit


class TestEditorStateStructure:
    """Verify _editor_state dict shape and constraints."""

    def test_editor_state_has_required_keys(self) -> None:
        """_editor_state dict must contain open_scene, game_running, recent_errors."""
        state = {
            "open_scene": "res://Main.tscn",
            "game_running": False,
            "recent_errors": [],
        }
        assert "open_scene" in state
        assert "game_running" in state
        assert "recent_errors" in state

    def test_open_scene_is_string(self) -> None:
        """open_scene must be a string (res:// path or empty)."""
        for value in ["res://Main.tscn", ""]:
            state = {"open_scene": value, "game_running": False, "recent_errors": []}
            assert isinstance(state["open_scene"], str)

    def test_game_running_is_bool(self) -> None:
        """game_running must be a boolean."""
        for value in [True, False]:
            state = {"open_scene": "", "game_running": value, "recent_errors": []}
            assert isinstance(state["game_running"], bool)

    def test_recent_errors_is_list_of_strings(self) -> None:
        """recent_errors must be a list of strings."""
        errors = ["Error at tower.gd:40: Invalid node path", "Null reference"]
        state = {"open_scene": "", "game_running": False, "recent_errors": errors}
        assert isinstance(state["recent_errors"], list)
        assert all(isinstance(e, str) for e in state["recent_errors"])

    def test_recent_errors_max_three_entries(self) -> None:
        """recent_errors contains at most 3 entries even if the ring buffer has 10."""
        full_buffer = [f"error_{i}" for i in range(10)]
        sliced = full_buffer[-3:]
        assert len(sliced) == 3


class TestEditorStateBackwardCompat:
    """Python-side handling of missing _editor_state (older addon)."""

    def test_defaults_to_empty_dict_when_missing(self) -> None:
        """Bridge response without _editor_state should default to {}."""
        response = {"nodes_created": 5, "status": "ok"}
        editor_state = response.get("_editor_state", {})
        assert editor_state == {}

    def test_present_when_addon_provides_it(self) -> None:
        """Bridge response with _editor_state preserves it."""
        response = {
            "nodes_created": 5,
            "_editor_state": {
                "open_scene": "res://Level.tscn",
                "game_running": True,
                "recent_errors": ["some error"],
            },
        }
        editor_state = response.get("_editor_state", {})
        assert editor_state["open_scene"] == "res://Level.tscn"
        assert editor_state["game_running"] is True
        assert len(editor_state["recent_errors"]) == 1
```

### Additional tests in existing file: `tests/test_tools/test_output_limits.py`

Add these tests to the existing `TestOutputLimitUtility` class:

```python
def test_editor_state_preserved_during_truncation(self) -> None:
    """_editor_state is preserved intact when output limit is applied."""
    big_list = [{"name": f"item_{i}", "data": "x" * 200} for i in range(100)]
    editor_state = {
        "open_scene": "res://Main.tscn",
        "game_running": False,
        "recent_errors": ["Error at line 40"],
    }
    result = {"items": big_list, "count": 100, "_editor_state": editor_state}
    out = apply_output_limit(result, "brief", list_keys=["items"])
    assert "_editor_state" in out
    assert out["_editor_state"] == editor_state
    assert out["_editor_state"]["open_scene"] == "res://Main.tscn"
    assert out["_editor_state"]["recent_errors"] == ["Error at line 40"]

def test_other_keys_still_truncated_with_editor_state_present(self) -> None:
    """Other list keys are still truncated normally when _editor_state is present."""
    big_list = [{"name": f"item_{i}", "data": "x" * 200} for i in range(100)]
    result = {
        "items": big_list,
        "count": 100,
        "_editor_state": {"open_scene": "", "game_running": False, "recent_errors": []},
    }
    out = apply_output_limit(result, "brief", list_keys=["items"])
    assert len(out["items"]) < 100
    assert out.get("output_truncated") is True
```

## Implementation Details

### 1. GDScript: `godotiq_server.gd` -- New Member Variable

Add a new member variable alongside the existing ones (near line 26, after `_godotiq_action_history`):

```gdscript
var _recent_errors: Array = []
const MAX_RECENT_ERRORS: int = 10
```

This is a ring buffer (FIFO eviction) holding the last 10 error strings.

### 2. GDScript: `godotiq_server.gd` -- New Helper `_record_error()`

Add a method to append errors and trim the buffer:

```gdscript
func _record_error(msg: String) -> void:
    _recent_errors.append(msg)
    if _recent_errors.size() > MAX_RECENT_ERRORS:
        _recent_errors = _recent_errors.slice(-MAX_RECENT_ERRORS)
```

### 3. GDScript: `godotiq_server.gd` -- New Helper `_get_editor_state()`

Returns a small fixed-size dict with ambient editor context:

```gdscript
func _get_editor_state() -> Dictionary:
    var scene_path := ""
    var root := EditorInterface.get_edited_scene_root()
    if root != null:
        scene_path = root.scene_file_path
    return {
        "open_scene": scene_path,
        "game_running": EditorInterface.is_playing_scene(),
        "recent_errors": _recent_errors.slice(-3),
    }
```

Key details:
- `EditorInterface.get_edited_scene_root()` returns null if no scene is open -- handle gracefully
- `scene_file_path` gives the `res://` path of the scene
- `EditorInterface.is_playing_scene()` returns a bool
- `_recent_errors.slice(-3)` returns the last 3 entries (or fewer if buffer has fewer)
- These are all cheap API calls (<1ms)

### 4. GDScript: `godotiq_server.gd` -- Modify `send_response()`

Currently at line 1489:

```gdscript
func send_response(peer_id: int, id: String, result: Dictionary) -> void:
    var msg := JSON.stringify({"id": id, "status": "ok", "result": result})
    _send_text(peer_id, msg)
```

Modify to inject `_editor_state` into the result dict before serialization:

```gdscript
func send_response(peer_id: int, id: String, result: Dictionary) -> void:
    result["_editor_state"] = _get_editor_state()
    var msg := JSON.stringify({"id": id, "status": "ok", "result": result})
    _send_text(peer_id, msg)
```

This is the key architectural decision: by injecting at the `send_response()` level, every handler automatically gets `_editor_state` in its response -- all existing handlers, `handle_game_response()`, the `_pending_run` confirmation in `_process()`, and any future handlers. No per-handler changes needed.

### 5. GDScript: `godotiq_server.gd` -- Modify `_send_error()`

Currently at line 1494:

```gdscript
func _send_error(peer_id: int, id: String, code: String, message: String) -> void:
    var msg := JSON.stringify({"id": id, "status": "error", "error": {"code": code, "message": message}})
    _send_text(peer_id, msg)
```

Modify to include `_editor_state` in error responses too:

```gdscript
func _send_error(peer_id: int, id: String, code: String, message: String) -> void:
    var msg := JSON.stringify({
        "id": id,
        "status": "error",
        "error": {"code": code, "message": message},
        "_editor_state": _get_editor_state(),
    })
    _send_text(peer_id, msg)
```

This ensures the agent gets ambient context even when operations fail.

### 6. GDScript: `godotiq_debugger.gd` -- Wire Error Capture

In the `"godotiq:error"` match branch (line 43-44):

```gdscript
"godotiq:error":
    # Store in ring buffer for _editor_state
    if data.size() >= 1:
        server._record_error(str(data[0]))
    server.send_event("runtime_error", {"data": data})
    return true
```

### 7. Python: `src/godotiq/tools/output_limit.py` -- Exclude `_editor_state`

Modify `apply_output_limit()` to extract `_editor_state` before truncation and re-inject after:

```python
def apply_output_limit(result, detail, list_keys=None):
    detail = _normalize_detail(detail)
    limit = _CHAR_LIMITS.get(detail)
    if limit is None:
        return result

    # Preserve _editor_state -- extract before truncation, re-inject after
    editor_state = result.pop("_editor_state", None)

    size = _estimate_size(result)
    if size <= limit:
        if editor_state is not None:
            result["_editor_state"] = editor_state
        return result

    # ... existing truncation logic (unchanged) ...

    # Re-inject preserved _editor_state
    if editor_state is not None:
        result["_editor_state"] = editor_state

    return result
```

## Response Shape After Implementation

Every successful bridge response:
```json
{
    "nodes_created": 5,
    "errors": [],
    "_editor_state": {
        "open_scene": "res://Main.tscn",
        "game_running": false,
        "recent_errors": ["Error at tower.gd:40: Invalid node path"]
    }
}
```

Every error response:
```json
{
    "id": "42",
    "status": "error",
    "error": {"code": "NO_SCENE", "message": "No scene is open"},
    "_editor_state": {
        "open_scene": "",
        "game_running": false,
        "recent_errors": []
    }
}
```

## Dependencies

- **No dependencies on other sections.** This section is the foundation -- Section 2 depends on it (pre-flight error responses include `_editor_state` via the modified `_send_error()`).

## Risks and Mitigations

| Risk | Mitigation |
|------|------------|
| `_get_editor_state()` adds latency | Cheap API calls (<1ms). Cache with 100ms TTL if >5ms. |
| Error ring buffer grows unbounded | Capped at 10 entries with FIFO eviction. |
| `apply_output_limit()` strips `_editor_state` | Explicitly excluded by extract-before/re-inject-after pattern. |
| Newer Python server connects to older addon | Default to `{}` via `.get("_editor_state", {})`. |
