Skip to content

Commit 1d912d1

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 1d912d1

7 files changed

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

0 commit comments

Comments
 (0)