maximem-synap-haystack 0.2.1__tar.gz → 0.2.2__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 (20) hide show
  1. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/PKG-INFO +2 -2
  2. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/PKG-INFO +2 -2
  3. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/SOURCES.txt +3 -0
  4. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/requires.txt +1 -1
  5. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/pyproject.toml +2 -2
  6. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/__init__.py +17 -2
  7. maximem_synap_haystack-0.2.2/synap_haystack/hooks.py +127 -0
  8. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/short_term.py +17 -5
  9. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/store.py +157 -11
  10. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/writer.py +16 -7
  11. maximem_synap_haystack-0.2.2/tests/test_default_path.py +215 -0
  12. maximem_synap_haystack-0.2.2/tests/test_hooks.py +103 -0
  13. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_short_term.py +19 -1
  14. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/dependency_links.txt +0 -0
  15. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/maximem_synap_haystack.egg-info/top_level.txt +0 -0
  16. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/setup.cfg +0 -0
  17. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/synap_haystack/retriever.py +0 -0
  18. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_retriever.py +0 -0
  19. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_store.py +0 -0
  20. {maximem_synap_haystack-0.2.1 → maximem_synap_haystack-0.2.2}/tests/test_writer.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maximem-synap-haystack
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Synap memory integration for Haystack — SynapMemoryStore + retriever/writer components
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
7
7
  Requires-Dist: maximem-synap>=0.5.3
8
- Requires-Dist: maximem-synap-integrations-common>=0.2.1
8
+ Requires-Dist: maximem-synap-integrations-common>=0.2.3
9
9
  Requires-Dist: haystack-ai>=2.0
10
10
  Provides-Extra: dev
11
11
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maximem-synap-haystack
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Synap memory integration for Haystack — SynapMemoryStore + retriever/writer components
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
7
7
  Requires-Dist: maximem-synap>=0.5.3
8
- Requires-Dist: maximem-synap-integrations-common>=0.2.1
8
+ Requires-Dist: maximem-synap-integrations-common>=0.2.3
9
9
  Requires-Dist: haystack-ai>=2.0
10
10
  Provides-Extra: dev
11
11
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -5,10 +5,13 @@ maximem_synap_haystack.egg-info/dependency_links.txt
5
5
  maximem_synap_haystack.egg-info/requires.txt
6
6
  maximem_synap_haystack.egg-info/top_level.txt
7
7
  synap_haystack/__init__.py
8
+ synap_haystack/hooks.py
8
9
  synap_haystack/retriever.py
9
10
  synap_haystack/short_term.py
10
11
  synap_haystack/store.py
11
12
  synap_haystack/writer.py
13
+ tests/test_default_path.py
14
+ tests/test_hooks.py
12
15
  tests/test_retriever.py
13
16
  tests/test_short_term.py
14
17
  tests/test_store.py
@@ -1,5 +1,5 @@
1
1
  maximem-synap>=0.5.3
2
- maximem-synap-integrations-common>=0.2.1
2
+ maximem-synap-integrations-common>=0.2.3
3
3
  haystack-ai>=2.0
4
4
 
5
5
  [dev]
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "maximem-synap-haystack"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Synap memory integration for Haystack — SynapMemoryStore + retriever/writer components"
9
9
  requires-python = ">=3.11"
10
10
  license = "Apache-2.0"
11
- dependencies = ["maximem-synap>=0.5.3", "maximem-synap-integrations-common>=0.2.1", "haystack-ai>=2.0"]
11
+ dependencies = ["maximem-synap>=0.5.3", "maximem-synap-integrations-common>=0.2.3", "haystack-ai>=2.0"]
12
12
 
13
13
  [project.optional-dependencies]
14
14
  dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
@@ -5,12 +5,20 @@ Store-centric, matching the official ``mem0-haystack`` shape:
5
5
  - :class:`SynapMemoryStore` — the memory store (a plain object, not a component)
6
6
  that owns all Synap SDK interaction. ``add_memories`` / ``search_memories`` /
7
7
  ``search_memories_as_single_message`` / ``delete_*`` + ``to_dict`` / ``from_dict``.
8
+ It is also a Haystack ``ChatMessageStore`` (``write_messages`` /
9
+ ``retrieve_messages`` / ``count_messages``), so an experimental ``Agent`` takes it
10
+ as ``chat_message_store=`` and loads Synap's compaction and the turns after it
11
+ instead of a ten-message window. Turns are written on the gRPC stream, which
12
+ the first one opens (``SYNAP_AUTO_LISTEN=false`` turns that off).
8
13
  - :class:`SynapMemoryRetriever` — ``@component`` returning ``ChatMessage``s
9
14
  (Mem0-shaped chat read path).
10
15
  - :class:`SynapRetriever` — ``@component`` returning ``Document``s (RAG read path).
11
16
  - :class:`SynapMemoryWriter` — ``@component`` that records conversation turns.
12
- - :class:`SynapShortTermContext` — ``@component`` for the short-term conversation
13
- context pipeline.
17
+ - :class:`SynapHistoryHook` / :class:`SynapRecordHook` — hooks for haystack-ai 3's
18
+ ``Agent(hooks=...)``: Synap's compaction in place of the earlier turns
19
+ (``before_llm``) and the run recorded on the stream (``after_run``).
20
+ - :class:`SynapShortTermContext` — ``@component`` emitting Synap's compaction of
21
+ the conversation for a prompt builder.
14
22
 
15
23
  The components accept a ready ``store=...`` or an ``sdk=...`` + scope kwargs.
