Skip to content

Commit 63ae2cd

Browse files
a7vinxclaude
andcommitted
test(protocol): record the fixtures from live sessions
The fixtures were written from the backend's protocol structs because no account was available. Running the recorder against a real session replaced 10 of the 18, and the differences are the point of having recorded them. Four assumptions were wrong. `session:input_state` on an open composer carries no detail; `session:llm_thinking` is often a bare placeholder rather than a tool_call; a `session:history` page returns `messages: null` and omits the cursor once exhausted; `session:state` during a conversation holds "chat". The tests asserted a scenario where they should have asserted a shape — a recording catches whichever instance the session produced, so conditions an ordinary session never reaches are now constructed by the test that needs them. The recorder's redaction had two faults, both found by reading what it wrote. Its phone pattern matched bare digit runs, so it rewrote message ids into a fake phone number; it now requires a leading "+" or separators, and identifier fields are exempt outright. And it scrubbed only phones and emails, while a form carries the account details it is asking for — a name, an address, an account number, a PIN, all pulled from the account's knowledge base, with the server's placeholders built from the same values. Both sides are now replaced wholesale, along with the account and session ids the recording ran under. The live prompt was a bill negotiation, which the agent spends six minutes thinking about. Asking it to call a friend gets the same coverage in forty seconds. Two observations from live traffic. `session:text` never arrived during a turn in any run: the composer reopens on streaming increments alone and the complete message is the durable record, so the README now says to assemble from parts. And `FormField` carries `description` and `source` on the wire, which the model was dropping. Verified: 94 offline tests and 8 live tests pass, ruff clean, build clean. Eight fixtures remain derived — no ordinary session reaches a restriction, a credit-blocked task, or a rich content document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 793738a commit 63ae2cd

17 files changed

Lines changed: 366 additions & 157 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ payloads, and semantics change compatibly or with notice.
5858

5959
Payloads may gain fields at any time — tolerate fields you do not recognise.
6060

61+
A turn commonly delivers `session:text_part` alone: the composer reopens once
62+
the agent has finished speaking, and the complete `session:text` is the durable
63+
record, read back from history. Assemble the parts by `message_id` rather than
64+
waiting for the complete message to arrive live.
65+
6166
## Everything else passes through
6267

6368
The server emits many more events. The SDK delivers every one of them unchanged

src/pine_assistant/models/form.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ class FormField(BaseModel):
1313
name: str
1414
type: str = "text"
1515
label: str | None = None
16+
description: str | None = None
1617
placeholder: str | None = None
18+
source: str | None = None
1719
is_required: bool | None = None
1820
pii_level: str | None = None
1921
prefilled: str | None = None

tests/integration/record_fixtures.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,78 @@
2626
from typing import Any
2727

2828
from pine_assistant import AsyncPineAI, S2CEvent, is_supported_event
29+
from tests.protocol.fake import SESSION_ID as PLACEHOLDER_SESSION_ID
2930

3031
FIXTURES = pathlib.Path(__file__).resolve().parents[1] / "protocol" / "fixtures"
31-
DEFAULT_PROMPT = "Help me negotiate my Comcast internet bill down to $50/month."
32+
DEFAULT_PROMPT = "Call my friend and ask what time Saturday's dinner starts."
3233

3334
# Values that identify a person or an account never reach a checked-in fixture.
35+
# The phone pattern requires a leading "+" or separators: Pine's identifiers are
36+
# long digit runs, and a looser pattern rewrites them into a fake phone number.
3437
REDACTIONS = (
35-
(re.compile(r"\+?\d[\d\-\s().]{7,}\d"), "+15555550100"),
38+
(re.compile(r"\+\d[\d\-\s().]{7,}\d"), "+15555550100"),
39+
(re.compile(r"\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b"), "+15555550100"),
3640
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "someone@example.com"),
3741
)
3842

