maximem-synap-semantic-kernel 0.2.2__tar.gz → 0.2.3__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 (17) hide show
  1. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/PKG-INFO +2 -2
  2. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/PKG-INFO +2 -2
  3. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/SOURCES.txt +2 -0
  4. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/requires.txt +1 -1
  5. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/pyproject.toml +2 -2
  6. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/__init__.py +11 -3
  7. maximem_synap_semantic_kernel-0.2.3/synap_semantic_kernel/history.py +98 -0
  8. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/short_term.py +19 -4
  9. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/stream.py +46 -33
  10. maximem_synap_semantic_kernel-0.2.3/tests/test_history.py +261 -0
  11. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_stream.py +6 -1
  12. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/dependency_links.txt +0 -0
  13. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/maximem_synap_semantic_kernel.egg-info/top_level.txt +0 -0
  14. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/setup.cfg +0 -0
  15. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/synap_semantic_kernel/plugin.py +0 -0
  16. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_plugin.py +0 -0
  17. {maximem_synap_semantic_kernel-0.2.2 → maximem_synap_semantic_kernel-0.2.3}/tests/test_short_term.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maximem-synap-semantic-kernel
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Synap memory integration for Microsoft Semantic Kernel
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: semantic-kernel>=1.37
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-semantic-kernel
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Synap memory integration for Microsoft Semantic Kernel
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: semantic-kernel>=1.37
10
10
  Provides-Extra: dev
11
11
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -5,9 +5,11 @@ maximem_synap_semantic_kernel.egg-info/dependency_links.txt
5
5
  maximem_synap_semantic_kernel.egg-info/requires.txt
6
6
  maximem_synap_semantic_kernel.egg-info/top_level.txt
7
7
  synap_semantic_kernel/__init__.py
8
+ synap_semantic_kernel/history.py
8
9
  synap_semantic_kernel/plugin.py
9
10
  synap_semantic_kernel/short_term.py
10
11
  synap_semantic_kernel/stream.py
12
+ tests/test_history.py
11
13
  tests/test_plugin.py
12
14
  tests/test_short_term.py
13
15
  tests/test_stream.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
  semantic-kernel>=1.37
4
4
 
5
5
  [dev]
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "maximem-synap-semantic-kernel"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Synap memory integration for Microsoft Semantic Kernel"
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", "semantic-kernel>=1.37"]
11
+ dependencies = ["maximem-synap>=0.5.3", "maximem-synap-integrations-common>=0.2.3", "semantic-kernel>=1.37"]
12
12
 
13
13
  [project.optional-dependencies]
14
14
  dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
@@ -8,11 +8,18 @@ Three plug-in points:
8
8
  message that carries Synap's recent-conversation block.
9
9
  3. **Report the turn** — :class:`SynapAgentThread` reports user and
10
10
  assistant turns, and :func:`attach_synap_filters` reports the tool call
