mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
refactor(core): session.messages is the canonical Turn trajectory
ChatSession.messages flips from list[dict] to list[Turn] — the in-memory canonical trajectory. Reads migrate to typed fields (turn.role, turn.text, turn.tool_calls); appends and assignments go through turn_from_dict / turns_from_dicts; the fork bulk-save and retry's multipart check read via turn_to_dict. _full_messages lowers Turns→dicts at the wire boundary — the fold/repair and provider translators still consume dicts until the next slice. The token-accounting helpers accept a dict or a Turn. Non-session consumers migrate too: coordinator_idle_observer and eval to typed fields (mypy-enumerated), and server's last-assistant extractor via turn_to_dict (an Any-typed call site mypy could not flag). An all-text multipart content list (the unreadable-attachment placeholder path) now round-trips faithfully through the adapter (single text block → str, multiple → list). Tests that inspected session.messages as dicts read it through the dicts_from_turns / turn_to_dict bridge; those that built it pass dicts through turns_from_dicts / turn_from_dict. Byte-identical wire harness; full non-live suite green (7130).
This commit is contained in:
+41
-31
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled, _CancelRef
|
||||
from turnstone.core.trajectory import dicts_from_turns, turn_from_dict
|
||||
|
||||
|
||||
class NullUI:
|
||||
@@ -191,7 +192,7 @@ class TestCancelDuringStreaming:
|
||||
# raw "Hello world" without a marker would look like the
|
||||
# final assistant answer to a coord LLM reading the child's
|
||||
# transcript.
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assistant_msgs = [m for m in dicts_from_turns(session.messages) if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
content = assistant_msgs[0]["content"]
|
||||
assert content.startswith("Hello world")
|
||||
@@ -261,13 +262,14 @@ class TestCancelDuringToolExecution:
|
||||
# Session should be idle
|
||||
assert ui.states[-1] == "idle"
|
||||
# Cancelled tool calls should have synthesized results
|
||||
tool_msgs = [m for m in session.messages if m["role"] == "tool"]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
tool_msgs = [m for m in msgs if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0]["tool_call_id"] == "tc_1"
|
||||
assert "Cancelled by user" in tool_msgs[0]["content"]
|
||||
assert tool_msgs[0].get("is_error") is True
|
||||
# The assistant message with tool_calls should still be present
|
||||
assistant_msgs = [m for m in session.messages if m.get("tool_calls")]
|
||||
assistant_msgs = [m for m in msgs if m.get("tool_calls")]
|
||||
assert len(assistant_msgs) == 1
|
||||
|
||||
|
||||
@@ -297,7 +299,7 @@ class TestCancelWhenIdle:
|
||||
session.send("hello")
|
||||
|
||||
# Should complete normally
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assistant_msgs = [m for m in dicts_from_turns(session.messages) if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["content"] == "ok"
|
||||
|
||||
@@ -537,7 +539,7 @@ class TestStreamAbort:
|
||||
assert any("cancelled" in i.lower() for i in ui.infos)
|
||||
# Partial content preserved AND annotated with the
|
||||
# cancelled-before-completion marker.
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assistant_msgs = [m for m in dicts_from_turns(session.messages) if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
content = assistant_msgs[0]["content"]
|
||||
assert content.startswith("Hello")
|
||||
@@ -783,7 +785,7 @@ class TestForceCancelThreaded:
|
||||
assert old_done.wait(timeout=10), "orphaned thread did not exit"
|
||||
|
||||
# The orphaned thread should NOT have appended its content
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assistant_msgs = [m for m in dicts_from_turns(session.messages) if m["role"] == "assistant"]
|
||||
# May have partial content from before cancel, but NOT the full
|
||||
# "Old content more" that would appear without the generation guard
|
||||
for msg in assistant_msgs:
|
||||
@@ -834,7 +836,7 @@ class TestForceCancelThreaded:
|
||||
|
||||
# The new generation should have completed successfully
|
||||
assert "idle" in ui.states
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assistant_msgs = [m for m in dicts_from_turns(session.messages) if m["role"] == "assistant"]
|
||||
assert any("Fresh response" in m.get("content", "") for m in assistant_msgs)
|
||||
|
||||
|
||||
@@ -864,14 +866,16 @@ class TestSynthesizeCancelledResults:
|
||||
ui = self._ui_with_tool_result_tracking()
|
||||
session = _make_session(ui=ui)
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "calling tools",
|
||||
"tool_calls": [
|
||||
{"id": "call_a", "function": {"name": "search", "arguments": "{}"}},
|
||||
{"id": "call_b", "function": {"name": "compute", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
turn_from_dict(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "calling tools",
|
||||
"tool_calls": [
|
||||
{"id": "call_a", "function": {"name": "search", "arguments": "{}"}},
|
||||
{"id": "call_b", "function": {"name": "compute", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
session._msg_tokens.append(1)
|
||||
|
||||
@@ -888,25 +892,29 @@ class TestSynthesizeCancelledResults:
|
||||
assert all(tr[2] == "Cancelled by user." for tr in ui.tool_results)
|
||||
# And the message list has the synthesized tool entries
|
||||
# (preserves the prior contract).
|
||||
tool_msgs = [m for m in session.messages if m.get("role") == "tool"]
|
||||
tool_msgs = [m for m in dicts_from_turns(session.messages) if m.get("role") == "tool"]
|
||||
assert len(tool_msgs) == 2
|
||||
|
||||
def test_skips_calls_already_answered(self, tmp_db):
|
||||
ui = self._ui_with_tool_result_tracking()
|
||||
session = _make_session(ui=ui)
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "call_a", "function": {"name": "search", "arguments": "{}"}},
|
||||
{"id": "call_b", "function": {"name": "compute", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
turn_from_dict(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "call_a", "function": {"name": "search", "arguments": "{}"}},
|
||||
{"id": "call_b", "function": {"name": "compute", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
session._msg_tokens.append(1)
|
||||
# call_a already answered.
|
||||
session.messages.append(
|
||||
{"role": "tool", "tool_call_id": "call_a", "content": "result"},
|
||||
turn_from_dict(
|
||||
{"role": "tool", "tool_call_id": "call_a", "content": "result"},
|
||||
)
|
||||
)
|
||||
session._msg_tokens.append(1)
|
||||
|
||||
@@ -928,17 +936,19 @@ class TestSynthesizeCancelledResults:
|
||||
ui = _ExplodingUI()
|
||||
session = _make_session(ui=ui)
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "call_a", "function": {"name": "search", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
turn_from_dict(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "call_a", "function": {"name": "search", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
session._msg_tokens.append(1)
|
||||
|
||||
# Must not raise.
|
||||
session._synthesize_cancelled_results("Cancelled by user.")
|
||||
|
||||
tool_msgs = [m for m in session.messages if m.get("role") == "tool"]
|
||||
tool_msgs = [m for m in dicts_from_turns(session.messages) if m.get("role") == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
|
||||
@@ -16,6 +16,7 @@ import pytest
|
||||
|
||||
from turnstone.console.coordinator_idle_observer import CoordinatorIdleObserver
|
||||
from turnstone.core.nudge_queue import NudgeQueue
|
||||
from turnstone.core.trajectory import Turn, turns_from_dicts
|
||||
from turnstone.core.workstream import WorkstreamKind, WorkstreamState
|
||||
|
||||
|
||||
@@ -73,7 +74,7 @@ class _FakeStorage:
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self._nudge_queue = NudgeQueue()
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
self.messages: list[Turn] = []
|
||||
self._wake_source_tag: str = ""
|
||||
self._metacog_state: dict[str, float] = {}
|
||||
self._mem_cfg = MagicMock(nudge_cooldown=300)
|
||||
@@ -150,10 +151,12 @@ class TestEnqueueOnIdle:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage, ws_id="child-a", state="running")
|
||||
_add_active_child(storage, ws_id="child-b", state="thinking")
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
@@ -181,10 +184,12 @@ class TestEnqueueOnIdle:
|
||||
_add_active_child(storage, state="closed")
|
||||
_add_active_child(storage, state="error")
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
||||
@@ -225,19 +230,21 @@ class TestWaitForWorkstreamSkip:
|
||||
def test_skips_when_last_assistant_used_wait(self, coord_setup):
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "kick off"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"function": {"name": "wait_for_workstream", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "kick off"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"function": {"name": "wait_for_workstream", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
||||
@@ -247,16 +254,21 @@ class TestWaitForWorkstreamSkip:
|
||||
def test_fires_when_last_assistant_used_different_tool(self, coord_setup):
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "call-1", "function": {"name": "spawn_workstream", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"function": {"name": "spawn_workstream", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
||||
@@ -268,10 +280,12 @@ class TestHardCap:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
|
||||
@@ -292,10 +306,12 @@ class TestHardCap:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
|
||||
@@ -321,10 +337,12 @@ class TestHardCap:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
|
||||
@@ -350,10 +368,12 @@ class TestCooldown:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
|
||||
@@ -372,10 +392,12 @@ class TestStorageFailure:
|
||||
def test_storage_exception_is_swallowed(self, coord_setup):
|
||||
mgr, storage, ws = coord_setup
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
storage.list_raises = True
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
@@ -389,10 +411,12 @@ class TestValidUntilPredicate:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage, ws_id="child-a", state="running")
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
||||
@@ -413,10 +437,12 @@ class TestValidUntilPredicate:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage, ws_id="child-a", state="running")
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
||||
@@ -432,10 +458,12 @@ class TestValidUntilPredicate:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
||||
@@ -456,10 +484,12 @@ class TestLifecycle:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
observer.start() # no-op
|
||||
@@ -471,10 +501,12 @@ class TestLifecycle:
|
||||
mgr, storage, ws = coord_setup
|
||||
_add_active_child(storage)
|
||||
# ≥2 messages so should_nudge's message_count > 1 gate clears.
|
||||
ws.session.messages = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
ws.session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
)
|
||||
observer = CoordinatorIdleObserver(mgr, storage)
|
||||
observer.start()
|
||||
observer.shutdown()
|
||||
|
||||
@@ -32,6 +32,7 @@ from tests.test_session_manager import FakeStorage
|
||||
from turnstone.core.idle_nudge_watcher import IdleNudgeWatcher
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.session_manager import SessionManager
|
||||
from turnstone.core.trajectory import dicts_from_turns, turn_from_dict
|
||||
from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -254,13 +255,14 @@ def test_idle_event_through_real_session_manager_drives_wake_send(real_mgr, tmp_
|
||||
# The synthesized empty user message landed in history with the
|
||||
# ``_source`` audit tag; the nudge follows it as a first-class
|
||||
# ``system`` turn (no _reminders side-channel).
|
||||
user_msgs = [m for m in ws.session.messages if m.get("role") == "user"]
|
||||
msgs = dicts_from_turns(ws.session.messages)
|
||||
user_msgs = [m for m in msgs if m.get("role") == "user"]
|
||||
assert user_msgs, "expected a synthesized user message from the wake"
|
||||
wake_msg = user_msgs[-1]
|
||||
assert wake_msg["content"] == ""
|
||||
assert wake_msg.get("_source") == "system_nudge"
|
||||
assert "_reminders" not in wake_msg
|
||||
sys_turns = [m for m in ws.session.messages if m.get("role") == "system"]
|
||||
sys_turns = [m for m in msgs if m.get("role") == "system"]
|
||||
assert {
|
||||
"role": "system",
|
||||
"_source": "idle_children",
|
||||
@@ -367,8 +369,8 @@ def test_coord_idle_with_active_children_emits_envelope_via_real_managers(coord_
|
||||
|
||||
# Pretend the coord has already had a real conversation so
|
||||
# ``should_nudge``'s message_count > 1 gate passes.
|
||||
coord.session.messages.append({"role": "user", "content": "spawn 2"})
|
||||
coord.session.messages.append({"role": "assistant", "content": "ok"})
|
||||
coord.session.messages.append(turn_from_dict({"role": "user", "content": "spawn 2"}))
|
||||
coord.session.messages.append(turn_from_dict({"role": "assistant", "content": "ok"}))
|
||||
|
||||
with (
|
||||
patch.object(coord.session, "_create_stream_with_retry", return_value=iter([])),
|
||||
@@ -392,11 +394,12 @@ def test_coord_idle_with_active_children_emits_envelope_via_real_managers(coord_
|
||||
# The synthetic empty-user turn landed; the idle_children nudge
|
||||
# follows it as a first-class ``system`` turn containing both
|
||||
# children.
|
||||
user_msgs = [m for m in coord.session.messages if m.get("role") == "user"]
|
||||
msgs = dicts_from_turns(coord.session.messages)
|
||||
user_msgs = [m for m in msgs if m.get("role") == "user"]
|
||||
wake_msg = user_msgs[-1]
|
||||
assert wake_msg["content"] == ""
|
||||
assert wake_msg.get("_source") == "system_nudge"
|
||||
sys_turns = [m for m in coord.session.messages if m.get("role") == "system"]
|
||||
sys_turns = [m for m in msgs if m.get("role") == "system"]
|
||||
idle_turns = [m for m in sys_turns if m["_source"] == "idle_children"]
|
||||
assert len(idle_turns) == 1
|
||||
text = idle_turns[0]["content"]
|
||||
|
||||
@@ -8,6 +8,7 @@ from turnstone.core.memory_relevance import (
|
||||
extract_recent_context,
|
||||
score_memories,
|
||||
)
|
||||
from turnstone.core.trajectory import turns_from_dicts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# score_memories
|
||||
@@ -303,7 +304,9 @@ class TestCompositionCandidateSelection:
|
||||
def test_recency_ceiling_regression(self, tmp_db):
|
||||
"""Old relevant memory not in recency top-N still injected via search path."""
|
||||
session = _make_session(fetch_limit=5, relevance_k=3)
|
||||
session.messages = [{"role": "user", "content": "postgres database configuration"}]
|
||||
session.messages = turns_from_dicts(
|
||||
[{"role": "user", "content": "postgres database configuration"}]
|
||||
)
|
||||
|
||||
old_mem = _make_mem(
|
||||
"ancient_db_config",
|
||||
@@ -343,7 +346,7 @@ class TestCompositionCandidateSelection:
|
||||
def test_sparse_match_union_fills_candidate_pool(self, tmp_db):
|
||||
"""Search returning < fetch_limit results unions with recency fillers."""
|
||||
session = _make_session(fetch_limit=5, relevance_k=4)
|
||||
session.messages = [{"role": "user", "content": "unique_term xyzzy"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "unique_term xyzzy"}])
|
||||
|
||||
hit_a = _make_mem("hit_alpha", content="unique_term xyzzy alpha", memory_id="m_ha")
|
||||
hit_b = _make_mem("hit_beta", content="unique_term xyzzy beta", memory_id="m_hb")
|
||||
@@ -374,7 +377,7 @@ class TestCompositionCandidateSelection:
|
||||
evict the recency-only memory the bug had been surfacing.
|
||||
"""
|
||||
session = _make_session(fetch_limit=10, relevance_k=3)
|
||||
session.messages = [{"role": "user", "content": "configure host"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "configure host"}])
|
||||
|
||||
# Search returns relevance_k=3 noise hits — enough to skip recency
|
||||
# under the OLD threshold, not enough to fill fetch_limit=10.
|
||||
@@ -409,7 +412,7 @@ class TestCompositionCandidateSelection:
|
||||
sets out to improve.
|
||||
"""
|
||||
session = _make_session(fetch_limit=10, relevance_k=3)
|
||||
session.messages = [{"role": "user", "content": "alpha"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "alpha"}])
|
||||
|
||||
# 5 search hits, none of which appear in recency.
|
||||
search_hits = [
|
||||
@@ -451,7 +454,7 @@ class TestCompositionCandidateSelection:
|
||||
scopes = coord._visible_scopes()
|
||||
assert scopes == [("coordinator", "coord-1")]
|
||||
# And: search uses those same scopes (no global/user fan-in)
|
||||
coord.messages = [{"role": "user", "content": "anything"}]
|
||||
coord.messages = turns_from_dicts([{"role": "user", "content": "anything"}])
|
||||
with patch(
|
||||
"turnstone.core.session.search_visible_structured_memories",
|
||||
return_value=[],
|
||||
@@ -492,13 +495,13 @@ class TestCompositionRerankFiltersWiring:
|
||||
|
||||
def test_threshold_zero_uses_reorder_mode(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.messages = [{"role": "user", "content": "alpha"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "alpha"}])
|
||||
# threshold 0 (disabled floor) -> reorder mode -> no suppression.
|
||||
assert self._capture_rerank_filters(session, 0.0) is False
|
||||
|
||||
def test_positive_threshold_uses_filter_mode(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.messages = [{"role": "user", "content": "alpha"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "alpha"}])
|
||||
# An active floor -> filter mode -> the reranker may empty the injection.
|
||||
assert self._capture_rerank_filters(session, 0.5) is True
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from turnstone.console.server import (
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
from turnstone.core.trajectory import turns_from_dicts
|
||||
from turnstone.server import (
|
||||
_deliver_notification,
|
||||
_extract_last_assistant_content,
|
||||
@@ -210,23 +211,27 @@ class TestValidateNotifyTargets:
|
||||
class TestExtractLastAssistantContent:
|
||||
def test_string_content(self):
|
||||
session = MagicMock()
|
||||
session.messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "world"},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "world"},
|
||||
]
|
||||
)
|
||||
assert _extract_last_assistant_content(session) == "world"
|
||||
|
||||
def test_structured_content(self):
|
||||
session = MagicMock()
|
||||
session.messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "part one"},
|
||||
{"type": "text", "text": "part two"},
|
||||
],
|
||||
},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "part one"},
|
||||
{"type": "text", "text": "part two"},
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
assert _extract_last_assistant_content(session) == "part one\npart two"
|
||||
|
||||
def test_empty_messages(self):
|
||||
@@ -236,29 +241,33 @@ class TestExtractLastAssistantContent:
|
||||
|
||||
def test_no_assistant_messages(self):
|
||||
session = MagicMock()
|
||||
session.messages = [{"role": "user", "content": "hello"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "hello"}])
|
||||
assert _extract_last_assistant_content(session) == ""
|
||||
|
||||
def test_picks_last_assistant(self):
|
||||
session = MagicMock()
|
||||
session.messages = [
|
||||
{"role": "assistant", "content": "first"},
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "second"},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "assistant", "content": "first"},
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "second"},
|
||||
]
|
||||
)
|
||||
assert _extract_last_assistant_content(session) == "second"
|
||||
|
||||
def test_skips_non_text_blocks(self):
|
||||
session = MagicMock()
|
||||
session.messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "123"},
|
||||
{"type": "text", "text": "result"},
|
||||
],
|
||||
},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "123"},
|
||||
{"type": "text", "text": "result"},
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
assert _extract_last_assistant_content(session) == "result"
|
||||
|
||||
|
||||
|
||||
+61
-42
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.trajectory import turn_to_dict, turns_from_dicts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -90,31 +91,35 @@ def _make_session(tmp_db) -> ChatSession:
|
||||
|
||||
def _populate_simple(session: ChatSession) -> None:
|
||||
"""Populate with 2 simple turns (no tool calls)."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
{"role": "assistant", "content": "I'm fine."},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
{"role": "assistant", "content": "I'm fine."},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [10, 20, 10, 20]
|
||||
|
||||
|
||||
def _populate_with_tools(session: ChatSession) -> None:
|
||||
"""Populate with 2 turns, first has tool calls."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Write a test"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc1", "function": {"name": "bash", "arguments": '{"cmd":"echo hi"}'}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc1", "content": "hi"},
|
||||
{"role": "assistant", "content": "Done."},
|
||||
{"role": "user", "content": "Fix the import"},
|
||||
{"role": "assistant", "content": "Fixed."},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "Write a test"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc1", "function": {"name": "bash", "arguments": '{"cmd":"echo hi"}'}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc1", "content": "hi"},
|
||||
{"role": "assistant", "content": "Done."},
|
||||
{"role": "user", "content": "Fix the import"},
|
||||
{"role": "assistant", "content": "Fixed."},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [10, 20, 10, 20, 10, 20]
|
||||
|
||||
|
||||
@@ -130,10 +135,12 @@ class TestFindTurnBoundaries:
|
||||
|
||||
def test_single_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
)
|
||||
assert session._find_turn_boundaries() == [0]
|
||||
|
||||
def test_multi_turn(self, tmp_db):
|
||||
@@ -165,8 +172,8 @@ class TestRewind:
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user + assistant
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0]["content"] == "Hello"
|
||||
assert session.messages[1]["content"] == "Hi there!"
|
||||
assert turn_to_dict(session.messages[0])["content"] == "Hello"
|
||||
assert turn_to_dict(session.messages[1])["content"] == "Hi there!"
|
||||
assert len(session._msg_tokens) == 2
|
||||
|
||||
def test_rewind_all_turns(self, tmp_db):
|
||||
@@ -196,7 +203,7 @@ class TestRewind:
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user "Fix the import" + assistant "Fixed."
|
||||
assert len(session.messages) == 4
|
||||
assert session.messages[-1]["content"] == "Done."
|
||||
assert turn_to_dict(session.messages[-1])["content"] == "Done."
|
||||
|
||||
def test_rewind_tokens_sync(self, tmp_db):
|
||||
"""_msg_tokens stays in sync with messages."""
|
||||
@@ -219,7 +226,7 @@ class TestRetry:
|
||||
assert msg == "How are you?"
|
||||
# Only Turn 1 remains, without the second user message
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[-1]["content"] == "Hi there!"
|
||||
assert turn_to_dict(session.messages[-1])["content"] == "Hi there!"
|
||||
|
||||
def test_retry_empty(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
@@ -249,10 +256,18 @@ class TestRetry:
|
||||
def test_retry_multipart_content_returns_none(self, tmp_db):
|
||||
"""retry() should refuse multipart (vision/image) messages."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "describe this"}]},
|
||||
{"role": "assistant", "content": "It's an image."},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "describe this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "It's an image."},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
# Messages should be unchanged
|
||||
@@ -261,10 +276,12 @@ class TestRetry:
|
||||
def test_retry_none_content_returns_none(self, tmp_db):
|
||||
"""retry() should handle content=None gracefully."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "Ok."},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "Ok."},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
|
||||
@@ -386,12 +403,14 @@ class TestRewindDBSync:
|
||||
save_message(ws_id, "user", "Bye")
|
||||
save_message(ws_id, "assistant", "Goodbye!")
|
||||
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "Bye"},
|
||||
{"role": "assistant", "content": "Goodbye!"},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "Bye"},
|
||||
{"role": "assistant", "content": "Goodbye!"},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [5, 5, 5, 5]
|
||||
|
||||
session.rewind(1)
|
||||
|
||||
+93
-69
@@ -11,6 +11,12 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession
|
||||
from turnstone.core.trajectory import (
|
||||
dicts_from_turns,
|
||||
turn_from_dict,
|
||||
turn_to_dict,
|
||||
turns_from_dicts,
|
||||
)
|
||||
|
||||
|
||||
class NullUI:
|
||||
@@ -198,7 +204,7 @@ class TestChatSessionConstruction:
|
||||
assert len(full) == len(session.system_messages)
|
||||
|
||||
# Add a user message
|
||||
session.messages.append({"role": "user", "content": "hello"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "hello"}))
|
||||
full = session._full_messages()
|
||||
assert len(full) == len(session.system_messages) + 1
|
||||
assert full[-1]["role"] == "user"
|
||||
@@ -834,10 +840,12 @@ class TestTitleRetry:
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
)
|
||||
# Mock provider to raise
|
||||
session._provider = MagicMock()
|
||||
session._provider.get_capabilities.return_value = ModelCapabilities()
|
||||
@@ -852,10 +860,12 @@ class TestTitleRetry:
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
)
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
session._provider = MagicMock()
|
||||
@@ -874,10 +884,12 @@ class TestTitleRetry:
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
)
|
||||
original_ws_id = session._ws_id
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
@@ -2750,11 +2762,11 @@ class TestMemoryCompositionDeferral:
|
||||
# __init__ composed against an empty history -> no query yet.
|
||||
assert session._system_composed_with_context is False
|
||||
# A whitespace-only "turn" (e.g. a wake send("")) is not a real query.
|
||||
session.messages.append({"role": "user", "content": " "})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": " "}))
|
||||
session._init_system_messages()
|
||||
assert session._system_composed_with_context is False
|
||||
# A real user message flips it (one-shot).
|
||||
session.messages.append({"role": "user", "content": "what is the weather"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "what is the weather"}))
|
||||
session._init_system_messages()
|
||||
assert session._system_composed_with_context is True
|
||||
|
||||
@@ -2768,7 +2780,7 @@ class TestMemoryCompositionDeferral:
|
||||
real_init = session._init_system_messages
|
||||
|
||||
def spy_init():
|
||||
seen_queries.append(extract_recent_context(session.messages))
|
||||
seen_queries.append(extract_recent_context(dicts_from_turns(session.messages)))
|
||||
real_init()
|
||||
|
||||
responses = [{"role": "assistant", "content": "ok"}]
|
||||
@@ -2827,14 +2839,14 @@ class TestMetacognitiveBuffers:
|
||||
legacy ``_reminders`` side-channel splice. The user turn content
|
||||
stays clean — the nudge is its own role=system trajectory turn."""
|
||||
session = _make_session()
|
||||
session.messages.append({"role": "user", "content": "hello there"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "hello there"}))
|
||||
session._msg_tokens.append(1)
|
||||
session._queue_user_advisory("correction", "ALERT_TEXT")
|
||||
with patch("turnstone.core.session.save_message"):
|
||||
session._emit_pending_user_nudges()
|
||||
# User turn untouched; a system turn now follows it.
|
||||
assert session.messages[-2] == {"role": "user", "content": "hello there"}
|
||||
assert session.messages[-1] == {
|
||||
assert turn_to_dict(session.messages[-2]) == {"role": "user", "content": "hello there"}
|
||||
assert turn_to_dict(session.messages[-1]) == {
|
||||
"role": "system",
|
||||
"_source": "correction",
|
||||
"content": "ALERT_TEXT",
|
||||
@@ -2845,24 +2857,24 @@ class TestMetacognitiveBuffers:
|
||||
|
||||
def test_emit_user_nudges_noop_when_buffer_empty(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.messages.append({"role": "user", "content": "untouched"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "untouched"}))
|
||||
session._msg_tokens.append(1)
|
||||
pre_len = len(session.messages)
|
||||
with patch("turnstone.core.session.save_message"):
|
||||
session._emit_pending_user_nudges()
|
||||
# No nudges → no system turn appended.
|
||||
assert len(session.messages) == pre_len
|
||||
assert session.messages[-1]["role"] == "user"
|
||||
assert turn_to_dict(session.messages[-1])["role"] == "user"
|
||||
|
||||
def test_emit_user_nudges_appends_one_system_turn_per_nudge(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.messages.append({"role": "user", "content": "user text"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "user text"}))
|
||||
session._msg_tokens.append(1)
|
||||
session._queue_user_advisory("denial", "FIRST")
|
||||
session._queue_user_advisory("correction", "SECOND")
|
||||
with patch("turnstone.core.session.save_message"):
|
||||
session._emit_pending_user_nudges()
|
||||
sys_turns = [m for m in session.messages if m.get("role") == "system"]
|
||||
sys_turns = [m for m in dicts_from_turns(session.messages) if m.get("role") == "system"]
|
||||
assert sys_turns == [
|
||||
{"role": "system", "_source": "denial", "content": "FIRST"},
|
||||
{"role": "system", "_source": "correction", "content": "SECOND"},
|
||||
@@ -3031,12 +3043,13 @@ class TestMetacognitiveBuffers:
|
||||
session.send("first")
|
||||
|
||||
# Role sequence: the nudge follows the clean tool message.
|
||||
roles = [m.get("role") for m in session.messages]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
roles = [m.get("role") for m in msgs]
|
||||
assert roles == ["user", "assistant", "tool", "system", "assistant"], (
|
||||
f"expected the tool_error nudge as a system turn after the tool, got {roles!r}"
|
||||
)
|
||||
assert session.messages[2]["content"] == "boom" # clean tool output
|
||||
sys_turn = session.messages[3]
|
||||
assert msgs[2]["content"] == "boom" # clean tool output
|
||||
sys_turn = msgs[3]
|
||||
assert sys_turn["_source"] == "tool_error"
|
||||
assert sys_turn["content"] == "you hit an error; check memory"
|
||||
|
||||
@@ -3085,16 +3098,17 @@ class TestMetacognitiveBuffers:
|
||||
# Role sequence: user -> assistant(tool_calls) -> tool ->
|
||||
# system(user_interjection) -> assistant(ack). No trailing user
|
||||
# row — the interjection is operator-context, not user input.
|
||||
roles = [m.get("role") for m in session.messages]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
roles = [m.get("role") for m in msgs]
|
||||
assert roles == ["user", "assistant", "tool", "system", "assistant"], (
|
||||
f"expected user->assistant->tool->system->assistant, got {roles!r}"
|
||||
)
|
||||
# Tool message content is the bare output — no envelope.
|
||||
tool_msg = session.messages[2]
|
||||
tool_msg = msgs[2]
|
||||
assert tool_msg["content"] == "ok"
|
||||
assert "<tool_output>" not in tool_msg["content"]
|
||||
# The system turn carries the queued interjection.
|
||||
sys_turn = session.messages[3]
|
||||
sys_turn = msgs[3]
|
||||
assert sys_turn["_source"] == "user_interjection"
|
||||
assert sys_turn["content"].endswith("User message: typed during tool")
|
||||
# The tool row was saved clean; a system row carries the interjection.
|
||||
@@ -3139,13 +3153,14 @@ class TestMetacognitiveBuffers:
|
||||
session._title_generated = True
|
||||
session.send("first")
|
||||
|
||||
roles = [m.get("role") for m in session.messages]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
roles = [m.get("role") for m in msgs]
|
||||
# Single trailing user row carrying the feedback before the
|
||||
# final assistant ack.
|
||||
assert roles == ["user", "assistant", "tool", "user", "assistant"], (
|
||||
f"expected feedback-as-user-row sequence, got {roles!r}"
|
||||
)
|
||||
assert session.messages[3]["content"] == "y, use full path"
|
||||
assert msgs[3]["content"] == "y, use full path"
|
||||
# Persisted via _append_user_turn -> save_message("user", ...).
|
||||
user_saves = [
|
||||
c for c in save_msg.call_args_list if len(c.args) >= 3 and c.args[1] == "user"
|
||||
@@ -3213,7 +3228,8 @@ class TestMetacognitiveBuffers:
|
||||
session._title_generated = True
|
||||
session.send("first")
|
||||
|
||||
roles = [m.get("role") for m in session.messages]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
roles = [m.get("role") for m in msgs]
|
||||
# NO back-to-back user rows. Pre-fix the user_feedback
|
||||
# appended a separate user row and the queue drained another:
|
||||
# roles == [..., "user", "user", ...] which broke strict
|
||||
@@ -3226,7 +3242,7 @@ class TestMetacognitiveBuffers:
|
||||
assert roles == ["user", "assistant", "tool", "user", "assistant"], (
|
||||
f"expected single-trailing-user shape, got {roles!r}"
|
||||
)
|
||||
flushed_content = session.messages[3]["content"]
|
||||
flushed_content = msgs[3]["content"]
|
||||
# The two pieces are joined by the canonical separator.
|
||||
assert flushed_content == "y, use full path\n\nlate arrival"
|
||||
# Queue cleared.
|
||||
@@ -3241,7 +3257,7 @@ class TestMetacognitiveBuffers:
|
||||
appended = session._flush_queued_messages(prefix="hello")
|
||||
assert appended is True
|
||||
assert len(session.messages) == pre_count + 1
|
||||
last = session.messages[-1]
|
||||
last = turn_to_dict(session.messages[-1])
|
||||
assert last["role"] == "user"
|
||||
assert last["content"] == "hello"
|
||||
|
||||
@@ -3254,7 +3270,7 @@ class TestMetacognitiveBuffers:
|
||||
session.queue_message("b", queue_msg_id="q-b")
|
||||
appended = session._flush_queued_messages(prefix="approve")
|
||||
assert appended is True
|
||||
last = session.messages[-1]
|
||||
last = turn_to_dict(session.messages[-1])
|
||||
assert last["role"] == "user"
|
||||
assert last["content"] == "approve\n\na\n\nb"
|
||||
assert session._queued_messages == {}
|
||||
@@ -3422,14 +3438,15 @@ class TestMetacognitiveBuffers:
|
||||
session.send("first")
|
||||
|
||||
# In-memory: tool row keeps the list content; a system turn follows.
|
||||
tool_msg = next(m for m in session.messages if m.get("role") == "tool")
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
tool_msg = next(m for m in msgs if m.get("role") == "tool")
|
||||
text_parts = [
|
||||
p["text"]
|
||||
for p in tool_msg["content"]
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
assert text_parts == ["the chart shows X"]
|
||||
sys_turn = next(m for m in session.messages if m.get("role") == "system")
|
||||
sys_turn = next(m for m in msgs if m.get("role") == "system")
|
||||
assert sys_turn["_source"] == "user_interjection"
|
||||
assert sys_turn["content"].endswith("User message: inspect the histogram")
|
||||
|
||||
@@ -3459,10 +3476,11 @@ class TestMetacognitiveBuffers:
|
||||
|
||||
# User turn landed clean; the start nudge follows it as a system turn.
|
||||
assert session.messages, "user message should have been appended"
|
||||
user_turns = [m for m in session.messages if m.get("role") == "user"]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
user_turns = [m for m in msgs if m.get("role") == "user"]
|
||||
assert user_turns[-1]["content"] == "first user message"
|
||||
assert "_reminders" not in user_turns[-1]
|
||||
sys_turns = [m for m in session.messages if m.get("role") == "system"]
|
||||
sys_turns = [m for m in msgs if m.get("role") == "system"]
|
||||
assert any(m["_source"] == "start" for m in sys_turns), (
|
||||
f"expected a start system turn, got {sys_turns!r}"
|
||||
)
|
||||
@@ -3478,7 +3496,7 @@ class TestMetacognitiveBuffers:
|
||||
line is gone. No ``on_info`` should fire from the drain."""
|
||||
session = _make_session()
|
||||
session.ui = MagicMock()
|
||||
session.messages.append({"role": "user", "content": "noted"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "noted"}))
|
||||
session._msg_tokens.append(1)
|
||||
session._queue_user_advisory("correction", "watch out")
|
||||
with patch("turnstone.core.session.save_message"):
|
||||
@@ -3494,7 +3512,7 @@ class TestMetacognitiveBuffers:
|
||||
operator bubble in lockstep with the originating tab."""
|
||||
session = _make_session()
|
||||
session.ui = MagicMock()
|
||||
session.messages.append({"role": "user", "content": "noted"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "noted"}))
|
||||
session._msg_tokens.append(1)
|
||||
session._queue_user_advisory("correction", "watch out")
|
||||
with patch("turnstone.core.session.save_message"):
|
||||
@@ -3511,13 +3529,13 @@ class TestMetacognitiveBuffers:
|
||||
session = _make_session()
|
||||
session.ui = MagicMock()
|
||||
session.ui.on_system_turn.side_effect = RuntimeError("queue full")
|
||||
session.messages.append({"role": "user", "content": "noted"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "noted"}))
|
||||
session._msg_tokens.append(1)
|
||||
session._queue_user_advisory("correction", "watch out")
|
||||
with patch("turnstone.core.session.save_message"):
|
||||
session._emit_pending_user_nudges()
|
||||
# The system turn was appended despite the hook raising.
|
||||
assert session.messages[-1] == {
|
||||
assert turn_to_dict(session.messages[-1]) == {
|
||||
"role": "system",
|
||||
"_source": "correction",
|
||||
"content": "watch out",
|
||||
@@ -3566,8 +3584,8 @@ class TestApplyPostExecuteAdvisories:
|
||||
so seed two messages to mirror that.
|
||||
"""
|
||||
session._mem_cfg.nudges = True
|
||||
session.messages.append({"role": "user", "content": "hi"})
|
||||
session.messages.append({"role": "assistant", "content": "ok"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "hi"}))
|
||||
session.messages.append(turn_from_dict({"role": "assistant", "content": "ok"}))
|
||||
|
||||
def test_three_identical_calls_fire_warning_and_advisory(self, tmp_db):
|
||||
session = _make_session()
|
||||
@@ -3774,7 +3792,7 @@ class TestUpdateTokenTableMsgsParam:
|
||||
def test_uses_provided_msgs_skips_re_application(self, tmp_db):
|
||||
session = _make_session()
|
||||
session._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
session.messages.append({"role": "user", "content": "hi"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "hi"}))
|
||||
# Patch _prepare_wire_messages to detect a redundant re-fold.
|
||||
with patch.object(
|
||||
session,
|
||||
@@ -3792,7 +3810,7 @@ class TestUpdateTokenTableMsgsParam:
|
||||
can't) pre-build the wire copy still get a sane calibration."""
|
||||
session = _make_session()
|
||||
session._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
session.messages.append({"role": "user", "content": "hi"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "hi"}))
|
||||
with patch.object(
|
||||
session,
|
||||
"_prepare_wire_messages",
|
||||
@@ -3911,7 +3929,7 @@ class TestUserAdvisoryCancelClear:
|
||||
)
|
||||
# The queued message landed in history before the second turn.
|
||||
user_texts: list[str] = []
|
||||
for m in session.messages:
|
||||
for m in dicts_from_turns(session.messages):
|
||||
if m.get("role") != "user":
|
||||
continue
|
||||
content = m.get("content")
|
||||
@@ -3986,12 +4004,13 @@ class TestDeliverWakeNudge:
|
||||
assert _user_pending(session) == []
|
||||
# Empty-content user message was appended; the nudge follows it as
|
||||
# a first-class system turn (no _reminders side-channel).
|
||||
user_msgs = [m for m in session.messages if m.get("role") == "user"]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
user_msgs = [m for m in msgs if m.get("role") == "user"]
|
||||
assert user_msgs, "wake should append a synthetic user message"
|
||||
wake_msg = user_msgs[-1]
|
||||
assert wake_msg["content"] == ""
|
||||
assert "_reminders" not in wake_msg
|
||||
sys_turns = [m for m in session.messages if m.get("role") == "system"]
|
||||
sys_turns = [m for m in msgs if m.get("role") == "system"]
|
||||
assert {"role": "system", "_source": "idle_children", "content": "your kids"} in sys_turns
|
||||
|
||||
def test_marks_source_tag_on_synthesized_user_msg(self, tmp_db):
|
||||
@@ -4013,7 +4032,7 @@ class TestDeliverWakeNudge:
|
||||
patch("turnstone.core.session.save_message"),
|
||||
):
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
user_msgs = [m for m in session.messages if m.get("role") == "user"]
|
||||
user_msgs = [m for m in dicts_from_turns(session.messages) if m.get("role") == "user"]
|
||||
wake_msg = user_msgs[-1]
|
||||
assert wake_msg.get("_source") == "system_nudge"
|
||||
|
||||
@@ -4056,7 +4075,7 @@ class TestDeliverWakeNudge:
|
||||
session._queue_user_advisory("denial", "don't do that next time")
|
||||
# Force enough memory + message context that should_nudge would
|
||||
# otherwise fire a fresh correction nudge.
|
||||
session.messages.append({"role": "user", "content": "earlier"})
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": "earlier"}))
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=iter([])),
|
||||
patch.object(
|
||||
@@ -4116,7 +4135,7 @@ class TestDeliverWakeNudge:
|
||||
):
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
|
||||
user_msgs = [m for m in session.messages if m.get("role") == "user"]
|
||||
user_msgs = [m for m in dicts_from_turns(session.messages) if m.get("role") == "user"]
|
||||
# Two user messages: the wake's synthetic empty turn (with
|
||||
# _source) AND the flushed real user input (without _source).
|
||||
wake_msg = next(m for m in user_msgs if m.get("content") == "")
|
||||
@@ -4145,10 +4164,11 @@ class TestDeliverWakeNudge:
|
||||
):
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
# The nudge system turn landed and stays (persistent history).
|
||||
sys_turns = [m for m in session.messages if m.get("role") == "system"]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
sys_turns = [m for m in msgs if m.get("role") == "system"]
|
||||
assert any(m["_source"] == "denial" and m["content"] == "leftover" for m in sys_turns)
|
||||
# No legacy delivered flag anywhere.
|
||||
assert all("_reminders_delivered" not in m for m in session.messages)
|
||||
assert all("_reminders_delivered" not in m for m in msgs)
|
||||
# Wake tag cleared even on exception (finally block).
|
||||
assert session._wake_source_tag == ""
|
||||
|
||||
@@ -4230,14 +4250,16 @@ class TestReminderSidechannelIsolation:
|
||||
the summary text and outlive the turn it advised."""
|
||||
session = _make_session()
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "user said this",
|
||||
"_reminders": [{"type": "correction", "text": "SECRET_NUDGE_TEXT"}],
|
||||
}
|
||||
turn_from_dict(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "user said this",
|
||||
"_reminders": [{"type": "correction", "text": "SECRET_NUDGE_TEXT"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
session.messages.append({"role": "assistant", "content": "ok"})
|
||||
summary = session._format_messages_for_summary(session.messages)
|
||||
session.messages.append(turn_from_dict({"role": "assistant", "content": "ok"}))
|
||||
summary = session._format_messages_for_summary(dicts_from_turns(session.messages))
|
||||
assert "SECRET_NUDGE_TEXT" not in summary
|
||||
assert "<system-reminder>" not in summary
|
||||
assert "user said this" in summary
|
||||
@@ -4249,16 +4271,18 @@ class TestReminderSidechannelIsolation:
|
||||
clean even when ``_reminders`` is populated."""
|
||||
session = _make_session()
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "first message body",
|
||||
"_reminders": [{"type": "start", "text": "SECRET_NUDGE_TEXT"}],
|
||||
}
|
||||
turn_from_dict(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "first message body",
|
||||
"_reminders": [{"type": "start", "text": "SECRET_NUDGE_TEXT"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
# Mirror the loop at session.py:_generate_title that pulls the
|
||||
# first user message into the title prompt.
|
||||
extracted_user = ""
|
||||
for m in session.messages:
|
||||
for m in dicts_from_turns(session.messages):
|
||||
content = m.get("content") or ""
|
||||
if isinstance(content, list):
|
||||
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
@@ -4290,7 +4314,7 @@ class TestReminderSidechannelIsolation:
|
||||
|
||||
wake_msgs = [
|
||||
m
|
||||
for m in resumed_fork.messages
|
||||
for m in dicts_from_turns(resumed_fork.messages)
|
||||
if m.get("role") == "user" and m.get("_source") == "system_nudge"
|
||||
]
|
||||
assert len(wake_msgs) == 1
|
||||
|
||||
@@ -12,6 +12,7 @@ from turnstone.core.memory import (
|
||||
register_workstream,
|
||||
)
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.trajectory import dicts_from_turns, turn_to_dict
|
||||
|
||||
PNG_1x1 = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
@@ -56,12 +57,12 @@ class TestPlainTextUnchanged:
|
||||
def test_no_attachments_stores_string_content(self, tmp_db, mock_openai_client):
|
||||
s = _make_session(mock_openai_client)
|
||||
_run_send(s, "hello")
|
||||
assert s.messages[-1] == {"role": "user", "content": "hello"}
|
||||
assert turn_to_dict(s.messages[-1]) == {"role": "user", "content": "hello"}
|
||||
|
||||
def test_empty_attachments_list_stores_string_content(self, tmp_db, mock_openai_client):
|
||||
s = _make_session(mock_openai_client)
|
||||
_run_send(s, "hello", attachments=[])
|
||||
assert s.messages[-1] == {"role": "user", "content": "hello"}
|
||||
assert turn_to_dict(s.messages[-1]) == {"role": "user", "content": "hello"}
|
||||
|
||||
|
||||
class TestMultipartBuild:
|
||||
@@ -75,7 +76,7 @@ class TestMultipartBuild:
|
||||
content=PNG_1x1,
|
||||
)
|
||||
_run_send(s, "what is this?", attachments=[att])
|
||||
msg = s.messages[-1]
|
||||
msg = turn_to_dict(s.messages[-1])
|
||||
assert msg["role"] == "user"
|
||||
assert isinstance(msg["content"], list)
|
||||
assert msg["content"][0] == {"type": "text", "text": "what is this?"}
|
||||
@@ -93,7 +94,7 @@ class TestMultipartBuild:
|
||||
content=b"# hi\n",
|
||||
)
|
||||
_run_send(s, "summarize", attachments=[att])
|
||||
msg = s.messages[-1]
|
||||
msg = turn_to_dict(s.messages[-1])
|
||||
doc = msg["content"][1]
|
||||
assert doc == {
|
||||
"type": "document",
|
||||
@@ -112,9 +113,10 @@ class TestMultipartBuild:
|
||||
Attachment("a3", "second.md", "text/markdown", "text", b"B"),
|
||||
]
|
||||
_run_send(s, "look", attachments=atts)
|
||||
types = [p["type"] for p in s.messages[-1]["content"]]
|
||||
msg = turn_to_dict(s.messages[-1])
|
||||
types = [p["type"] for p in msg["content"]]
|
||||
assert types == ["text", "image_url", "document", "document"]
|
||||
docs = [p for p in s.messages[-1]["content"] if p["type"] == "document"]
|
||||
docs = [p for p in msg["content"] if p["type"] == "document"]
|
||||
assert docs[0]["document"]["data"] == "A"
|
||||
assert docs[1]["document"]["data"] == "B"
|
||||
|
||||
@@ -122,7 +124,7 @@ class TestMultipartBuild:
|
||||
s = _make_session(mock_openai_client)
|
||||
att = Attachment("a1", "bad.bin", "text/plain", "text", b"\xff\xfe")
|
||||
_run_send(s, "read this", attachments=[att])
|
||||
parts = s.messages[-1]["content"]
|
||||
parts = turn_to_dict(s.messages[-1])["content"]
|
||||
assert any(
|
||||
p.get("type") == "text" and p.get("text") == "[unreadable attachment: bad.bin]"
|
||||
for p in parts
|
||||
@@ -225,7 +227,7 @@ class TestProviderIntegration:
|
||||
]
|
||||
_run_send(s, "look at both", attachments=atts)
|
||||
|
||||
_, converted = AnthropicProvider()._convert_messages([s.messages[-1]])
|
||||
_, converted = AnthropicProvider()._convert_messages(dicts_from_turns([s.messages[-1]]))
|
||||
assert len(converted) == 1
|
||||
content = converted[0]["content"]
|
||||
types = [p["type"] for p in content]
|
||||
@@ -250,7 +252,7 @@ class TestProviderIntegration:
|
||||
Attachment("a2", "notes.md", "text/markdown", "text", b"hi"),
|
||||
]
|
||||
_run_send(s, "desc", attachments=atts)
|
||||
meta = s.messages[-1].get("_attachments_meta")
|
||||
meta = turn_to_dict(s.messages[-1]).get("_attachments_meta")
|
||||
assert meta == [
|
||||
{"kind": "image", "filename": "dog.png", "mime_type": "image/png"},
|
||||
{"kind": "text", "filename": "notes.md", "mime_type": "text/markdown"},
|
||||
@@ -264,7 +266,7 @@ class TestProviderIntegration:
|
||||
s = _make_session(mock_openai_client)
|
||||
atts = [Attachment("a1", "x.md", "text/markdown", "text", b"x")]
|
||||
_run_send(s, "hi", attachments=atts)
|
||||
out = sanitize_messages([s.messages[-1]])
|
||||
out = sanitize_messages(dicts_from_turns([s.messages[-1]]))
|
||||
for k in out[0]:
|
||||
assert not k.startswith("_"), f"{k!r} leaked to wire"
|
||||
|
||||
@@ -277,7 +279,7 @@ class TestProviderIntegration:
|
||||
]
|
||||
_run_send(s, "review", attachments=atts)
|
||||
|
||||
out = sanitize_messages([s.messages[-1]])
|
||||
out = sanitize_messages(dicts_from_turns([s.messages[-1]]))
|
||||
parts = out[0]["content"]
|
||||
types = [p["type"] for p in parts]
|
||||
assert types == ["text", "text"]
|
||||
|
||||
@@ -19,6 +19,7 @@ from turnstone.core.memory import (
|
||||
)
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.trajectory import turn_to_dict
|
||||
|
||||
# ── Workstream registration ───────────────────────────────────────────
|
||||
|
||||
@@ -298,7 +299,7 @@ class TestResumeWorkstream:
|
||||
assert result is True
|
||||
assert session._ws_id == "old_ws_123"
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0]["content"] == "hello world"
|
||||
assert turn_to_dict(session.messages[0])["content"] == "hello world"
|
||||
assert session._title_generated is True
|
||||
|
||||
def test_resume_nonexistent_returns_false(self, tmp_db, mock_openai_client):
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.trajectory import turns_from_dicts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -144,7 +145,7 @@ class TestContextOverflowRecovery:
|
||||
"""Test that context-length errors trigger compact-and-retry."""
|
||||
|
||||
def test_openai_context_length_error_triggers_compact(self, session):
|
||||
session.messages = [{"role": "user", "content": "hi"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "hi"}])
|
||||
session._msg_tokens = [1]
|
||||
|
||||
call_count = 0
|
||||
@@ -175,7 +176,7 @@ class TestContextOverflowRecovery:
|
||||
assert call_count == 2
|
||||
|
||||
def test_anthropic_prompt_too_long_triggers_compact(self, session):
|
||||
session.messages = [{"role": "user", "content": "hi"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "hi"}])
|
||||
session._msg_tokens = [1]
|
||||
|
||||
call_count = 0
|
||||
@@ -205,7 +206,7 @@ class TestContextOverflowRecovery:
|
||||
compact_mock.assert_called_once_with(auto=True)
|
||||
|
||||
def test_non_context_error_propagates(self, session):
|
||||
session.messages = [{"role": "user", "content": "hi"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "hi"}])
|
||||
session._msg_tokens = [1]
|
||||
|
||||
with (
|
||||
@@ -222,7 +223,7 @@ class TestContextOverflowRecovery:
|
||||
session.send("hello")
|
||||
|
||||
def test_compact_failure_raises_original_error(self, session):
|
||||
session.messages = [{"role": "user", "content": "hi"}]
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "hi"}])
|
||||
session._msg_tokens = [1]
|
||||
|
||||
with (
|
||||
|
||||
@@ -38,6 +38,15 @@ _ROUNDTRIP: list[dict[str, Any]] = [
|
||||
],
|
||||
"_attachments_meta": [{"kind": "image", "filename": "x.png", "mime_type": "image/png"}],
|
||||
},
|
||||
{
|
||||
# All-text multipart list (the unreadable-attachment placeholder path)
|
||||
# stays a list — must not collapse to a joined string.
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "read this"},
|
||||
{"type": "text", "text": "[unreadable attachment: bad.bin]"},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
{"role": "assistant", "content": ""}, # empty assistant (no text, no tools)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ import pytest
|
||||
from tests._helpers import patch_session_storage
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.trajectory import dicts_from_turns
|
||||
from turnstone.core.watch import WatchRunner
|
||||
|
||||
|
||||
@@ -120,10 +121,11 @@ def test_watch_fires_then_user_send_drains_envelope(tmp_db, monkeypatch):
|
||||
# The watch text lands as a first-class operator-context ``system``
|
||||
# turn following the user turn (the production drain seam now emits
|
||||
# ``make_system_turn`` rather than the legacy ``_reminders`` splice).
|
||||
user_msgs = [m for m in session.messages if m.get("role") == "user"]
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
user_msgs = [m for m in msgs if m.get("role") == "user"]
|
||||
assert user_msgs, "expected a user message in history"
|
||||
assert "_reminders" not in user_msgs[-1]
|
||||
sys_turns = [m for m in session.messages if m.get("role") == "system"]
|
||||
sys_turns = [m for m in msgs if m.get("role") == "system"]
|
||||
assert any(
|
||||
m["_source"] == "watch_triggered" and "watch payload body" in m["content"]
|
||||
for m in sys_turns
|
||||
@@ -176,10 +178,9 @@ def test_three_back_to_back_watch_fires_drain_into_one_turn(tmp_db, monkeypatch)
|
||||
|
||||
# All three drained into three operator-context system turns after
|
||||
# the single user turn.
|
||||
msgs = dicts_from_turns(session.messages)
|
||||
watch_turns = [
|
||||
m
|
||||
for m in session.messages
|
||||
if m.get("role") == "system" and m.get("_source") == "watch_triggered"
|
||||
m for m in msgs if m.get("role") == "system" and m.get("_source") == "watch_triggered"
|
||||
]
|
||||
assert len(watch_turns) == 3
|
||||
bodies = [m["content"] for m in watch_turns]
|
||||
@@ -187,7 +188,7 @@ def test_three_back_to_back_watch_fires_drain_into_one_turn(tmp_db, monkeypatch)
|
||||
assert any("fire two" in b for b in bodies)
|
||||
assert any("fire three" in b for b in bodies)
|
||||
# And there's exactly ONE assistant turn (not three).
|
||||
assistant_turns = [m for m in session.messages if m.get("role") == "assistant"]
|
||||
assistant_turns = [m for m in msgs if m.get("role") == "assistant"]
|
||||
assert len(assistant_turns) == 1
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from turnstone.core.metacognition import (
|
||||
format_idle_children_nudge,
|
||||
should_nudge,
|
||||
)
|
||||
from turnstone.core.trajectory import Role
|
||||
from turnstone.core.workstream import WorkstreamKind, WorkstreamState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -250,13 +251,10 @@ class CoordinatorIdleObserver:
|
||||
``wait_for_workstream`` tool call, return ``True``.
|
||||
"""
|
||||
for msg in reversed(session.messages):
|
||||
if msg.get("role") != "assistant":
|
||||
if msg.role is not Role.ASSISTANT:
|
||||
continue
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
fn = tc.get("function", {}) or {}
|
||||
if fn.get("name") == "wait_for_workstream":
|
||||
return True
|
||||
return False # found the most recent assistant turn — done
|
||||
# The most recent assistant turn — done after this one.
|
||||
return any(tc.name == "wait_for_workstream" for tc in msg.tool_calls)
|
||||
return False
|
||||
|
||||
def _active_children(self, ws: Workstream) -> list[dict[str, str]]:
|
||||
|
||||
+60
-46
@@ -123,6 +123,14 @@ from turnstone.core.tools import (
|
||||
TASK_AUTO_TOOLS,
|
||||
merge_mcp_tools,
|
||||
)
|
||||
from turnstone.core.trajectory import (
|
||||
Role,
|
||||
Turn,
|
||||
dicts_from_turns,
|
||||
turn_from_dict,
|
||||
turn_to_dict,
|
||||
turns_from_dicts,
|
||||
)
|
||||
from turnstone.core.watch import WATCH_REMINDER_OPTIONAL_KEYS
|
||||
from turnstone.core.web import check_ssrf, strip_html
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
@@ -1018,7 +1026,10 @@ class ChatSession:
|
||||
self._ws_id = ws_id or uuid.uuid4().hex
|
||||
self._title_generated = False
|
||||
self._read_files: set[str] = set()
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
# The canonical in-memory trajectory. Wire prep (fold/repair) + the
|
||||
# provider translators still consume dicts, so ``_full_messages`` lowers
|
||||
# Turns→dicts at that boundary until those layers migrate.
|
||||
self.messages: list[Turn] = []
|
||||
self._last_usage: dict[str, int] | None = None
|
||||
self._msg_tokens: list[int] = [] # parallel to self.messages
|
||||
self._system_tokens = 0 # tokens for system_messages
|
||||
@@ -2375,13 +2386,10 @@ class ChatSession:
|
||||
user_msg = ""
|
||||
asst_msg = ""
|
||||
for m in self.messages:
|
||||
content = m.get("content") or ""
|
||||
# Handle multi-part content (vision messages)
|
||||
if isinstance(content, list):
|
||||
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
if m["role"] == "user" and not user_msg:
|
||||
content = m.text # joins text blocks; multipart attachments contribute none
|
||||
if m.role is Role.USER and not user_msg:
|
||||
user_msg = content[:300]
|
||||
elif m["role"] == "assistant" and not asst_msg:
|
||||
elif m.role is Role.ASSISTANT and not asst_msg:
|
||||
asst_msg = content[:200]
|
||||
if user_msg and asst_msg:
|
||||
break
|
||||
@@ -2474,12 +2482,12 @@ class ChatSession:
|
||||
so the resumed/forked workstream behaves identically to the
|
||||
original. Returns True on success.
|
||||
"""
|
||||
messages = load_messages(ws_id)
|
||||
if not messages:
|
||||
turns = turns_from_dicts(load_messages(ws_id))
|
||||
if not turns:
|
||||
return False
|
||||
if not fork:
|
||||
self._ws_id = ws_id
|
||||
self.messages = messages
|
||||
self.messages = turns
|
||||
self._read_files.clear()
|
||||
self._repeat_detector.clear()
|
||||
self._last_usage = None
|
||||
@@ -2491,7 +2499,7 @@ class ChatSession:
|
||||
log.info(
|
||||
"Resuming ws=%s: %d messages, provider=%s, model=%s",
|
||||
ws_id,
|
||||
len(messages),
|
||||
len(self.messages),
|
||||
type(self._provider).__name__,
|
||||
self.model,
|
||||
)
|
||||
@@ -2574,7 +2582,8 @@ class ChatSession:
|
||||
if fork:
|
||||
# Bulk-insert all messages in a single transaction for performance.
|
||||
bulk_rows: list[dict[str, Any]] = []
|
||||
for msg in self.messages:
|
||||
for turn in self.messages:
|
||||
msg = turn_to_dict(turn)
|
||||
tc = msg.get("tool_calls")
|
||||
tc_json = json.dumps(tc) if tc else None
|
||||
# Provider-fidelity blocks ride the in-memory
|
||||
@@ -2828,7 +2837,7 @@ class ChatSession:
|
||||
if self.instructions:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(self.instructions)
|
||||
context = extract_recent_context(self.messages)
|
||||
context = extract_recent_context(dicts_from_turns(self.messages))
|
||||
if context.strip():
|
||||
# Composed against a real user-message query at least once; send()
|
||||
# uses this to know the deferred first-turn recompose is done.
|
||||
@@ -2871,8 +2880,12 @@ class ChatSession:
|
||||
self._agent_system_messages = list(new_system_messages)
|
||||
|
||||
def _full_messages(self) -> list[dict[str, Any]]:
|
||||
"""System messages + conversation history."""
|
||||
return self.system_messages + self.messages
|
||||
"""System messages + conversation history, lowered to wire dicts.
|
||||
|
||||
``self.messages`` is the canonical ``Turn`` trajectory; the wire-prep and
|
||||
provider layers still consume dicts, so the Turns are lowered here (this
|
||||
boundary moves inward when those layers migrate)."""
|
||||
return self.system_messages + dicts_from_turns(self.messages)
|
||||
|
||||
def _prepare_wire_messages(
|
||||
self,
|
||||
@@ -3563,7 +3576,7 @@ class ChatSession:
|
||||
}
|
||||
for a in attachments
|
||||
]
|
||||
self.messages.append(user_msg)
|
||||
self.messages.append(turn_from_dict(user_msg))
|
||||
self._msg_tokens.append(max(1, int(self._msg_char_count(user_msg) / self._chars_per_token)))
|
||||
# DB row stores the raw text only; attachment bytes are written
|
||||
# content-addressed into workstream_attachments and the ordered id-list
|
||||
@@ -3696,7 +3709,7 @@ class ChatSession:
|
||||
before the wire by ``sanitize_messages``).
|
||||
"""
|
||||
turn = make_system_turn(source, content, **meta)
|
||||
self.messages.append(turn)
|
||||
self.messages.append(turn_from_dict(turn))
|
||||
self._msg_tokens.append(max(1, int(self._msg_char_count(turn) / self._chars_per_token)))
|
||||
save_message(
|
||||
self._ws_id,
|
||||
@@ -3784,7 +3797,10 @@ class ChatSession:
|
||||
# flips True inside the recompose, so this fires once and the prefix
|
||||
# stays cache-stable after. Per-turn refresh is the larger redesign on
|
||||
# another branch.
|
||||
if not self._system_composed_with_context and extract_recent_context(self.messages).strip():
|
||||
if (
|
||||
not self._system_composed_with_context
|
||||
and extract_recent_context(dicts_from_turns(self.messages)).strip()
|
||||
):
|
||||
self._init_system_messages()
|
||||
|
||||
try:
|
||||
@@ -3863,7 +3879,7 @@ class ChatSession:
|
||||
# actually counted.
|
||||
self._update_token_table(assistant_msg, msgs=msgs)
|
||||
self._print_status_line() # Report usage for EVERY API call
|
||||
self.messages.append(assistant_msg)
|
||||
self.messages.append(turn_from_dict(assistant_msg))
|
||||
# Clear per-turn inflight buffers — the assistant
|
||||
# message is now in the history list a refresh would
|
||||
# replay, so the in_progress_snapshot shouldn't re-
|
||||
@@ -4063,7 +4079,7 @@ class ChatSession:
|
||||
tool_is_error = self._tool_error_flags.pop(tc_id, False)
|
||||
if tool_is_error:
|
||||
tool_msg["is_error"] = True
|
||||
self.messages.append(tool_msg)
|
||||
self.messages.append(turn_from_dict(tool_msg))
|
||||
|
||||
# Token estimation — image content uses a fixed heuristic
|
||||
if isinstance(output, list):
|
||||
@@ -4188,7 +4204,7 @@ class ChatSession:
|
||||
else:
|
||||
msg["content"] = "[generation cancelled before completion]"
|
||||
save_message(self._ws_id, "assistant", msg["content"], event_id=self._ui_event_id())
|
||||
self.messages.append(msg)
|
||||
self.messages.append(turn_from_dict(msg))
|
||||
tok_est = max(
|
||||
1,
|
||||
int(self._msg_char_count(msg) / self._chars_per_token),
|
||||
@@ -4248,7 +4264,7 @@ class ChatSession:
|
||||
assistant_idx = None
|
||||
for i in range(len(self.messages) - 1, -1, -1):
|
||||
msg = self.messages[i]
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
if msg.role is Role.ASSISTANT and msg.tool_calls:
|
||||
assistant_idx = i
|
||||
break
|
||||
if assistant_idx is None:
|
||||
@@ -4257,22 +4273,15 @@ class ChatSession:
|
||||
# Collect tool_call IDs that already have results
|
||||
answered_ids: set[str] = set()
|
||||
for msg in self.messages[assistant_idx + 1 :]:
|
||||
if msg.get("role") == "tool":
|
||||
answered_ids.add(msg.get("tool_call_id", ""))
|
||||
if msg.role is Role.TOOL:
|
||||
answered_ids.add(msg.tool_call_id or "")
|
||||
|
||||
# Synthesize results for unanswered tool_calls
|
||||
for tc in self.messages[assistant_idx].get("tool_calls", []):
|
||||
tc_id = tc.get("id", "")
|
||||
func_name = tc.get("function", {}).get("name", "")
|
||||
for tc in self.messages[assistant_idx].tool_calls:
|
||||
tc_id = tc.id
|
||||
func_name = tc.name
|
||||
if tc_id and tc_id not in answered_ids:
|
||||
self.messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": reason,
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
self.messages.append(Turn.tool(tc_id, reason, is_error=True))
|
||||
self._msg_tokens.append(1)
|
||||
save_message(
|
||||
self._ws_id,
|
||||
@@ -4302,7 +4311,7 @@ class ChatSession:
|
||||
|
||||
def _find_turn_boundaries(self) -> list[int]:
|
||||
"""Return indices of user messages in self.messages (turn start positions)."""
|
||||
return [i for i, m in enumerate(self.messages) if m["role"] == "user"]
|
||||
return [i for i, m in enumerate(self.messages) if m.role is Role.USER]
|
||||
|
||||
def rewind(self, n: int) -> int:
|
||||
"""Drop the last *n* complete turns from the conversation.
|
||||
@@ -4334,7 +4343,7 @@ class ChatSession:
|
||||
if not boundaries:
|
||||
return None
|
||||
last_user_idx = boundaries[-1]
|
||||
content = self.messages[last_user_idx].get("content")
|
||||
content = turn_to_dict(self.messages[last_user_idx]).get("content")
|
||||
# Multipart messages (vision/images) have list-type content;
|
||||
# retry only supports plain text.
|
||||
if not isinstance(content, str) or not content:
|
||||
@@ -4751,7 +4760,7 @@ class ChatSession:
|
||||
_IMAGE_TOKENS = 1000
|
||||
|
||||
@staticmethod
|
||||
def _msg_text_chars(msg: dict[str, Any]) -> tuple[int, int, int]:
|
||||
def _msg_text_chars(msg: dict[str, Any] | Turn) -> tuple[int, int, int]:
|
||||
"""Return ``(text_chars, image_count, doc_chars)`` for a message.
|
||||
|
||||
Counts textual content + structural overhead (role, tool_call
|
||||
@@ -4763,7 +4772,12 @@ class ChatSession:
|
||||
calibration — provider-native document blocks (Anthropic) and
|
||||
inlined text (OpenAI/Google) tokenize differently, so it's
|
||||
safer to exclude them from the text calibration.
|
||||
|
||||
Accepts a wire dict or a canonical ``Turn`` (the latter is lowered to
|
||||
its dict form so the char accounting matches what the provider sees).
|
||||
"""
|
||||
if isinstance(msg, Turn):
|
||||
msg = turn_to_dict(msg)
|
||||
content = msg.get("content")
|
||||
n = 0
|
||||
images = 0
|
||||
@@ -4791,12 +4805,12 @@ class ChatSession:
|
||||
n += len(msg.get("tool_call_id", ""))
|
||||
return n, images, doc_chars
|
||||
|
||||
def _msg_char_count(self, msg: dict[str, Any]) -> int:
|
||||
def _msg_char_count(self, msg: dict[str, Any] | Turn) -> int:
|
||||
"""Count characters in a message, including structural overhead.
|
||||
|
||||
Includes role markers, tool_call IDs, image placeholders, and
|
||||
document-part characters so that the budget estimate reflects
|
||||
the full payload the provider sees.
|
||||
the full payload the provider sees. Accepts a wire dict or a ``Turn``.
|
||||
"""
|
||||
text_chars, images, doc_chars = self._msg_text_chars(msg)
|
||||
return text_chars + doc_chars + int(images * self._IMAGE_TOKENS * self._chars_per_token)
|
||||
@@ -4949,8 +4963,8 @@ class ChatSession:
|
||||
last_user_content = None
|
||||
if auto:
|
||||
for m in reversed(self.messages):
|
||||
if m["role"] == "user":
|
||||
last_user_content = m.get("content") or ""
|
||||
if m.role is Role.USER:
|
||||
last_user_content = m.text or ""
|
||||
break
|
||||
|
||||
to_summarize = self.messages
|
||||
@@ -4980,7 +4994,7 @@ class ChatSession:
|
||||
return
|
||||
|
||||
# Build summary prompt and call model
|
||||
formatted = self._format_messages_for_summary(selected)
|
||||
formatted = self._format_messages_for_summary(dicts_from_turns(selected))
|
||||
summary_msgs = [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -5070,7 +5084,7 @@ class ChatSession:
|
||||
before_tokens = self._system_tokens + sum(self._msg_tokens)
|
||||
summary_user = {"role": "user", "content": "[Conversation summary]"}
|
||||
summary_asst = {"role": "assistant", "content": summary}
|
||||
self.messages = [summary_user, summary_asst]
|
||||
self.messages = turns_from_dicts([summary_user, summary_asst])
|
||||
# File contents are gone after compaction — force re-read before edit_file
|
||||
self._read_files.clear()
|
||||
self._repeat_detector.clear()
|
||||
@@ -5363,7 +5377,7 @@ class ChatSession:
|
||||
|
||||
heuristic_verdicts = judge.evaluate(
|
||||
pending,
|
||||
list(self.messages), # snapshot — daemon thread must not see mutations
|
||||
dicts_from_turns(self.messages), # snapshot — daemon thread must not see mutations
|
||||
callback=_on_verdict,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
@@ -12437,7 +12451,7 @@ class ChatSession:
|
||||
# Clear history when toggling ON if it contains tool messages,
|
||||
# because the API rejects tool-call history without tool definitions
|
||||
if self.creative_mode and any(
|
||||
m.get("tool_calls") or m.get("role") == "tool" for m in self.messages
|
||||
m.tool_calls or m.role is Role.TOOL for m in self.messages
|
||||
):
|
||||
self.messages.clear()
|
||||
self._read_files.clear()
|
||||
|
||||
@@ -199,13 +199,16 @@ def _content_from_raw(raw: Any) -> tuple[ContentBlock, ...]:
|
||||
def _content_to_raw(content: tuple[ContentBlock, ...]) -> str | list[dict[str, Any]]:
|
||||
"""Inverse of :func:`_content_from_raw`.
|
||||
|
||||
All-text content collapses to a plain string (the 95% case, and what an empty
|
||||
turn round-trips to); any non-text block forces the multipart list form.
|
||||
A single text block collapses to a plain string (the 95% case, and what an
|
||||
empty turn round-trips to); multiple blocks — or any non-text block — take
|
||||
the multipart list form. The multiple-block rule preserves an all-*text*
|
||||
multipart list (the unreadable-attachment placeholder path emits one) instead
|
||||
of collapsing it to a joined string.
|
||||
"""
|
||||
if not content:
|
||||
return ""
|
||||
if all(isinstance(b, TextBlock) for b in content):
|
||||
return "".join(b.text for b in content if isinstance(b, TextBlock))
|
||||
if len(content) == 1 and isinstance(content[0], TextBlock):
|
||||
return content[0].text
|
||||
parts: list[dict[str, Any]] = []
|
||||
for b in content:
|
||||
if isinstance(b, TextBlock):
|
||||
|
||||
+6
-5
@@ -36,6 +36,7 @@ from turnstone.core.providers import LLMProvider, create_client, create_provider
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage import init_storage, reset_storage
|
||||
from turnstone.core.tools import INTERACTIVE_TOOLS, PRIMARY_KEY_MAP
|
||||
from turnstone.core.trajectory import Role, turn_from_dict
|
||||
|
||||
# Eval evaluates interactive-session agent behaviour — coordinator tools
|
||||
# require a console-hosted session and aren't exercised by the harness.
|
||||
@@ -280,7 +281,7 @@ class HeadlessSession(ChatSession):
|
||||
tool: str, args: dict, result: str (truncated), turn: int
|
||||
"""
|
||||
self.tool_call_log = []
|
||||
self.messages.append({"role": "user", "content": user_input})
|
||||
self.messages.append(turn_from_dict({"role": "user", "content": user_input}))
|
||||
self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token)))
|
||||
|
||||
for turn in range(max_turns):
|
||||
@@ -321,7 +322,7 @@ class HeadlessSession(ChatSession):
|
||||
# Cap parallel tool calls to prevent degenerate repetition
|
||||
assistant_msg["tool_calls"] = result.tool_calls[:10]
|
||||
|
||||
self.messages.append(assistant_msg)
|
||||
self.messages.append(turn_from_dict(assistant_msg))
|
||||
msg_len = len(assistant_msg.get("content") or "")
|
||||
self._msg_tokens.append(max(1, int(msg_len / self._chars_per_token)))
|
||||
|
||||
@@ -401,7 +402,7 @@ class HeadlessSession(ChatSession):
|
||||
"tool_call_id": tc_id,
|
||||
"content": raw_output,
|
||||
}
|
||||
self.messages.append(tool_msg)
|
||||
self.messages.append(turn_from_dict(tool_msg))
|
||||
self._msg_tokens.append(max(1, int(len(output) / self._chars_per_token)))
|
||||
|
||||
return self.tool_call_log
|
||||
@@ -507,8 +508,8 @@ def _run_single_test(
|
||||
|
||||
# Extract results before releasing session
|
||||
for msg in reversed(session.messages):
|
||||
if msg["role"] == "assistant" and msg.get("content"):
|
||||
final_content = msg["content"]
|
||||
if msg.role is Role.ASSISTANT and msg.text:
|
||||
final_content = msg.text
|
||||
break
|
||||
message_count = len(session.messages)
|
||||
total_usage = session.total_usage
|
||||
|
||||
+3
-1
@@ -88,6 +88,7 @@ from turnstone.core.session_ui_base import (
|
||||
fire_judge_verdict_metric,
|
||||
)
|
||||
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
|
||||
from turnstone.core.trajectory import turn_to_dict
|
||||
from turnstone.core.web_helpers import version_html as _version_html
|
||||
from turnstone.core.workstream import (
|
||||
Workstream,
|
||||
@@ -1627,7 +1628,8 @@ def _validate_notify_targets(raw: Any) -> tuple[str, str]:
|
||||
|
||||
def _extract_last_assistant_content(session: Any) -> str:
|
||||
"""Return the text content of the last assistant message."""
|
||||
for msg in reversed(session.messages):
|
||||
for turn in reversed(session.messages):
|
||||
msg = turn_to_dict(turn)
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
|
||||
Reference in New Issue
Block a user