diff --git a/changes/485.fixed.md b/changes/485.fixed.md new file mode 100644 index 000000000..3edb8a64e --- /dev/null +++ b/changes/485.fixed.md @@ -0,0 +1,2 @@ +**Activity shows what a chat tool was given on Windows again.** A line like "query: Saturday plans" could be missing +under a tool Sentient used in a chat, because two messages saved in the same instant got mixed up. diff --git a/sentient/activity/timeline.py b/sentient/activity/timeline.py index c40bd04a9..b4b5164ec 100644 --- a/sentient/activity/timeline.py +++ b/sentient/activity/timeline.py @@ -335,10 +335,12 @@ def _chat_item(self, row: Any) -> Item: app_label=label, ref=ref, undo=undo, _args=args, _apps=also) async def _call_arguments(self, session_id: str, call_id: str, at: str) -> dict | None: - """The arguments a chat tool call was given, read from the assistant message that made it.""" + """The arguments a chat tool call was given, read from the assistant message that made it. Found by the call's + id: Windows' clock can give a tool result and the next call the same ``created_at`` (#485).""" row = await self.store.fetchone( "SELECT tool_calls FROM messages WHERE session_id = ? AND role = 'assistant' AND created_at <= ?" - " AND tool_calls IS NOT NULL ORDER BY created_at DESC LIMIT 1", (session_id, at), + " AND tool_calls IS NOT NULL AND instr(tool_calls, ?) > 0 ORDER BY created_at DESC LIMIT 1", + (session_id, at, json.dumps(call_id)), # quoted and escaped as stored; the loop below checks the id ) calls = _loads(row["tool_calls"]) if row else None for call in calls if isinstance(calls, list) else []: diff --git a/tests/test_claude_code.py b/tests/test_claude_code.py index 8ae39af23..988f70ee9 100644 --- a/tests/test_claude_code.py +++ b/tests/test_claude_code.py @@ -822,8 +822,8 @@ async def one() -> str: return await llm.complete_text("fast", [{"role": "user", "content": "hi"}]) first = asyncio.create_task(one()) - for _ in range(300): - if claude_code._live: + for _ in range(300): # running and logged, not just spawned: slow to start on Windows (claude.cmd, then Python) + if claude_code._live and fake_claude.runs(): break await asyncio.sleep(0.05) second = asyncio.create_task(one()) diff --git a/tests/test_timeline.py b/tests/test_timeline.py index a81292035..ac19feb49 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -106,6 +106,19 @@ async def _all(s: SentientApp, **kw) -> list[dict]: raise AssertionError("paging never ended") +async def test_a_call_id_that_json_escapes_is_still_found(config, isolated_home): + s = await _app(config, isolated_home) + try: + sid = await s.store.create_session(channel="web", title="Trip") + call = {"id": "call_é1", "type": "function", + "function": {"name": "mail_search", "arguments": json.dumps({"query": "Goa"})}} + await s.store.add_message(sid, "assistant", "", tool_calls=[call]) + row = await s.store.fetchone("SELECT created_at FROM messages WHERE session_id = ?", (sid,)) + assert await s.timeline._call_arguments(sid, "call_é1", row["created_at"]) == {"query": "Goa"} + finally: + await s.stop() + + async def test_chat_tool_calls_and_approvals_say_what_and_why(config, isolated_home): sent: list[str] = [] llm = FakeProvider(replies=[ @@ -147,6 +160,32 @@ async def test_chat_tool_calls_and_approvals_say_what_and_why(config, isolated_h await s.stop() +async def test_a_calls_details_survive_a_shared_timestamp(config, isolated_home): + # Windows' clock (Python 3.12) ticks every 15.6 ms: a tool result and the next call often share created_at + s = await _app(config, isolated_home) + try: + sid = await s.store.create_session(channel="web", title="Weekend plans") + search = {"id": "c1", "type": "function", + "function": {"name": "mail_search", "arguments": json.dumps({"query": "Saturday plans"})}} + send = {"id": "c2", "type": "function", # its arguments mention c1, the way a reply to it might + "function": {"name": "mail_send", "arguments": json.dumps({"to": "Arjun", "body": "re c1", + "ref": "c1"})}} + await s.store.add_message(sid, "assistant", "", tool_calls=[search]) + await s.store.add_message(sid, "tool", json.dumps({"results": []}), tool_call_id="c1", name="mail_search") + await s.store.add_message(sid, "assistant", "", tool_calls=[send]) + # c1 is asked a tick earlier; its result and the next call (c2) land in the same tick + at = "2026-10-20T10:00:00+00:00" + await s.store.execute("UPDATE messages SET created_at = ? WHERE session_id = ?", (at, sid)) + await s.store.execute( + "UPDATE messages SET created_at = ? WHERE session_id = ? AND instr(tool_calls, 'mail_search') > 0", + ("2026-10-20T09:59:59.984000+00:00", sid), + ) + assert await s.timeline._call_arguments(sid, "c1", at) == {"query": "Saturday plans"} + assert await s.timeline._call_arguments(sid, "c2", at) == {"to": "Arjun", "body": "re c1", "ref": "c1"} + finally: + await s.stop() + + async def test_incognito_chats_never_show(config, isolated_home): s = await _app(config, isolated_home, FakeProvider(replies=[[tool_call("mail_search", query="secret")], "ok"])) try: