4848from typing import Any
4949
5050import main as ex
51+ import pydantic
5152import temporalio .client
5253import temporalio .testing
5354import temporalio .worker
5455from _durability_worker import LOGGED_ACTIVITIES # noqa: PLC2701
5556
57+ import ai
58+
5659# ── Helpers ──────────────────────────────────────────────────────
5760
5861
@@ -71,6 +74,22 @@ def read_activity_log(log_file: pathlib.Path) -> Counter[str]:
7174 return Counter (log_file .read_text ().splitlines ())
7275
7376
77+ _EVENT_ADAPTER : pydantic .TypeAdapter [ai .events .Event ] = pydantic .TypeAdapter (
78+ ai .events .DiscriminatedEvent
79+ )
80+
81+
82+ def read_streamed_events (event_log : pathlib .Path ) -> list [ai .events .Event ]:
83+ """Deserialize the events the LLM activity wrote to the streaming sink."""
84+ if not event_log .exists ():
85+ return []
86+ return [
87+ _EVENT_ADAPTER .validate_json (line )
88+ for line in event_log .read_text ().splitlines ()
89+ if line
90+ ]
91+
92+
7493QUERY = "What's the weather and population of New York and Los Angeles?"
7594
7695
@@ -92,11 +111,12 @@ async def test_happy_path(
92111 task_queue = ex .TASK_QUEUE ,
93112 )
94113
95- assert result , "expected non-empty text"
114+ assert result , "expected a non-empty conversation"
115+ final_text = ai .messages .Message .model_validate (result [- 1 ]).text
96116 assert (
97- "8,336,817" in result or "8336817" in result
98- ), f"expected NYC population in result, got: { result !r} "
99- print (f" ✓ workflow { wid } produced { len (result )} chars " )
117+ "8,336,817" in final_text or "8336817" in final_text
118+ ), f"expected NYC population in result, got: { final_text !r} "
119+ print (f" ✓ workflow { wid } produced { len (result )} messages " )
100120 print (f" ✓ activity calls: { dict (read_activity_log (log_file ))} " )
101121 return wid
102122
@@ -118,6 +138,80 @@ async def test_replay_determinism(
118138 print (f" ✓ replay clean for { workflow_id } ({ len (history .events )} events)" )
119139
120140
141+ # ── Test: workflow-minted ids are stable across replay ───────────
142+
143+
144+ async def test_workflow_minted_ids_are_deterministic (
145+ client : temporalio .client .Client , workflow_id : str
146+ ) -> None :
147+ print ("\n ── test_workflow_minted_ids_are_deterministic ─────" )
148+
149+ def ids (messages : list [dict [str , Any ]]) -> list [str ]:
150+ return [
151+ id_
152+ for m in messages
153+ for id_ in (m ["id" ], * (p ["id" ] for p in m ["parts" ]))
154+ ]
155+
156+ handle = client .get_workflow_handle (workflow_id )
157+ original = ids (await handle .result ())
158+
159+ # Querying the closed workflow forces a worker to replay the history
160+ # and re-run ``run`` to answer -- so these messages are rebuilt by the
161+ # replay, not the original execution.
162+ async with make_worker (client ):
163+ replayed = ids (await handle .query (ex .WeatherWorkflow .messages ))
164+
165+ # Any id generated in workflow code (system/user/tool messages and the
166+ # tool-result parts inside them) would be re-minted on replay and
167+ # diverge here. Ids sourced from a cached activity result survive
168+ # identically regardless.
169+ assert original == replayed , (
170+ "workflow re-minted ids on replay -- nondeterministic id "
171+ "generation in workflow code:\n "
172+ f" original: { original } \n "
173+ f" replayed: { replayed } "
174+ )
175+ print (f" ✓ all { len (original )} message/part ids stable across replay" )
176+
177+
178+ # ── Test: streamed ids match the final returned messages ─────────
179+
180+
181+ async def test_stream_ids_match_final_messages (
182+ client : temporalio .client .Client , event_log : pathlib .Path
183+ ) -> None :
184+ print ("\n ── test_stream_ids_match_final_messages ───────────" )
185+ event_log .write_text ("" )
186+
187+ async with make_worker (client ):
188+ wid = f"ids-{ uuid .uuid4 ().hex [:8 ]} "
189+ messages = await client .execute_workflow (
190+ ex .WeatherWorkflow .run ,
191+ QUERY ,
192+ id = wid ,
193+ task_queue = ex .TASK_QUEUE ,
194+ )
195+
196+ streamed = read_streamed_events (event_log )
197+ streamed_ids = {e .message .id for e in streamed }
198+ final_ids = {m ["id" ] for m in messages }
199+ assert streamed , "no events reached the streaming sink"
200+
201+ # Every assistant turn streamed to the sink must be identifiable in
202+ # the durable result by the same message id. Today the workflow's
203+ # Stream re-mints the id when it reassembles the message, so the id a
204+ # client saw streaming live is absent from the final messages.
205+ missing = streamed_ids - final_ids
206+ assert not missing , (
207+ f"message ids streamed to the sink are absent from the final "
208+ f"returned messages: { sorted (missing )} \n "
209+ f" streamed: { sorted (streamed_ids )} \n "
210+ f" final: { sorted (final_ids )} "
211+ )
212+ print (f" ✓ all { len (streamed_ids )} streamed id(s) present in final" )
213+
214+
121215# ── Test 3: activity caching across a worker restart ─────────────
122216
123217
@@ -234,11 +328,15 @@ def _unraisablehook(unraisable: Any) -> None:
234328
235329
236330async def main () -> None :
237- log_file = pathlib .Path (tempfile .mkdtemp ()) / "activity_log.txt"
331+ tmp = pathlib .Path (tempfile .mkdtemp ())
332+ log_file = tmp / "activity_log.txt"
333+ event_log = tmp / "event_log.txt"
238334 # In-process workers (worker2 below, plus the happy-path worker)
239335 # share this module's ``LOGGED_ACTIVITIES``, which look at this env
240336 # var to decide whether to log. Set it for the whole run.
241337 os .environ ["DURABILITY_ACTIVITY_LOG" ] = str (log_file )
338+ # The LLM activity emits every stream event to this sink.
339+ os .environ [ex ._EVENT_LOG_ENV ] = str (event_log )
242340
243341 print ("Starting embedded Temporal dev server..." )
244342 async with (
@@ -248,6 +346,8 @@ async def main() -> None:
248346
249347 wid = await test_happy_path (client , log_file )
250348 await test_replay_determinism (client , wid )
349+ await test_workflow_minted_ids_are_deterministic (client , wid )
350+ await test_stream_ids_match_final_messages (client , event_log )
251351 await test_activity_caching (env , client , log_file )
252352
253353 print ("\n All durability checks passed." )
0 commit comments