getbsquare 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -0,0 +1,5 @@
1
+ .venv
2
+ dist
3
+ *.egg-info
4
+ __pycache__
5
+ .pytest_cache
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: getbsquare
3
+ Version: 0.1.0
4
+ Summary: Backend host-action coordination for injectable AG-UI / PydanticAI agents
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: fastapi>=0.110
8
+ Provides-Extra: dev
9
+ Requires-Dist: httpx>=0.27; extra == 'dev'
10
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Provides-Extra: examples
13
+ Requires-Dist: pydantic-ai; extra == 'examples'
14
+ Requires-Dist: uvicorn[standard]; extra == 'examples'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # bsquare-host
18
+
19
+ Backend host-action coordination for injectable AG-UI / PydanticAI agents.
20
+ See the repo root README for the full picture.
@@ -0,0 +1,4 @@
1
+ # bsquare-host
2
+
3
+ Backend host-action coordination for injectable AG-UI / PydanticAI agents.
4
+ See the repo root README for the full picture.
@@ -0,0 +1,19 @@
1
+ """bsquare-host: backend host-action coordination for injectable agents."""
2
+
3
+ from .app import mount_agent_app
4
+ from .host_actions import (
5
+ get_coordination_stats,
6
+ host_action_proxy,
7
+ setup_host_action_endpoint,
8
+ )
9
+ from .state import AgentState
10
+
11
+ __all__ = [
12
+ "host_action_proxy",
13
+ "setup_host_action_endpoint",
14
+ "get_coordination_stats",
15
+ "mount_agent_app",
16
+ "AgentState",
17
+ ]
18
+
19
+ __version__ = "0.1.0"
@@ -0,0 +1,39 @@
1
+ """One-call wiring for an injectable agent backend."""
2
+
3
+ from typing import Callable, Optional, Sequence
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+
8
+ from .host_actions import setup_host_action_endpoint
9
+
10
+
11
+ def mount_agent_app(
12
+ agent,
13
+ app: FastAPI,
14
+ deps_factory: Callable[[], object],
15
+ *,
16
+ path: str = "/agent",
17
+ cors_origins: Optional[Sequence[str]] = None,
18
+ ) -> FastAPI:
19
+ """Wire an AG-UI agent, the host-action endpoint, and (optionally) CORS.
20
+
21
+ Args:
22
+ agent: an object exposing ``to_ag_ui(deps=...)`` (e.g. a PydanticAI Agent).
23
+ app: the FastAPI app to mount onto.
24
+ deps_factory: zero-arg callable returning the agent's deps instance.
25
+ Called once at mount time (not per request).
26
+ path: mount path for the AG-UI app (default ``/agent``).
27
+ cors_origins: if given, a permissive CORS middleware is added for them.
28
+ """
29
+ if cors_origins is not None:
30
+ app.add_middleware(
31
+ CORSMiddleware,
32
+ allow_origins=list(cors_origins),
33
+ allow_credentials=True,
34
+ allow_methods=["*"],
35
+ allow_headers=["*"],
36
+ )
37
+ setup_host_action_endpoint(app)
38
+ app.mount(path, agent.to_ag_ui(deps=deps_factory()))
39
+ return app
@@ -0,0 +1,317 @@
1
+ """
2
+ Host action coordination system for injectable AG-UI / PydanticAI agents.
3
+
4
+ This module provides the production-ready @host_action_proxy decorator and coordination
5
+ infrastructure that lets backend tools trigger an action on the host page (via the
6
+ injectable widget) and wait for its result, coordinated per tool_call_id.
7
+
8
+ Usage in any agent:
9
+ from bsquare_host import host_action_proxy, setup_host_action_endpoint
10
+
11
+ @your_agent.tool
12
+ @host_action_proxy(timeout=3.0)
13
+ async def get_page_info(ctx: RunContext[YourDeps], host_result: dict = None) -> str:
14
+ if host_result and host_result.get('success'):
15
+ return f"Retrieved page information: {host_result.get('result')}"
16
+ return "Unable to retrieve page information at this time."
17
+
18
+ # Add to your FastAPI app:
19
+ setup_host_action_endpoint(app)
20
+
21
+ Diagnostic messages are emitted via the ``bsquare_host`` logger at DEBUG level, so the
22
+ library stays silent by default. Enable them with::
23
+
24
+ import logging
25
+ logging.getLogger("bsquare_host").setLevel(logging.DEBUG)
26
+ """
27
+
28
+ import asyncio
29
+ import inspect
30
+ import logging
31
+ import time
32
+ import uuid
33
+ from typing import Dict, Any, Callable
34
+ from fastapi import FastAPI
35
+
36
+ logger = logging.getLogger("bsquare_host")
37
+
38
+ # Global coordination state - shared across all agents using this module
39
+ # This is intentionally global to allow coordination between:
40
+ # 1. Multiple decorated tools within an agent
41
+ # 2. The /host-action-result endpoint
42
+ # 3. Frontend widget result submissions
43
+ host_action_results: Dict[str, Any] = {}
44
+ pending_host_actions: Dict[str, asyncio.Event] = {}
45
+
46
+
47
+ def host_action_proxy(timeout: float = 5.0, auto_format: bool = False):
48
+ """
49
+ Production-ready decorator for backend tools that coordinate with frontend host actions.
50
+
51
+ Features:
52
+ - Multi-user safe using tool_call_id coordination
53
+ - Automatic timeout handling and cleanup
54
+ - Zero race conditions between concurrent users
55
+ - Graceful error handling and fallback responses
56
+
57
+ Args:
58
+ timeout: Maximum time to wait for host action result (default: 5.0 seconds)
59
+ auto_format: If True, automatically handle timeout/success/error responses (default: False)
60
+
61
+ Usage Option 1 - Manual handling (existing pattern):
62
+ @your_agent.tool
63
+ @host_action_proxy(timeout=3.0)
64
+ async def navigate_to_page(ctx: RunContext[YourDeps], page: str, host_result: dict = None) -> str:
65
+ if host_result and host_result.get('timeout'):
66
+ return f"Navigation to {page} is taking longer than expected..."
67
+
68
+ if host_result and host_result.get('success'):
69
+ result = host_result.get('result', {})
70
+ if result.get('success'):
71
+ return f"Navigation completed: {result}"
72
+
73
+ return f"Unable to navigate to {page} page at this time."
74
+
75
+ Usage Option 2 - Auto-format (eliminates boilerplate):
76
+ @your_agent.tool
77
+ @host_action_proxy(timeout=3.0, auto_format=True)
78
+ async def navigate_to_page(ctx: RunContext[YourDeps], page: str) -> dict:
79
+ '''Navigate to a specific page in the construction management system.'''
80
+ return {
81
+ "action": "navigate_to_page",
82
+ "params": {"page": page},
83
+ "timeout_message": f"Navigation to {page} is taking longer than expected...",
84
+ "error_message": f"Unable to navigate to {page} page at this time."
85
+ }
86
+ """
87
+ def decorator(func: Callable) -> Callable:
88
+ # The wrapper below is generic (ctx, *args, **kwargs), but agent
89
+ # frameworks (e.g. PydanticAI) build each tool's JSON schema by
90
+ # introspecting the function signature. If they see (*args, **kwargs)
91
+ # the model is never told the real parameters and calls the tool with
92
+ # none -- so the host action runs against empty params. Advertise the
93
+ # wrapped function's real signature instead, hiding the internal
94
+ # host_result coordination parameter.
95
+ _orig_sig = inspect.signature(func)
96
+ _public_params = [
97
+ p for name, p in _orig_sig.parameters.items() if name != "host_result"
98
+ ]
99
+ _public_sig = _orig_sig.replace(parameters=_public_params)
100
+ _public_annotations = {
101
+ k: v
102
+ for k, v in getattr(func, "__annotations__", {}).items()
103
+ if k != "host_result"
104
+ }
105
+
106
+ async def wrapper(ctx, *args, **kwargs):
107
+ action_name = func.__name__
108
+ logger.debug(f"Host Action: {action_name} decorator wrapper called")
109
+
110
+ # Get tool call ID from context - this is unique per tool call
111
+ tool_call_id = ctx.tool_call_id
112
+ if not tool_call_id:
113
+ # Fallback to generating unique ID if not available
114
+ tool_call_id = str(uuid.uuid4())
115
+
116
+ logger.debug(f"Host Action: Using tool_call_id: {tool_call_id}")
117
+
118
+ # Set up waiting mechanism using unique tool_call_id
119
+ pending_host_actions[tool_call_id] = asyncio.Event()
120
+
121
+ # Extract parameters from function signature
122
+ sig = _orig_sig
123
+ func_params = {}
124
+
125
+ # Get parameters excluding ctx and host_result
126
+ param_names = [p for p in sig.parameters.keys() if p not in ['ctx', 'host_result']]
127
+ for i, param_name in enumerate(param_names):
128
+ if i < len(args):
129
+ func_params[param_name] = args[i]
130
+
131
+ # Only add non-host_result kwargs to func_params
132
+ filtered_kwargs = {k: v for k, v in kwargs.items() if k != 'host_result'}
133
+ func_params.update(filtered_kwargs)
134
+
135
+ # Store clean args and kwargs for function calls
136
+ clean_args = args[:len(param_names)]
137
+ clean_kwargs = filtered_kwargs
138
+
139
+ # Set pending host action in state for frontend (include tool_call_id)
140
+ # Handle both dict and dataclass state objects
141
+ if hasattr(ctx.deps.state, 'pending_host_action'):
142
+ # Dataclass-style state
143
+ ctx.deps.state.pending_host_action = {
144
+ "action": action_name,
145
+ "params": func_params,
146
+ "tool_call_id": tool_call_id,
147
+ "timestamp": time.time()
148
+ }
149
+ else:
150
+ # Dict-style state
151
+ ctx.deps.state["pending_host_action"] = {
152
+ "action": action_name,
153
+ "params": func_params,
154
+ "tool_call_id": tool_call_id,
155
+ "timestamp": time.time()
156
+ }
157
+
158
+ logger.debug(f"Host Action: Set pending_host_action for {action_name} with tool_call_id {tool_call_id}, waiting for result...")
159
+
160
+ # Wait for host action result with timeout
161
+ try:
162
+ await asyncio.wait_for(
163
+ pending_host_actions[tool_call_id].wait(),
164
+ timeout=timeout
165
+ )
166
+
167
+ # Get result using tool_call_id
168
+ action_result = host_action_results.get(tool_call_id)
169
+
170
+ if action_result:
171
+ result = action_result.get('result', {})
172
+ logger.debug(f"Host Action: Found {action_name} result for tool_call_id {tool_call_id}: {result}")
173
+
174
+ # Clean up
175
+ del pending_host_actions[tool_call_id]
176
+ del host_action_results[tool_call_id]
177
+
178
+ # Handle auto-format vs manual mode
179
+ if auto_format:
180
+ return _auto_format_response(action_result, action_name, func)
181
+ else:
182
+ # Call original function with host_result
183
+ return await func(ctx, host_result=action_result)
184
+ else:
185
+ logger.debug(f"Host Action: No result found for {action_name} with tool_call_id {tool_call_id}")
186
+ if auto_format:
187
+ # Get function metadata for error handling
188
+ func_result = await func(ctx, *clean_args, **clean_kwargs)
189
+ if isinstance(func_result, dict) and 'error_message' in func_result:
190
+ return func_result['error_message']
191
+ return f"Unable to complete {action_name} at this time."
192
+ else:
193
+ return await func(ctx, host_result=None)
194
+
195
+ except asyncio.TimeoutError:
196
+ logger.debug(f"Host Action: {action_name} timed out after {timeout}s (tool_call_id: {tool_call_id})")
197
+ # Clean up
198
+ if tool_call_id in pending_host_actions:
199
+ del pending_host_actions[tool_call_id]
200
+
201
+ if auto_format:
202
+ # Get function metadata for timeout handling
203
+ func_result = await func(ctx, *clean_args, **clean_kwargs)
204
+ if isinstance(func_result, dict) and 'timeout_message' in func_result:
205
+ return func_result['timeout_message']
206
+ return f"{action_name} is taking longer than expected..."
207
+ else:
208
+ # Call function with timeout indication
209
+ return await func(ctx, host_result={'timeout': True})
210
+
211
+ # Preserve original function metadata and advertise the real signature
212
+ # (minus host_result) so tool-schema builders see the true parameters.
213
+ wrapper.__name__ = func.__name__
214
+ wrapper.__doc__ = func.__doc__
215
+ wrapper.__annotations__ = _public_annotations
216
+ wrapper.__signature__ = _public_sig
217
+
218
+ return wrapper
219
+ return decorator
220
+
221
+
222
+ def _auto_format_response(action_result: dict, action_name: str, func: Callable) -> str:
223
+ """
224
+ Auto-format host action responses based on success/failure patterns.
225
+
226
+ Args:
227
+ action_result: Result from host action execution
228
+ action_name: Name of the action that was executed
229
+ func: Original function for extracting custom messages
230
+
231
+ Returns:
232
+ Formatted response string
233
+ """
234
+ if action_result.get('success'):
235
+ result = action_result.get('result', {})
236
+ if result.get('success'):
237
+ # Format successful result - show data if meaningful
238
+ if isinstance(result, dict) and len(result) > 1:
239
+ return f"✅ {action_name.replace('_', ' ').title()} completed successfully: {result}"
240
+ else:
241
+ return f"✅ {action_name.replace('_', ' ').title()} completed successfully"
242
+ else:
243
+ # Host action succeeded but returned failure
244
+ error_msg = result.get('error', 'Unknown error')
245
+ return f"❌ {action_name.replace('_', ' ').title()} failed: {error_msg}"
246
+ else:
247
+ # Host action itself failed
248
+ error_msg = action_result.get('error', 'Host action execution failed')
249
+ return f"❌ {action_name.replace('_', ' ').title()} failed: {error_msg}"
250
+
251
+
252
+ def setup_host_action_endpoint(app: FastAPI):
253
+ """
254
+ Add the /host-action-result endpoint to a FastAPI app.
255
+
256
+ This endpoint receives results from frontend host actions and coordinates
257
+ with waiting backend tools through the global coordination system.
258
+
259
+ Args:
260
+ app: FastAPI application instance
261
+
262
+ Usage:
263
+ from bsquare_host import setup_host_action_endpoint
264
+
265
+ app = FastAPI()
266
+ setup_host_action_endpoint(app)
267
+ """
268
+
269
+ @app.post("/host-action-result")
270
+ async def receive_host_action_result(request: dict):
271
+ """Unified endpoint for receiving host action results from frontend widgets."""
272
+ logger.debug(f"Host Action: Received result: {request.get('toolName')} -> {request.get('result')}")
273
+
274
+ # Extract coordination data
275
+ tool_call_id = request.get('toolCallId')
276
+ tool_name = request.get('toolName')
277
+ result = request.get('result')
278
+ success = request.get('success', False)
279
+
280
+ if not tool_call_id:
281
+ return {"status": "error", "detail": "toolCallId is required"}
282
+
283
+ # Store result and notify waiting tools
284
+ host_action_results[tool_call_id] = {
285
+ 'result': result,
286
+ 'success': success,
287
+ 'toolName': tool_name
288
+ }
289
+
290
+ # Notify waiting tool if exists (using tool_call_id)
291
+ if tool_call_id in pending_host_actions:
292
+ logger.debug(f"Host Action: Found waiting tool for tool_call_id '{tool_call_id}', setting event!")
293
+ pending_host_actions[tool_call_id].set()
294
+ else:
295
+ logger.debug(f"Host Action: No waiting tool found for tool_call_id '{tool_call_id}'")
296
+
297
+ if success:
298
+ logger.debug(f"Host action {tool_name} completed successfully")
299
+ else:
300
+ logger.debug(f"Host action {tool_name} failed")
301
+
302
+ return {"status": "received", "toolCallId": tool_call_id}
303
+
304
+
305
+ def get_coordination_stats() -> Dict[str, Any]:
306
+ """
307
+ Get current coordination system statistics for debugging.
308
+
309
+ Returns:
310
+ Dictionary with current state of coordination system
311
+ """
312
+ return {
313
+ "pending_actions": len(pending_host_actions),
314
+ "stored_results": len(host_action_results),
315
+ "pending_tool_call_ids": list(pending_host_actions.keys()),
316
+ "result_tool_call_ids": list(host_action_results.keys())
317
+ }
@@ -0,0 +1,20 @@
1
+ """Base state for agents that coordinate with frontend host actions."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+
6
+
7
+ @dataclass
8
+ class AgentState:
9
+ """Carry the fields the host-action handshake reads and writes.
10
+
11
+ - ``pending_host_action`` is set by :func:`host_action_proxy` so the
12
+ frontend knows which action to run.
13
+ - ``host_action_result`` mirrors the frontend's reply (the canonical copy
14
+ lives in the coordination map keyed by ``tool_call_id``).
15
+ - ``api_token`` is forwarded by the widget in the AG-UI ``state`` field.
16
+ """
17
+
18
+ pending_host_action: Optional[Dict[str, Any]] = None
19
+ host_action_result: Optional[Dict[str, Any]] = None
20
+ api_token: Optional[str] = None
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "getbsquare"
7
+ version = "0.1.0"
8
+ description = "Backend host-action coordination for injectable AG-UI / PydanticAI agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = ["fastapi>=0.110"]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "httpx>=0.27"]
16
+ examples = ["pydantic-ai", "uvicorn[standard]"]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["bsquare_host"]
20
+
21
+ [tool.pytest.ini_options]
22
+ asyncio_mode = "auto"
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Dict, Optional
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.testclient import TestClient
6
+
7
+ from bsquare_host.app import mount_agent_app
8
+
9
+
10
+ @dataclass
11
+ class _State:
12
+ pending_host_action: Optional[Dict[str, Any]] = None
13
+ host_action_result: Optional[Dict[str, Any]] = None
14
+
15
+
16
+ @dataclass
17
+ class _Deps:
18
+ state: _State
19
+
20
+
21
+ class _FakeAgent:
22
+ """Stands in for a PydanticAI agent; mirrors the .to_ag_ui() contract."""
23
+
24
+ def __init__(self):
25
+ self.deps_passed = None
26
+
27
+ def to_ag_ui(self, deps=None):
28
+ self.deps_passed = deps
29
+ sub = FastAPI()
30
+
31
+ @sub.get("/")
32
+ def root():
33
+ return {"ok": True}
34
+
35
+ return sub
36
+
37
+
38
+ def test_mount_agent_app_wires_endpoints_and_mount():
39
+ app = FastAPI()
40
+ agent = _FakeAgent()
41
+ mount_agent_app(agent, app, lambda: _Deps(_State()), cors_origins=["*"])
42
+ client = TestClient(app)
43
+
44
+ # host-action-result endpoint is present
45
+ r = client.post(
46
+ "/host-action-result",
47
+ json={"toolCallId": "x", "toolName": "t", "result": {}, "success": True},
48
+ )
49
+ assert r.status_code == 200
50
+
51
+ # agent is mounted at /agent
52
+ r2 = client.get("/agent/")
53
+ assert r2.status_code == 200
54
+ assert r2.json() == {"ok": True}
55
+
56
+ # deps factory was invoked and passed through
57
+ assert isinstance(agent.deps_passed, _Deps)
@@ -0,0 +1,160 @@
1
+ import asyncio
2
+ import inspect
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.testclient import TestClient
8
+
9
+ from bsquare_host.host_actions import (
10
+ host_action_proxy,
11
+ setup_host_action_endpoint,
12
+ pending_host_actions,
13
+ host_action_results,
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class _State:
19
+ pending_host_action: Optional[Dict[str, Any]] = None
20
+ host_action_result: Optional[Dict[str, Any]] = None
21
+
22
+
23
+ @dataclass
24
+ class _Deps:
25
+ state: _State
26
+
27
+
28
+ class _Ctx:
29
+ def __init__(self, tool_call_id, deps):
30
+ self.tool_call_id = tool_call_id
31
+ self.deps = deps
32
+
33
+
34
+ @host_action_proxy(timeout=2.0, auto_format=True)
35
+ async def navigate_to_page(ctx, page: str) -> str:
36
+ return f"Navigated to {page}"
37
+
38
+
39
+ @host_action_proxy(timeout=0.2, auto_format=True)
40
+ async def slow_action(ctx) -> str:
41
+ return "done"
42
+
43
+
44
+ @host_action_proxy(timeout=2.0)
45
+ async def manual_action(ctx, page: str, host_result: dict = None) -> str:
46
+ return f"manual {page}"
47
+
48
+
49
+ def test_decorator_exposes_wrapped_parameters_to_schema():
50
+ # PydanticAI (and any tool framework) builds a tool's JSON schema by
51
+ # introspecting the function signature. The decorator must advertise the
52
+ # wrapped function's real parameters -- NOT the wrapper's (ctx, *args,
53
+ # **kwargs) -- otherwise the model is never told the tool takes `page` and
54
+ # calls it with no arguments (the action then runs against empty params).
55
+ sig = inspect.signature(navigate_to_page)
56
+ assert "page" in sig.parameters, "the 'page' parameter must be visible to the schema builder"
57
+ assert sig.parameters["page"].annotation is str
58
+ kinds = {p.kind for p in sig.parameters.values()}
59
+ assert inspect.Parameter.VAR_POSITIONAL not in kinds, "*args must not leak into the tool schema"
60
+ assert inspect.Parameter.VAR_KEYWORD not in kinds, "**kwargs must not leak into the tool schema"
61
+
62
+
63
+ def test_decorator_hides_internal_host_result_param():
64
+ # `host_result` is an internal coordination channel, not a model-facing
65
+ # argument; it must never appear in the advertised signature.
66
+ sig = inspect.signature(manual_action)
67
+ assert "page" in sig.parameters
68
+ assert "host_result" not in sig.parameters
69
+
70
+
71
+ async def test_handshake_accepts_keyword_arguments():
72
+ # PydanticAI invokes tools with keyword arguments, so the params advertised
73
+ # to the frontend must be captured from kwargs, not only positional args.
74
+ ctx = _Ctx("call-kw", _Deps(_State()))
75
+ task = asyncio.create_task(navigate_to_page(ctx, page="reports"))
76
+ await asyncio.sleep(0.05)
77
+
78
+ assert ctx.deps.state.pending_host_action["params"] == {"page": "reports"}
79
+
80
+ host_action_results["call-kw"] = {
81
+ "result": {"success": True, "page": "reports"},
82
+ "success": True,
83
+ "toolName": "navigate_to_page",
84
+ }
85
+ pending_host_actions["call-kw"].set()
86
+
87
+ result = await task
88
+ assert "completed successfully" in result
89
+ assert "call-kw" not in pending_host_actions
90
+ assert "call-kw" not in host_action_results
91
+
92
+
93
+ async def test_handshake_returns_formatted_success():
94
+ ctx = _Ctx("call-1", _Deps(_State()))
95
+ task = asyncio.create_task(navigate_to_page(ctx, "reports"))
96
+ await asyncio.sleep(0.05) # let the decorator register the pending action
97
+
98
+ # The decorator must have advertised the pending action on state:
99
+ assert ctx.deps.state.pending_host_action["action"] == "navigate_to_page"
100
+ assert ctx.deps.state.pending_host_action["params"] == {"page": "reports"}
101
+
102
+ # Simulate the frontend posting its result (what the endpoint does):
103
+ host_action_results["call-1"] = {
104
+ "result": {"success": True, "page": "reports"},
105
+ "success": True,
106
+ "toolName": "navigate_to_page",
107
+ }
108
+ pending_host_actions["call-1"].set()
109
+
110
+ result = await task
111
+ assert "completed successfully" in result
112
+ # Coordination state is cleaned up after a successful handshake:
113
+ assert "call-1" not in pending_host_actions
114
+ assert "call-1" not in host_action_results
115
+
116
+
117
+ async def test_timeout_returns_message():
118
+ ctx = _Ctx("call-2", _Deps(_State()))
119
+ result = await slow_action(ctx) # nobody ever sets the event
120
+ assert "taking longer than expected" in result
121
+ assert "call-2" not in pending_host_actions
122
+
123
+
124
+ def test_endpoint_sets_event_and_stores_result():
125
+ app = FastAPI()
126
+ setup_host_action_endpoint(app)
127
+ client = TestClient(app)
128
+
129
+ pending_host_actions["call-3"] = asyncio.Event()
130
+ resp = client.post(
131
+ "/host-action-result",
132
+ json={
133
+ "toolCallId": "call-3",
134
+ "toolName": "navigate_to_page",
135
+ "result": {"success": True},
136
+ "success": True,
137
+ },
138
+ )
139
+ assert resp.status_code == 200
140
+ assert resp.json()["toolCallId"] == "call-3"
141
+ assert pending_host_actions["call-3"].is_set()
142
+ assert host_action_results["call-3"]["success"] is True
143
+
144
+ pending_host_actions.pop("call-3", None)
145
+ host_action_results.pop("call-3", None)
146
+
147
+
148
+ def test_endpoint_rejects_missing_tool_call_id():
149
+ app = FastAPI()
150
+ setup_host_action_endpoint(app)
151
+ client = TestClient(app)
152
+ resp = client.post(
153
+ "/host-action-result",
154
+ json={"toolName": "x", "result": {}, "success": True}, # no toolCallId
155
+ )
156
+ assert resp.status_code == 200
157
+ assert resp.json().get("status") == "error"
158
+ # the None key must NOT have been written
159
+ from bsquare_host.host_actions import host_action_results
160
+ assert None not in host_action_results
@@ -0,0 +1,12 @@
1
+ import bsquare_host
2
+
3
+
4
+ def test_public_api_exports():
5
+ for name in (
6
+ "host_action_proxy",
7
+ "setup_host_action_endpoint",
8
+ "get_coordination_stats",
9
+ "mount_agent_app",
10
+ "AgentState",
11
+ ):
12
+ assert hasattr(bsquare_host, name), f"missing export: {name}"
@@ -0,0 +1,14 @@
1
+ from bsquare_host.state import AgentState
2
+
3
+
4
+ def test_agent_state_defaults_are_none():
5
+ s = AgentState()
6
+ assert s.pending_host_action is None
7
+ assert s.host_action_result is None
8
+ assert s.api_token is None
9
+
10
+
11
+ def test_agent_state_is_mutable():
12
+ s = AgentState()
13
+ s.pending_host_action = {"action": "navigate_to_page"}
14
+ assert s.pending_host_action["action"] == "navigate_to_page"