11
- and its result. Turns go out on the Synap stream when one is open and
12
- fall back to ``sdk.conversation.record_message`` when it is not; never
13
- both.
11
+ and its result. The first turn opens the Synap gRPC stream, and turns go
12
+ out on it; they fall back to ``sdk.conversation.record_message`` only if
13
+ the stream cannot open, never both. ``SYNAP_AUTO_LISTEN=false`` keeps
14
+ everything on REST.
15
+
16
+ :class:`SynapAgentThread` also sends the model Synap's compaction in place of
17
+ the earlier turns once Synap has compacted the conversation
18
+ (:class:`SynapChatHistoryReducer`). Semantic Kernel's own reducers stay in
19
+ place and are the fallback when Synap has nothing.
14
20
  """
15
21
 
22
+ from synap_semantic_kernel.history import SynapChatHistoryReducer
16
23
  from synap_semantic_kernel.plugin import SynapPlugin
17
24
  from synap_semantic_kernel.short_term import synap_st_chat_message
18
25
  from synap_semantic_kernel.stream import (
@@ -25,6 +32,7 @@ __all__ = [
25
32
  "SynapPlugin",
26
33
  "synap_st_chat_message",
27
34
  "SynapAgentThread",
35
+ "SynapChatHistoryReducer",
28
36
  "attach_synap_filters",
29
37
  "synap_auto_function_filter",
30
38
  ]
@@ -0,0 +1,98 @@
1
+ """Put Synap's compaction where Semantic Kernel's chat history goes.
2
+
3
+ Semantic Kernel's answer to a long chat is a :class:`ChatHistoryReducer`: a
4
+ ``ChatHistory`` with a ``reduce()`` that truncates or summarizes it in place.
5
+ :class:`SynapChatHistoryReducer` is one whose ``reduce()`` asks Synap for the
6
+ conversation as Synap holds it and, once a compaction exists, replaces every
7
+ turn before the current one with the compaction block and the turns after it
8
+ (:func:`synap_integrations_common.splice_history`). With no compaction yet, or
9
+ on any error, it changes nothing: the history stays as the framework has it.
10
+
11
+ A reducer only runs when somebody calls ``reduce()``. Semantic Kernel's agents
12
+ never do: ``ChatCompletionAgent`` builds the model's input from
13
+ ``thread.get_messages()`` and does not reduce, and ``ChatHistoryAgentThread``
14
+ appends with the synchronous ``add_message``, which skips ``auto_reduce``.
15
+ :class:`~synap_semantic_kernel.SynapAgentThread` therefore calls ``reduce()``
16
+ itself, at the top of ``get_messages()``, which is the one place every agent
17
+ call reads the history.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any, List, Optional, Sequence
24
+
25
+ from pydantic import Field
26
+ from semantic_kernel.contents import AuthorRole, ChatMessageContent, TextContent
27
+ from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
28
+
29
+ from synap_integrations_common import fetch_compacted_history, splice_history
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _SYSTEM_ROLES = (AuthorRole.SYSTEM, AuthorRole.DEVELOPER)
34
+
35
+
36
+ def _role(message: ChatMessageContent) -> str:
37
+ if message.role in _SYSTEM_ROLES:
38
+ return "system"
39
+ if message.role == AuthorRole.USER:
40
+ return "user"
41
+ if message.role == AuthorRole.ASSISTANT:
42
+ return "assistant"
43
+ return "other"
44
+
45
+
46
+ def _text(message: ChatMessageContent) -> str:
47
+ """Every text block, not just the first (``.content`` drops the rest)."""
48
+ parts = [item.text for item in message.items if isinstance(item, TextContent) and item.text]
49
+ return "".join(parts) if parts else (message.content or "")
50
+
51
+
52
+ def _make(role: str, text: str) -> ChatMessageContent:
53
+ author = {"system": AuthorRole.SYSTEM, "assistant": AuthorRole.ASSISTANT}.get(role, AuthorRole.USER)
54
+ return ChatMessageContent(role=author, content=text)
55
+
56
+
57
+ async def splice_messages(
58
+ sdk: Any, conversation_id: str, messages: Sequence[ChatMessageContent],
59
+ ) -> Optional[List[ChatMessageContent]]:
60
+ """The messages with Synap's compaction in place of the earlier turns, or
61
+ None when Synap has nothing to replace them with. Never raises."""
62
+ history = await fetch_compacted_history(sdk, conversation_id)
63
+ if history is None:
64
+ return None
65
+ try:
66
+ return splice_history(
67
+ messages, history, role_of=_role, text_of=_text, make_message=_make,
68
+ )
69
+ except Exception: # noqa: BLE001 — the framework's own history still stands
70
+ logger.warning("synap semantic-kernel: splice failed (conversation_id=%s)",
71
+ conversation_id, exc_info=True)
72
+ return None
73
+
74
+
75
+ class SynapChatHistoryReducer(ChatHistoryReducer):
76
+ """A ``ChatHistory`` that swaps in Synap's compaction when reduced.
77
+
78
+ Use it anywhere Semantic Kernel takes a ``ChatHistory``; call
79
+ ``await history.reduce()`` before a model call. :class:`SynapAgentThread`
80
+ does that for you.
81
+
82
+ ``reduce()`` returns the history when it changed it and ``None`` when it
83
+ did not, as Semantic Kernel's own reducers do.
84
+ """
85
+
86
+ sdk: Any = Field(exclude=True)
87
+ conversation_id: str
88
+ target_count: int = Field(default=1, gt=0)
89
+
90
+ async def reduce(self) -> Optional["SynapChatHistoryReducer"]:
91
+ spliced = await splice_messages(self.sdk, self.conversation_id, self.messages)
92
+ if spliced is None:
93
+ return None
94
+ self.messages = spliced
95
+ return self
96
+
97
+
98
+ __all__ = ["SynapChatHistoryReducer", "splice_messages"]
@@ -18,6 +18,12 @@ Quality contract identical to the LangGraph adapter:
18
18
  - Empty ST is a no-op — never wipes the user's system text.
19
19
  - Returns ``None`` when both ST and ``system`` are empty so callers can
20
20
  skip the system message cleanly.
21
+
22
+ The ST block carries the recent turns. It is for a call that sends the model
23
+ no history of its own; next to one, those turns would go out twice. Pass the
24
+ history you are sending to the factory and the block is left out when it holds
25
+ any turns. A :class:`SynapAgentThread` already sends Synap's compaction in
26
+ place of the earlier turns, so it needs neither.
21
27
  """
22
28
 
23
29
  from __future__ import annotations
@@ -25,7 +31,7 @@ from __future__ import annotations
25
31
  import logging
26
32
  from typing import Awaitable, Callable, Literal, Optional
27
33
 
28
- from semantic_kernel.contents import AuthorRole, ChatMessageContent
34
+ from semantic_kernel.contents import AuthorRole, ChatHistory, ChatMessageContent
29
35
  from maximem_synap import MaximemSynapSDK
30
36
  from synap_integrations_common import (
31
37
  SynapIntegrationError,
@@ -119,7 +125,7 @@ def synap_st_chat_message(
119
125
  preamble_open: Optional[str] = _DEFAULT_OPEN,
120
126
  preamble_close: Optional[str] = _DEFAULT_CLOSE,
121
127
  on_error: _OnError = "fallback",
122
- ) -> Callable[[], Awaitable[Optional[ChatMessageContent]]]:
128
+ ) -> Callable[..., Awaitable[Optional[ChatMessageContent]]]:
123
129
  """Return an async factory producing a combined system ``ChatMessageContent``.
124
130
 
125
131
  Call the returned coroutine to get a
