Skip to content

Commit 353da24

Browse files
authored
fix(activity): find a chat tool call's details by its id, not its timestamp (#486)
* 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 * fix(activity): look up a call id quoted and escaped as it is stored Review on #486: the lookup searched for the raw '"<id>"'. 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
1 parent 5c7e093 commit 353da24

4 files changed

Lines changed: 47 additions & 4 deletions

File tree

‎changes/485.fixed.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
**Activity shows what a chat tool was given on Windows again.** A line like "query: Saturday plans" could be missing
2+
under a tool Sentient used in a chat, because two messages saved in the same instant got mixed up.

‎sentient/activity/timeline.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,10 +335,12 @@ def _chat_item(self, row: Any) -> Item:
335335
app_label=label, ref=ref, undo=undo, _args=args, _apps=also)
336336

337337
async def _call_arguments(self, session_id: str, call_id: str, at: str) -> dict | None:
338-
"""The arguments a chat tool call was given, read from the assistant message that made it."""
338+
"""The arguments a chat tool call was given, read from the assistant message that made it. Found by the call's
339+
id: Windows' clock can give a tool result and the next call the same ``created_at`` (#485)."""
339340
row = await self.store.fetchone(
340341
"SELECT tool_calls FROM messages WHERE session_id = ? AND role = 'assistant' AND created_at <= ?"
341-
" AND tool_calls IS NOT NULL ORDER BY created_at DESC LIMIT 1", (session_id, at),
342+
" AND tool_calls IS NOT NULL AND instr(tool_calls, ?) > 0 ORDER BY created_at DESC LIMIT 1",
343+
(session_id, at, json.dumps(call_id)), # quoted and escaped as stored; the loop below checks the id
342344
)
343345
calls = _loads(row["tool_calls"]) if row else None
344346
for call in calls if isinstance(calls, list) else []:

‎tests/test_claude_code.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -822,8 +822,8 @@ async def one() -> str:
822822
return await llm.complete_text("fast", [{"role": "user", "content": "hi"}])
823823

824824
first = asyncio.create_task(one())
825-
for _ in range(300):
826-
if claude_code._live:
825+
for _ in range(300): # running and logged, not just spawned: slow to start on Windows (claude.cmd, then Python)
826+
if claude_code._live and fake_claude.runs():
827827
break
828828
await asyncio.sleep(0.05)
829829
second = asyncio.create_task(one())

‎tests/test_timeline.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,19 @@ async def _all(s: SentientApp, **kw) -> list[dict]:
106106
raise AssertionError("paging never ended")
107107

108108

109+
async def test_a_call_id_that_json_escapes_is_still_found(config, isolated_home):
110+
s = await _app(config, isolated_home)
111+
try:
112+
sid = await s.store.create_session(channel="web", title="Trip")
113+
call = {"id": "call_é1", "type": "function",
114+
"function": {"name": "mail_search", "arguments": json.dumps({"query": "Goa"})}}
115+
await s.store.add_message(sid, "assistant", "", tool_calls=[call])
116+
row = await s.store.fetchone("SELECT created_at FROM messages WHERE session_id = ?", (sid,))
117+
assert await s.timeline._call_arguments(sid, "call_é1", row["created_at"]) == {"query": "Goa"}
118+
finally:
119+
await s.stop()
120+
121+
109122
async def test_chat_tool_calls_and_approvals_say_what_and_why(config, isolated_home):
110123
sent: list[str] = []
111124
llm = FakeProvider(replies=[
@@ -147,6 +160,32 @@ async def test_chat_tool_calls_and_approvals_say_what_and_why(config, isolated_h
147160
await s.stop()
148161

149162

163+
async def test_a_calls_details_survive_a_shared_timestamp(config, isolated_home):
164+
# Windows' clock (Python 3.12) ticks every 15.6 ms: a tool result and the next call often share created_at
165+
s = await _app(config, isolated_home)
166+
try:
167+
sid = await s.store.create_session(channel="web", title="Weekend plans")
168+
search = {"id": "c1", "type": "function",
169+
"function": {"name": "mail_search", "arguments": json.dumps({"query": "Saturday plans"})}}
170+
send = {"id": "c2", "type": "function", # its arguments mention c1, the way a reply to it might
171+
"function": {"name": "mail_send", "arguments": json.dumps({"to": "Arjun", "body": "re c1",
172+
"ref": "c1"})}}
173+
await s.store.add_message(sid, "assistant", "", tool_calls=[search])
174+
await s.store.add_message(sid, "tool", json.dumps({"results": []}), tool_call_id="c1", name="mail_search")
175+
await s.store.add_message(sid, "assistant", "", tool_calls=[send])
176+
# c1 is asked a tick earlier; its result and the next call (c2) land in the same tick
177+
at = "2026-10-20T10:00:00+00:00"
178+
await s.store.execute("UPDATE messages SET created_at = ? WHERE session_id = ?", (at, sid))
179+
await s.store.execute(
180+
"UPDATE messages SET created_at = ? WHERE session_id = ? AND instr(tool_calls, 'mail_search') > 0",
181+
("2026-10-20T09:59:59.984000+00:00", sid),
182+
)
183+
assert await s.timeline._call_arguments(sid, "c1", at) == {"query": "Saturday plans"}
184+
assert await s.timeline._call_arguments(sid, "c2", at) == {"to": "Arjun", "body": "re c1", "ref": "c1"}
185+
finally:
186+
await s.stop()
187+
188+
150189
async def test_incognito_chats_never_show(config, isolated_home):
151190
s = await _app(config, isolated_home, FakeProvider(replies=[[tool_call("mail_search", query="secret")], "ok"]))
152191
try:

0 commit comments

Comments
 (0)