44
55import threading
66from abc import ABC , abstractmethod
7- from typing import Any
7+ from typing import Any , Protocol , runtime_checkable
88
99from ..models import ToolSpec
1010
@@ -36,6 +36,57 @@ def wait(self) -> str:
3636 return self ._result or ""
3737
3838
39+ @runtime_checkable
40+ class ToolRuntime (Protocol ):
41+ """The session surface a ``ToolContext`` proxies to.
42+
43+ ``ToolContext`` needs only this slice of the full ``Session`` —
44+ a project directory, a cancel event, and a handful of callbacks
45+ for user questions, diff/todo recording, skill lookup, sub-agent
46+ delegation, and plan-mode exit. Typing the context against this
47+ protocol (instead of ``Any``) documents the real dependency and
48+ lets a tool be exercised with a lightweight fake runtime, no full
49+ ``Session`` required::
50+
51+ class FakeRuntime:
52+ project_dir = "/tmp"
53+ cancel_event = threading.Event()
54+ def ask_questions(self, questions): return "..."
55+ ... # only the methods the tool under test touches
56+
57+ ctx = ToolContext(FakeRuntime())
58+ tool.run(args, ctx)
59+
60+ ``ToolContext`` still accepts ``None`` and objects implementing only
61+ part of this surface (its methods guard each call), so the protocol
62+ describes the *complete* runtime while partial fakes remain valid.
63+
64+ ``project_dir`` / ``cancel_event`` are declared as read-only
65+ properties (``ToolContext`` only ever reads them): that admits both
66+ a plain-attribute implementation (the real ``Session``) and a
67+ property-backed one (test doubles), whereas a plain mutable-attribute
68+ declaration would be invariant and reject a ``property``.
69+ """
70+
71+ @property
72+ def project_dir (self ) -> str : ...
73+
74+ @property
75+ def cancel_event (self ) -> threading .Event : ...
76+
77+ def ask_questions (self , questions : list [dict ]) -> str : ...
78+
79+ def record_diff (self , diff_text : str ) -> None : ...
80+
81+ def update_todos (self , todos : list [dict ]) -> None : ...
82+
83+ def find_skill (self , name : str ) -> str | None : ...
84+
85+ def run_subagent (self , subagent_type : str , description : str , prompt : str ) -> str : ...
86+
87+ def plan_exit (self ) -> str : ...
88+
89+
3990class ToolContext :
4091 """Runtime context handed to tools.
4192
@@ -44,7 +95,7 @@ class ToolContext:
4495 proxy to the session when present; defaults are safe no-ops.
4596 """
4697
47- def __init__ (self , session : Any = None ) -> None :
98+ def __init__ (self , session : ToolRuntime | None = None ) -> None :
4899 self .session = session
49100
50101 @property
@@ -81,7 +132,7 @@ def plan_exit(self) -> str:
81132 return "Not in plan mode; PlanExit has no effect. Continue as normal."
82133
83134 @property
84- def cancel_event (self ) -> Any :
135+ def cancel_event (self ) -> threading . Event | None :
85136 """Session cancel event (set when the user presses Ctrl-C)."""
86137 if self .session and hasattr (self .session , "cancel_event" ):
87138 return self .session .cancel_event
0 commit comments