diff --git a/docs/api_reference.md b/docs/api_reference.md index 5ac7d775..f79ea574 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -2053,24 +2053,24 @@ result.pay( Start call tapping/monitoring. **Parameters:** -- `uri` (str): URI to send tapped audio to +- `uri` (str): URI to send tapped audio to — `rtp://IP:port`, `ws://example.com`, or `wss://example.com` - `control_id` (Optional[str]): Unique identifier for this tap -- `direction` (str): Tap direction: "both", "inbound", "outbound" (default: "both") -- `codec` (str): Audio codec: "PCMU", "PCMA", "G722" (default: "PCMU") +- `direction` (str): Tap direction: "speak", "listen", "both" (default: "both") +- `codec` (str): Audio codec: "PCMU" or "PCMA" (default: "PCMU") - `rtp_ptime` (int): RTP packet time in milliseconds (default: 20) - `status_url` (Optional[str]): Status webhook URL **Usage:** ```python # Basic call tapping -result.tap("sip:monitor@company.com") +result.tap("wss://monitor.company.com/tap") # Tap with specific settings result.tap( - uri="sip:quality@company.com", + uri="rtp://192.168.1.100:5004", control_id="quality_monitor_001", direction="both", - codec="G722" + codec="PCMA" ) ``` diff --git a/docs/swaig_reference.md b/docs/swaig_reference.md index c75ed143..2b61707e 100644 --- a/docs/swaig_reference.md +++ b/docs/swaig_reference.md @@ -423,9 +423,9 @@ result.tap( - `control_id`: Identifier for this tap to use with stop_tap (optional, auto-generated if not provided) **Audio Configuration:** -- `direction`: Audio direction to tap (default: "both") +- `direction`: Audio direction to tap (default: "both"; always sent — the underlying SWML verb defaults to "speak" when omitted) - `"speak"`: What party says - - `"hear"`: What party hears + - `"listen"`: What party hears - `"both"`: What party hears and says - `codec`: Codec for tap stream - "PCMU" or "PCMA" (default: "PCMU") - `rtp_ptime`: RTP packetization time in milliseconds (default: 20) diff --git a/rest/docs/calling.md b/rest/docs/calling.md index 903c3bc7..16a89924 100644 --- a/rest/docs/calling.md +++ b/rest/docs/calling.md @@ -208,9 +208,13 @@ client.calling.ai_stop(call_id, control_id="ai-1") ## Live Transcribe & Translate +`action` is an object keyed by the phase (`start`/`summarize`), with the phase's +parameters inside it — or the literal string `"stop"`: + ```python -client.calling.live_transcribe(call_id, action="start", lang="en") -client.calling.live_translate(call_id, action="start", from_lang="en", to_lang="es") +client.calling.live_transcribe(call_id, action={"start": {"lang": "en", "webhook": "https://example.com/transcripts"}}) +client.calling.live_transcribe(call_id, action="stop") +client.calling.live_translate(call_id, action={"start": {"from_lang": "en", "to_lang": "es"}}) ``` ## Fax diff --git a/signalwire/signalwire/ai_chat/gateway.py b/signalwire/signalwire/ai_chat/gateway.py index 4f904c7b..823767f8 100644 --- a/signalwire/signalwire/ai_chat/gateway.py +++ b/signalwire/signalwire/ai_chat/gateway.py @@ -540,8 +540,13 @@ async def proxy(request: Request) -> Response: return JSONResponse({"status": "ended"}, headers=cors) if method == "create_conversation": + # prepare() decides whether a timeout applies; dropping it here + # would report one number to the browser below while the + # service quietly keeps its own default. info = await self._client.create_conversation( - params["id"], config_url=params["config_url"] + params["id"], + config_url=params["config_url"], + timeout=params.get("conversation_timeout"), ) if minted: cors["X-Chat-Handle"] = minted diff --git a/signalwire/signalwire/core/function_result.py b/signalwire/signalwire/core/function_result.py index b1a50923..acea5c90 100644 --- a/signalwire/signalwire/core/function_result.py +++ b/signalwire/signalwire/core/function_result.py @@ -480,7 +480,7 @@ def execute_swml( """ # Detect input type and normalize to appropriate format if isinstance(swml_content, str): - # Raw SWML string - parse to dict so we can add transfer key if needed + # Raw SWML string - parse to dict so the action carries a document try: import json @@ -496,11 +496,15 @@ def execute_swml( else: raise TypeError("swml_content must be string, dict, or SWML object") - action = swml_data + # transfer rides BESIDE the SWML document, not inside it — the same + # shape connect() and swml_transfer() emit. Inside the document it is + # not a SWML key and the call never exits the agent. + action: dict[str, Any] = {"SWML": swml_data} if transfer: action["transfer"] = "true" - return self.add_action("SWML", action) + self.action.append(action) + return self def hangup(self) -> "FunctionResult": """ @@ -1362,7 +1366,7 @@ def tap( self, uri: str, control_id: str | None = None, - direction: Literal["speak", "hear", "both"] = "both", + direction: Literal["speak", "listen", "both"] = "both", codec: Literal["PCMU", "PCMA"] = "PCMU", rtp_ptime: int = 20, status_url: str | None = None, @@ -1380,7 +1384,7 @@ def tap( Default is generated and stored in tap_control_id variable direction: Direction of audio to tap (default: "both") "speak" = what party says - "hear" = what party hears + "listen" = what party hears "both" = what party hears and says codec: Codec for tap media stream - "PCMU" or "PCMA" (default: "PCMU") rtp_ptime: Packetization time in milliseconds for RTP (default: 20) @@ -1393,7 +1397,7 @@ def tap( ValueError: If direction or codec values are invalid """ # Validate direction parameter - valid_directions = ["speak", "hear", "both"] + valid_directions = ["speak", "listen", "both"] if direction not in valid_directions: raise ValueError(f"direction must be one of {valid_directions}") @@ -1412,8 +1416,9 @@ def tap( # Add optional parameters if they differ from defaults if control_id: tap_params["control_id"] = control_id - if direction != "both": - tap_params["direction"] = direction + # Always sent: the verb's own default is "speak", not this helper's + # "both", so omitting it would tap less than the caller asked for. + tap_params["direction"] = direction if codec != "PCMU": tap_params["codec"] = codec if rtp_ptime != 20: diff --git a/signalwire/signalwire/search/search_service.py b/signalwire/signalwire/search/search_service.py index d7193718..c54a320e 100644 --- a/signalwire/signalwire/search/search_service.py +++ b/signalwire/signalwire/search/search_service.py @@ -7,6 +7,8 @@ See LICENSE file in the project root for full license information. """ +from __future__ import annotations + import hashlib import json from collections.abc import Awaitable, Callable diff --git a/tests/unit/ai_chat/test_gateway.py b/tests/unit/ai_chat/test_gateway.py index 72e52d77..2697df0c 100644 --- a/tests/unit/ai_chat/test_gateway.py +++ b/tests/unit/ai_chat/test_gateway.py @@ -608,3 +608,40 @@ async def test_start_then_reload_replays_the_same_conversation( assert "messages" in replay.json() # and it asked the service for the conversation the handle names assert service.seen[-1]["params"]["id"] == gateway.read_handle(handle) + + +async def test_start_forwards_the_configured_timeout_upstream(service: Any) -> None: + """prepare() puts conversation_timeout in the start params, but the HTTP + dispatch used to rebuild the create_conversation call and drop it — the + browser was told 900 while the service kept its 3600 default. The number + the page schedules its idle warning around must be the number the service + actually enforces.""" + gw = make_gateway( + service, + allowed_origins=["https://shop.example.com"], + conversation_timeout=900, + ) + try: + async with asgi(gw) as http: + started = await http.post( + "/chat/", json={"method": "start"}, headers=HEADERS + ) + assert started.status_code == 200 + assert started.json()["timeout"] == 900 + sent = service.seen[-1] + assert sent["method"] == "create_conversation" + assert sent["params"]["conversation_timeout"] == 900 + + # The chat path auto-creates too, and takes the same timeout. + handle = started.headers["x-chat-handle"] + chatted = await http.post( + "/chat/", + json={"message": "hi", "handle": handle}, + headers=HEADERS, + ) + assert chatted.status_code == 200 + sent = service.seen[-1] + assert sent["method"] == "chat" + assert sent["params"]["conversation_timeout"] == 900 + finally: + await gw._client.close() diff --git a/tests/unit/core/test_function_result.py b/tests/unit/core/test_function_result.py index 58d52bdc..9c4869ee 100644 --- a/tests/unit/core/test_function_result.py +++ b/tests/unit/core/test_function_result.py @@ -543,8 +543,8 @@ def test_execute_swml_dict_does_not_mutate_original(self) -> None: # The original dict should NOT have 'transfer' key added assert "transfer" not in original - # But the action's SWML should have it - assert result.action[0]["SWML"]["transfer"] == "true" + # The action carries it beside the document + assert result.action[0]["transfer"] == "true" def test_execute_swml_sdk_object_with_to_dict(self) -> None: """Test execute_swml with an SDK object that has to_dict()""" @@ -569,12 +569,18 @@ def test_execute_swml_invalid_type_list(self) -> None: FunctionResult().execute_swml([1, 2, 3]) def test_execute_swml_with_transfer_true(self) -> None: - """Test execute_swml with transfer=True adds transfer key""" + """transfer is a SIBLING of the SWML key — the platform's documented + action shape, and the one connect()/swml_transfer() emit. Inside the + document it is not a SWML key and the call never exits the agent.""" swml_dict = {"version": "1.0.0", "sections": {"main": []}} result = FunctionResult().execute_swml(swml_dict, transfer=True) action = result.action[0] - assert action["SWML"]["transfer"] == "true" + assert action["transfer"] == "true" + assert "transfer" not in action["SWML"] + # Same action shape as the live-proven connect() helper + connect_action = FunctionResult().connect("+15551234567").action[0] + assert set(action.keys()) == set(connect_action.keys()) def test_execute_swml_with_transfer_false(self) -> None: """Test execute_swml with transfer=False does not add transfer key""" @@ -582,6 +588,7 @@ def test_execute_swml_with_transfer_false(self) -> None: result = FunctionResult().execute_swml(swml_dict, transfer=False) action = result.action[0] + assert "transfer" not in action assert "transfer" not in action["SWML"] def test_execute_swml_chaining(self) -> None: @@ -996,8 +1003,10 @@ def test_tap_default_params(self) -> None: swml = result.action[0]["SWML"] tap_params = swml["sections"]["main"][0]["tap"] assert tap_params["uri"] == "rtp://192.168.1.1:5000" - # Default params should not be included - assert "direction" not in tap_params + # direction is always emitted: the SWML verb's own default is "speak", + # so leaving it out would silently tap less than the documented "both". + assert tap_params["direction"] == "both" + # Params whose helper defaults match the verb defaults stay omitted. assert "codec" not in tap_params assert "rtp_ptime" not in tap_params @@ -1040,11 +1049,18 @@ def test_tap_negative_rtp_ptime(self) -> None: with pytest.raises(ValueError, match="rtp_ptime must be a positive integer"): FunctionResult().tap("rtp://1.2.3.4:5000", rtp_ptime=-10) - def test_tap_direction_hear(self) -> None: - """Test tap with direction=hear""" - result = FunctionResult().tap("rtp://1.2.3.4:5000", direction="hear") + def test_tap_direction_listen(self) -> None: + """Test tap with direction=listen (the verb's name for the hear side)""" + result = FunctionResult().tap("rtp://1.2.3.4:5000", direction="listen") tap_params = result.action[0]["SWML"]["sections"]["main"][0]["tap"] - assert tap_params["direction"] == "hear" + assert tap_params["direction"] == "listen" + + def test_tap_direction_hear_is_rejected(self) -> None: + """"hear" was never a SWML tap direction — the verb's enum is + speak/listen/both, so emitting it produced a tap the platform + rejects. A loud error beats a silent no-op tap.""" + with pytest.raises(ValueError, match="direction must be one of"): + FunctionResult().tap("rtp://1.2.3.4:5000", direction="hear") # type: ignore[arg-type] # intentional invalid input def test_tap_chaining(self) -> None: """Test tap returns self for chaining""" @@ -1166,7 +1182,7 @@ def test_validated_closed_sets_declared_as_literal(self) -> None: literal_cases = [ (FunctionResult.record_call, "format", ("wav", "mp3", "mp4")), (FunctionResult.record_call, "direction", ("speak", "listen", "both")), - (FunctionResult.tap, "direction", ("speak", "hear", "both")), + (FunctionResult.tap, "direction", ("speak", "listen", "both")), (FunctionResult.tap, "codec", ("PCMU", "PCMA")), ] for fn, param, expected in literal_cases: diff --git a/tests/unit/core/test_swml_service_swaig.py b/tests/unit/core/test_swml_service_swaig.py index ba0ea9ee..5de231bf 100644 --- a/tests/unit/core/test_swml_service_swaig.py +++ b/tests/unit/core/test_swml_service_swaig.py @@ -243,13 +243,14 @@ def test_can_emit_ai_sidecar_verb(self) -> None: svc.add_section("main") ok = svc.add_verb_to_section("main", "answer", {}) assert ok - # ai_sidecar isn't in the live SWML schema yet — bypass via raw doc. - # Once the schema lands, callers will use add_verb_to_section directly. - svc._current_document["sections"]["main"].append({"ai_sidecar": { + # ai_sidecar landed in the bundled schema (4645d48), so the supported + # path works — and routes the verb through schema validation. + ok = svc.add_verb_to_section("main", "ai_sidecar", { "prompt": "real-time copilot", "lang": "en-US", "direction": ["remote-caller", "local-caller"], - }}) + }) + assert ok rendered = json.loads(svc.render_document()) verbs = [list(v.keys())[0] for v in rendered["sections"]["main"]] assert "answer" in verbs