agentforge-chat 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/PKG-INFO +7 -7
  2. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/README.md +3 -2
  3. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/pyproject.toml +9 -6
  4. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/build.py +3 -0
  5. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/history.py +9 -1
  6. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/manifest.yaml +1 -1
  7. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/session.py +118 -2
  8. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/sqlite.py +14 -2
  9. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_session.py +108 -0
  10. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/.gitignore +0 -0
  11. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/LICENSE +0 -0
  12. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/__init__.py +0 -0
  13. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_idempotency.py +0 -0
  14. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_locks.py +0 -0
  15. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_segment.py +0 -0
  16. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_window.py +0 -0
  17. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/py.typed +0 -0
  18. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/tokenisers.py +0 -0
  19. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/truncation.py +0 -0
  20. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_chat_build.py +0 -0
  21. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_chat_streaming_per_token.py +0 -0
  22. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_in_memory_history.py +0 -0
  23. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_sentence_window.py +0 -0
  24. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_session_safety_modes.py +0 -0
  25. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_sqlite_history.py +0 -0
  26. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_token_budget_tokeniser.py +0 -0
  27. {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_truncation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentforge-chat
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Chat-agent runtime (ChatSession + history drivers + truncation) for AgentForge
5
5
  Project-URL: Homepage, https://github.com/Scaffoldic/agentforge-py
6
6
  Project-URL: Repository, https://github.com/Scaffoldic/agentforge-py
@@ -19,10 +19,9 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.13
22
- Requires-Dist: agentforge-core~=0.2.2
23
- Requires-Dist: agentforge-py~=0.2.2
24
- Provides-Extra: sqlite
25
- Requires-Dist: aiosqlite>=0.20; extra == 'sqlite'
22
+ Requires-Dist: agentforge-core~=0.2.4
23
+ Requires-Dist: agentforge-py~=0.2.4
24
+ Requires-Dist: aiosqlite>=0.20
26
25
  Description-Content-Type: text/markdown
27
26
 
28
27
  # agentforge-chat
@@ -39,10 +38,11 @@ for the design and runbook.
39
38
 
40
39
  ```bash
41
40
  pip install agentforge-chat
42
- # or, with the SQLite driver pre-pulled:
43
- pip install "agentforge-chat[sqlite]"
44
41
  ```
45
42
 
43
+ The SQLite history driver (`SqliteChatHistory`) works out of the box —
44
+ `aiosqlite` ships as a hard dependency.
45
+
46
46
  ## Three-line chat from a one-shot agent
47
47
 
48
48
  ```python
@@ -12,10 +12,11 @@ for the design and runbook.
12
12
 
13
13
  ```bash
14
14
  pip install agentforge-chat
15
- # or, with the SQLite driver pre-pulled:
16
- pip install "agentforge-chat[sqlite]"
17
15
  ```
18
16
 
17
+ The SQLite history driver (`SqliteChatHistory`) works out of the box —
18
+ `aiosqlite` ships as a hard dependency.
19
+
19
20
  ## Three-line chat from a one-shot agent
20
21
 
21
22
  ```python
@@ -9,7 +9,7 @@
9
9
 
10
10
  [project]
11
11
  name = "agentforge-chat"
12
- version = "0.2.2"
12
+ version = "0.2.4"
13
13
  description = "Chat-agent runtime (ChatSession + history drivers + truncation) for AgentForge"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.13"
@@ -30,13 +30,16 @@ classifiers = [
30
30
  ]
31
31
 
32
32
  dependencies = [
33
- "agentforge-core ~= 0.2.2",
34
- "agentforge-py ~= 0.2.2",
33
+ "agentforge-core ~= 0.2.4",
34
+ "agentforge-py ~= 0.2.4",
35
+ # aiosqlite is a hard dependency, not an optional extra: the package
36
+ # eagerly imports SqliteChatHistory (agentforge_chat/__init__.py ->
37
+ # sqlite.py -> `import aiosqlite`), so `import agentforge_chat` fails
38
+ # without it. Mirrors the sibling agentforge-memory-sqlite, which also
39
+ # hard-deps aiosqlite. (bug-015 audit.)
40
+ "aiosqlite>=0.20",
35
41
  ]
36
42
 
37
- [project.optional-dependencies]
38
- sqlite = ["aiosqlite>=0.20"]
39
-
40
43
  [project.urls]
41
44
  Homepage = "https://github.com/Scaffoldic/agentforge-py"
42
45
  Repository = "https://github.com/Scaffoldic/agentforge-py"
@@ -48,6 +48,7 @@ async def build_chat_session_from_config(
48
48
  per_session = None
49
49
  idem_window = 60.0
50
50
  safety_mode: SafetyMode = "buffer-then-stream"
51
+ persist_steps = True
51
52
  if chat_cfg is not None:
52
53
  if chat_cfg.history is not None:
53
54
  history = await _build_history(chat_cfg.history.driver, chat_cfg.history.config)
@@ -57,6 +58,7 @@ async def build_chat_session_from_config(
57
58
  per_session = chat_cfg.session.per_session_budget_usd
58
59
  idem_window = chat_cfg.session.idempotency_window_s
59
60
  safety_mode = chat_cfg.session.safety_mode
61
+ persist_steps = chat_cfg.session.persist_steps
60
62
  return ChatSession(
61
63
  agent=agent,
62
64
  session_id=session_id,
@@ -68,6 +70,7 @@ async def build_chat_session_from_config(
68
70
  per_session_budget_usd=per_session,
69
71
  idempotency_window_s=idem_window,
70
72
  safety_mode=safety_mode,
73
+ persist_steps=persist_steps,
71
74
  )
72
75
 
73
76
 
@@ -77,7 +77,10 @@ class InMemoryChatHistory(ChatHistoryStore):
77
77
  before: datetime | None = None,
78
78
  ) -> list[SessionInfo]:
79
79
  async with self._lock:
80
- out = [self._build_info(sid) for sid in self._turns]
80
+ # Include sessions that exist only via metadata (created before
81
+ # their first turn — bug-018), not just sessions with turns.
82
+ sids = set(self._turns) | set(self._meta)
83
+ out = [self._build_info(sid) for sid in sids]
81
84
  if owner is not None:
82
85
  out = [s for s in out if s.owner == owner]
83
86
  if before is not None:
@@ -92,6 +95,11 @@ class InMemoryChatHistory(ChatHistoryStore):
92
95
  bag[k] = v
93
96
  if "owner" in metadata:
94
97
  self._owners[session_id] = metadata["owner"]
98
+ # Register timestamps so a metadata-only session (created before
99
+ # its first turn — bug-018) carries sane created/last_active.
100
+ now = datetime.now(UTC)
101
+ self._created_at.setdefault(session_id, now)
102
+ self._last_active.setdefault(session_id, now)
95
103
 
96
104
  async def expire_before(self, cutoff: datetime) -> int:
97
105
  async with self._lock:
@@ -11,7 +11,7 @@ distribution:
11
11
  config_block:
12
12
  modules.chat:
13
13
  history:
14
- driver: memory # memory | sqlite (sqlite needs `agentforge-chat[sqlite]`)
14
+ driver: memory # memory | sqlite (both ship with agentforge-chat)
15
15
  config: {}
16
16
  truncation:
17
17
  strategy: sliding_window
@@ -23,6 +23,7 @@ streaming work documented in feat-020 §10 deferrals.
23
23
  from __future__ import annotations
24
24
 
25
25
  import asyncio
26
+ import json
26
27
  import time
27
28
  from collections.abc import AsyncIterator, Callable
28
29
  from typing import Any, Literal
@@ -36,6 +37,7 @@ from agentforge_core.production.exceptions import (
36
37
  GuardrailViolation,
37
38
  )
38
39
  from agentforge_core.values.chat import ChatChunk, ChatResponse, ChatTurn, StreamingEvent
40
+ from agentforge_core.values.messages import ToolCall
39
41
 
40
42
  from agentforge_chat._idempotency import IdempotencyCache
41
43
  from agentforge_chat._locks import (
@@ -74,6 +76,7 @@ class ChatSession:
74
76
  on_turn: OnTurnHook | None = None,
75
77
  session_lock_factory: SessionLockFactory | None = None,
76
78
  safety_mode: SafetyMode = "buffer-then-stream",
79
+ persist_steps: bool = True,
77
80
  ) -> None:
78
81
  self._agent = agent
79
82
  self._session_id = session_id if session_id is not None else uuid4().hex
@@ -97,6 +100,7 @@ class ChatSession:
97
100
  self._turn_count = 0
98
101
  self._closed = False
99
102
  self._safety_mode: SafetyMode = safety_mode
103
+ self._persist_steps = persist_steps
100
104
 
101
105
  # ------------------------------------------------------------------
102
106
  # Public properties
@@ -200,13 +204,17 @@ class ChatSession:
200
204
  result = await self._agent.run(task)
201
205
  duration_ms = int((time.monotonic() - start) * 1000)
202
206
  validated_out = await self._agent._guardrails.check_output(self._extract_text(result), ctx)
207
+ # Persist intermediate tool steps BEFORE the final assistant turn so
208
+ # the chat history reflects causal ordering (user → tool calls →
209
+ # tool results → final answer). bug-010.
210
+ tool_calls = await self._persist_steps_from_result(result)
203
211
  assistant_turn = await self._persist_assistant(validated_out, result, duration_ms)
204
212
  self._enforce_budgets(result.cost_usd)
205
213
  response = ChatResponse(
206
214
  content=validated_out,
207
215
  turn_id=assistant_turn.id,
208
216
  run_id=result.run_id,
209
- tool_calls=(),
217
+ tool_calls=tool_calls,
210
218
  tokens_in=result.tokens_in,
211
219
  tokens_out=result.tokens_out,
212
220
  cost_usd=result.cost_usd,
@@ -254,6 +262,103 @@ class ChatSession:
254
262
  return output
255
263
  return str(output)
256
264
 
265
+ async def _persist_steps_from_result(self, result: Any) -> tuple[ToolCall, ...]:
266
+ """Persist `act` and `observe` steps from `result.steps` as
267
+ `ChatTurn`s ahead of the final assistant turn (bug-010).
268
+
269
+ Returns the aggregated tool_calls so the caller can attach them
270
+ to both the final assistant `ChatTurn.tool_calls` and the
271
+ synchronous `ChatResponse.tool_calls`. No-op when
272
+ `self._persist_steps` is False.
273
+ """
274
+ if not self._persist_steps:
275
+ return ()
276
+ tool_calls: list[ToolCall] = []
277
+ for step in result.steps:
278
+ if step.tool_call is None:
279
+ continue
280
+ if step.kind == "act":
281
+ tool_calls.append(step.tool_call)
282
+ content = (
283
+ step.content if isinstance(step.content, str) else json.dumps(step.content)
284
+ )
285
+ turn = ChatTurn(
286
+ id=uuid4().hex,
287
+ session_id=self._session_id,
288
+ role="assistant",
289
+ content=content,
290
+ run_id=result.run_id,
291
+ tool_calls=(step.tool_call,),
292
+ tokens_in=step.tokens_in,
293
+ tokens_out=step.tokens_out,
294
+ cost_usd=step.cost_usd,
295
+ )
296
+ await self._history.append(turn)
297
+ if self._on_turn is not None:
298
+ self._on_turn(turn)
299
+ elif step.kind == "observe":
300
+ content = (
301
+ step.content if isinstance(step.content, str) else json.dumps(step.content)
302
+ )
303
+ turn = ChatTurn(
304
+ id=uuid4().hex,
305
+ session_id=self._session_id,
306
+ role="tool",
307
+ content=content,
308
+ tool_call_id=step.tool_call.id,
309
+ run_id=result.run_id,
310
+ )
311
+ await self._history.append(turn)
312
+ if self._on_turn is not None:
313
+ self._on_turn(turn)
314
+ return tuple(tool_calls)
315
+
316
+ async def _persist_steps_from_events(
317
+ self,
318
+ events: list[StreamingEvent],
319
+ *,
320
+ run_id: str,
321
+ ) -> None:
322
+ """Stream-path mirror of `_persist_steps_from_result` (bug-010).
323
+
324
+ Walks the buffered `kind="step"` events captured during
325
+ `agent.stream(...)`. `tool_call` rides on `event.metadata` as a
326
+ serialised dict (see `strategies._base._events_for_new_steps`)
327
+ so the session can persist tool turns without reaching into
328
+ `AgentState`.
329
+ """
330
+ if not self._persist_steps:
331
+ return
332
+ for event in events:
333
+ meta = event.metadata
334
+ kind = meta.get("kind")
335
+ tool_call_dict = meta.get("tool_call")
336
+ if kind not in ("act", "observe") or not isinstance(tool_call_dict, dict):
337
+ continue
338
+ tool_call = ToolCall.model_validate(tool_call_dict)
339
+ content = event.content if isinstance(event.content, str) else json.dumps(event.content)
340
+ if kind == "act":
341
+ turn = ChatTurn(
342
+ id=uuid4().hex,
343
+ session_id=self._session_id,
344
+ role="assistant",
345
+ content=content,
346
+ run_id=run_id,
347
+ tool_calls=(tool_call,),
348
+ )
349
+ else: # observe
350
+ turn = ChatTurn(
351
+ id=uuid4().hex,
352
+ session_id=self._session_id,
353
+ role="tool",
354
+ content=content,
355
+ tool_call_id=tool_call.id,
356
+ run_id=run_id,
357
+ )
358
+ await self._history.append(turn)
359
+ if self._on_turn is not None:
360
+ self._on_turn(turn)
361
+
257
362
  async def _persist_assistant(
258
363
  self,
259
364
  text: str,
@@ -343,7 +448,7 @@ class ChatSession:
343
448
  async for chunk in self._chunks_for(response):
344
449
  yield chunk
345
450
 
346
- async def _stream_per_token( # noqa: PLR0912
451
+ async def _stream_per_token( # noqa: PLR0912, PLR0915
347
452
  self,
348
453
  message: str,
349
454
  *,
@@ -370,6 +475,10 @@ class ChatSession:
370
475
  assistant_turn_id = uuid4().hex
371
476
  cumulative = ""
372
477
  run_summary: dict[str, Any] | None = None
478
+ # Collected step events (bug-010): persisted as ChatTurns after
479
+ # the stream finishes but before the final assistant turn, so
480
+ # tool calls + results appear in causal order on disk.
481
+ step_events: list[StreamingEvent] = []
373
482
  start = time.monotonic()
374
483
  buffered = self._safety_mode in ("sentence-window", "stream-then-redact")
375
484
  window = _SentenceWindowBuffer() if buffered else None
@@ -381,6 +490,8 @@ class ChatSession:
381
490
  # event and complete naturally so `Agent.stream`'s
382
491
  # `finally: reset_run(token)` fires deterministically.
383
492
  continue
493
+ if event.kind == "step":
494
+ step_events.append(event)
384
495
  if event.kind == "text" and isinstance(event.content, str):
385
496
  if window is not None:
386
497
  for sentence in window.push(event.content):
@@ -431,6 +542,11 @@ class ChatSession:
431
542
  else cumulative
432
543
  )
433
544
  validated_out = await self._agent._guardrails.check_output(final_text, ctx)
545
+ # Persist intermediate tool steps before the final assistant
546
+ # turn (bug-010, stream path mirror of `_persist_steps_from_result`).
547
+ await self._persist_steps_from_events(
548
+ step_events, run_id=str(run_summary.get("run_id", ""))
549
+ )
434
550
  assistant_turn = ChatTurn(
435
551
  id=assistant_turn_id,
436
552
  session_id=self._session_id,
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import json
13
13
  from collections.abc import Mapping
14
- from datetime import datetime
14
+ from datetime import UTC, datetime
15
15
  from pathlib import Path
16
16
  from types import TracebackType
17
17
  from typing import Any
@@ -200,12 +200,24 @@ class SqliteChatHistory(ChatHistoryStore):
200
200
  return [await self._row_to_info(row) for row in rows]
201
201
 
202
202
  async def update_session_metadata(self, session_id: str, metadata: Mapping[str, Any]) -> None:
203
+ # Create the session row if it doesn't exist yet (bug-018):
204
+ # ChatServer records owner/metadata before the first turn is
205
+ # appended. DO NOTHING leaves an existing row (and its
206
+ # last_active_at) untouched.
207
+ now_iso = datetime.now(UTC).isoformat()
208
+ await self._db.execute(
209
+ """INSERT INTO chat_sessions
210
+ (id, owner, created_at, last_active_at, metadata)
211
+ VALUES (?, NULL, ?, ?, ?)
212
+ ON CONFLICT(id) DO NOTHING""",
213
+ (session_id, now_iso, now_iso, "{}"),
214
+ )
203
215
  async with self._db.execute(
204
216
  "SELECT metadata, owner FROM chat_sessions WHERE id = ?",
205
217
  (session_id,),
206
218
  ) as cur:
207
219
  row = await cur.fetchone()
208
- if row is None:
220
+ if row is None: # pragma: no cover — the INSERT above guarantees a row
209
221
  raise ModuleError(f"Cannot update metadata for unknown session {session_id!r}")
210
222
  existing = json.loads(row["metadata"])
211
223
  existing.update(dict(metadata))
@@ -19,6 +19,7 @@ from agentforge_core.values.messages import (
19
19
  LLMResponse,
20
20
  Message,
21
21
  TokenUsage,
22
+ ToolCall,
22
23
  ToolSpec,
23
24
  )
24
25
  from agentforge_core.values.state import AgentState, Step
@@ -241,3 +242,110 @@ async def test_close_is_idempotent() -> None:
241
242
  session = _session(session_id="t14")
242
243
  await session.close()
243
244
  await session.close()
245
+
246
+
247
+ # ----------------------------------------------------------------------
248
+ # bug-010 — persist intermediate tool steps to history
249
+ # ----------------------------------------------------------------------
250
+
251
+
252
+ class _ToolUseStrategy(ReasoningStrategy):
253
+ """Strategy that simulates one think/act/observe iteration with a
254
+ tool call, then a final think with the answer. Used to exercise
255
+ bug-010 persistence."""
256
+
257
+ def __init__(self, *, tool_id: str = "tc-1", tool_name: str = "ping") -> None:
258
+ self._tool_id = tool_id
259
+ self._tool_name = tool_name
260
+
261
+ async def run(self, state: AgentState) -> AgentState:
262
+ runtime = state.metadata.get(RUNTIME_KEY)
263
+ if runtime is not None:
264
+ runtime.budget.commit(0.01)
265
+ tc = ToolCall(id=self._tool_id, name=self._tool_name, arguments={"target": "x"})
266
+ state.steps.append(Step(iteration=0, kind="think", content="planning"))
267
+ state.steps.append(
268
+ Step(
269
+ iteration=0,
270
+ kind="act",
271
+ content={"tool": tc.name, "arguments": dict(tc.arguments)},
272
+ tool_call=tc,
273
+ cost_usd=0.0,
274
+ )
275
+ )
276
+ state.steps.append(
277
+ Step(
278
+ iteration=0,
279
+ kind="observe",
280
+ content='{"ok": true}',
281
+ tool_call=tc,
282
+ cost_usd=0.0,
283
+ )
284
+ )
285
+ state.steps.append(
286
+ Step(iteration=1, kind="think", content="all good", cost_usd=0.01),
287
+ )
288
+ return state
289
+
290
+
291
+ def _tool_session(**kwargs: Any) -> ChatSession:
292
+ agent = Agent(model=_FakeLLM(), strategy=_ToolUseStrategy())
293
+ return ChatSession(agent, history_store=InMemoryChatHistory(), **kwargs)
294
+
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_persist_steps_records_act_and_observe_turns() -> None:
298
+ """bug-010: tool steps from result.steps must persist to chat history
299
+ so generative-UI clients can render them and the next turn's prompt
300
+ sees prior tool context."""
301
+ session = _tool_session(session_id="t-bug010-run")
302
+ await session.send("do the thing")
303
+ turns = await session.history()
304
+ roles_then_ids = [(t.role, t.tool_call_id, t.tool_calls) for t in turns]
305
+ # Expected shape: user, assistant(act with tool_calls), tool(observation),
306
+ # assistant(final answer).
307
+ assert [r for r, _, _ in roles_then_ids] == ["user", "assistant", "tool", "assistant"]
308
+ _, _, act_tcs = roles_then_ids[1]
309
+ assert len(act_tcs) == 1
310
+ assert act_tcs[0].id == "tc-1"
311
+ assert act_tcs[0].name == "ping"
312
+ _, observe_tcid, _ = roles_then_ids[2]
313
+ assert observe_tcid == "tc-1"
314
+
315
+
316
+ @pytest.mark.asyncio
317
+ async def test_response_tool_calls_populated_from_steps() -> None:
318
+ """bug-010: ChatResponse.tool_calls aggregates the tool calls from
319
+ `result.steps` instead of being the previously-hardcoded empty tuple."""
320
+ session = _tool_session(session_id="t-bug010-resp")
321
+ response = await session.send("do the thing")
322
+ assert len(response.tool_calls) == 1
323
+ assert response.tool_calls[0].id == "tc-1"
324
+
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_persist_steps_false_keeps_history_lean() -> None:
328
+ """bug-010 opt-out: setting persist_steps=False reverts to the
329
+ pre-fix shape (user + final assistant only). Useful when an
330
+ external consumer reconstructs tool history from another source."""
331
+ session = _tool_session(session_id="t-bug010-off", persist_steps=False)
332
+ response = await session.send("do the thing")
333
+ turns = await session.history()
334
+ assert [t.role for t in turns] == ["user", "assistant"]
335
+ # And the response still surfaces an empty tool_calls (opt-out is
336
+ # consistent across persistence + response).
337
+ assert response.tool_calls == ()
338
+
339
+
340
+ @pytest.mark.asyncio
341
+ async def test_stream_persists_step_turns() -> None:
342
+ """bug-010 stream path: `_stream_per_token` must persist tool turns
343
+ via `_persist_steps_from_events` so streaming and non-streaming
344
+ paths produce the same on-disk shape."""
345
+ session = _tool_session(session_id="t-bug010-stream")
346
+ async for _ in await session.stream("do the thing"):
347
+ pass
348
+ turns = await session.history()
349
+ assert [t.role for t in turns] == ["user", "assistant", "tool", "assistant"]
350
+ # tool turn pairs by id with the prior assistant turn's tool_calls
351
+ assert turns[1].tool_calls[0].id == turns[2].tool_call_id
File without changes