From a5367e6e686dd0e6f4f9e70309cfaea3c9bacb45 Mon Sep 17 00:00:00 2001 From: Tannmay-ts Date: Tue, 20 Oct 2026 16:38:41 +0530 Subject: [PATCH 1/2] fix(activity): find a chat tool call's details by its id, not its timestamp Windows' clock advances every 15.6 ms, so a tool result and the next assistant message often share created_at. Timeline._call_arguments took the latest assistant message at or before the result and could pick the next call's, dropping this call's "query: ..." line from Activity. It now finds the message that holds the call's id. test_stop_everything_drops_calls_still_waiting killed the fake Claude Code as soon as the process existed; it now waits until the fake has logged its run, which comes late on Windows (claude.cmd, then cmd.exe, then Python). Closes #485 --- changes/485.fixed.md | 2 ++ sentient/activity/timeline.py | 6 ++++-- tests/test_claude_code.py | 4 ++-- tests/test_timeline.py | 20 ++++++++++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 changes/485.fixed.md 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..99732dfe2 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, f'"{call_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..91baccf7a 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -147,6 +147,26 @@ 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", + "function": {"name": "mail_send", "arguments": json.dumps({"to": "Arjun", "body": "See you"})}} + 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]) + at = "2026-10-20T10:00:00+00:00" + await s.store.execute("UPDATE messages SET created_at = ? WHERE session_id = ?", (at, 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": "See you"} + 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: From 7ddf6ce12182ead757f125887aafa9549f4f939a Mon Sep 17 00:00:00 2001 From: Tannmay-ts Date: Wed, 21 Oct 2026 04:05:00 +0530 Subject: [PATCH 2/2] fix(activity): look up a call id quoted and escaped as it is stored Review on #486: the lookup searched for the raw '""'. Tool calls are saved with json.dumps, so an id with a character JSON escapes was never found. Search for json.dumps(call_id) instead; the Python check after the query still confirms the id. The same-timestamp test now places the call a tick earlier, with its result and the next call in the same tick, so it fails on main every time, and a new test covers an id that JSON escapes. Refs #485 --- sentient/activity/timeline.py | 2 +- tests/test_timeline.py | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/sentient/activity/timeline.py b/sentient/activity/timeline.py index 99732dfe2..b4b5164ec 100644 --- a/sentient/activity/timeline.py +++ b/sentient/activity/timeline.py @@ -340,7 +340,7 @@ async def _call_arguments(self, session_id: str, call_id: str, at: str) -> dict 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 AND instr(tool_calls, ?) > 0 ORDER BY created_at DESC LIMIT 1", - (session_id, at, f'"{call_id}"'), + (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_timeline.py b/tests/test_timeline.py index 91baccf7a..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=[ @@ -154,15 +167,21 @@ async def test_a_calls_details_survive_a_shared_timestamp(config, isolated_home) 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", - "function": {"name": "mail_send", "arguments": json.dumps({"to": "Arjun", "body": "See you"})}} + 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": "See you"} + assert await s.timeline._call_arguments(sid, "c2", at) == {"to": "Arjun", "body": "re c1", "ref": "c1"} finally: await s.stop()