43+
# Fields whose value is the user's own data. A form carries the account details
44+
# it is asking for — names, addresses, account numbers, PINs — and the server
45+
# builds its placeholders from them, so both sides are replaced wholesale rather
46+
# than pattern-matched. A form's submitted `content` goes too — it is a mapping
47+
# of answers, unlike the `content` string on a text or state event.
48+
# The key stays, the shape stays, the value does not.
49+
USER_DATA_KEYS = frozenset({"prefilled", "placeholder"})
50+
USER_DATA_PLACEHOLDER = "[redacted]"
51+
52+
# The account and session a recording ran under are not part of the shape being
53+
# recorded, and a fixture carrying a real session id cannot be replayed into a
54+
# flow test — the client would filter it out as belonging elsewhere.
55+
PLACEHOLDER_USER_ID = "100000000000000001"
56+
57+
# Identifier fields are never redacted — they are opaque numbers, and rewriting
58+
# one destroys the shape the fixture exists to record.
59+
OPAQUE_KEYS = frozenset({
60+
"id", "event_id", "message_id", "session_id", "request_id", "operation_id",
61+
"quoted_message_id", "thinking_id", "turn_id", "revision", "next_message_id",
62+
"max_message_revision", "since_revision", "device_id", "user_id",
63+
})
64+
3965

4066
def redact(value: Any) -> Any:
4167
if isinstance(value, str):
4268
for pattern, replacement in REDACTIONS:
4369
value = pattern.sub(replacement, value)
4470
return value
4571
if isinstance(value, dict):
46-
return {k: redact(v) for k, v in value.items()}
72+
out = {}
73+
for k, v in value.items():
74+
if k in OPAQUE_KEYS:
75+
out[k] = v
76+
elif k in USER_DATA_KEYS and isinstance(v, str) and v:
77+
out[k] = USER_DATA_PLACEHOLDER
78+
elif k == "content" and isinstance(v, dict) and v:
79+
out[k] = {key: USER_DATA_PLACEHOLDER for key in v}
80+
else:
81+
out[k] = redact(v)
82+
return out
4783
if isinstance(value, list):
4884
return [redact(v) for v in value]
4985
return value
5086

5187

