Skip to content

Commit 810e24f

Browse files
amreetkhuntiaclaude
andcommitted
feat(widget): add redirect support to quick-reply pills
Quick-reply chicklets could only send their value back to the agent. A pill can now optionally carry an action, reusing the catalog's existing ActionUnion: an open_url action makes the pill redirect (honoring a new target: new_tab|same_tab) instead of messaging the agent. - ui_catalog: OpenUrlAction gains target; QuickReplyItem gains action - ui_prompt: add a redirect example so the LLM can emit redirect pills - types: QuickReplyOption (static config chicklets) gains action - chat schema: QuickReplyWire carries action to the frontend - widget handler: pass action through; only apply the value=label fallback for message pills (redirect pills have null value) Backward compatible: action defaults to None (existing send-to-agent behavior), target defaults to new_tab. JSONB column, no migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 786a4b7 commit 810e24f

7 files changed

Lines changed: 242 additions & 10 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,7 @@ logs
5454
.claude/settings.local.json
5555
.claude/memory/
5656
CLAUDE.local.md
57-
.claude/settings.json
57+
.claude/settings.json
58+
59+
# Local scratch / test fixtures
60+
temp/

app/ai/voice/agents/breeze_buddy/template/types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
model_validator,
1717
)
1818

19+
from app.ai.voice.agents.breeze_buddy.template.ui_catalog import ActionUnion
1920
from app.ai.voice.llm.types import LLMConfiguration
2021
from app.core.deprecation import log_deprecated_fields
2122
from app.core.logger import logger
@@ -658,14 +659,28 @@ class QuickReplyOption(BaseModel):
658659
omitted it falls back to ``label``, which lets the display text differ
659660
from the agent instruction (e.g. label="Track my order" while the
660661
backend receives a more explicit instruction).
662+
663+
Set ``action`` to override the click behavior. With an ``open_url``
664+
action the chicklet **redirects** (opens the URL, honoring ``target``)
665+
instead of messaging the agent; ``value`` is then optional and the
666+
label fallback is not applied.
661667
"""
662668

663669
label: str = Field(..., min_length=1, description="Button text shown to the user.")
664670
value: Optional[str] = Field(
665671
None,
666672
description=(
667673
"Payload sent to the backend. Defaults to label when absent "
668-
"the fallback is applied server-side before the value reaches the client."
674+
"the fallback is applied server-side before the value reaches the client. "
675+
"Ignored when ``action`` is an ``open_url`` redirect."
676+
),
677+
)
678+
action: Optional[ActionUnion] = Field(
679+
None,
680+
description=(
681+
"Optional click action. When omitted the chicklet sends ``value`` "
682+
"to the agent (default). An ``open_url`` action makes it redirect "
683+
"instead of messaging the agent."
669684
),
670685
)
671686

app/ai/voice/agents/breeze_buddy/template/ui_catalog.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,23 @@ class ToAssistantAction(BaseModel):
8686

8787

8888
class OpenUrlAction(BaseModel):
89-
"""Open ``url`` in a new tab. Server-provided URLs only."""
89+
"""Open ``url``. Server-provided URLs only.
90+
91+
``target`` controls where it opens: ``new_tab`` (default — preserves the
92+
historical behavior) or ``same_tab`` to navigate the current page (e.g.
93+
a checkout redirect)."""
9094

9195
model_config = ConfigDict(extra="forbid")
9296

9397
type: Literal["open_url"] = "open_url"
9498
url: HttpUrl
99+
target: Literal["new_tab", "same_tab"] = Field(
100+
"new_tab",
101+
description=(
102+
"Where to open the URL. ``new_tab`` (default) opens a new tab; "
103+
"``same_tab`` navigates the current page."
104+
),
105+
)
95106

96107

97108
ActionUnion = Union[ToAssistantAction, OpenUrlAction]
@@ -208,8 +219,13 @@ class Buttons(_CatalogBase):
208219
class QuickReplyItem(BaseModel):
209220
"""One pill in a ``QuickReplies`` row.
210221
211-
``value`` is what gets sent to the agent; ``label`` is what the user
212-
sees. When ``value`` is absent the widget echoes ``label`` verbatim.
222+
Default behavior (no ``action``): clicking sends ``value`` back to the
223+
agent (``label`` when ``value`` is absent) — a ``to_assistant`` round-trip.
224+
225+
Set ``action`` to override the click behavior. With an ``open_url``
226+
action the pill **redirects** (opens the URL, honoring ``target``)
227+
instead of messaging the agent — useful for "View your order",
228+
"Checkout", help links, etc. ``value`` is then optional.
213229
"""
214230

215231
model_config = ConfigDict(extra="forbid")
@@ -219,7 +235,16 @@ class QuickReplyItem(BaseModel):
219235
None,
220236
description=(
221237
"Payload sent to the agent. Falls back to ``label`` when absent — "
222-
"lets the display text differ from the agent instruction."
238+
"lets the display text differ from the agent instruction. Ignored "
239+
"when ``action`` is an ``open_url`` redirect."
240+
),
241+
)
242+
action: Optional[ActionUnion] = Field(
243+
None,
244+
description=(
245+
"Optional click action. When omitted the pill sends ``value`` to "
246+
"the agent (default). An ``open_url`` action makes the pill "
247+
"redirect instead of messaging the agent."
223248
),
224249
)
225250

app/ai/voice/agents/breeze_buddy/template/ui_prompt.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,14 @@
8989
"items": [
9090
{"label": "Yes, confirm"},
9191
{"label": "No, cancel", "value": "cancel_order_intent"},
92-
{"label": "Show more options"},
92+
{
93+
"label": "View your order",
94+
"action": {
95+
"type": "open_url",
96+
"url": "https://shop.example/orders/123",
97+
"target": "new_tab",
98+
},
99+
},
93100
],
94101
},
95102
"Handoff": {

app/api/routers/breeze_buddy/widget/handlers.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ def _synthetic_widget_user(widget_config_id: str) -> UserInfo:
141141
# ---------------------------------------------------------------------------
142142

143143

144+
def _is_redirect_action(action: object) -> bool:
145+
"""True when a quick-reply action is an ``open_url`` redirect (vs. the
146+
default send-to-agent behavior). Such pills carry no ``value``."""
147+
return getattr(action, "type", None) == "open_url"
148+
149+
144150
def _extract_widget_config(
145151
template: object,
146152
) -> tuple[List[QuickReplyWire], bool]:
@@ -155,7 +161,15 @@ def _extract_widget_config(
155161
return [], True
156162
quick_replies: List[QuickReplyWire] = [
157163
QuickReplyWire(
158-
label=qr.label, value=qr.value if qr.value is not None else qr.label
164+
label=qr.label,
165+
# Redirect pills (open_url action) don't message the agent, so the
166+
# label->value fallback only applies to plain message pills.
167+
value=(
168+
qr.value
169+
if qr.value is not None
170+
else (None if _is_redirect_action(qr.action) else qr.label)
171+
),
172+
action=qr.action,
159173
)
160174
for qr in (getattr(configurations, "quick_replies", None) or [])
161175
]

app/schemas/breeze_buddy/chat.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from pydantic import BaseModel, Field
1313

14+
from app.ai.voice.agents.breeze_buddy.template.ui_catalog import ActionUnion
15+
1416

1517
class ChatSessionStatus(str, Enum):
1618
"""Lifecycle status of a chat session."""
@@ -447,8 +449,17 @@ class QuickReplyWire(BaseModel):
447449
value: Optional[str] = Field(
448450
None,
449451
description=(
450-
"Payload sent to the backend. Always populated — falls back to "
451-
"label server-side when not explicitly set in the template."
452+
"Payload sent to the backend. Populated for message pills — falls "
453+
"back to label server-side when not explicitly set in the template. "
454+
"May be null when ``action`` is an ``open_url`` redirect."
455+
),
456+
)
457+
action: Optional[ActionUnion] = Field(
458+
None,
459+
description=(
460+
"Optional click action. When null the pill sends ``value`` to the "
461+
"agent (default). An ``open_url`` action makes the pill redirect "
462+
"(opening the URL, honoring its ``target``) instead of messaging."
452463
),
453464
)
454465

tests/test_quick_reply_redirect.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# pyrefly: ignore-errors
2+
# Pydantic dynamic-attribute noise: validate_props("QuickReplies", ...) returns
3+
# _CatalogBase per its signature, so concrete attrs (.items) read as
4+
# missing-attribute; and the optional ``action`` field is Optional[ActionUnion],
5+
# so pyrefly can't narrow `.type` / `.target` after a runtime assert. The
6+
# intentional invalid-literal case (target="popup") also trips bad-argument-type.
7+
# All resolve fine at runtime; same suppression as test_tile_validation.py.
8+
"""Tests for redirect (``open_url``) support on quick-reply pills.
9+
10+
Quick replies historically only sent ``value`` back to the agent. A pill
11+
can now optionally carry an ``action`` — an ``open_url`` action makes it
12+
redirect instead of messaging. These tests pin both the new behavior and
13+
backward compatibility (no ``action`` => unchanged send-to-agent path).
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from types import SimpleNamespace
19+
20+
import pytest
21+
from pydantic import ValidationError
22+
23+
from app.ai.voice.agents.breeze_buddy.template.types import QuickReplyOption
24+
25+
# Load the template package (ui_catalog) before any chat/* module — same
26+
# circular-import precaution as the sibling ui_prompt / ui_stream tests.
27+
from app.ai.voice.agents.breeze_buddy.template.ui_catalog import (
28+
OpenUrlAction,
29+
QuickReplyItem,
30+
validate_props,
31+
)
32+
from app.api.routers.breeze_buddy.widget.handlers import _extract_widget_config
33+
from app.schemas.breeze_buddy.chat import QuickReplyWire
34+
35+
# ---------------------------------------------------------------------------
36+
# OpenUrlAction.target
37+
# ---------------------------------------------------------------------------
38+
39+
40+
def test_open_url_defaults_to_new_tab():
41+
"""Existing open_url actions (no target) keep opening in a new tab."""
42+
action = OpenUrlAction(url="https://shop.example/orders/1")
43+
assert action.target == "new_tab"
44+
45+
46+
def test_open_url_accepts_same_tab():
47+
action = OpenUrlAction(url="https://shop.example/checkout", target="same_tab")
48+
assert action.target == "same_tab"
49+
50+
51+
def test_open_url_rejects_unknown_target():
52+
with pytest.raises(ValidationError):
53+
OpenUrlAction(url="https://shop.example/x", target="popup")
54+
55+
56+
# ---------------------------------------------------------------------------
57+
# Dynamic QuickReplies primitive
58+
# ---------------------------------------------------------------------------
59+
60+
61+
def test_quick_replies_with_redirect_action_validates():
62+
qr = validate_props(
63+
"QuickReplies",
64+
{
65+
"items": [
66+
{"label": "Yes"},
67+
{
68+
"label": "View order",
69+
"action": {
70+
"type": "open_url",
71+
"url": "https://shop.example/orders/1",
72+
"target": "same_tab",
73+
},
74+
},
75+
]
76+
},
77+
)
78+
assert qr.items[0].action is None # backward-compat: message pill
79+
assert qr.items[1].action.type == "open_url"
80+
assert qr.items[1].action.target == "same_tab"
81+
82+
83+
def test_quick_reply_item_without_action_is_backward_compatible():
84+
item = QuickReplyItem(label="Track", value="Track my order")
85+
assert item.action is None
86+
assert item.value == "Track my order"
87+
88+
89+
def test_quick_reply_item_accepts_to_assistant_action():
90+
item = QuickReplyItem(label="x", action={"type": "to_assistant", "msg": "hi"})
91+
assert item.action.type == "to_assistant"
92+
93+
94+
# ---------------------------------------------------------------------------
95+
# Static config model (QuickReplyOption) + wire (QuickReplyWire)
96+
# ---------------------------------------------------------------------------
97+
98+
99+
def test_static_option_redirect_needs_no_value():
100+
opt = QuickReplyOption(
101+
label="Checkout",
102+
action={"type": "open_url", "url": "https://shop.example/checkout"},
103+
)
104+
assert opt.value is None
105+
assert opt.action.type == "open_url"
106+
107+
108+
def test_static_option_without_action_is_backward_compatible():
109+
opt = QuickReplyOption(label="Track", value="Track my order")
110+
assert opt.action is None
111+
112+
113+
def test_wire_carries_action_with_null_value():
114+
wire = QuickReplyWire(
115+
label="Checkout",
116+
value=None,
117+
action={"type": "open_url", "url": "https://shop.example/checkout"},
118+
)
119+
assert wire.value is None
120+
assert wire.action.target == "new_tab"
121+
122+
123+
# ---------------------------------------------------------------------------
124+
# Widget handler — value fallback only for message pills
125+
# ---------------------------------------------------------------------------
126+
127+
128+
def _template_with_quick_replies(options):
129+
configurations = SimpleNamespace(quick_replies=options, enable_text_input=True)
130+
return SimpleNamespace(configurations=configurations)
131+
132+
133+
def test_handler_message_pill_falls_back_to_label():
134+
template = _template_with_quick_replies([QuickReplyOption(label="Track my order")])
135+
replies, enable_text_input = _extract_widget_config(template)
136+
assert enable_text_input is True
137+
assert replies[0].value == "Track my order" # label fallback
138+
assert replies[0].action is None
139+
140+
141+
def test_handler_redirect_pill_has_null_value_and_passes_action():
142+
template = _template_with_quick_replies(
143+
[
144+
QuickReplyOption(
145+
label="View your order",
146+
action={"type": "open_url", "url": "https://shop.example/orders/1"},
147+
)
148+
]
149+
)
150+
replies, _ = _extract_widget_config(template)
151+
# Redirect pills don't message the agent — no label fallback.
152+
assert replies[0].value is None
153+
assert replies[0].action.type == "open_url"
154+
155+
156+
def test_handler_no_configurations_returns_defaults():
157+
assert _extract_widget_config(SimpleNamespace(configurations=None)) == ([], True)

0 commit comments

Comments
 (0)