16
24
  """
@@ -20,10 +28,17 @@ from synap_haystack.retriever import SynapMemoryRetriever, SynapRetriever
20
28
  from synap_haystack.writer import SynapMemoryWriter
21
29
  from synap_haystack.short_term import SynapShortTermContext
22
30
 
31
+ try: # needs haystack-ai 3's Agent hooks only at run time; importing is safe
32
+ from synap_haystack.hooks import SynapHistoryHook, SynapRecordHook
33
+ except ImportError: # pragma: no cover
34
+ SynapHistoryHook = SynapRecordHook = None # type: ignore[assignment,misc]
35
+
23
36
  __all__ = [
24
37
  "SynapMemoryStore",
25
38
  "SynapMemoryRetriever",
26
39
  "SynapRetriever",
27
40
  "SynapMemoryWriter",
28
41
  "SynapShortTermContext",
42
+ "SynapHistoryHook",
43
+ "SynapRecordHook",
29
44
  ]
@@ -0,0 +1,127 @@
1
+ """Synap's compaction in place of a Haystack agent's earlier turns, via hooks.
2
+
3
+ Haystack 3's ``Agent`` has no chat-message store. It takes ``hooks``: objects run
4
+ at points in its loop that mutate the live ``State`` in place. That is where its
5
+ own compaction lives (``haystack.hooks.compaction.CompactionHook``), and it is
6
+ where Synap's goes:
7
+
8
+ - :class:`SynapHistoryHook` on ``before_llm``: once Synap has compacted the
9
+ conversation, every turn before the current one in ``state["messages"]`` is
10
+ replaced by Synap's compaction (a system message) and the turns after it.
11
+ Until then, and whenever Synap cannot answer, the messages are left alone.
12
+ The agent's own compaction hooks can stay installed: with a short history they
13
+ do not fire, and they are the fallback.
14
+ - :class:`SynapRecordHook` on ``after_run``: records the run's turns on Synap's
15
+ gRPC stream (opened by the first one) and reports its tool calls, results and
16
+ reasoning, through :meth:`SynapMemoryStore.write_messages`.
17
+
18
+ ::
19
+
20
+ store = SynapMemoryStore(sdk, user_id="alice")
21
+ agent = Agent(
22
+ chat_generator=...,
23
+ hooks={
24
+ "before_llm": [SynapHistoryHook(store, "conv-1")],
25
+ "after_run": [SynapRecordHook(store, "conv-1")],
26
+ },
27
+ )
28
+
29
+ On haystack-ai 2.x use ``SynapMemoryStore`` as the experimental ``Agent``'s
30
+ ``chat_message_store`` instead; this module needs the 3.x ``Agent.hooks``.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ from typing import Any, Dict
37
+
38
+ from haystack.core.serialization import default_from_dict, default_to_dict
39
+ from haystack.dataclasses import ChatMessage, ChatRole
40
+
41
+ from synap_integrations_common import (
42
+ fetch_compacted_history,
43
+ run_async,
44
+ splice_history,
45
+ )
46
+
47
+ from synap_haystack.store import RECORDED_KEY, SynapMemoryStore
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ _ROLES = {ChatRole.USER: "user", ChatRole.ASSISTANT: "assistant", ChatRole.SYSTEM: "system"}
52
+
53
+
54
+ def _role_of(message: ChatMessage) -> str:
55
+ return _ROLES.get(message.role, "other")
56
+
57
+
58
+ def _text_of(message: ChatMessage) -> str:
59
+ return message.text or ""
60
+
61
+
62
+ def _replace(_current: Any, new: Any) -> Any:
63
+ return new
64
+
65
+
66
+ class _StoreHook:
67
+ """Shared construction and serialization for the two hooks."""
68
+
69
+ def __init__(self, store: SynapMemoryStore, chat_history_id: str) -> None:
70
+ if store is None:
71
+ raise ValueError(f"{type(self).__name__} requires a store")
72
+ if not chat_history_id or not str(chat_history_id).strip():
73
+ raise ValueError(f"{type(self).__name__} requires a non-empty chat_history_id")
74
+ self.store = store
75
+ self.chat_history_id = chat_history_id
76
+
77
+ def to_dict(self) -> Dict[str, Any]:
78
+ return default_to_dict(self, store=self.store.to_dict(), chat_history_id=self.chat_history_id)
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: Dict[str, Any]) -> "_StoreHook":
82
+ init = dict(data.get("init_parameters", {}))
83
+ init["store"] = SynapMemoryStore.from_dict(init["store"])
84
+ return default_from_dict(cls, {**data, "init_parameters": init})
85
+
86
+
87
+ class SynapHistoryHook(_StoreHook):
88
+ """``before_llm`` hook: Synap's compaction in place of the earlier turns."""
89
+
90
+ def run(self, state: Any) -> None:
91
+ run_async(self.run_async(state))
92
+
93
+ async def run_async(self, state: Any) -> None:
94
+ messages = list(state.get("messages") or [])
95
+ if not any(_role_of(m) == "user" for m in messages):
96
+ return
97
+ history = await fetch_compacted_history(self.store.sdk, self.chat_history_id)
98
+ if history is None:
99
+ return
100
+
101
+ def make(role: str, text: str) -> ChatMessage:
102
+ if role == "system":
103
+ return ChatMessage.from_system(text)
104
+ meta = {RECORDED_KEY: self.chat_history_id} # Synap already has it
105
+ return (ChatMessage.from_assistant(text, meta=meta) if role == "assistant"
106
+ else ChatMessage.from_user(text, meta=meta))
107
+
108
+ try:
109
+ spliced = splice_history(messages, history, role_of=_role_of, text_of=_text_of,
110
+ make_message=make)
111
+ except Exception: # noqa: BLE001 — the agent's own messages still stand
112
+ logger.warning("synap: could not splice the compacted history", exc_info=True)
113
+ return
114
+ state.set("messages", spliced, handler_override=_replace)
115
+
116
+
117
+ class SynapRecordHook(_StoreHook):
118
+ """``after_run`` hook: record the run on Synap, each message once."""
119
+
120
+ def run(self, state: Any) -> None:
121
+ self.store.write_messages(self.chat_history_id, list(state.get("messages") or []))
122
+
123
+ async def run_async(self, state: Any) -> None:
124
+ await self.store.awrite_messages(self.chat_history_id, list(state.get("messages") or []))
125
+
126
+
127
+ __all__ = ["SynapHistoryHook", "SynapRecordHook"]
@@ -5,8 +5,14 @@ Mirrors the LangGraph template, adapted to Haystack's
5
5
  ``sdk.conversation.context.get_context_for_prompt`` (cache-first behind
6
6
  ``SYNAP_SDK_ST_AUTHORITATIVE``).
