diff --git a/docs/tools.md b/docs/tools.md index 125d2a00..05f49783 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -28,13 +28,19 @@ schema plus turnstone-specific metadata keys: } ``` -**Metadata keys** (stripped before sending the schema to the model): +**Metadata keys** (stripped before sending the schema to the model; the full +set lives in `_META_KEYS` in `turnstone/core/tools.py`): -| Key | Type | Meaning | -|----------------|------|---------| -| `task_agent` | bool | Tool is available to task sub-agents. | -| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). | -| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. | +| Key | Type | Meaning | +|------------------|------|---------| +| `task_agent` | bool | Tool is available to task sub-agents. | +| `coordinator` | bool | Tool is available to coordinator sessions. Without `interactive: true` alongside it, this reads as coord-only and the tool is stripped from interactive sessions. | +| `interactive` | bool | Opt a `coordinator: true` tool back into interactive sessions (dual-kind tools like `memory`). | +| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). | +| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. | +| `kind_variants` | dict | Per-kind description / parameter-schema overlays so each session kind sees only the surface it can use (see `memory.json`). | +| `cwd_note` | str | Sentence appended to the description at session build time with `{working_dir}` substituted — declare on tools whose semantics depend on the process working directory (see `bash.json`, `apply_cwd_context`). | +| `workspace_note` | str | Companion sentence naming the operator-configured workspace directory, `{workspace_dir}` substituted; dropped when no workspace is configured. | --- diff --git a/tests/test_cwd_tool_notes.py b/tests/test_cwd_tool_notes.py index 9d6cfa73..3de9c0da 100644 --- a/tests/test_cwd_tool_notes.py +++ b/tests/test_cwd_tool_notes.py @@ -123,6 +123,14 @@ class TestNoteMetadataInvariants: assert "cwd_note" not in t["function"] assert "workspace_note" not in t["function"] + def test_workspace_note_wording_uniform(self): + # The workspace fact is one node-level value, so its sentence is + # deliberately identical across the fs tools (unlike cwd_note, whose + # prose is per-tool). Guards a one-file reword from drifting the + # copies apart, independent of the exact wording. + notes = {_META[name]["workspace_note"] for name in _FS_TOOLS} + assert len(notes) == 1, notes + # --------------------------------------------------------------------------- # ChatSession build sites @@ -208,3 +216,16 @@ class TestSessionCwdNotes: session._on_mcp_tools_changed() assert _desc(session._tools, "bash").count(f"Commands run in {os.getcwd()}") == 1 assert _desc(session._task_tools, "bash").count(f"Commands run in {os.getcwd()}") == 1 + + def test_mcp_drop_surface_keeps_single_note(self, tmp_db): + # The MCP-disconnect rebuild (_drop_mcp_surface, reached via resume() + # adopting an MCP-off persona) is the third rebuild trigger — the note + # must survive it, exactly once, on both lanes. + mock_mcp = MagicMock() + mock_mcp.get_tools.return_value = [] + with patch("turnstone.core.session.get_workspace_dir", return_value=None): + session = _make_session(mcp_client=mock_mcp) + session._drop_mcp_surface() + assert session._mcp_client is None + assert _desc(session._tools, "bash").count(f"Commands run in {os.getcwd()}") == 1 + assert _desc(session._task_tools, "bash").count(f"Commands run in {os.getcwd()}") == 1