88+
def anonymize(envelope: dict[str, Any]) -> dict[str, Any]:
89+
"""Replace the identities the recording ran under. Redaction cannot reach
90+
them: an account id is an opaque number, exempt from pattern matching so it
91+
does not get rewritten into a fake phone number."""
92+
source = envelope.get("metadata", {}).get("source")
93+
if isinstance(source, dict) and source.get("user_id"):
94+
source["user_id"] = PLACEHOLDER_USER_ID
95+
payload = envelope.get("payload")
96+
if isinstance(payload, dict) and payload.get("session_id"):
97+
payload["session_id"] = PLACEHOLDER_SESSION_ID
98+
return envelope
99+
100+
52101
async def record(prompt: str, raw_dir: pathlib.Path | None) -> dict[str, dict[str, Any]]:
53102
token = os.environ.get("PINE_ACCESS_TOKEN", "")
54103
user_id = os.environ.get("PINE_USER_ID", "")
@@ -104,7 +153,7 @@ def write_fixtures(seen: dict[str, dict[str, Any]]) -> tuple[list[str], list[str
104153
continue
105154
name = event_type.replace("session:", "")
106155
FIXTURES.joinpath(f"{name}.json").write_text(
107-
json.dumps(redact(envelope), indent=2) + "\n"
156+
json.dumps(anonymize(redact(envelope)), indent=2) + "\n"
108157
)
109158
provenance[event_type] = {
110159
"source": "recorded", "derived_from": None, "recorded_at": now,

tests/integration/test_live.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
pytestmark = pytest.mark.skipif(SKIP, reason="PINE_INTEGRATION not set")
2323

24-
PROMPT = "Help me negotiate my Comcast internet bill down to $50/month."
24+
PROMPT = "Call my friend and ask what time Saturday's dinner starts."
2525

2626

2727
def make_client() -> AsyncPineAI:
@@ -78,13 +78,19 @@ async def test_create_list_get_delete(self):
7878

7979
class TestSupportedSurface:
8080
async def test_a_turn_produces_a_substantive_response(self, session):
81-
"""Text, a rich document, or a form — all inside the scope."""
81+
"""Streamed text, a complete message, a rich document, or a form.
82+
83+
A turn often ends on streaming increments alone: the composer reopens
84+
once the agent has finished speaking, and the complete `session:text`
85+
is the durable record, read back from history rather than awaited here.
86+
"""
8287
client, sid = session
8388
events = [e async for e in client.chat(sid, PROMPT)]
8489

8590
types = {e.type for e in events}
8691
assert types & {
8792
S2CEvent.SESSION_TEXT.value,
93+
S2CEvent.SESSION_TEXT_PART.value,
8894
S2CEvent.SESSION_RICH_CONTENT.value,
8995
S2CEvent.SESSION_FORM_TO_USER.value,
9096
}, f"no substantive response; saw {sorted(types)}"
Lines changed: 138 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,151 @@
11
{
22
"metadata": {
3-
"event_id": "00000000-0000-4000-8000-956047064724",
4-
"request_id": "00000000-0000-4000-8000-000000000001",
5-
"timestamp": "2026-08-08T00:00:00Z",
3+
"event_id": "ad1590eb-8050-4fbc-872b-4c6f232a2b65",
4+
"group_id": "53144355-7b24-43e2-9bcb-6048993ffcdc",
5+
"is_required_action": true,
6+
"is_volatile": false,
7+
"request_id": "7f8f3223-0659-4e03-a4eb-d85743b67728",
68
"source": {
7-
"role": "agent"
9+
"role": "agent",
10+
"user_id": "100000000000000001"
811
},
9-
"is_volatile": false
12+
"timestamp": "2026-08-08T13:39:54Z"
1013
},
11-
"type": "session:form_to_user",
1214
"payload": {
13-
"session_id": "1900000000000000001",
14-
"message_id": "1900000000000000101",
15-
"type": "session:form_to_user",
1615
"data": {
17-
"message_to_user": "I need your account details.",
1816
"form": {
1917
"fields": [
2018
{
21-
"name": "account_number",
22-
"type": "text",
23-
"label": "Account number",
19+
"description": "Xfinity 16-digit account number required to identify your account during negotiation.",
20+
"is_required": true,
21+
"name": "Account Number",
22+
"pii_level": "L2",
23+
"placeholder": "[redacted]",
24+
"prefilled": "[redacted]",
25+
"source": "knowledge_base",
26+
"type": "text"
27+
},
28+
{
29+
"description": "4-digit account security PIN for Xfinity customer service authentication.",
2430
"is_required": true,
25-
"pii_level": "high"
31+
"name": "Account PIN",
32+
"pii_level": "L3",
33+
"placeholder": "[redacted]",
34+
"prefilled": "[redacted]",
35+
"source": "knowledge_base",
36+
"type": "text"
37+
},
38+
{
39+
"description": "Service location address associated with your Xfinity internet subscription.",
40+
"is_required": true,
41+
"name": "Service Address",
42+
"pii_level": "L3",
43+
"placeholder": "[redacted]",
44+
"prefilled": "[redacted]",
45+
"source": "knowledge_base",
46+
"type": "text"
47+
},
48+
{
49+
"description": "Primary phone number registered on your Xfinity account for identity verification.",
50+
"is_required": true,
51+
"name": "Phone Number on File",
52+
"pii_level": "L2",
53+
"placeholder": "[redacted]",
54+
"prefilled": "[redacted]",
55+
"source": "knowledge_base",
56+
"type": "text"
57+
},
58+
{
59+
"description": "Last 4 digits of SSN used as fallback account verification by Xfinity reps.",
60+
"name": "Last Four Digits of SSN",
61+
"pii_level": "L2",
62+
"placeholder": "[redacted]",
63+
"prefilled": "[redacted]",
64+
"source": "knowledge_base",
65+
"type": "text"
66+
},
67+
{
68+
"description": "Your current Xfinity internet speed tier. Knowing this helps determine upgrade/retention promo paths.",
69+
"is_required": true,
70+
"name": "Current Internet Plan",
71+
"options": [
72+
"Gigabit Extra (1200 Mbps)",
73+
"Superfast (800 Mbps / Blast!)",
74+
"Fast (500 Mbps) / Connect More (300 Mbps)",
75+
"Gigabit (1000 Mbps)",
76+
"Other / Not Sure"
77+
],
78+
"pii_level": "L1",
79+
"placeholder": "[redacted]",
80+
"prefilled": "[redacted]",
81+
"source": "agent",
82+
"type": "radio"
83+
},
84+
{
85+
"description": "Xfinity provides monthly discounts ($10/mo for bank account, $5/mo for credit card) when enrolled in Autopay.",
86+
"is_required": true,
87+
"name": "Autopay Method Preference",
88+
"options": [
89+
"I (Charles) am willing to switch autopay to Bank Account for maximum discount ($10/mo savings)",
90+
"I (Charles) prefer keeping Credit/Debit Card Autopay ($5/mo savings)",
91+
"I (Charles) do not want to enroll in Autopay"
92+
],
93+
"pii_level": "L1",
94+
"placeholder": "[redacted]",
95+
"source": "knowledge_base",
96+
"type": "radio"
97+
},
98+
{
99+
"description": "Xfinity gateway equipment rental is typically $15/mo. We can negotiate to waive it or discuss equipment options.",
100+
"is_required": true,
101+
"name": "Equipment Preference",
102+
"options": [
103+
"You (Pine) should negotiate to include or waive the Xfinity Gateway rental fee",
104+
"I (Charles) will purchase my own modem/router to eliminate the $15/mo rental fee",
105+
"I (Charles) prefer to keep paying for the Xfinity Gateway rental as-is"
106+
],
107+
"pii_level": "L1",
108+
"placeholder": "[redacted]",
109+
"source": "agent",
110+
"type": "radio"
111+
},
112+
{
113+
"description": "Promotional pricing often requires a 1-year or 2-year price lock contract.",
114+
"is_required": true,
115+
"name": "Term Commitment Preference",
116+
"options": [
117+
"I (Charles) am willing to accept a 1-year or 2-year price guarantee contract to reach $50/mo",
118+
"I (Charles) prefer a no-contract plan only"
119+
],
120+
"pii_level": "L1",
121+
"placeholder": "[redacted]",
122+
"source": "agent",
123+
"type": "radio"
124+
},
125+
{
126+
"description": "If the exact $50/month rate on your current tier is unavailable, select acceptable fallback options.",
127+
"is_required": true,
128+
"name": "Negotiation Flexibility",
129+
"options": [
130+
"Plans with a slightly higher price (e.g., $60 - $75/month)",
131+
"Plans with lower internet speeds (e.g., 300 Mbps or 500 Mbps)",
132+
"Plans with a 1-year or 2-year contract or price guarantee",
133+
"Switching autopay method to bank account for maximum discount",
134+
"None: only my preferred plan at $50/month is acceptable"
135+
],
136+
"pii_level": "L1",
137+
"placeholder": "[redacted]",
138+
"source": "agent",
139+
"type": "multiselect"
26140
}
27-
],
28-
"is_submitted": false
29-
}
30-
}
31-
}
141+
]
142+
},
143+
"message_to_user": "I'm on it! I've put together a game plan to negotiate your Comcast bill down to $50/month and checked out what options and competitor rates we can use as leverage. I just need a couple of quick details from you to get started\u2014please check the form I sent over!"
144+
},
145+
"message_id": "816303810342297600",
146+
"revision": "3008941",
147+
"session_id": "1900000000000000001",
148+
"type": "session:form_to_user"
149+
},
150+
"type": "session:form_to_user"
32151
}
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
{
22
"metadata": {
3-
"event_id": "00000000-0000-4000-8000-096445077343",
4-
"request_id": "00000000-0000-4000-8000-000000000001",
5-
"timestamp": "2026-08-08T00:00:00Z",
3+
"event_id": "698064fe-5890-4f33-a5df-8f6c997624bc",
4+
"request_id": "05c25164-29ac-4596-80e9-76196533a3e6",
5+
"timestamp": "2026-08-08T13:40:31Z",
66
"source": {
7-
"role": "system"
7+
"role": "system",
8+
"user_id": "100000000000000001"
89
},
910
"is_volatile": false
1011
},
1112
"type": "session:history",
1213
"payload": {
1314
"session_id": "1900000000000000001",
14-
"message_id": "1900000000000000101",
1515
"type": "session:history",
1616
"data": {
17-
"messages": [],
18-
"next_message_id": "1900000000000000090",
19-
"order": "desc"
17+
"messages": null,
18+
"order": "DESC"
2019
}
2120
}
2221
}
Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
{
22
"metadata": {
3-
"event_id": "00000000-0000-4000-8000-409211220820",
4-
"request_id": "00000000-0000-4000-8000-000000000001",
5-
"timestamp": "2026-08-08T00:00:00Z",
3+
"event_id": "4850bdc9-d79b-40f5-9580-2dadd7ea7b51",
4+
"timestamp": "2026-08-08T13:40:31Z",
65
"source": {
76
"role": "system"
87
},
@@ -11,12 +10,11 @@
1110
"type": "session:input_state",
1211
"payload": {
1312
"session_id": "1900000000000000001",
14-
"message_id": "1900000000000000101",
13+
"message_id": "816303965535735808",
1514
"type": "session:input_state",
1615
"data": {
17-
"content": "input_disabled",
18-
"detail": "Please confirm the order to start executing the task.",
19-
"code": "task_ready"
16+
"content": "waiting_input",
17+
"code": "default"
2018
}
2119
}
2220
}

0 commit comments

Comments
 (0)