7
7
 
8
- Exposes :class:`SynapShortTermContext` — a component that emits the
9
- formatted Synap short-term context string on its ``synap_st`` output.
8
+ Exposes :class:`SynapShortTermContext` — a component that emits Synap's
9
+ compaction of the conversation, as a string, on its ``synap_st`` output. It is
10
+ the compaction only: the turns after it are the ones your pipeline already
11
+ passes (a chat history, an agent's messages), and Synap's ``formatted_context``
12
+ carries them too, so a block built from it showed every recent turn twice. Until
13
+ the conversation has been compacted the block is empty. (An agent should use
14
+ ``SynapMemoryStore`` as its ``chat_message_store`` instead, which replaces the
15
+ history it loads.)
10
16
  Wire it before your prompt builder, then reference ``{synap_st}`` (or
11
17
  your prompt builder's variable name) in the template. The component
12
18
  re-fetches on every pipeline run, so ST stays fresh.
@@ -18,7 +24,7 @@ Quality contract identical to the LangGraph adapter:
18
24
  raise abort the whole pipeline. The default ``on_error="fallback"``
19
25
  emits the bare user system text (or empty string) on SDK failure,
20
26
  with an ERROR log. ``on_error="raise"`` surfaces failures.
21
- - Empty ST is a no-op — never wipes the user's system text.
27
+ - No compaction yet is a no-op — never wipes the user's system text.
22
28
  """
23
29
 
24
30
  from __future__ import annotations
@@ -141,8 +147,14 @@ class SynapShortTermContext:
141
147
  conversation_id=self.conversation_id,
142
148
  style=self.style,
143
149
  )
144
- if getattr(response, "available", False):
145
- st_block = (getattr(response, "formatted_context", None) or "").strip()
150
+ compaction = getattr(response, "context_block", None)
151
+ if getattr(response, "available", True) is not False and isinstance(compaction, str) \
152
+ and compaction.strip():
153
+ parts = [compaction.strip()]
154
+ found = getattr(response, "history_block", None)
155
+ if isinstance(found, str) and found.strip():
156
+ parts.append(found.strip())
157
+ st_block = "\n\n".join(parts)
146
158
  except SynapIntegrationError:
147
159
  if self.on_error == "raise":
148
160
  raise
@@ -7,14 +7,30 @@ is a plain object that owns *all* Synap SDK interaction, and the retriever/write
7
7
  shape ``synap_langgraph.SynapStore`` follows for LangGraph — the store is the
8
8
  center of gravity; everything else delegates to it.
9
9
 
10
- Why a memory store (and not the generic ``ChatMessageStore`` protocol)? Memory —
11
- unlike a verbatim chat log — is *semantic and cross-conversation*: writes are
12
- extracted/synthesised server-side, reads are query-driven. So this implements
13
- Mem0's ``add_memories`` / ``search_memories`` contract rather than Haystack's
14
- recency-based ``ChatMessageStore`` protocol (``write_messages`` /
15
- ``retrieve_messages`` keyed by ``chat_history_id``), which is designed for
16
- verbatim turn storage. This is the same reason Mem0 deliberately did not conform
17
- to ``ChatMessageStore``.
10
+ It implements two contracts, because Haystack's experimental ``Agent`` has two
11
+ slots and Synap fits both:
12
+
13
+ - Mem0's ``add_memories`` / ``search_memories`` (the ``memory_store=`` slot):
14
+ semantic and cross-conversation, writes extracted server-side, reads
15
+ query-driven.
16
+ - Haystack's ``ChatMessageStore`` protocol (the ``chat_message_store=`` slot):
17
+ ``write_messages`` / ``retrieve_messages`` / ``count_messages`` /
18
+ ``delete_*``, with ``chat_history_id`` as the Synap conversation id. This is the
19
+ one place a Haystack agent's history is decided: the agent's
20
+ ``ChatMessageRetriever`` asks for the last ten messages and puts them before
21
+ the new ones. Here that history is Synap's compaction of the conversation (a
22
+ system message) followed by the turns after it, so once Synap has compacted,
23
+ the model sees the compaction instead of a ten-message window. Haystack has no
24
+ compaction of its own to leave in place; the window is the fallback when Synap
25
+ has nothing yet. Give the agent the store in both slots if you want both::
26
+
27
+ store = SynapMemoryStore(sdk, user_id="alice", conversation_id="conv-1")
28
+ agent = Agent(chat_generator=..., chat_message_store=store, memory_store=store)
29
+ agent.run(messages=[...], chat_message_store_kwargs={"chat_history_id": "conv-1"})
30
+
31
+ The agent hands both stores the same messages, history included, so a message
32
+ this store has recorded or retrieved carries ``meta["synap_recorded"]`` and is
33
+ never recorded or reported a second time.
18
34
 
19
35
  Scope:
20
36
 
@@ -52,7 +68,8 @@ tool message, and ``ReasoningContent(reasoning_text)`` on either.
52
68
  it as ``origin.id``, which is what ties a call to its result.
53
69
 
54
70
  Only the turns are also memory, and they follow the one rule that matters:
55
- **stream first, REST only as a fallback, never both**. The server persists
71
+ **on the gRPC stream (opened by the first event, no ``listen()`` call needed;
72
+ ``SYNAP_AUTO_LISTEN=false`` turns that off), REST only as a fallback, never both**. The server persists
56
73
  ``user_message`` and ``assistant_message`` straight off the stream
57
74
  (``grpc/servicer.py``), so a ``record_message`` on top of a delivered stream
58
75
  event writes the turn twice and extracts it twice. Tool calls, tool results
@@ -76,6 +93,7 @@ from haystack.dataclasses import ChatMessage
76
93
 
77
94
  from maximem_synap import MaximemSynapSDK
78
95
  from synap_integrations_common import SynapIntegrationError, run_async
96
+ from synap_integrations_common.history import HISTORY_CLOSE, HISTORY_OPEN
79
97
  from synap_integrations_common.stream_events import (
80
98
  report_reasoning,
81
99
  report_tool_call,
@@ -89,6 +107,9 @@ logger = logging.getLogger(__name__)
89
107
  # only user/assistant turns carry signal; system/tool messages are skipped.
90
108
  _WRITE_ROLES = frozenset(("user", "assistant"))
91
109
 
110
+ # Set in a message's meta once this store has recorded or retrieved it.
111
+ RECORDED_KEY = "synap_recorded"
112
+
92
113
 
93
114
  class SynapMemoryStore:
94
115
  """Haystack-native memory store backed by Synap semantic memory.
@@ -203,6 +224,13 @@ class SynapMemoryStore:
203
224
  for msg in messages:
204
225
  role = _role_of(msg)
205
226
 
227
+ # Recorded or retrieved by this store already: an agent hands the
228
+ # same messages (history included) to its chat store and its memory
229
+ # store, and to either again on the next run.
230
+ if _is_recorded(msg):
231
+ results.append({"role": role, "status": "skipped", "reason": "already recorded"})
232
+ continue
233
+
206
234
  # ── the live stream ──────────────────────────────────────────
207
235
  # Reported for every role, including the ones that are never
208
236
  # written as memory: a tool result is not a turn, but it is
@@ -218,6 +246,22 @@ class SynapMemoryStore:
218
246
  role,
219
247
  sorted(_WRITE_ROLES),
220
248
  )
249
+ if role != "system":
250
+ _mark_recorded(msg, conv_id) # its tool results went out above
251
+ continue
252
+
253
+ text = msg.text or ""
254
+ calls = getattr(msg, "tool_calls", None)
255
+ if not text.strip() or (role == "assistant" and calls):
256
+ # A tool call lap is not an answer: its words (when it has any)
257
+ # narrate a step, and the anticipation event for "a turn ended"
258
+ # is the final reply. With no words at all it used to go out as
259
+ # an empty turn, which the stream dropped and REST refused, so
260
+ # the message counted as a failed write.
261
+ results.append({"role": role, "status": "skipped",
262
+ "reason": "tool call" if text.strip() else "no text"})
263
+ await self._report_tool_calls(msg, ids)
264
+ _mark_recorded(msg, conv_id)
221
265
  continue
222
266
 
223
267
  # Stream first, REST only as a fallback, never both.
@@ -230,7 +274,7 @@ class SynapMemoryStore:
230
274
  # fallback and lose it silently. REST fails loudly instead, which
231
275
  # is what this store did before there was a stream at all.
232
276
  if uid and await report_turn(
233
- self.sdk, role=role, content=msg.text or "", **ids
277
+ self.sdk, role=role, content=text, **ids
234
278
  ):
235
279
  written += 1
236
280
  results.append({
@@ -244,7 +288,7 @@ class SynapMemoryStore:
244
288
  resp = await self.sdk.conversation.record_message(
245
289
  conversation_id=conv_id,
246
290
  role=role,
247
- content=msg.text or "",
291
+ content=text,
248
292
  user_id=uid or None,
249
293
  customer_id=cid,
250
294
  )
@@ -269,6 +313,8 @@ class SynapMemoryStore:
269
313
 
270
314
  # After the turn: the model says its piece, then asks for tools.
271
315
  await self._report_tool_calls(msg, ids)
316
+ if results[-1]["status"] == "written":
317
+ _mark_recorded(msg, conv_id)
272
318
 
273
319
  processed = written + failed
274
320
  if processed > 0 and written == 0:
@@ -428,6 +474,93 @@ class SynapMemoryStore:
428
474
  )
429
475
  return [Document(content=r["content"], meta=r["meta"]) for r in records]
430
476
 
477
+ # ── Haystack's ChatMessageStore protocol ───────────────────────────────────
478
+ #
479
+ # `chat_history_id` is the Synap conversation id.
480
+
481
+ def write_messages(self, chat_history_id: str, messages: List[ChatMessage]) -> int:
482
+ """Record the messages this store has not seen. Returns how many turns it wrote.
483
+
484
+ An agent's chat writer passes the whole run, history and system prompt
485
+ included; only what is new and said by a user or the assistant is a
486
+ turn. Tool calls, results and reasoning go on the stream.
487
+ """
488
+ return run_async(self.awrite_messages(chat_history_id, messages))
489
+
490
+ async def awrite_messages(self, chat_history_id: str, messages: List[ChatMessage]) -> int:
491
+ results = await self.aadd_memories(messages=messages, conversation_id=chat_history_id)
492
+ return sum(1 for r in results if r.get("status") == "written")
493
+
494
+ def retrieve_messages(self, chat_history_id: str, last_k: Optional[int] = None) -> List[ChatMessage]:
495
+ """Synap's history of the conversation, oldest first.
496
+
497
+ Once Synap has compacted the conversation: its compaction as a system
498
+ message, then the turns after it. Before that: the recent turns.
499
+ ``last_k`` keeps only that many of the turns (the compaction is always
500
+ kept). Degrades to ``[]`` on SDK failure.
501
+ """
502
+ return run_async(self.aretrieve_messages(chat_history_id, last_k))
503
+
504
+ async def aretrieve_messages(
505
+ self, chat_history_id: str, last_k: Optional[int] = None
506
+ ) -> List[ChatMessage]:
507
+ ctx = await self._acontext(chat_history_id)
508
+ if ctx is None:
509
+ return []
510
+ out: List[ChatMessage] = []
511
+ compaction = getattr(ctx, "context_block", None)
512
+ if getattr(ctx, "available", True) is not False and isinstance(compaction, str) \
513
+ and compaction.strip():
514
+ parts = [compaction.strip()]
515
+ found = getattr(ctx, "history_block", None)
516
+ if isinstance(found, str) and found.strip():
517
+ parts.append(found.strip())
518
+ out.append(ChatMessage.from_system(
519
+ f"{HISTORY_OPEN}\n" + "\n\n".join(parts) + f"\n{HISTORY_CLOSE}"))
520
+ turns: List[ChatMessage] = []
521
+ for rm in getattr(ctx, "recent_messages", None) or []:
522
+ role, content = getattr(rm, "role", None), getattr(rm, "content", None)
523
+ if not isinstance(content, str) or not content.strip():
524
+ continue
525
+ meta = {RECORDED_KEY: chat_history_id}
526
+ if role == "user":
527
+ turns.append(ChatMessage.from_user(content, meta=meta))
528
+ elif role == "assistant":
529
+ turns.append(ChatMessage.from_assistant(content, meta=meta))
530
+ if last_k is not None and last_k >= 0:
531
+ turns = turns[-last_k:] if last_k else []
532
+ return out + turns
533
+
534
+ def count_messages(self, chat_history_id: str) -> int:
535
+ """How many messages Synap holds for the conversation (0 on SDK failure)."""
536
+ return run_async(self.acount_messages(chat_history_id))
537
+
538
+ async def acount_messages(self, chat_history_id: str) -> int:
539
+ ctx = await self._acontext(chat_history_id)
540
+ total = getattr(ctx, "total_message_count", 0) if ctx is not None else 0
541
+ return total if isinstance(total, int) else 0
542
+
543
+ def delete_messages(self, chat_history_id: str) -> None:
544
+ """No-op: Synap has no public delete API (warns once)."""
545
+ self._warn_delete()
546
+
547
+ def delete_all_messages(self) -> None:
548
+ """No-op: Synap has no public delete API (warns once)."""
549
+ self._warn_delete()
550
+
551
+ async def _acontext(self, chat_history_id: str) -> Any:
552
+ try:
553
+ return await self.sdk.conversation.context.get_context_for_prompt(
554
+ conversation_id=chat_history_id,
555
+ )
556
+ except Exception as exc: # noqa: BLE001 — read-side degrades gracefully
557
+ logger.error(
558
+ "SynapMemoryStore.retrieve_messages: get_context_for_prompt failed "
559
+ "conversation_id=%s error=%s",
560
+ chat_history_id, exc, exc_info=True,
561
+ )
562
+ return None
563
+
431
564
  # ── delete (no public Synap delete API) ────────────────────────────────────
432
565
 
433
566
  def delete_memory(self, memory_id: str, **kwargs: Any) -> None:
@@ -525,6 +658,19 @@ def _role_of(message: ChatMessage) -> str:
525
658
  return getattr(role, "value", role)
526
659
 
527
660
 
661
+ def _is_recorded(message: ChatMessage) -> bool:
662
+ meta = getattr(message, "meta", None)
663
+ return isinstance(meta, dict) and bool(meta.get(RECORDED_KEY))
664
+
665
+
666
+ def _mark_recorded(message: ChatMessage, conversation_id: str) -> None:
667
+ """Stamp a message so no store records it twice. `meta` is a plain dict on a
668
+ frozen dataclass, so this does not need to rebuild the message."""
669
+ meta = getattr(message, "meta", None)
670
+ if isinstance(meta, dict):
671
+ meta[RECORDED_KEY] = conversation_id
672
+
673
+
528
674
  def _message_id(resp: Any) -> Optional[str]:
529
675
  if isinstance(resp, dict):
530
676
  return resp.get("message_id")
@@ -7,7 +7,11 @@ failure policy (see ``store.py``).
7
7
  Semantics:
8
8
 
9
9
  - Accepts ``Document``s where ``content`` is the message text and
10
- ``meta["role"]`` is ``"user"`` or ``"assistant"``.
10
+ ``meta["role"]`` is ``"user"`` or ``"assistant"``, and/or ``ChatMessage``s on
11
+ ``messages``. Connect an agent's or generator's ``messages`` / ``replies`` to
12
+ ``messages`` and tool calls, tool results and reasoning reach Synap's stream
13
+ with the turns; a ``Document`` is text and role only, so it can carry none of
14
+ that.
11
15
  - Tracks ``written_count`` / ``failed_count`` / ``skipped_count`` separately and
12
16
  exposes all three plus ``first_error`` so downstream components can branch on
13
17
  partial failures.
@@ -84,9 +88,14 @@ class SynapMemoryWriter:
84
88
  skipped_count=int,
85
89
  first_error=Optional[str],
86
90
  )
87
- def run(self, documents: List[Document]) -> Dict[str, object]:
88
- messages: List[ChatMessage] = []
91
+ def run(
92
+ self,
93
+ documents: Optional[List[Document]] = None,
94
+ messages: Optional[List[ChatMessage]] = None,
95
+ ) -> Dict[str, object]:
96
+ chat_messages: List[ChatMessage] = list(messages or [])
89
97
  skipped = 0
98
+ documents = documents or []
90
99
  for doc in documents:
91
100
  role = doc.meta.get("role", "user")
92
101
  if role not in _VALID_ROLES:
@@ -98,16 +107,16 @@ class SynapMemoryWriter:
98
107
  )