@@ -127,6 +133,10 @@ def synap_st_chat_message(
127
133
  is ``<ST block in preamble tags>\\n\\n<system>``. Returns ``None``
128
134
  when both inputs are empty.
129
135
 
136
+ The coroutine takes an optional ``chat_history``: the history you are about
137
+ to send. When it holds user or assistant turns the ST block (which carries
138
+ the recent turns) is left out and only ``system`` is returned.
139
+
130
140
  Args:
131
141
  sdk: Initialised :class:`MaximemSynapSDK`.
132
142
  conversation_id: Synap conversation ID. **Required.**
@@ -161,8 +171,13 @@ def synap_st_chat_message(
161
171
  sdk, conversation_id, style, on_error, "synap_st_chat_message"
162
172
  )
163
173
 
164
- async def _factory() -> Optional[ChatMessageContent]:
165
- st_block = await _fetch_st_block(
174
+ async def _factory(
175
+ chat_history: Optional[ChatHistory] = None,
176
+ ) -> Optional[ChatMessageContent]:
177
+ sends_turns = chat_history is not None and any(
178
+ m.role in (AuthorRole.USER, AuthorRole.ASSISTANT) for m in chat_history.messages
179
+ )
180
+ st_block = "" if sends_turns else await _fetch_st_block(
166
181
  sdk,
167
182
  conversation_id,
168
183
  style,
@@ -18,8 +18,13 @@ two places, and a caller wires both:
18
18
  ``user_message`` and ``assistant_message`` from the stream itself
19
19
  (``grpc/servicer.py``, ``_persist_message``), so reporting a turn AND calling
20
20
  ``sdk.conversation.record_message`` writes it twice and extracts it twice.
21
- :func:`report_turn` returns whether it actually went out, which is what makes
22
- the choice decidable rather than guessed.
21
+ :func:`record_turn` makes that choice, and the first turn opens the stream
22
+ when none is running (``SYNAP_AUTO_LISTEN=false`` turns that off).
23
+
24
+ **Synap's compaction replaces Semantic Kernel's.** The thread is also where the
25
+ agent reads its history (``get_messages``), so once Synap has compacted the
26
+ conversation the model is sent the compaction block and the turns after it in
27
+ place of the earlier turns (see :mod:`synap_semantic_kernel.history`).
23
28
 
24
29
  Tool calls, tool results and reasoning are anticipation hints. The server does
25
30
  not persist them, so they have no REST fallback and no double-write to avoid.
@@ -34,7 +39,7 @@ from __future__ import annotations
34
39
 
35
40
  import json
36
41
  import logging
37
- from typing import Any, Awaitable, Callable, Optional
42
+ from typing import Any, AsyncIterable, Awaitable, Callable, Optional
38
43
 
39
44
  from semantic_kernel.agents import ChatHistoryAgentThread
40
45
  from semantic_kernel.contents import (
@@ -50,15 +55,17 @@ from semantic_kernel.contents.kernel_content import KernelContent
50
55
  from semantic_kernel.filters import FilterTypes
51
56
 
52
57
  from maximem_synap import MaximemSynapSDK
53
- from synap_integrations_common import wrap_sdk_errors_async
58
+ from synap_integrations_common import is_synap_history
54
59
  from synap_integrations_common.stream_events import (
55
60
  end_session,
61
+ record_turn,
56
62
  report_reasoning,
57
63
  report_tool_call,
58
64
  report_tool_result,
59
- report_turn,
60
65
  )
61
66
 
67
+ from synap_semantic_kernel.history import SynapChatHistoryReducer, splice_messages
68
+
62
69
  logger = logging.getLogger(__name__)
63
70
 
64
71
 
@@ -173,7 +180,8 @@ def _carries_tool_traffic(message: ChatMessageContent) -> bool:
173
180
 
174
181
 
175
182
  class SynapAgentThread(ChatHistoryAgentThread):
176
- """A ``ChatHistoryAgentThread`` that reports each turn to Synap.
183
+ """A ``ChatHistoryAgentThread`` that reports each turn to Synap and sends
184
+ the model Synap's compaction once there is one.
177
185
 
178
186
  Drop it in where you would pass a plain thread::
179
187
 
@@ -193,8 +201,12 @@ class SynapAgentThread(ChatHistoryAgentThread):
193
201
  a B2C instance (user_context_isolation=equals_customer): the server
194
202
  rejects a call carrying one with HTTP 400. Leave it unset there.
195
203
  Empty string means customer-less.
196
- chat_history / thread_id: passed straight through to
197
- :class:`ChatHistoryAgentThread`.
204
+ chat_history: the thread's history. Left unset it is a
205
+ :class:`SynapChatHistoryReducer`, which Synap's compaction replaces
206
+ turns in. Pass your own (a Semantic Kernel reducer, say) and it
207
+ stays as it is: the model is still sent Synap's compaction in place
208
+ of the earlier turns, but your history is not rewritten.
209
+ thread_id: passed straight through to :class:`ChatHistoryAgentThread`.
198
210
  """
199
211
 
200
212
  def __init__(
@@ -213,6 +225,8 @@ class SynapAgentThread(ChatHistoryAgentThread):
213
225
  raise ValueError("SynapAgentThread requires a non-empty conversation_id")
214
226
  if not user_id:
215
227
  raise ValueError("SynapAgentThread requires a non-empty user_id")
228
+ if chat_history is None:
229
+ chat_history = SynapChatHistoryReducer(sdk=sdk, conversation_id=conversation_id)
216
230
  super().__init__(chat_history=chat_history, thread_id=thread_id)
217
231
  self._sdk = sdk
218
232
  self._synap_conversation_id = conversation_id
@@ -240,6 +254,23 @@ class SynapAgentThread(ChatHistoryAgentThread):
240
254
  self._synap_conversation_id, exc, exc_info=True,
241
255
  )
242
256
 
257
+ async def get_messages(self) -> AsyncIterable[ChatMessageContent]:
258
+ """The history the agent sends the model: Synap's compaction in place of
259
+ the turns before the current one, once Synap has compacted the
260
+ conversation; the framework's own messages otherwise."""
261
+ ours = isinstance(self._chat_history, SynapChatHistoryReducer)
262
+ if ours:
263
+ await self._chat_history.reduce() # never raises: see splice_messages
264
+ # Raises on a deleted thread, as the base class does.
265
+ messages = [message async for message in super().get_messages()]
266
+ if not ours:
267
+ spliced = await splice_messages(
268
+ self._sdk, self._synap_conversation_id, messages)
269
+ if spliced is not None:
270
+ messages = spliced
271
+ for message in messages:
272
+ yield message
273
+
243
274
  async def _delete(self) -> None:
244
275
  """Deleting the thread ends the Synap session for its conversation."""
245
276
  await end_session(self._sdk, self._synap_conversation_id)
@@ -268,40 +299,22 @@ class SynapAgentThread(ChatHistoryAgentThread):
268
299
  # so reporting it here would extract the same words twice.
269
300
  return
270
301
  text = _turn_text(message)
271
- if not text:
302
+ if not text or is_synap_history(text):
272
303
  return
273
304
  await self._record_turn(
274
305
  "user" if role == AuthorRole.USER else "assistant", text
275
306
  )
276
307
 
277
308
  async def _record_turn(self, role: str, content: str) -> None:
278
- # ⚠ Stream first, REST only for what the stream did not take, never
279
- # both: the server persists the turn from the stream itself, so a
280
- # `record_message` on top of a delivered event stores it twice and
281
- # extracts it twice.
282
- if await report_turn(
309
+ # Stream first (opened if need be), REST only if the stream cannot
310
+ # carry it, never both: the server persists the turn from the stream
311
+ # itself, so a `record_message` on top of a delivered event stores it
312
+ # twice and extracts it twice.
313
+ await record_turn(
283
314
  self._sdk, role=role, content=content,
284
315
  conversation_id=self._synap_conversation_id,
285
316
  user_id=self._synap_user_id, customer_id=self._synap_customer_id,
286
- ):
287
- return
288
- # The one swallow point is `_on_new_message`; everything below it is
289
- # free to raise, and `wrap_sdk_errors_async` adds the operation name
290
- # and the ids to the log on the way past.
291
- async with wrap_sdk_errors_async(
292
- "semantic_kernel.record_turn",
293
- logger,
294
- conversation_id=self._synap_conversation_id,
295
- user_id=self._synap_user_id,
296
- role=role,
297
- ):
298
- await self._sdk.conversation.record_message(
299
- conversation_id=self._synap_conversation_id,
300
- role=role,
301
- content=content,
302
- user_id=self._synap_user_id,
303
- customer_id=self._synap_customer_id,
304
- )
317
+ )
305
318
 
306
319
 
307
320
  # ─── the tool call and its result ───────────────────────────────────────
@@ -0,0 +1,261 @@
1
+ """Synap's compaction reaches the model through Semantic Kernel's thread.
2
+
3
+ These run a real ``ChatCompletionAgent`` on a service that records exactly the
4
+ chat history it was sent, so they prove the framework reads the history through
5
+ the seam, not that a stand-in does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from types import SimpleNamespace
11
+ from typing import Any, List
12
+ from unittest.mock import AsyncMock
13
+
14
+ import pytest
15
+ from semantic_kernel.agents import ChatCompletionAgent
16
+ from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
17
+ from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
18
+ from semantic_kernel.contents import AuthorRole, ChatHistory, ChatMessageContent
19
+ from semantic_kernel.contents.history_reducer.chat_history_truncation_reducer import (
20
+ ChatHistoryTruncationReducer,
21
+ )
22
+
23
+ from synap_integrations_common import set_auto_listen
24
+ from synap_semantic_kernel import (
25
+ SynapAgentThread,
26
+ SynapChatHistoryReducer,
27
+ synap_st_chat_message,
28
+ )
29
+
30
+ BLOCK = "## Conversation so far (compacted 2026-10-22)\nThe user is planning a trip to Lisbon."
31
+
32
+
33
+ class RecordingService(ChatCompletionClientBase):
34
+ """Answers "ok" and keeps what it was sent."""
35
+
36
+ ai_model_id: str = "fake"
37
+ sent: List[List[ChatMessageContent]] = []
38
+
39
+ async def _inner_get_chat_message_contents(self, chat_history, settings):
40
+ self.sent.append(list(chat_history.messages))
41
+ return [ChatMessageContent(role=AuthorRole.ASSISTANT, content="ok")]
42
+
43
+ def get_prompt_execution_settings_class(self):
44
+ return PromptExecutionSettings
45
+
46
+ @property
47
+ def last(self) -> str:
48
+ return "\n".join(f"{m.role.value}:{m.content}" for m in self.sent[-1])
49
+
50
+
51
+ class FakeInstance:
52
+ """Behaves like sdk.instance: listen() flips is_listening on."""
53
+
54
+ def __init__(self):
55
+ self.is_listening = False
56
+ self.listen_calls = 0
57
+ self.send_message = AsyncMock()
58
+ self.record_tool_call = AsyncMock()
59
+ self.record_tool_result = AsyncMock()
60
+ self.record_thinking = AsyncMock()
61
+ self.end_session = AsyncMock()
62
+ self._controller = SimpleNamespace(_transport=object())
63
+
64
+ async def listen(self):
65
+ self.listen_calls += 1
66
+ self.is_listening = True
67
+
68
+ async def stop_listening(self):
69
+ self.is_listening = False
70
+
71
+
72
+ def make_sdk(compaction=True, recent=(("user", "flights?"), ("assistant", "TAP at 9am"))):
73
+ ctx = SimpleNamespace(context_block=None, history_block=None, recent_messages=[], compaction_id=None)
74
+ if compaction:
75
+ ctx = SimpleNamespace(
76
+ context_block=BLOCK, history_block=None, compaction_id="cmp_1",
77
+ recent_messages=[SimpleNamespace(role=r, content=c) for r, c in recent],
78
+ )
79
+ return SimpleNamespace(
80
+ instance=FakeInstance(),
81
+ _initialized=True,
82
+ conversation=SimpleNamespace(
83
+ record_message=AsyncMock(),
84
+ context=SimpleNamespace(get_context_for_prompt=AsyncMock(return_value=ctx)),
85
+ ),
86
+ )
87
+
88
+
89
+ def earlier_turns() -> ChatHistory:
90
+ return ChatHistory(messages=[
91
+ ChatMessageContent(role=AuthorRole.USER, content="I want to visit Portugal"),
92
+ ChatMessageContent(role=AuthorRole.ASSISTANT, content="Great, when?"),
93
+ ChatMessageContent(role=AuthorRole.USER, content="In May, for a week"),
94
+ ChatMessageContent(role=AuthorRole.ASSISTANT, content="Lisbon is lovely in May."),
95
+ ])
96
+
97
+
98
+ def agent_on(service):
99
+ return ChatCompletionAgent(service=service, name="travel", instructions="You are a travel agent.")
100
+
101
+
102
+ def thread_for(sdk, **kw):
103
+ return SynapAgentThread(sdk, conversation_id="conv-1", user_id="u1", customer_id="c1", **kw)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # The seam, driven by the real framework
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ class TestCompactionReachesTheModel:
112
+ @pytest.mark.asyncio
113
+ async def test_the_default_thread_sends_the_compaction_in_place_of_earlier_turns(self):
114
+ svc = RecordingService(sent=[])
115
+ thread = thread_for(make_sdk(), chat_history=None)
116
+ for m in earlier_turns().messages:
117
+ await thread.on_new_message(m)
118
+ await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
119
+ seen = svc.last
120
+ assert "The user is planning a trip to Lisbon" in seen
121
+ assert "<synap_conversation_history>" in seen
122
+ assert "I want to visit Portugal" not in seen
123
+ assert "In May, for a week" not in seen
124
+ assert "TAP at 9am" in seen
125
+ assert "and the hotel?" in seen
126
+ # the agent's own instructions are still first
127
+ assert svc.sent[-1][0].content == "You are a travel agent."
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_the_default_history_is_the_synap_reducer(self):
131
+ assert isinstance(thread_for(make_sdk())._chat_history, SynapChatHistoryReducer)
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_messages_are_untouched_when_synap_has_no_compaction(self):
135
+ svc = RecordingService(sent=[])
136
+ thread = thread_for(make_sdk(compaction=False))
137
+ for m in earlier_turns().messages:
138
+ await thread.on_new_message(m)
139
+ await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
140
+ assert "I want to visit Portugal" in svc.last
141
+ assert "In May, for a week" in svc.last
142
+ assert "synap_conversation_history" not in svc.last
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_messages_are_untouched_when_the_sdk_fails(self):
146
+ svc = RecordingService(sent=[])
147
+ sdk = make_sdk()
148
+ sdk.conversation.context.get_context_for_prompt = AsyncMock(side_effect=RuntimeError("down"))
149
+ thread = thread_for(sdk)
150
+ for m in earlier_turns().messages:
151
+ await thread.on_new_message(m)
152
+ await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
153
+ assert "In May, for a week" in svc.last
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_the_compaction_is_never_recorded_back_as_a_turn(self):
157
+ svc = RecordingService(sent=[])
158
+ sdk = make_sdk()
159
+ thread = thread_for(sdk)
160
+ for m in earlier_turns().messages:
161
+ await thread.on_new_message(m)
162
+ sdk.instance.send_message.reset_mock()
163
+ await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
164
+ await agent_on(svc).get_response(messages="and the car?", thread=thread)
165
+ for call in sdk.instance.send_message.await_args_list:
166
+ assert "synap_conversation_history" not in call.kwargs["content"]
167
+ assert svc.last.count("<synap_conversation_history>") == 1
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_a_callers_own_history_is_spliced_for_the_model_but_not_rewritten(self):
171
+ svc = RecordingService(sent=[])
172
+ mine = ChatHistoryTruncationReducer(target_count=50, threshold_count=10)
173
+ for m in earlier_turns().messages:
174
+ mine.add_message(m)
175
+ thread = thread_for(make_sdk(), chat_history=mine)
176
+ await agent_on(svc).get_response(messages="and the hotel?", thread=thread)
177
+ assert "The user is planning a trip to Lisbon" in svc.last
178
+ assert "In May, for a week" not in svc.last
179
+ stored = " ".join(str(m.content) for m in mine.messages)
180
+ assert "In May, for a week" in stored
181
+ assert "synap_conversation_history" not in stored
182
+
183
+ @pytest.mark.asyncio
184
+ async def test_the_reducer_on_its_own_replaces_in_place_and_reports_a_change(self):
185
+ history = SynapChatHistoryReducer(sdk=make_sdk(), conversation_id="conv-1")
186
+ for m in earlier_turns().messages:
187
+ history.add_message(m)
188
+ history.add_message(ChatMessageContent(role=AuthorRole.USER, content="and the hotel?"))
189
+ assert await history.reduce() is history
190
+ text = " ".join(str(m.content) for m in history.messages)
191
+ assert "Lisbon" in text and "In May, for a week" not in text and "and the hotel?" in text
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_the_reducer_returns_none_when_there_is_nothing_to_replace(self):
195
+ history = SynapChatHistoryReducer(sdk=make_sdk(compaction=False), conversation_id="conv-1")
196
+ history.add_message(ChatMessageContent(role=AuthorRole.USER, content="hi"))
197
+ assert await history.reduce() is None
198
+ assert len(history.messages) == 1
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # The ST message does not repeat turns the framework sends
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ class TestSTMessageDoesNotDuplicateTurns:
207
+ def _sdk(self):
208
+ sdk = make_sdk(compaction=False)
209
+ sdk.conversation.context.get_context_for_prompt = AsyncMock(return_value=SimpleNamespace(
210
+ available=True, formatted_context="User asked about Lisbon."))
211
+ return sdk
212
+
213
+ @pytest.mark.asyncio
214
+ async def test_with_no_history_the_block_is_sent(self):
215
+ msg = await synap_st_chat_message(self._sdk(), "conv-1", system="Be brief.")()
216
+ assert "User asked about Lisbon." in msg.content
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_next_to_a_history_with_turns_only_the_system_text_is_sent(self):
220
+ sdk = self._sdk()
221
+ msg = await synap_st_chat_message(sdk, "conv-1", system="Be brief.")(earlier_turns())
222
+ assert msg.content == "Be brief."
223
+ sdk.conversation.context.get_context_for_prompt.assert_not_awaited()
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # The stream is the default path: nobody called listen()
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ class TestStreamOpensByDefault:
232
+ @pytest.mark.asyncio
233
+ async def test_the_first_turn_opens_the_stream_and_both_turns_go_on_it(self):
234
+ svc = RecordingService(sent=[])
235
+ sdk = make_sdk(compaction=False)
236
+ assert sdk.instance.is_listening is False
237
+ await agent_on(svc).get_response(messages="where is my order", thread=thread_for(sdk))
238
+ assert sdk.instance.listen_calls == 1
239
+ sent = [(c.kwargs["event_type"], c.kwargs["content"])
240
+ for c in sdk.instance.send_message.await_args_list]
241
+ assert sent == [("user_message", "where is my order"), ("assistant_message", "ok")]
242
+ sdk.conversation.record_message.assert_not_awaited()
243
+
244
+ @pytest.mark.asyncio
245
+ async def test_a_stream_that_will_not_open_falls_back_to_rest(self):
246
+ svc = RecordingService(sent=[])
247
+ sdk = make_sdk(compaction=False)
248
+ sdk.instance.listen = AsyncMock(side_effect=ConnectionError("no route"))
249
+ await agent_on(svc).get_response(messages="hi", thread=thread_for(sdk))
250
+ roles = [c.kwargs["role"] for c in sdk.conversation.record_message.await_args_list]
251
+ assert roles == ["user", "assistant"]
252
+ sdk.instance.send_message.assert_not_awaited()
253
+
254
+ @pytest.mark.asyncio
255
+ async def test_opting_out_keeps_turns_on_rest_and_never_listens(self):
256
+ svc = RecordingService(sent=[])
257
+ sdk = make_sdk(compaction=False)
258
+ set_auto_listen(sdk, False)
259
+ await agent_on(svc).get_response(messages="hi", thread=thread_for(sdk))
260
+ assert sdk.instance.listen_calls == 0
261
+ assert sdk.conversation.record_message.await_count == 2
@@ -5,7 +5,8 @@ model search and store memory, and short_term put a context block in the
5
5
  prompt, but nothing ever told Synap what was said. Anticipation saw an empty
6
6
  conversation.
7
7
 
8
- The rule these pin is "stream first, REST only as a fallback, never both".
8
+ The rule these pin is "stream first (opened by the first turn), REST only as a
9
+ fallback, never both".
9
10
  The server persists `user_message` and `assistant_message` from the stream
10
11
  itself, so a `record_message` on top of a delivered stream event writes the
11
12
  same turn twice and extracts it twice.
@@ -44,6 +45,7 @@ from semantic_kernel.functions import (
44
45
  kernel_function,
45
46
  )
46
47
 
48
+ from synap_integrations_common import set_auto_listen
47
49
  from synap_semantic_kernel.stream import (
48
50
  SynapAgentThread,
49
51
  _tool_args,
@@ -64,6 +66,9 @@ _rebuild_auto_function_invocation_context()
64
66
  def _streaming_sdk(is_listening: bool = True):
65
67
  sdk = MagicMock()
66
68
  sdk.instance.is_listening = is_listening
69
+ if not is_listening:
70
+ # these tests are about the REST fallback; opening a stream is test_history's
71
+ set_auto_listen(sdk, False)
67
72
  sdk.instance.send_message = AsyncMock()
68
73
  sdk.instance.record_tool_call = AsyncMock()
69
74
  sdk.instance.record_tool_result = AsyncMock()