test_llm.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155
  1. """Tests for the LLM client and chat session (no network)."""
  2. import pytest
  3. from stt_daemon.assistant.llm import (
  4. BriefEngine,
  5. ChatSession,
  6. ConversationEngine,
  7. LlmClient,
  8. LlmError,
  9. LlmResponse,
  10. )
  11. from stt_daemon.config import LlmConfig
  12. def _make_client(responder, cfg=None):
  13. cfg = cfg or LlmConfig()
  14. calls: list[dict] = []
  15. def opener(payload, timeout):
  16. calls.append(payload)
  17. return responder(payload)
  18. return LlmClient(cfg, opener=opener), calls
  19. def _reply(content="", reasoning=None, tool_calls=None, finish="stop"):
  20. message = {"role": "assistant", "content": content}
  21. if reasoning is not None:
  22. message["reasoning_content"] = reasoning
  23. if tool_calls:
  24. message["tool_calls"] = tool_calls
  25. return {"choices": [{"message": message, "finish_reason": finish}]}
  26. def test_reasoning_content_is_ignored():
  27. client, _ = _make_client(
  28. lambda _p: _reply(content="It is noon.", reasoning="thinking hard about it")
  29. )
  30. response = client.chat([{"role": "user", "content": "hi"}])
  31. assert response.content == "It is noon."
  32. assert "thinking hard" not in response.content
  33. def test_cache_prompt_and_endpoint_are_sent():
  34. client, calls = _make_client(lambda _p: _reply(content="ok"))
  35. client.chat([{"role": "user", "content": "hi"}])
  36. assert calls[0]["body"]["cache_prompt"] is True
  37. assert calls[0]["body"]["stream"] is False
  38. assert calls[0]["__url__"] == LlmConfig().endpoint
  39. def test_model_is_sent_when_set():
  40. cfg = LlmConfig(model="gpt-4o-mini")
  41. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  42. client.chat([{"role": "user", "content": "hi"}])
  43. assert calls[0]["body"]["model"] == "gpt-4o-mini"
  44. def test_model_omitted_when_empty():
  45. client, calls = _make_client(lambda _p: _reply(content="ok"))
  46. client.chat([{"role": "user", "content": "hi"}])
  47. assert "model" not in calls[0]["body"]
  48. def test_api_key_env_adds_bearer_header(monkeypatch):
  49. monkeypatch.setenv("STT_TEST_LLM_KEY", "sekret")
  50. cfg = LlmConfig(api_key_env="STT_TEST_LLM_KEY")
  51. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  52. client.chat([{"role": "user", "content": "hi"}])
  53. assert calls[0]["headers"]["Authorization"] == "Bearer sekret"
  54. assert calls[0]["headers"]["Content-Type"] == "application/json"
  55. def test_missing_api_key_env_sends_no_authorization(monkeypatch):
  56. monkeypatch.delenv("STT_TEST_LLM_KEY", raising=False)
  57. cfg = LlmConfig(api_key_env="STT_TEST_LLM_KEY")
  58. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  59. client.chat([{"role": "user", "content": "hi"}])
  60. assert "Authorization" not in calls[0]["headers"]
  61. def test_no_api_key_sends_only_content_type(monkeypatch):
  62. client, calls = _make_client(lambda _p: _reply(content="ok"))
  63. client.chat([{"role": "user", "content": "hi"}])
  64. assert calls[0]["headers"] == {"Content-Type": "application/json"}
  65. def test_session_header_and_user_agent_sent_when_set():
  66. cfg = LlmConfig(
  67. session_header="x-opencode-session",
  68. session_id="stt-chat",
  69. user_agent="stt-daemon/1.0",
  70. )
  71. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  72. client.chat([{"role": "user", "content": "hi"}])
  73. assert calls[0]["headers"]["x-opencode-session"] == "stt-chat"
  74. assert calls[0]["headers"]["User-Agent"] == "stt-daemon/1.0"
  75. def test_session_header_omitted_when_id_empty(caplog):
  76. cfg = LlmConfig(session_header="x-opencode-session", session_id="")
  77. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  78. client.chat([{"role": "user", "content": "hi"}])
  79. assert "x-opencode-session" not in calls[0]["headers"]
  80. assert "session_id is empty" in caplog.text
  81. def test_session_header_omitted_when_header_empty():
  82. cfg = LlmConfig(session_header="", session_id="stt-chat")
  83. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  84. client.chat([{"role": "user", "content": "hi"}])
  85. assert "stt-chat" not in calls[0]["headers"].values()
  86. assert calls[0]["headers"] == {"Content-Type": "application/json"}
  87. def test_reasoning_effort_is_sent_when_set():
  88. cfg = LlmConfig(reasoning_effort="high")
  89. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  90. client.chat([{"role": "user", "content": "hi"}])
  91. assert calls[0]["body"]["reasoning_effort"] == "high"
  92. def test_reasoning_effort_omitted_when_empty():
  93. client, calls = _make_client(lambda _p: _reply(content="ok"))
  94. client.chat([{"role": "user", "content": "hi"}])
  95. assert "reasoning_effort" not in calls[0]["body"]
  96. def test_extra_body_merged_and_overrides_defaults():
  97. cfg = LlmConfig(extra_body={"enable_thinking": True, "temperature": 0.1})
  98. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  99. client.chat([{"role": "user", "content": "hi"}])
  100. assert calls[0]["body"]["enable_thinking"] is True
  101. assert calls[0]["body"]["temperature"] == 0.1
  102. def test_tools_sent_only_when_provided():
  103. client, calls = _make_client(lambda _p: _reply(content="ok"))
  104. client.chat([{"role": "user", "content": "hi"}])
  105. assert "tools" not in calls[0]["body"]
  106. client.chat(
  107. [{"role": "user", "content": "hi"}],
  108. tools=[{"type": "function", "function": {"name": "get_datetime"}}],
  109. )
  110. assert calls[1]["body"]["tools"][0]["function"]["name"] == "get_datetime"
  111. assert calls[1]["body"]["tool_choice"] == "auto"
  112. def test_id_slot_sent_only_when_slot_id_provided():
  113. client, calls = _make_client(lambda _p: _reply(content="ok"))
  114. client.chat([{"role": "user", "content": "hi"}])
  115. assert "id_slot" not in calls[0]["body"]
  116. client.chat([{"role": "user", "content": "hi"}], slot_id=1)
  117. assert calls[1]["body"]["id_slot"] == 1
  118. def test_slot_and_on_io_never_passed_to_old_fake_when_none():
  119. seen: list[dict] = []
  120. class _OldFake:
  121. def chat(self, messages, tools=None, max_tokens=None, timeout_s=None):
  122. seen.append(
  123. {
  124. "messages": messages,
  125. "tools": tools,
  126. "max_tokens": max_tokens,
  127. "timeout_s": timeout_s,
  128. }
  129. )
  130. return {"ok": True}
  131. fake = _OldFake()
  132. slot_id = None
  133. on_io = None
  134. kwargs: dict = {}
  135. if slot_id is not None:
  136. kwargs["slot_id"] = slot_id
  137. if on_io is not None:
  138. kwargs["on_io"] = on_io
  139. fake.chat([{"role": "user", "content": "hi"}], **kwargs)
  140. assert seen[0] == {
  141. "messages": [{"role": "user", "content": "hi"}],
  142. "tools": None,
  143. "max_tokens": None,
  144. "timeout_s": None,
  145. }
  146. def test_tool_calls_are_parsed():
  147. client, _ = _make_client(
  148. lambda _p: _reply(
  149. tool_calls=[
  150. {
  151. "id": "1",
  152. "type": "function",
  153. "function": {"name": "get_datetime", "arguments": "{}"},
  154. }
  155. ],
  156. finish="tool_calls",
  157. )
  158. )
  159. response = client.chat([{"role": "user", "content": "time?"}], tools=[{"type": "function"}])
  160. assert len(response.tool_calls) == 1
  161. assert response.tool_calls[0].name == "get_datetime"
  162. assert response.finish_reason == "tool_calls"
  163. def test_missing_choices_raises():
  164. client, _ = _make_client(lambda _p: {"choices": []})
  165. with pytest.raises(LlmError):
  166. client.chat([{"role": "user", "content": "hi"}])
  167. def test_chat_session_appends_and_keeps_prefix():
  168. client, calls = _make_client(lambda _p: _reply(content="hello"))
  169. session = ChatSession(LlmConfig(), client)
  170. assert session.send("hi") == "hello"
  171. session.send("again")
  172. assert [m["role"] for m in session.history] == [
  173. "user",
  174. "assistant",
  175. "user",
  176. "assistant",
  177. ]
  178. first = calls[0]["body"]["messages"]
  179. second = calls[1]["body"]["messages"]
  180. assert second[: len(first)] == first # append-only, prefix preserved
  181. assert second[0]["content"] == LlmConfig().system_prompt_chat
  182. def test_chat_session_trims_in_blocks_and_keeps_system_prompt():
  183. cfg = LlmConfig(
  184. max_context_tokens=1330,
  185. max_tokens=1024,
  186. compaction_safety_margin=256,
  187. trim_block_turns=1,
  188. compaction_enabled=False,
  189. ) # prompt_budget == 50
  190. client, calls = _make_client(lambda _p: _reply(content="ok"))
  191. session = ChatSession(cfg, client)
  192. for _ in range(10):
  193. session.send("x" * 40)
  194. assert len(session.history) <= 6
  195. assert calls[-1]["body"]["messages"][0]["content"] == cfg.system_prompt_chat
  196. def test_chat_session_reset_forgets_history():
  197. client, _ = _make_client(lambda _p: _reply(content="ok"))
  198. session = ChatSession(LlmConfig(), client)
  199. session.send("hi")
  200. session.reset()
  201. assert session.history == []
  202. def test_chat_session_drops_reply_when_reset_mid_flight():
  203. holder: list[ChatSession] = []
  204. def opener(_payload, _timeout):
  205. holder[0].reset()
  206. return _reply(content="late reply")
  207. holder.append(ChatSession(LlmConfig(), LlmClient(LlmConfig(), opener=opener)))
  208. assert holder[0].send("hi") == ""
  209. assert holder[0].history == []
  210. # ---------------------------------------------------------------------------
  211. # Phase C tests
  212. # ---------------------------------------------------------------------------
  213. def _compact_cfg(
  214. *,
  215. budget_chars=200,
  216. trigger_ratio=0.8,
  217. keep_turns=1,
  218. compaction_max_tokens=64,
  219. compaction_timeout_s=5.0,
  220. compaction_input_tokens=8192,
  221. compaction_enabled=True,
  222. trim_block_turns=1,
  223. ):
  224. """Return a small LlmConfig whose prompt_budget == budget_chars // 4 tokens.
  225. prompt_budget = max_context_tokens - max_tokens - compaction_safety_margin
  226. We want budget_chars // 4 tokens, so:
  227. max_context_tokens = (budget_chars // 4) + max_tokens + compaction_safety_margin
  228. """
  229. max_tokens = 16
  230. compaction_safety_margin = 0
  231. max_context_tokens = (budget_chars // 4) + max_tokens + compaction_safety_margin
  232. return LlmConfig(
  233. max_context_tokens=max_context_tokens,
  234. max_tokens=max_tokens,
  235. compaction_safety_margin=compaction_safety_margin,
  236. compaction_enabled=compaction_enabled,
  237. compaction_trigger_ratio=trigger_ratio,
  238. compaction_keep_turns=keep_turns,
  239. compaction_max_tokens=compaction_max_tokens,
  240. compaction_timeout_s=compaction_timeout_s,
  241. compaction_input_tokens=compaction_input_tokens,
  242. trim_block_turns=trim_block_turns,
  243. system_prompt_chat="SYS",
  244. system_prompt_command="CMD",
  245. compaction_system_prompt="COMPACT",
  246. )
  247. def test_compaction_fires_and_window_shrinks():
  248. """Crossing the trigger compacts: window shrinks, summary present in next prompt."""
  249. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  250. # prompt_budget = 200//4 = 50 tokens; trigger at 40 tokens (0.8 * 50)
  251. summary_text = "summary of old turns"
  252. call_index = 0
  253. def responder(payload):
  254. nonlocal call_index
  255. msgs = payload["body"]["messages"]
  256. call_index += 1
  257. # Summarizer calls have the compaction system prompt.
  258. if msgs[0]["content"] == "COMPACT":
  259. return _reply(content=summary_text)
  260. return _reply(content="ok")
  261. client, calls = _make_client(responder, cfg)
  262. session = ChatSession(cfg, client)
  263. # Send enough turns to exceed the trigger (each "x"*40 = 40 chars = 10 tokens).
  264. # After 4 turns (user+assistant pairs = 8 messages * 10 tokens = 80 tokens > 40 trigger).
  265. for _ in range(4):
  266. session.send("x" * 40)
  267. # After compaction the window must be smaller than the full history.
  268. assert len(session.history) <= 4 # keep_turns=1 → keep=2 messages
  269. # The summary must be set.
  270. assert session.summary != ""
  271. # The next chat call must include the summary as a second system message,
  272. # with the chat system prompt FIRST.
  273. session.send("after compaction")
  274. chat_calls = [c for c in calls if c["body"]["messages"][0]["content"] == "SYS"]
  275. last_chat = chat_calls[-1]["body"]["messages"]
  276. assert last_chat[0]["role"] == "system"
  277. assert last_chat[0]["content"] == "SYS"
  278. assert last_chat[1]["role"] == "system"
  279. assert "Earlier conversation summary:" in last_chat[1]["content"]
  280. assert summary_text in last_chat[1]["content"]
  281. def test_summary_never_appended_to_history():
  282. """The summary text must not appear as a history entry."""
  283. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  284. summary_text = "SUMMARY_SENTINEL"
  285. def responder(payload):
  286. msgs = payload["body"]["messages"]
  287. if msgs[0]["content"] == "COMPACT":
  288. return _reply(content=summary_text)
  289. return _reply(content="ok")
  290. client, _ = _make_client(responder, cfg)
  291. session = ChatSession(cfg, client)
  292. for _ in range(4):
  293. session.send("x" * 40)
  294. for msg in session.history:
  295. assert summary_text not in msg["content"]
  296. def test_summarizer_failure_falls_back_to_trim():
  297. """On summarizer failure the session falls back to _trim() and stays within budget."""
  298. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  299. def responder(payload):
  300. msgs = payload["body"]["messages"]
  301. if msgs[0]["content"] == "COMPACT":
  302. return _reply(content="", finish="stop") # empty → failure
  303. return _reply(content="ok")
  304. client, _ = _make_client(responder, cfg)
  305. session = ChatSession(cfg, client)
  306. for _ in range(4):
  307. session.send("x" * 40)
  308. # Summary must remain empty (fallback used trim, not compaction).
  309. assert session.summary == ""
  310. # History must be bounded within the budget.
  311. assert session._estimate_tokens() <= cfg.prompt_budget
  312. def test_summarizer_length_finish_reason_falls_back_to_trim():
  313. """finish_reason == 'length' on the summarizer is treated as failure."""
  314. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  315. def responder(payload):
  316. msgs = payload["body"]["messages"]
  317. if msgs[0]["content"] == "COMPACT":
  318. return _reply(content="partial summary", finish="length")
  319. return _reply(content="ok")
  320. client, _ = _make_client(responder, cfg)
  321. session = ChatSession(cfg, client)
  322. for _ in range(4):
  323. session.send("x" * 40)
  324. assert session.summary == ""
  325. assert session._estimate_tokens() <= cfg.prompt_budget
  326. def test_summarizer_llmerror_falls_back_to_trim():
  327. """LlmError from the summarizer falls back to _trim()."""
  328. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  329. def responder(payload):
  330. msgs = payload["body"]["messages"]
  331. if msgs[0]["content"] == "COMPACT":
  332. raise LlmError("network error")
  333. return _reply(content="ok")
  334. client, _ = _make_client(responder, cfg)
  335. session = ChatSession(cfg, client)
  336. for _ in range(4):
  337. session.send("x" * 40)
  338. assert session.summary == ""
  339. assert session._estimate_tokens() <= cfg.prompt_budget
  340. def test_reset_clears_summary():
  341. """reset() clears both history and summary."""
  342. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  343. summary_text = "old summary"
  344. def responder(payload):
  345. msgs = payload["body"]["messages"]
  346. if msgs[0]["content"] == "COMPACT":
  347. return _reply(content=summary_text)
  348. return _reply(content="ok")
  349. client, _ = _make_client(responder, cfg)
  350. session = ChatSession(cfg, client)
  351. for _ in range(4):
  352. session.send("x" * 40)
  353. assert session.summary != ""
  354. session.reset()
  355. assert session.history == []
  356. assert session.summary == ""
  357. def test_compaction_disabled_never_calls_summarizer():
  358. """compaction_enabled=False never calls the summarizer and still trims."""
  359. cfg = _compact_cfg(
  360. budget_chars=200,
  361. trigger_ratio=0.8,
  362. keep_turns=1,
  363. compaction_enabled=False,
  364. )
  365. summarizer_called = False
  366. def responder(payload):
  367. nonlocal summarizer_called
  368. msgs = payload["body"]["messages"]
  369. if msgs[0]["content"] == "COMPACT":
  370. summarizer_called = True
  371. return _reply(content="should not happen")
  372. return _reply(content="ok")
  373. client, _ = _make_client(responder, cfg)
  374. session = ChatSession(cfg, client)
  375. for _ in range(6):
  376. session.send("x" * 40)
  377. assert not summarizer_called
  378. assert session.summary == ""
  379. # History must still be bounded (trim ran).
  380. assert session._estimate_tokens() <= cfg.prompt_budget
  381. def test_nothing_to_evict_skips_summarizer():
  382. """When len(history) <= keep, compaction is skipped and trim runs instead."""
  383. # keep_turns=10 → keep=20 messages; we only send 2 turns (4 messages).
  384. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=10)
  385. summarizer_called = False
  386. def responder(payload):
  387. nonlocal summarizer_called
  388. msgs = payload["body"]["messages"]
  389. if msgs[0]["content"] == "COMPACT":
  390. summarizer_called = True
  391. return _reply(content="should not happen")
  392. return _reply(content="ok")
  393. client, _ = _make_client(responder, cfg)
  394. session = ChatSession(cfg, client)
  395. # Push over the trigger but keep history shorter than keep=20.
  396. for _ in range(4):
  397. session.send("x" * 40)
  398. assert not summarizer_called
  399. def test_chunk_and_fold_large_evicted():
  400. """Large evicted list triggers chunk-and-fold (more than one summarizer call).
  401. budget_chars=160 → prompt_budget=40 tokens, trigger at 32 tokens (0.8 * 40).
  402. Each turn: user='x'*40 (40 chars) + assistant='ok' (2 chars) = ~10 tokens.
  403. Compaction fires at turn 4 with 6 evicted messages.
  404. compaction_input_tokens=1 → char_limit=4 chars per chunk, so each 40-char
  405. or 2-char message is its own chunk → 6 summarizer calls for 6 chunks.
  406. """
  407. cfg = _compact_cfg(
  408. budget_chars=160,
  409. trigger_ratio=0.8,
  410. keep_turns=1,
  411. compaction_input_tokens=1, # 1 token * 4 = 4 chars per chunk → each msg is a chunk
  412. )
  413. summarizer_calls = []
  414. def responder(payload):
  415. msgs = payload["body"]["messages"]
  416. if msgs[0]["content"] == "COMPACT":
  417. summarizer_calls.append(payload)
  418. return _reply(content="folded")
  419. return _reply(content="ok")
  420. client, _ = _make_client(responder, cfg)
  421. session = ChatSession(cfg, client)
  422. # 4 turns → 8 messages → fires compaction with 6 evicted (keep=2).
  423. for _ in range(4):
  424. session.send("x" * 40)
  425. # With compaction_input_tokens=1 (4 chars/chunk) and 40-char messages,
  426. # each message is its own chunk → multiple summarizer calls.
  427. assert len(summarizer_calls) > 1
  428. def test_chat_per_request_max_tokens_override():
  429. """LlmClient.chat sends per-request max_tokens when given, cfg default otherwise."""
  430. cfg = LlmConfig(max_tokens=1024, timeout_s=60.0)
  431. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  432. # Default: uses cfg.max_tokens.
  433. client.chat([{"role": "user", "content": "hi"}])
  434. assert calls[0]["body"]["max_tokens"] == 1024
  435. # Override: uses the supplied value.
  436. client.chat([{"role": "user", "content": "hi"}], max_tokens=128)
  437. assert calls[1]["body"]["max_tokens"] == 128
  438. def test_chat_per_request_timeout_override():
  439. """LlmClient.chat uses per-request timeout_s when given, cfg default otherwise."""
  440. cfg = LlmConfig(max_tokens=1024, timeout_s=60.0)
  441. recorded_timeouts: list[float] = []
  442. def opener(payload, timeout):
  443. recorded_timeouts.append(timeout)
  444. return _reply(content="ok")
  445. client = LlmClient(cfg, opener=opener)
  446. # Default timeout.
  447. client.chat([{"role": "user", "content": "hi"}])
  448. assert recorded_timeouts[0] == 60.0
  449. # Per-request override.
  450. client.chat([{"role": "user", "content": "hi"}], timeout_s=5.0)
  451. assert recorded_timeouts[1] == 5.0
  452. def test_summarizer_uses_compaction_max_tokens_and_timeout():
  453. """The summarizer call uses compaction_max_tokens and compaction_timeout_s."""
  454. cfg = _compact_cfg(
  455. budget_chars=200,
  456. trigger_ratio=0.8,
  457. keep_turns=1,
  458. compaction_max_tokens=42,
  459. compaction_timeout_s=7.0,
  460. )
  461. recorded: list[dict] = []
  462. def opener(payload, timeout):
  463. msgs = payload["body"]["messages"]
  464. if msgs[0]["content"] == "COMPACT":
  465. recorded.append({"max_tokens": payload["body"]["max_tokens"], "timeout": timeout})
  466. return _reply(content="summary")
  467. return _reply(content="ok")
  468. client = LlmClient(cfg, opener=opener)
  469. session = ChatSession(cfg, client)
  470. for _ in range(4):
  471. session.send("x" * 40)
  472. assert len(recorded) >= 1
  473. assert recorded[0]["max_tokens"] == 42
  474. assert recorded[0]["timeout"] == 7.0
  475. def test_notify_called_on_compaction():
  476. """notify('compacted', turns=N) is called after a successful compaction."""
  477. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  478. notifications: list[tuple] = []
  479. def notify(kind, **fields):
  480. notifications.append((kind, fields))
  481. def responder(payload):
  482. msgs = payload["body"]["messages"]
  483. if msgs[0]["content"] == "COMPACT":
  484. return _reply(content="summary")
  485. return _reply(content="ok")
  486. client = LlmClient(cfg, opener=lambda p, t: responder(p))
  487. session = ChatSession(cfg, client, notify=notify)
  488. for _ in range(4):
  489. session.send("x" * 40)
  490. assert any(kind == "compacted" for kind, _ in notifications)
  491. compacted = [(k, f) for k, f in notifications if k == "compacted"]
  492. assert compacted[0][1]["turns"] > 0
  493. def test_notify_not_called_when_none():
  494. """ChatSession works without a notify callback (default None)."""
  495. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  496. def responder(payload):
  497. msgs = payload["body"]["messages"]
  498. if msgs[0]["content"] == "COMPACT":
  499. return _reply(content="summary")
  500. return _reply(content="ok")
  501. client = LlmClient(cfg, opener=lambda p, t: responder(p))
  502. session = ChatSession(cfg, client) # no notify
  503. # Must not raise.
  504. for _ in range(4):
  505. session.send("x" * 40)
  506. def test_system_prompt_is_first_message_after_compaction():
  507. """After compaction the chat system prompt is always the first message."""
  508. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  509. def responder(payload):
  510. msgs = payload["body"]["messages"]
  511. if msgs[0]["content"] == "COMPACT":
  512. return _reply(content="summary text")
  513. return _reply(content="ok")
  514. client, calls = _make_client(responder, cfg)
  515. session = ChatSession(cfg, client)
  516. for _ in range(4):
  517. session.send("x" * 40)
  518. # Find the last chat call (system prompt == "SYS", not "COMPACT").
  519. chat_calls = [c for c in calls if c["body"]["messages"][0]["content"] == "SYS"]
  520. assert chat_calls, "no chat calls found after compaction"
  521. last_msgs = chat_calls[-1]["body"]["messages"]
  522. assert last_msgs[0]["role"] == "system"
  523. assert last_msgs[0]["content"] == "SYS"
  524. def test_chat_session_truncated_empty_reply_notifies_and_is_not_appended():
  525. """A length-limited empty reply is reported and never stored as an empty turn."""
  526. notifications: list[tuple] = []
  527. def notify(kind, **fields):
  528. notifications.append((kind, fields))
  529. client, _ = _make_client(lambda _p: _reply(content="", finish="length"))
  530. session = ChatSession(LlmConfig(), client, notify=notify)
  531. assert session.send("hi") == ""
  532. assert session.history == [{"role": "user", "content": "hi"}]
  533. assert notifications == [("truncated", {"message": "response truncated"})]
  534. def test_chat_session_truncated_partial_reply_is_appended_and_notified():
  535. """A truncated but non-empty reply is kept and reported as truncated."""
  536. notifications: list[tuple] = []
  537. def notify(kind, **fields):
  538. notifications.append((kind, fields))
  539. client, _ = _make_client(lambda _p: _reply(content="partial answer", finish="length"))
  540. session = ChatSession(LlmConfig(), client, notify=notify)
  541. assert session.send("hi") == "partial answer"
  542. assert session.history[-1] == {"role": "assistant", "content": "partial answer"}
  543. assert any(kind == "truncated" for kind, _ in notifications)
  544. def test_on_io_receives_request_then_response():
  545. """chat(..., on_io=cb) reports the request (url+body) then the response payload."""
  546. client, _ = _make_client(lambda _p: _reply(content="ok"))
  547. seen: list[dict] = []
  548. response = client.chat([{"role": "user", "content": "hi"}], on_io=seen.append)
  549. assert response.content == "ok"
  550. assert [event["phase"] for event in seen] == ["request", "response"]
  551. assert seen[0]["url"] == LlmConfig().endpoint
  552. assert seen[0]["body"]["messages"] == [{"role": "user", "content": "hi"}]
  553. assert seen[0]["body"]["max_tokens"] == LlmConfig().max_tokens
  554. assert seen[1]["payload"]["choices"][0]["message"]["content"] == "ok"
  555. assert seen[1]["error"] is None
  556. def test_on_io_response_phase_reports_duration_ms():
  557. """The response phase carries a non-negative float duration_ms."""
  558. client, _ = _make_client(lambda _p: _reply(content="ok"))
  559. seen: list[dict] = []
  560. client.chat([{"role": "user", "content": "hi"}], on_io=seen.append)
  561. assert "duration_ms" not in seen[0] # request phase carries no timing
  562. assert isinstance(seen[1]["duration_ms"], float)
  563. assert seen[1]["duration_ms"] >= 0.0
  564. def test_on_io_response_phase_reports_opener_error():
  565. """An opener raising still yields a response phase with the error set."""
  566. import urllib.error
  567. def opener(_payload, _timeout):
  568. raise urllib.error.URLError("down")
  569. client = LlmClient(LlmConfig(), opener=opener)
  570. seen: list[dict] = []
  571. with pytest.raises(LlmError):
  572. client.chat([{"role": "user", "content": "hi"}], on_io=seen.append)
  573. assert [event["phase"] for event in seen] == ["request", "response"]
  574. assert seen[1]["payload"] is None
  575. assert "down" in seen[1]["error"]
  576. assert isinstance(seen[1]["duration_ms"], float)
  577. assert seen[1]["duration_ms"] >= 0.0
  578. def test_raising_on_io_does_not_break_chat():
  579. """A throwing on_io callback is swallowed and the reply still parses."""
  580. def bad(_event):
  581. raise RuntimeError("boom")
  582. client, _ = _make_client(lambda _p: _reply(content="ok"))
  583. assert client.chat([{"role": "user", "content": "hi"}], on_io=bad).content == "ok"
  584. # ---------------------------------------------------------------------------
  585. # ConversationEngine tests (Todo 2)
  586. # ---------------------------------------------------------------------------
  587. def _fake_engine_client(record=None, content="ok", finish="stop"):
  588. """Old-style fake: accepts no slot_id/on_io keywords, returns LlmResponse."""
  589. class _Fake:
  590. def chat(self, messages, max_tokens=None, timeout_s=None):
  591. if record is not None:
  592. record.append(
  593. {
  594. "messages": messages,
  595. "max_tokens": max_tokens,
  596. "timeout_s": timeout_s,
  597. }
  598. )
  599. return LlmResponse(content=content, finish_reason=finish)
  600. return _Fake()
  601. def test_engine_custom_system_prompt_is_first():
  602. client, calls = _make_client(lambda _p: _reply(content="ok"))
  603. engine = ConversationEngine(LlmConfig(), client, system_prompt="CUSTOM")
  604. assert engine.send("hi") == "ok"
  605. assert calls[0]["body"]["messages"][0] == {
  606. "role": "system",
  607. "content": "CUSTOM",
  608. }
  609. def test_engine_custom_compaction_prompt_is_used():
  610. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  611. seen_prompts: list[str] = []
  612. def responder(payload):
  613. msgs = payload["body"]["messages"]
  614. if msgs[0]["content"] == "BRIEF-COMPACT":
  615. seen_prompts.append(msgs[0]["content"])
  616. return _reply(content="brief summary")
  617. assert msgs[0]["content"] == "SYS2"
  618. return _reply(content="ok")
  619. client, _ = _make_client(responder, cfg)
  620. engine = ConversationEngine(
  621. cfg,
  622. client,
  623. system_prompt="SYS2",
  624. compaction_system_prompt="BRIEF-COMPACT",
  625. )
  626. for _ in range(4):
  627. engine.send("x" * 40)
  628. assert seen_prompts, "summarizer never ran with the custom prompt"
  629. assert engine.summary == "brief summary"
  630. def test_engine_slot_id_sent_only_when_set():
  631. client, calls = _make_client(lambda _p: _reply(content="ok"))
  632. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
  633. engine.send("hi")
  634. assert "id_slot" not in calls[0]["body"]
  635. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS", slot_id=3)
  636. engine.send("hi")
  637. assert calls[1]["body"]["id_slot"] == 3
  638. def test_engine_omits_slot_and_on_io_for_old_fake_when_none():
  639. record: list[dict] = []
  640. fake = _fake_engine_client(record)
  641. engine = ConversationEngine(LlmConfig(), fake, system_prompt="SYS")
  642. assert engine.send("hi") == "ok"
  643. assert record[0]["messages"][0] == {"role": "system", "content": "SYS"}
  644. assert record[0]["max_tokens"] is None
  645. assert record[0]["timeout_s"] is None
  646. def test_engine_forwards_per_call_on_io():
  647. client, _ = _make_client(lambda _p: _reply(content="ok"))
  648. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
  649. seen: list[dict] = []
  650. assert engine.send("hi", on_io=seen.append) == "ok"
  651. assert [event["phase"] for event in seen] == ["request", "response"]
  652. def test_engine_forwards_slot_and_on_io_when_set():
  653. record: list[dict] = []
  654. class _NewFake:
  655. def chat(self, messages, max_tokens=None, timeout_s=None, **kwargs):
  656. record.append(kwargs)
  657. return LlmResponse(content="ok", finish_reason="stop")
  658. def hook(_event):
  659. pass
  660. engine = ConversationEngine(
  661. LlmConfig(), _NewFake(), system_prompt="SYS", slot_id=7
  662. )
  663. assert engine.send("hi", on_io=hook) == "ok"
  664. assert record[0]["slot_id"] == 7
  665. assert record[0]["on_io"] is hook
  666. def test_engine_forwards_max_tokens_and_timeout():
  667. record: list[dict] = []
  668. fake = _fake_engine_client(record)
  669. engine = ConversationEngine(
  670. LlmConfig(), fake, system_prompt="SYS", max_tokens=11, timeout_s=2.5
  671. )
  672. engine.send("hi")
  673. assert record[0]["max_tokens"] == 11
  674. assert record[0]["timeout_s"] == 2.5
  675. def test_last_reply_returns_latest_assistant():
  676. fake = _fake_engine_client(content="first")
  677. engine = ConversationEngine(LlmConfig(), fake, system_prompt="SYS")
  678. assert engine.last_reply() == ""
  679. engine.send("hi")
  680. assert engine.last_reply() == "first"
  681. engine.history.append({"role": "user", "content": "again"})
  682. engine.history.append({"role": "assistant", "content": "second"})
  683. assert engine.last_reply() == "second"
  684. def test_finalize_none_restores_history_and_summary_copy():
  685. notifications: list[tuple] = []
  686. def notify(kind, **fields):
  687. notifications.append((kind, fields))
  688. client, _ = _make_client(lambda _p: _reply(content="raw reply"))
  689. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS", notify=notify)
  690. engine.history = [
  691. {"role": "user", "content": "u1"},
  692. {"role": "assistant", "content": "a1"},
  693. ]
  694. engine.summary = "prior summary"
  695. assert engine.send("new?", finalize=lambda reply, finish: None) == ""
  696. assert engine.history == [
  697. {"role": "user", "content": "u1"},
  698. {"role": "assistant", "content": "a1"},
  699. ]
  700. assert engine.summary == "prior summary"
  701. assert notifications == []
  702. def test_finalize_transformed_string_is_appended():
  703. client, _ = _make_client(lambda _p: _reply(content="raw"))
  704. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
  705. seen_args: list[tuple] = []
  706. def finalize(reply, finish):
  707. seen_args.append((reply, finish))
  708. return "EDITED: " + reply
  709. assert engine.send("hi", finalize=finalize) == "EDITED: raw"
  710. assert seen_args == [("raw", "stop")]
  711. assert engine.history[-1] == {"role": "assistant", "content": "EDITED: raw"}
  712. assert engine.last_reply() == "EDITED: raw"
  713. def test_finalize_empty_string_is_appended_not_restored():
  714. client, _ = _make_client(lambda _p: _reply(content="raw"))
  715. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
  716. engine.history = [
  717. {"role": "user", "content": "u1"},
  718. {"role": "assistant", "content": "a1"},
  719. ]
  720. assert engine.send("new?", finalize=lambda reply, finish: "") == ""
  721. assert engine.history == [
  722. {"role": "user", "content": "u1"},
  723. {"role": "assistant", "content": "a1"},
  724. {"role": "user", "content": "new?"},
  725. {"role": "assistant", "content": ""},
  726. ]
  727. assert engine.history[-1] == {"role": "assistant", "content": ""}
  728. def test_finalize_reject_after_reset_mid_flight_stays_empty():
  729. client, _ = _make_client(lambda _p: _reply(content="late"))
  730. holder: list[ConversationEngine] = []
  731. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
  732. holder.append(engine)
  733. engine.summary = "prior summary"
  734. def finalize(reply, finish):
  735. holder[0].reset()
  736. return None
  737. assert engine.send("hi", finalize=finalize) == ""
  738. assert engine.history == []
  739. assert engine.summary == ""
  740. def test_finalize_snapshot_is_a_copy():
  741. client, _ = _make_client(lambda _p: _reply(content="raw"))
  742. engine = ConversationEngine(LlmConfig(), client, system_prompt="SYS")
  743. engine.history = [
  744. {"role": "user", "content": "u1"},
  745. {"role": "assistant", "content": "a1"},
  746. ]
  747. def finalize(reply, finish):
  748. engine.history.append({"role": "assistant", "content": "POISON"})
  749. engine.history.append({"role": "user", "content": "POISON"})
  750. return None
  751. assert engine.send("new?", finalize=finalize) == ""
  752. assert engine.history == [
  753. {"role": "user", "content": "u1"},
  754. {"role": "assistant", "content": "a1"},
  755. ]
  756. def test_summarizer_never_pinned_to_engine_slot():
  757. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  758. client, calls = _make_client(
  759. lambda p: (
  760. _reply(content="s")
  761. if p["body"]["messages"][0]["content"] == "CUSTOM-COMPACT"
  762. else _reply(content="ok")
  763. ),
  764. cfg,
  765. )
  766. engine = ConversationEngine(
  767. cfg,
  768. client,
  769. system_prompt="SYS",
  770. compaction_system_prompt="CUSTOM-COMPACT",
  771. slot_id=5,
  772. )
  773. for _ in range(4):
  774. engine.send("x" * 40)
  775. chat_calls = [c for c in calls if c["body"]["messages"][0]["content"] == "SYS"]
  776. compact_calls = [
  777. c for c in calls if c["body"]["messages"][0]["content"] == "CUSTOM-COMPACT"
  778. ]
  779. assert chat_calls and all(c["body"]["id_slot"] == 5 for c in chat_calls)
  780. assert compact_calls and all("id_slot" not in c["body"] for c in compact_calls)
  781. def test_chat_session_is_engine_with_chat_prompt():
  782. client, calls = _make_client(lambda _p: _reply(content="hello"))
  783. session = ChatSession(LlmConfig(), client)
  784. assert isinstance(session, ConversationEngine)
  785. assert session.system == LlmConfig().system_prompt_chat
  786. assert session.send("hi") == "hello"
  787. assert calls[0]["body"]["messages"][0]["content"] == LlmConfig().system_prompt_chat
  788. assert session.last_reply() == "hello"
  789. def test_brief_engine_sends_static_system_and_single_user_turn():
  790. client, calls = _make_client(lambda _p: _reply(content="BRIEF v1"))
  791. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS", max_tokens=99)
  792. assert engine.send("CURRENT LEDGER:\n(none yet)") == "BRIEF v1"
  793. assert len(calls) == 1
  794. messages = calls[0]["body"]["messages"]
  795. assert messages[0] == {"role": "system", "content": "SYS"}
  796. assert len(messages) == 2
  797. assert messages[1]["content"].startswith("CURRENT LEDGER:")
  798. assert calls[0]["body"]["max_tokens"] == 99
  799. def test_brief_engine_second_call_still_sends_two_messages():
  800. client, calls = _make_client(lambda _p: _reply(content="BRIEF"))
  801. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
  802. engine.send("first")
  803. engine.send("second")
  804. assert len(calls) == 2
  805. for call in calls:
  806. assert len(call["body"]["messages"]) == 2
  807. def test_brief_engine_is_stateless_between_calls():
  808. client, calls = _make_client(lambda _p: _reply(content="BRIEF"))
  809. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
  810. engine.send("first")
  811. engine.send("second")
  812. assert calls[0]["body"]["messages"][1]["content"] == "first"
  813. assert calls[1]["body"]["messages"][1]["content"] == "second"
  814. def test_brief_engine_finalize_none_returns_empty():
  815. client, _ = _make_client(lambda _p: _reply(content="raw"))
  816. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
  817. assert engine.send("q", finalize=lambda reply, finish: None) == ""
  818. def test_brief_engine_finalize_transform_is_returned():
  819. client, _ = _make_client(lambda _p: _reply(content="raw"))
  820. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
  821. result = engine.send("q", finalize=lambda reply, finish: "CLEANED: " + reply)
  822. assert result == "CLEANED: raw"
  823. def test_brief_engine_length_reply_is_returned_with_finish_reason():
  824. client, _ = _make_client(lambda _p: _reply(content="half", finish="length"))
  825. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
  826. seen: list[str] = []
  827. result = engine.send("q", finalize=lambda reply, finish: seen.append(finish) or reply)
  828. assert result == "half"
  829. assert seen == ["length"]
  830. def test_brief_engine_reset_invalidates_in_flight_reply():
  831. client, _ = _make_client(lambda _p: _reply(content="BRIEF"))
  832. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS")
  833. def finalize(reply, finish):
  834. engine.reset()
  835. return reply
  836. assert engine.send("q", finalize=finalize) == ""
  837. def test_brief_engine_passes_slot_and_io_hook():
  838. client, calls = _make_client(lambda _p: _reply(content="BRIEF"))
  839. engine = BriefEngine(LlmConfig(), client, system_prompt="SYS", slot_id=5)
  840. seen: list[dict] = []
  841. engine.send("q", on_io=lambda part: seen.append(part))
  842. assert calls[0]["body"]["id_slot"] == 5
  843. assert any(part["phase"] == "request" for part in seen)