99
108
  continue
100
109
  if role == "user":
101
- messages.append(ChatMessage.from_user(doc.content or ""))
110
+ chat_messages.append(ChatMessage.from_user(doc.content or ""))
102
111
  else:
103
- messages.append(ChatMessage.from_assistant(doc.content or ""))
112
+ chat_messages.append(ChatMessage.from_assistant(doc.content or ""))
104
113
 
105
114
  # add_memories raises SynapIntegrationError on 100% failure — propagate.
106
115
  results = (
107
116
  self.store.add_memories(
108
- messages=messages, conversation_id=self.conversation_id
117
+ messages=chat_messages, conversation_id=self.conversation_id
109
118
  )
110
- if messages
119
+ if chat_messages
111
120
  else []
112
121
  )
113
122
 
@@ -0,0 +1,215 @@
1
+ """The defaults, against Haystack's real experimental ``Agent``.
2
+
3
+ Nothing here calls ``listen()``. The SDK is a fake whose ``instance.is_listening``
4
+ starts False, the way a fresh SDK's does, and the generator is a scripted fake
5
+ that keeps every message list it was sent.
6
+ """
7
+
8
+ from types import SimpleNamespace
9
+ from typing import Any, Dict, List, Optional
10
+ from unittest.mock import AsyncMock
11
+
12
+ import pytest
13
+ from haystack import Document, component
14
+ from haystack.dataclasses import ChatMessage, ToolCall
15
+ from haystack.tools import Tool
16
+
17
+ from synap_haystack import SynapMemoryStore, SynapMemoryWriter
18
+
19
+ # The experimental Agent (and its chat_message_store slot) needs haystack-ai 2.x;
20
+ # haystack-experimental 0.19 does not import against haystack-ai 3.
21
+ try:
22
+ from haystack_experimental.components.agents import Agent
23
+ except ImportError:
24
+ Agent = None
25
+
26
+ needs_experimental_agent = pytest.mark.skipif(
27
+ Agent is None, reason="haystack_experimental's Agent needs haystack-ai 2.x")
28
+
29
+ COMPACTION = "## Conversation so far (compacted 1x)\nThe user is planning a trip to Lisbon."
30
+
31
+
32
+ class FakeInstance:
33
+ def __init__(self):
34
+ self.is_listening = False
35
+ self.listen_calls = 0
36
+ self.send_message = AsyncMock()
37
+ self.record_tool_call = AsyncMock()
38
+ self.record_tool_result = AsyncMock()
39
+ self.record_thinking = AsyncMock()
40
+
41
+ async def listen(self):
42
+ self.listen_calls += 1
43
+ self.is_listening = True
44
+
45
+
46
+ def make_sdk(context=None):
47
+ return SimpleNamespace(
48
+ instance=FakeInstance(),
49
+ instance_id="inst",
50
+ conversation=SimpleNamespace(
51
+ record_message=AsyncMock(return_value={"message_id": "m1"}),
52
+ context=SimpleNamespace(get_context_for_prompt=AsyncMock(return_value=context)),
53
+ ),
54
+ fetch=AsyncMock(return_value=SimpleNamespace(
55
+ facts=[], preferences=[], episodes=[], emotions=[], temporal_events=[], scope_map={})),
56
+ )
57
+
58
+
59
+ def ctx(compaction=COMPACTION, turns=(), total=0):
60
+ return SimpleNamespace(
61
+ context_block=compaction, history_block=None, available=True,
62
+ formatted_context="recent", total_message_count=total,
63
+ recent_messages=[SimpleNamespace(role=r, content=t) for r, t in turns],
64
+ )
65
+
66
+
67
+ @component
68
+ class ScriptedGenerator:
69
+ def __init__(self, script):
70
+ self.script = script
71
+ self.seen: List[Any] = []
72
+
73
+ @component.output_types(replies=List[ChatMessage])
74
+ def run(self, messages: List[ChatMessage], tools: Optional[Any] = None, **kwargs) -> Dict[str, Any]:
75
+ self.seen.append(list(messages))
76
+ return {"replies": [self.script.pop(0)]}
77
+
78
+
79
+ def lookup(city: str) -> str:
80
+ return f"{city}: sunny"
81
+
82
+
83
+ TOOL = Tool(name="lookup", description="Look a city up.",
84
+ parameters={"type": "object", "properties": {"city": {"type": "string"}},
85
+ "required": ["city"]},
86
+ function=lookup)
87
+
88
+
89
+ def tool_turn():
90
+ return [
91
+ ChatMessage.from_assistant("Let me check.", tool_calls=[
92
+ ToolCall(tool_name="lookup", arguments={"city": "Lisbon"}, id="c1")]),
93
+ ChatMessage.from_assistant("It is sunny in Lisbon."),
94
+ ]
95
+
96
+
97
+ def agent_for(script, **stores):
98
+ gen = ScriptedGenerator(script)
99
+ agent = Agent(chat_generator=gen, tools=[TOOL], system_prompt="Be brief.", **stores)
100
+ return gen, agent
101
+
102
+
103
+ def sent(sdk, event_type):
104
+ return [c.kwargs["content"] for c in sdk.instance.send_message.await_args_list
105
+ if c.kwargs["event_type"] == event_type]
106
+
107
+
108
+ def texts(messages):
109
+ return [m.text for m in messages]
110
+
111
+
112
+ def test_the_store_is_a_chat_message_store():
113
+ store = SynapMemoryStore(make_sdk(), user_id="u1")
114
+ for name in ("to_dict", "from_dict", "count_messages", "write_messages",
115
+ "delete_messages", "delete_all_messages", "retrieve_messages"):
116
+ assert callable(getattr(store, name))
117
+ if Agent is not None:
118
+ Agent(chat_generator=ScriptedGenerator([]), chat_message_store=store)
119
+
120
+
121
+ @needs_experimental_agent
122
+ def test_an_agent_loads_synaps_compaction_instead_of_a_ten_message_window():
123
+ sdk = make_sdk(ctx(turns=[("user", "synap kept q"), ("assistant", "synap kept a")]))
124
+ store = SynapMemoryStore(sdk, user_id="u1")
125
+ gen, agent = agent_for([ChatMessage.from_assistant("Book the 9am flight.")], chat_message_store=store)
126
+
127
+ agent.run(messages=[ChatMessage.from_user("what now?")],
128
+ chat_message_store_kwargs={"chat_history_id": "c1"})
129
+
130
+ seen = gen.seen[0]
131
+ assert seen[0].text == "Be brief."
132
+ assert "The user is planning a trip to Lisbon." in seen[1].text and seen[1].is_from("system")
133
+ assert texts(seen[2:]) == ["synap kept q", "synap kept a", "what now?"]
134
+
135
+
136
+ @needs_experimental_agent
137
+ def test_an_agent_before_the_first_compaction_gets_the_recent_turns():
138
+ sdk = make_sdk(ctx(compaction=None, turns=[("user", "q1"), ("assistant", "a1")]))
139
+ gen, agent = agent_for([ChatMessage.from_assistant("ok")],
140
+ chat_message_store=SynapMemoryStore(sdk, user_id="u1"))
141
+ agent.run(messages=[ChatMessage.from_user("q2")], chat_message_store_kwargs={"chat_history_id": "c1"})
142
+ assert texts(gen.seen[0][1:]) == ["q1", "a1", "q2"]
143
+
144
+
145
+ def test_last_k_keeps_the_compaction_and_trims_the_turns():
146
+ sdk = make_sdk(ctx(turns=[("user", "t1"), ("assistant", "t2"), ("user", "t3"), ("assistant", "t4")]))
147
+ msgs = SynapMemoryStore(sdk, user_id="u1").retrieve_messages("c1", last_k=2)
148
+ assert msgs[0].is_from("system") and texts(msgs[1:]) == ["t3", "t4"]
149
+
150
+
151
+ @needs_experimental_agent
152
+ def test_an_agent_run_records_each_turn_once_on_an_auto_opened_stream():
153
+ sdk = make_sdk(ctx(compaction=None, turns=[("user", "q1"), ("assistant", "a1")]))
154
+ store = SynapMemoryStore(sdk, user_id="u1", conversation_id="c1")
155
+ gen, agent = agent_for(tool_turn(), chat_message_store=store, memory_store=store)
156
+
157
+ agent.run(messages=[ChatMessage.from_user("weather in Lisbon?")],
158
+ chat_message_store_kwargs={"chat_history_id": "c1"})
159
+
160
+ assert sdk.instance.listen_calls == 1 # opened by the first turn, never by the caller
161
+ # Both slots got the same messages, history included: each turn goes out once,
162
+ # and the retrieved history (already in Synap) not at all.
163
+ assert sent(sdk, "user_message") == ["weather in Lisbon?"]
164
+ assert sent(sdk, "assistant_message") == ["It is sunny in Lisbon."] # the tool-call lap has no words to record
165
+ sdk.conversation.record_message.assert_not_awaited()
166
+ sdk.instance.record_tool_call.assert_awaited_once()
167
+ sdk.instance.record_tool_result.assert_awaited_once()
168
+
169
+
170
+ def test_a_message_with_no_words_is_not_an_empty_turn():
171
+ sdk = make_sdk()
172
+ store = SynapMemoryStore(sdk, user_id="u1")
173
+ results = store.add_memories(
174
+ messages=[ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="lookup", arguments={}, id="c1")])],
175
+ conversation_id="c1")
176
+ assert results == [{"role": "assistant", "status": "skipped", "reason": "no text"}]
177
+ sdk.instance.send_message.assert_not_awaited()
178
+ sdk.conversation.record_message.assert_not_awaited()
179
+ sdk.instance.record_tool_call.assert_awaited_once()
180
+
181
+
182
+ def test_count_and_delete():
183
+ store = SynapMemoryStore(make_sdk(ctx(total=7)), user_id="u1")
184
+ assert store.count_messages("c1") == 7
185
+ store.delete_messages("c1")
186
+ store.delete_all_messages() # warn once, no-op
187
+
188
+
189
+ def test_retrieve_degrades_to_nothing_on_an_outage():
190
+ sdk = make_sdk()
191
+ sdk.conversation.context.get_context_for_prompt.side_effect = RuntimeError("down")
192
+ store = SynapMemoryStore(sdk, user_id="u1")
193
+ assert store.retrieve_messages("c1") == []
194
+ assert store.count_messages("c1") == 0
195
+
196
+
197
+ def test_the_writer_keeps_tool_calls_and_results_when_given_messages():
198
+ sdk = make_sdk()
199
+ writer = SynapMemoryWriter(store=SynapMemoryStore(sdk, user_id="u1"), conversation_id="c1")
200
+ result = writer.run(messages=[
201
+ ChatMessage.from_user("weather?"),
202
+ ChatMessage.from_assistant("checking", tool_calls=[ToolCall(tool_name="lookup", arguments={"city": "X"}, id="c1")]),
203
+ ChatMessage.from_tool("sunny", origin=ToolCall(tool_name="lookup", arguments={"city": "X"}, id="c1")),
204
+ ])
205
+ assert result["written_count"] == 1 # the question; "checking" narrates a tool call
206
+ sdk.instance.record_tool_call.assert_awaited_once()
207
+ sdk.instance.record_tool_result.assert_awaited_once()
208
+
209
+
210
+ def test_the_writer_still_takes_documents():
211
+ sdk = make_sdk()
212
+ writer = SynapMemoryWriter(store=SynapMemoryStore(sdk, user_id="u1"), conversation_id="c1")
213
+ result = writer.run(documents=[Document(content="hi", meta={"role": "user"})])
214
+ assert result["written_count"] == 1
215
+ assert sent(sdk, "user_message") == ["hi"]
@@ -0,0 +1,103 @@
1
+ """The hooks, against haystack-ai 3's real ``Agent``.
2
+
3
+ Nothing here calls ``listen()``: the SDK is a fake whose ``instance.is_listening``
4
+ starts False.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import pytest
10
+ from haystack import component
11
+ from haystack.dataclasses import ChatMessage, ToolCall
12
+ from haystack.tools import Tool
13
+
14
+ from synap_haystack import SynapMemoryStore
15
+
16
+ from .test_default_path import COMPACTION, ctx, make_sdk, sent, texts, TOOL, tool_turn, ScriptedGenerator
17
+
18
+ Agent = pytest.importorskip("haystack.components.agents", exc_type=ImportError).Agent
19
+ hooks_mod = pytest.importorskip("haystack.hooks", exc_type=ImportError)
20
+ from synap_haystack import SynapHistoryHook, SynapRecordHook # noqa: E402
21
+
22
+ OLD = [ChatMessage.from_user("old q1"), ChatMessage.from_assistant("old a1"),
23
+ ChatMessage.from_user("old q2"), ChatMessage.from_assistant("old a2")]
24
+
25
+
26
+ def agent_for(script, store, with_record=True):
27
+ gen = ScriptedGenerator(script)
28
+ hooks = {"before_llm": [SynapHistoryHook(store, "c1")]}
29
+ if with_record:
30
+ hooks["after_run"] = [SynapRecordHook(store, "c1")]
31
+ return gen, Agent(chat_generator=gen, tools=[TOOL], system_prompt="Be brief.", hooks=hooks)
32
+
33
+
34
+ def test_the_model_sees_synaps_compaction_in_place_of_the_earlier_turns():
35
+ sdk = make_sdk(ctx(turns=[("user", "synap kept q"), ("assistant", "synap kept a")]))
36
+ gen, agent = agent_for([ChatMessage.from_assistant("Book the 9am flight.")],
37
+ SynapMemoryStore(sdk, user_id="u1"))
38
+
39
+ agent.run(messages=[*OLD, ChatMessage.from_user("what now?")])
40
+
41
+ seen = gen.seen[0]
42
+ assert seen[0].text == "Be brief."
43
+ assert seen[1].is_from("system") and "The user is planning a trip to Lisbon." in seen[1].text
44
+ assert texts(seen[2:]) == ["synap kept q", "synap kept a", "what now?"]
45
+ assert not any("old q" in (m.text or "") for m in seen)
46
+
47
+
48
+ def test_the_messages_are_untouched_until_synap_has_compacted():
49
+ sdk = make_sdk(ctx(compaction=None))
50
+ gen, agent = agent_for([ChatMessage.from_assistant("fine")], SynapMemoryStore(sdk, user_id="u1"))
51
+ agent.run(messages=[*OLD, ChatMessage.from_user("what now?")])
52
+ assert texts(gen.seen[0][1:]) == ["old q1", "old a1", "old q2", "old a2", "what now?"]
53
+
54
+
55
+ def test_a_synap_outage_does_not_stop_the_run():
56
+ sdk = make_sdk()
57
+ sdk.conversation.context.get_context_for_prompt.side_effect = RuntimeError("down")
58
+ gen, agent = agent_for([ChatMessage.from_assistant("fine")], SynapMemoryStore(sdk, user_id="u1"))
59
+ agent.run(messages=[ChatMessage.from_user("hi")])
60
+ assert gen.seen[0][-1].text == "hi"
61
+
62
+
63
+ def test_the_tool_loop_stays_paired_on_every_lap():
64
+ sdk = make_sdk(ctx())
65
+ gen, agent = agent_for(tool_turn(), SynapMemoryStore(sdk, user_id="u1"), with_record=False)
66
+ agent.run(messages=[*OLD, ChatMessage.from_user("weather?")])
67
+ second = gen.seen[1]
68
+ assert any(m.tool_call_results for m in second)
69
+ assert not any("old q" in (m.text or "") for m in second)
70
+ assert sdk.conversation.context.get_context_for_prompt.await_count == 2 # per model call
71
+
72
+
73
+ def test_a_run_is_recorded_once_on_an_auto_opened_stream():
74
+ sdk = make_sdk(ctx(compaction=None))
75
+ gen, agent = agent_for(tool_turn(), SynapMemoryStore(sdk, user_id="u1"))
76
+
77
+ agent.run(messages=[ChatMessage.from_user("weather in Lisbon?")])
78
+
79
+ assert sdk.instance.listen_calls == 1 # opened by the first turn, never by the caller
80
+ assert sent(sdk, "user_message") == ["weather in Lisbon?"]
81
+ assert sent(sdk, "assistant_message") == ["It is sunny in Lisbon."] # not the tool-call lap
82
+ sdk.conversation.record_message.assert_not_awaited()
83
+ sdk.instance.record_tool_call.assert_awaited_once()
84
+ sdk.instance.record_tool_result.assert_awaited_once()
85
+
86
+
87
+ def test_history_the_hook_loaded_is_not_recorded_again():
88
+ sdk = make_sdk(ctx(turns=[("user", "synap kept q"), ("assistant", "synap kept a")]))
89
+ gen, agent = agent_for([ChatMessage.from_assistant("Book it.")], SynapMemoryStore(sdk, user_id="u1"))
90
+ agent.run(messages=[ChatMessage.from_user("what now?")])
91
+ assert sent(sdk, "user_message") == ["what now?"]
92
+ assert sent(sdk, "assistant_message") == ["Book it."]
93
+
94
+
95
+ def test_the_hooks_serialize():
96
+ store = SynapMemoryStore(make_sdk(), user_id="u1")
97
+ data = SynapHistoryHook(store, "c1").to_dict()
98
+ assert data["init_parameters"]["chat_history_id"] == "c1"
99
+
100
+
101
+ def test_the_hooks_need_a_conversation():
102
+ with pytest.raises(ValueError, match="chat_history_id"):
103
+ SynapHistoryHook(SynapMemoryStore(make_sdk(), user_id="u1"), "")
@@ -25,9 +25,14 @@ from synap_integrations_common import SynapIntegrationError
25
25
 
26
26
 
27
27
  def _make_response(formatted: str | None, available: bool):
28
+ """`formatted` is the compaction. `formatted_context` is what the server sends
29
+ beside it: the compaction AND the recent turns, which the pipeline already
30
+ passes, so it must never reach the prompt."""
28
31
  resp = MagicMock()
29
32
  resp.available = available
30
- resp.formatted_context = formatted
33
+ resp.context_block = formatted
34
+ resp.history_block = None
35
+ resp.formatted_context = f"{formatted}\nRECENT-TURNS-DUPLICATE" if formatted else None
31
36
  return resp
32
37
 
33
38
 
@@ -39,6 +44,19 @@ def _fake_sdk(formatted: str | None = "Customer is on Pro plan.", available: boo
39
44
  return sdk
40
45
 
41
46
 
47
+ class TestNoDuplicatedTurns:
48
+ def test_emits_the_compaction_and_not_the_recent_turns(self):
49
+ sdk = _fake_sdk(formatted="## Conversation so far (compacted 1x)\nThey asked about refunds.")
50
+ out = SynapShortTermContext(sdk, "conv_abc", system="Be brief.").run()["synap_st"]
51
+ assert "They asked about refunds." in out
52
+ assert "RECENT-TURNS-DUPLICATE" not in out
53
+
54
+ def test_no_compaction_yet_emits_only_the_system_text(self):
55
+ sdk = _fake_sdk(formatted=None, available=True)
56
+ sdk.conversation.context.get_context_for_prompt.return_value.formatted_context = "recent"
57
+ assert SynapShortTermContext(sdk, "conv_abc", system="Be brief.").run()["synap_st"] == "Be brief."
58
+
59
+
42
60
  # ---------------------------------------------------------------------------
43
61
  # Construction validation
44
62
  # ---------------------------------------------------------------------------