Files
2026-07-13 13:12:00 +08:00

4591 lines
177 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for SqlAlchemyConversationStore."""
from __future__ import annotations
import pytest
from sqlalchemy import text
from omnigent.db.utils import get_or_create_engine
from omnigent.entities import (
ErrorData,
FunctionCallData,
FunctionCallOutputData,
MessageData,
NewConversationItem,
ReasoningData,
)
from omnigent.server.auth import RESERVED_USER_LOCAL
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
from omnigent.stores.host_store import HostStore
# ── CRUD ──────────────────────────────────────────────
def test_create_and_get(conversation_store: SqlAlchemyConversationStore) -> None:
conv = conversation_store.create_conversation()
assert conv.id.startswith("conv_")
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.id == conv.id
def test_get_nonexistent(conversation_store: SqlAlchemyConversationStore) -> None:
assert conversation_store.get_conversation("conv_none") is None
def test_get_conversations_bulk(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``get_conversations`` returns one entry per resolvable id, omits
unknown ids, and carries each row's batched labels — matching what
a per-id ``get_conversation`` fan-out would produce, which is what
the ``WS /v1/sessions/updates`` rescan relies on.
"""
a = conversation_store.create_conversation(title="alpha")
b = conversation_store.create_conversation(title="beta")
# Label only one row to prove labels are joined per-id, not smeared
# across the batch or dropped for the unlabeled row.
conversation_store.set_labels(a.id, {"omnigent.ui": "terminal"})
result = conversation_store.get_conversations([a.id, b.id, "conv_missing"])
# The unknown id is omitted rather than mapped to None — the caller
# treats absence as "no longer resolves".
assert set(result) == {a.id, b.id}
# Titles prove the real rows came back, not placeholder shells.
assert result[a.id].title == "alpha"
assert result[b.id].title == "beta"
# Labels are attached to the row they belong to and only that row.
assert result[a.id].labels == {"omnigent.ui": "terminal"}
assert result[b.id].labels == {}
def test_get_conversations_empty_input_skips_query(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Empty id list returns an empty map without a database round-trip."""
assert conversation_store.get_conversations([]) == {}
def test_list_latest_message_items_for_conversations(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``list_latest_message_items_for_conversations`` returns newest message
rows per conversation in one batched call.
The child-session summary route depends on this helper to avoid an
event-loop-blocking N+1 ``list_items`` fan-out. This test seeds two
conversations with interleaved item types, asks for two messages per
conversation, and asserts exact per-conversation ordering and filtering.
:param conversation_store: SQLAlchemy conversation store fixture.
"""
conv_a = conversation_store.create_conversation(title="alpha")
conv_b = conversation_store.create_conversation(title="beta")
conv_empty = conversation_store.create_conversation(title="empty")
conversation_store.append(
conv_a.id,
[
NewConversationItem(
type="message",
response_id="resp_a",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "alpha old"}],
agent="worker",
),
),
NewConversationItem(
type="reasoning",
response_id="resp_a",
data=ReasoningData(
agent="worker",
summary=[{"type": "summary_text", "text": "thinking"}],
),
),
NewConversationItem(
type="message",
response_id="resp_a",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "alpha new"}],
agent="worker",
),
),
],
)
conversation_store.append(
conv_b.id,
[
NewConversationItem(
type="message",
response_id="resp_b",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "bravo old"}],
agent="worker",
),
),
NewConversationItem(
type="message",
response_id="resp_b",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "bravo middle"}],
agent="worker",
),
),
NewConversationItem(
type="message",
response_id="resp_b",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "bravo new"}],
agent="worker",
),
),
],
)
result = conversation_store.list_latest_message_items_for_conversations(
[conv_a.id, conv_b.id, conv_empty.id, "conv_missing"],
per_conversation_limit=2,
)
def _texts(conversation_id: str) -> list[str]:
"""Extract assistant text from the returned message items.
:param conversation_id: Conversation id whose returned messages
should be inspected.
:returns: Ordered list of text blocks from that conversation's
returned message items.
"""
texts: list[str] = []
for item in result[conversation_id]:
assert isinstance(item.data, MessageData)
texts.append(item.data.content[0]["text"])
return texts
assert set(result) == {conv_a.id, conv_b.id, conv_empty.id, "conv_missing"}
assert _texts(conv_a.id) == ["alpha new", "alpha old"]
assert _texts(conv_b.id) == ["bravo new", "bravo middle"]
assert result[conv_empty.id] == []
assert result["conv_missing"] == []
def test_update_title(conversation_store: SqlAlchemyConversationStore) -> None:
conv = conversation_store.create_conversation()
updated = conversation_store.update_conversation(conv.id, title="Chat 1")
assert updated is not None
assert updated.title == "Chat 1"
assert conversation_store.update_conversation("conv_none", title="x") is None
def test_update_archived_round_trip(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``update_conversation(archived=...)`` persists the flag both ways
and a fresh conversation defaults to not-archived.
Re-fetching via ``get_conversation`` (a separate read from the DB)
proves the column was actually written, not just reflected on the
in-session ORM object. A failure here means the archive column
isn't persisted — the sidebar's archive button would appear to do
nothing after a refresh.
"""
conv = conversation_store.create_conversation()
# Default state: a brand-new session is not archived. Read it back
# from the DB so we exercise the server_default / column mapping.
assert conversation_store.get_conversation(conv.id).archived is False
archived = conversation_store.update_conversation(conv.id, archived=True)
assert archived is not None
assert archived.archived is True
# Persisted, not just on the returned object.
assert conversation_store.get_conversation(conv.id).archived is True
unarchived = conversation_store.update_conversation(conv.id, archived=False)
assert unarchived is not None
assert unarchived.archived is False
assert conversation_store.get_conversation(conv.id).archived is False
def test_update_archived_none_leaves_unchanged(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``archived=None`` (the default) must not touch the stored flag.
The PATCH route passes ``archived=body.archived`` on every session
update — including title-only edits where ``archived`` is ``None``.
If ``None`` were treated as "set to false", renaming an archived
session would silently unarchive it. This guards that.
"""
conv = conversation_store.create_conversation()
conversation_store.update_conversation(conv.id, archived=True)
# A title-only update (archived defaults to None) must leave the
# archived flag set.
conversation_store.update_conversation(conv.id, title="Renamed")
refetched = conversation_store.get_conversation(conv.id)
assert refetched.title == "Renamed"
assert refetched.archived is True, (
"archived=None must leave the flag unchanged; a title-only edit "
"unarchived the session, which would lose archive state on rename."
)
def test_update_archived_bumps_updated_at(
conversation_store: SqlAlchemyConversationStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Toggling archived advances ``updated_at`` (like title/effort do).
The clock is monkeypatched to a fixed, larger value so the bump is
deterministic regardless of wall-clock resolution. The web client
relies on this bump being acknowledged (``markConversationSeen``)
so a self-initiated archive isn't mistaken for new activity — if
the bump regressed to a no-op, that contract would silently change.
"""
conv = conversation_store.create_conversation()
created_at = conversation_store.get_conversation(conv.id).updated_at
# Pin the clock past created_at so the new updated_at is unambiguous.
monkeypatch.setattr(
"omnigent.stores.conversation_store.sqlalchemy_store.now_epoch",
lambda: created_at + 100,
)
updated = conversation_store.update_conversation(conv.id, archived=True)
assert updated is not None
assert updated.updated_at == created_at + 100, (
f"archiving should stamp updated_at via now_epoch(); expected "
f"{created_at + 100}, got {updated.updated_at}. If it equals "
f"{created_at}, the archive write didn't mark the row changed."
)
# ── Append & list items ──────────────────────────────
def test_append_and_list_items(conversation_store: SqlAlchemyConversationStore) -> None:
conv = conversation_store.create_conversation()
items = conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_001",
data=MessageData(role="user", content=[{"type": "input_text", "text": "Hello"}]),
),
NewConversationItem(
type="message",
response_id="resp_001",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "Hi there!"}],
agent="test-agent",
),
),
],
)
assert len(items) == 2
assert items[0].id.startswith("msg_")
assert items[1].id.startswith("msg_")
page = conversation_store.list_items(conv.id)
assert len(page.data) == 2
assert page.data[0].data.role == "user"
assert page.data[1].data.role == "assistant"
def test_append_records_human_author_attribution(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""A human-authored item round-trips its author identity.
Analogue of the comment ``created_by`` contract: the actor who
posted the message is recorded on the persisted item and read back
on listing.
"""
conv = conversation_store.create_conversation()
[persisted] = conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_attr",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "Please review"}],
),
created_by="alice@example.com",
)
],
)
assert persisted.created_by == "alice@example.com"
[read_back] = conversation_store.list_items(conv.id).data
assert read_back.created_by == "alice@example.com"
def test_append_leaves_created_by_none_for_agent_items(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Items appended without an actor (agent/tool/system) read back None.
Keeps agent output distinguishable from human-authored messages.
"""
conv = conversation_store.create_conversation()
[persisted] = conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_agent",
data=MessageData(
role="assistant",
agent="my-agent",
content=[{"type": "output_text", "text": "Done"}],
),
)
],
)
assert persisted.created_by is None
[read_back] = conversation_store.list_items(conv.id).data
assert read_back.created_by is None
def test_append_function_call_items(
conversation_store: SqlAlchemyConversationStore,
) -> None:
conv = conversation_store.create_conversation()
items = conversation_store.append(
conv.id,
[
NewConversationItem(
type="function_call",
response_id="resp_002",
data=FunctionCallData(
agent="test-agent",
name="get_weather",
arguments='{"city": "SF"}',
call_id="call_001",
),
),
NewConversationItem(
type="function_call_output",
response_id="resp_002",
data=FunctionCallOutputData(
call_id="call_001",
output='{"temp": 65}',
),
),
],
)
assert items[0].id.startswith("fc_")
assert items[1].id.startswith("fco_")
def test_append_tool_output_with_nul_bytes(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""
Tool output containing NUL (0x00) bytes must still persist.
Reproduces the production failure where a tool returned bytes from
a binary file: the embedded NUL hit a Postgres text column and
aborted the whole INSERT (``psycopg.DataError: PostgreSQL text
fields cannot contain NUL (0x00) bytes``), so the function call
output never persisted. SQLite tolerates NUL, so the deterministic
guard below is reading the persisted columns and asserting no raw
NUL survived — that fails if the store stops stripping NUL.
"""
conv = conversation_store.create_conversation()
# "marker_unique_token" is space-delimited from the NUL run so we
# can later prove it remained FTS-indexable after sanitization.
nul_output = "binary chunk \x00\x00\x00\x00 marker_unique_token"
items = conversation_store.append(
conv.id,
[
NewConversationItem(
type="function_call_output",
response_id="resp_nul",
data=FunctionCallOutputData(
call_id="call_nul",
output=nul_output,
),
),
],
)
# append() returning normally (not raising DataError) is the
# primary reproduction: pre-fix this INSERT aborted on Postgres.
item_id = items[0].id
assert item_id.startswith("fco_")
# The payload round-trips faithfully: NUL is preserved in the data
# column (json.dumps escapes it to the literal 6-char
# backslash-u0000 sequence, which Postgres accepts and json.loads
# decodes back). Stripping is lossy for the FTS index only, never
# for the stored output.
page = conversation_store.list_items(conv.id)
assert page.data[0].data.output == nul_output
# Deterministic backend-agnostic guard: inspect the persisted
# columns directly. search_text must have had its raw NUL removed
# (this is the line that fails if append() stops calling
# strip_nul_bytes); data must carry no raw NUL either (json
# escaping keeps it as the literal backslash-u0000 sequence).
engine = get_or_create_engine(db_uri)
with engine.connect() as conn:
row = conn.execute(
text("SELECT data, search_text FROM conversation_items WHERE id = :id"),
{"id": item_id},
).one()
assert "\x00" not in row.search_text
assert "\x00" not in row.data
# The meaningful, NUL-adjacent token is still searchable, proving
# sanitization dropped only the NUL and left real tokens indexed.
results = conversation_store.search("marker_unique_token", conversation_id=conv.id)
assert [r.id for r in results] == [item_id]
def test_append_reasoning_item(conversation_store: SqlAlchemyConversationStore) -> None:
conv = conversation_store.create_conversation()
items = conversation_store.append(
conv.id,
[
NewConversationItem(
type="reasoning",
response_id="resp_003",
data=ReasoningData(
agent="test-agent",
summary=[{"type": "summary_text", "text": "Thinking..."}],
),
),
],
)
assert items[0].id.startswith("rs_")
def test_append_error_item_round_trips_for_history(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Persisted ``error`` items survive the real SQLAlchemy store path
and flatten to the same shape ``GET /sessions/{id}/items`` returns.
A regression here reproduces the web symptom where a live
``response.error`` banner appears but disappears after refresh:
either append/list rejects the new item type, or the API shape no
longer contains the fields ``itemsToBlocks`` needs to rebuild the
banner.
:param conversation_store: SQLAlchemy conversation store fixture.
"""
conv = conversation_store.create_conversation()
[persisted] = conversation_store.append(
conv.id,
[
NewConversationItem(
type="error",
response_id="resp_failed",
data=ErrorData(
source="execution",
code="native_terminal_start_failed",
message="Native Codex requires the 'codex' CLI on PATH.",
),
),
],
)
assert persisted.id.startswith("err_")
[read_back] = conversation_store.list_items(conv.id).data
assert read_back.id == persisted.id
assert read_back.response_id == "resp_failed"
assert isinstance(read_back.data, ErrorData)
assert read_back.data.message == "Native Codex requires the 'codex' CLI on PATH."
assert read_back.to_api_dict() == {
"id": persisted.id,
"response_id": "resp_failed",
"type": "error",
"status": "completed",
"source": "execution",
"code": "native_terminal_start_failed",
"message": "Native Codex requires the 'codex' CLI on PATH.",
}
# ── Ordering & cursors ───────────────────────────────
def test_position_ordering(conversation_store: SqlAlchemyConversationStore) -> None:
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_a",
data=MessageData(role="user", content=[{"type": "input_text", "text": "First"}]),
),
],
)
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_b",
data=MessageData(role="user", content=[{"type": "input_text", "text": "Second"}]),
),
],
)
page = conversation_store.list_items(conv.id)
assert len(page.data) == 2
texts = [page.data[i].data.content[0]["text"] for i in range(2)]
assert texts == ["First", "Second"]
def test_unique_position_constraint(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
The (conversation_id, position) pair has a unique index.
Verify that manually inserting a duplicate position raises
IntegrityError, confirming the safety net is in place.
"""
from sqlalchemy.exc import IntegrityError
from omnigent.db.db_models import SqlConversationItem
from omnigent.db.enum_codecs import encode_item_status, encode_item_type
from omnigent.db.utils import generate_item_id
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_dup",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "first"}],
),
),
],
)
# Directly insert a row at position 0 (already taken) to
# confirm the unique constraint rejects it.
with pytest.raises(IntegrityError):
with conversation_store._session() as session:
session.add(
SqlConversationItem(
id=generate_item_id("message"),
conversation_id=conv.id,
response_id="resp_dup",
created_at=0,
status=encode_item_status("completed"),
position=0, # duplicate
type=encode_item_type("message"),
data='{"role":"user","content":[]}',
search_text="",
)
)
def test_concurrent_appends_do_not_collide_on_position(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Two concurrent ``append()`` calls on the same conversation
must not collide on position assignment.
Reproduces the UNIQUE-constraint race observed 2026-04-30 in
a live REPL session with the user's 20-shell scenario: the
agent loop's incremental tool-call persist and the steering
inbox's auto-injection of idle-notification user messages
both ran ``append()`` for the same conversation_id. Both
transactions read ``max(position) = N``, both INSERTed at
position ``N+1``, the loser crashed with
``IntegrityError: UNIQUE constraint failed:
conversation_items.conversation_id, conversation_items.position``.
The race window came from SQLite starting transactions as
DEFERRED (no write lock until first DML), so the
``select(max(position))`` in :meth:`append` ran without
holding any lock. Fixed by upgrading
:meth:`_lock_conversation` from a no-op on SQLite to an
UPDATE that escalates the transaction to RESERVED.
What this test proves and what a failure means:
- Running N appends concurrently on the same conversation
produces N items with N distinct positions, no
``IntegrityError``. If a thread raises, the
transaction-escalation fix regressed.
- Final positions are contiguous from 0 to ``items_per_thread *
threads - 1``. Gaps would mean a write succeeded but its
position was reused — a worse failure mode than the
IntegrityError.
Does NOT use mocks: real :class:`SqlAlchemyConversationStore`,
real SQLite (the conftest's per-test temp DB), real threads
so SQLite's actual lock semantics participate.
"""
import threading
conv = conversation_store.create_conversation()
threads_count = 8
items_per_thread = 5
errors: list[Exception] = []
errors_lock = threading.Lock()
def _append_n(thread_idx: int) -> None:
try:
for i in range(items_per_thread):
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id=f"resp_t{thread_idx}",
data=MessageData(
role="user",
content=[
{
"type": "input_text",
"text": f"t{thread_idx}-{i}",
}
],
),
),
],
)
except Exception as exc:
with errors_lock:
errors.append(exc)
threads = [threading.Thread(target=_append_n, args=(i,)) for i in range(threads_count)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30.0)
# No thread raised — the lock escalation prevented every
# IntegrityError. If non-empty, the race regressed and the
# user-reported "UNIQUE constraint failed" symptom is back.
assert errors == [], (
f"Concurrent appends raised {len(errors)} error(s); the "
f"first was: {errors[0]!r}. The position-race fix in "
f"_lock_conversation regressed — SQLite transactions are "
f"running concurrent SELECT max(position) without a "
f"write lock again."
)
# All items persisted with distinct IDs. Position
# uniqueness is enforced by the
# ``ix_conversation_items_conversation_id_position`` UNIQUE
# index on the SQL table, so any race that produced
# duplicate positions would have raised IntegrityError —
# caught above. The remaining check here is that no append
# silently dropped: every (thread, item) pair surfaced as
# exactly one ConversationItem in the public listing.
items = conversation_store.list_items(conv.id, limit=1000).data
expected_count = threads_count * items_per_thread
assert len(items) == expected_count, (
f"expected {expected_count} items; got {len(items)}. "
f"Some appends silently dropped despite not raising — "
f"that would be a worse regression than the original "
f"IntegrityError."
)
item_ids = {item.id for item in items}
assert len(item_ids) == expected_count, (
f"expected {expected_count} distinct item IDs; got "
f"{len(item_ids)}. Duplicate IDs would mean an item was "
f"persisted twice or list_items returned duplicates."
)
# Positions are contiguous 0..N-1. The UNIQUE index catches *reused*
# positions (IntegrityError, asserted above), but a counter that
# over-advances — or an append silently skipped — leaves a *gap* with no
# error. list_items hides position, so assert on the raw column directly.
assert _stored_positions(conversation_store, conv.id) == list(range(expected_count)), (
"concurrent appends must allocate a gap-free 0..N-1 position sequence"
)
def test_heavy_batch_racing_steering_append_does_not_collide(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Models the exact user-reported race shape from 2026-04-30:
one path appends a heavy batch (assistant message +
function_call + function_call_output items, mirroring the
workflow's incremental tool-call persist via
``_handle_executor_event``), while a second path concurrently
appends a single user message (mirroring the steering
inbox's auto-injection of an idle notification).
The unit test ``test_concurrent_appends_do_not_collide_on_position``
proves the SQL-level race exists with N homogeneous threads.
This test pins the SHAPE of the user's actual failure: a
multi-item append (which holds the reserve longer because it
inserts more rows) racing a single-item append (the steering
write). If a regression introduces a DIFFERENT race window
that only surfaces under this asymmetric pattern, this test
catches it.
On revert: the heavy-batch thread holds RESERVED while
inserting message + fc + fco, the steering thread blocks on
busy_timeout, then re-reads max_pos. With the fix, no
IntegrityError. Without the fix, the steering thread reads
max_pos before the heavy batch commits and one of them
crashes.
"""
import threading
conv = conversation_store.create_conversation()
barrier = threading.Barrier(2, timeout=10.0)
errors: list[tuple[str, Exception]] = []
errors_lock = threading.Lock()
def _heavy_batch() -> None:
"""Mimics ``_handle_executor_event``: 1 message + 1 fc + 1 fco per call."""
try:
barrier.wait()
for i in range(20):
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_workflow",
data=MessageData(
role="assistant",
content=[
{
"type": "output_text",
"text": f"calling tool {i}",
}
],
agent="agent_test",
),
),
NewConversationItem(
type="function_call",
response_id="resp_workflow",
data=FunctionCallData(
name="sys_terminal_launch",
arguments=f'{{"session": "sh{i}"}}',
call_id=f"call_{i}",
agent="agent_test",
),
),
NewConversationItem(
type="function_call_output",
response_id="resp_workflow",
data=FunctionCallOutputData(
call_id=f"call_{i}",
output='{"status": "launched"}',
),
),
],
)
except Exception as exc:
with errors_lock:
errors.append(("heavy_batch", exc))
def _steering_appends() -> None:
"""Mimics the steering inbox auto-injecting idle notifications."""
try:
barrier.wait()
for i in range(20):
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_steering",
data=MessageData(
role="user",
content=[
{
"type": "input_text",
"text": (f"[System: terminal sh{i} is idle]"),
}
],
),
),
],
)
except Exception as exc:
with errors_lock:
errors.append(("steering", exc))
heavy = threading.Thread(target=_heavy_batch)
steering = threading.Thread(target=_steering_appends)
heavy.start()
steering.start()
heavy.join(timeout=30.0)
steering.join(timeout=30.0)
# Neither thread crashed — the lock escalation serialized
# both. Without the fix, one of them raises
# ``IntegrityError: UNIQUE constraint failed:
# conversation_items.conversation_id, conversation_items.position``.
assert errors == [], (
f"Heavy-batch + steering append race produced {len(errors)} "
f"error(s). First: {errors[0]!r}. The user-reported "
f"2026-04-30 IntegrityError is back."
)
# Total = 20 batches × 3 items + 20 steering items = 80.
# Position uniqueness is enforced by the SQL UNIQUE index
# on (conversation_id, position) — any race that produced a
# duplicate would have raised above. So count + ID
# distinctness is sufficient evidence that the lock
# escalation worked.
items = conversation_store.list_items(conv.id, limit=1000).data
assert len(items) == 80, f"expected 80 items; got {len(items)}"
item_ids = {item.id for item in items}
assert len(item_ids) == 80, (
f"expected 80 distinct item IDs; got {len(item_ids)}. "
f"Duplicate IDs would mean an item was persisted twice."
)
# Gap-free 0..79 across both threads: the heavy batch advances the
# next_position counter by 3 and the steering append by 1, so a counter
# that mis-advances under this asymmetric race would skip or reuse a
# position without tripping the UNIQUE index. Assert the raw column.
assert _stored_positions(conversation_store, conv.id) == list(range(80)), (
"heavy-batch + steering appends must allocate a gap-free 0..79 sequence"
)
def _make_5_items(conversation_store: SqlAlchemyConversationStore, conv_id: str):
"""Helper: append 5 messages and return the persisted items."""
return conversation_store.append(
conv_id,
[
NewConversationItem(
type="message",
response_id="resp_x",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": f"msg-{i}"}],
),
)
for i in range(5)
],
)
def test_list_items_after_cursor(
conversation_store: SqlAlchemyConversationStore,
) -> None:
conv = conversation_store.create_conversation()
items = _make_5_items(conversation_store, conv.id)
page = conversation_store.list_items(conv.id, after=items[1].id, limit=2)
assert len(page.data) == 2
assert page.data[0].id == items[2].id
def test_list_items_desc_order(
conversation_store: SqlAlchemyConversationStore,
) -> None:
conv = conversation_store.create_conversation()
_make_5_items(conversation_store, conv.id)
page_asc = conversation_store.list_items(conv.id, order="asc")
page_desc = conversation_store.list_items(conv.id, order="desc")
assert [it.id for it in page_asc.data] == list(reversed([it.id for it in page_desc.data]))
def test_list_items_desc_with_after_cursor(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""In desc order, 'after' means items with lower position."""
conv = conversation_store.create_conversation()
items = _make_5_items(conversation_store, conv.id)
# desc full page: [4, 3, 2, 1, 0]
page1 = conversation_store.list_items(conv.id, limit=2, order="desc")
assert page1.data[0].id == items[4].id
assert page1.data[1].id == items[3].id
assert page1.has_more is True
page2 = conversation_store.list_items(conv.id, limit=2, order="desc", after=page1.last_id)
assert page2.data[0].id == items[2].id
assert page2.data[1].id == items[1].id
def test_list_items_before_cursor(
conversation_store: SqlAlchemyConversationStore,
) -> None:
conv = conversation_store.create_conversation()
items = _make_5_items(conversation_store, conv.id)
# asc order: [0, 1, 2, 3, 4]; before item[3] should give [0, 1, 2]
page = conversation_store.list_items(conv.id, before=items[3].id, order="asc")
assert [it.id for it in page.data] == [items[i].id for i in range(3)]
# ── Conversation ID / response ID lookups ────────────
def test_search(conversation_store: SqlAlchemyConversationStore) -> None:
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_s1",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "weather in Paris"}],
),
),
NewConversationItem(
type="message",
response_id="resp_s1",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "sunny and warm"}],
agent="test-agent",
),
),
],
)
results = conversation_store.search("Paris")
assert len(results) == 1
assert results[0].type == "message"
results = conversation_store.search("sunny")
assert len(results) == 1
assert conversation_store.search("nonexistent") == []
def test_search_scoped_to_conversation(
conversation_store: SqlAlchemyConversationStore,
) -> None:
conv1 = conversation_store.create_conversation()
conv2 = conversation_store.create_conversation()
conversation_store.append(
conv1.id,
[
NewConversationItem(
type="message",
response_id="r1",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "hello world"}],
),
),
],
)
conversation_store.append(
conv2.id,
[
NewConversationItem(
type="message",
response_id="r2",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "hello universe"}],
),
),
],
)
# Unscoped: both match "hello"
assert len(conversation_store.search("hello")) == 2
# Scoped: only one per conversation
assert len(conversation_store.search("hello", conversation_id=conv1.id)) == 1
assert len(conversation_store.search("hello", conversation_id=conv2.id)) == 1
def test_search_function_call_item(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""FTS indexes function_call items by name and arguments."""
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="function_call",
response_id="resp_fc",
data=FunctionCallData(
agent="test-agent",
name="get_weather",
arguments='{"city": "Tokyo"}',
call_id="call_1",
),
),
],
)
assert len(conversation_store.search("get_weather")) == 1
assert len(conversation_store.search("Tokyo")) == 1
assert conversation_store.search("nonexistent") == []
def test_list_conversations_search_query_matches_content(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``list_conversations(search_query=...)`` matches conversations
whose title OR item content contains the query substring.
:param conversation_store: The conversation store fixture.
"""
conv_title = conversation_store.create_conversation()
conversation_store.update_conversation(conv_title.id, title="deployment runbook")
conv_content = conversation_store.create_conversation()
conversation_store.update_conversation(conv_content.id, title="General chat")
conversation_store.append(
conv_content.id,
[
NewConversationItem(
type="message",
response_id="resp_gc1",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "fix the deployment pipeline"}],
),
),
],
)
conv_neither = conversation_store.create_conversation()
conversation_store.update_conversation(conv_neither.id, title="Unrelated session")
page = conversation_store.list_conversations(search_query="deployment")
matched_ids = {c.id for c in page.data}
assert conv_title.id in matched_ids, "title match should be included"
assert conv_content.id in matched_ids, "content match should be included"
assert conv_neither.id not in matched_ids, "non-matching conversation excluded"
def test_list_conversations_search_query_content_only(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
A conversation with no title but matching item content is
returned by ``search_query``.
:param conversation_store: The conversation store fixture.
"""
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_co1",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "the server is healthy"}],
agent="test-agent",
),
),
],
)
page = conversation_store.list_conversations(search_query="healthy")
assert len(page.data) == 1
assert page.data[0].id == conv.id
def test_list_conversations_search_snippet_on_content_match(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
A content match carries a ``search_snippet`` excerpt of the matching
text; a title-only match leaves it ``None``.
:param conversation_store: The conversation store fixture.
"""
conv_content = conversation_store.create_conversation()
conversation_store.update_conversation(conv_content.id, title="General chat")
conversation_store.append(
conv_content.id,
[
NewConversationItem(
type="message",
response_id="resp_snip1",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "please fix the deployment pipeline"}],
),
),
],
)
conv_title = conversation_store.create_conversation()
conversation_store.update_conversation(conv_title.id, title="deployment runbook")
by_id = {
c.id: c for c in conversation_store.list_conversations(search_query="deployment").data
}
# Content match: snippet present and contains the query term.
assert by_id[conv_content.id].search_snippet is not None
assert "deployment" in by_id[conv_content.id].search_snippet.lower()
# Title-only match: no snippet (the title already shows the hit).
assert by_id[conv_title.id].search_snippet is None
def test_list_conversations_search_snippet_absent_without_query(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Non-search listings never populate ``search_snippet``.
:param conversation_store: The conversation store fixture.
"""
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_snip2",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "hello world"}],
),
),
],
)
page = conversation_store.list_conversations()
assert all(c.search_snippet is None for c in page.data)
def test_list_conversations_search_snippet_uses_earliest_match(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
With multiple matching turns, the snippet comes from the earliest one.
Exercises the ``MIN(position)`` join path: two turns match the query;
the snippet must be built from the first turn's text, not a later one.
:param conversation_store: The conversation store fixture.
"""
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_early",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "deployment first mention"}],
),
),
NewConversationItem(
type="message",
response_id="resp_late",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "deployment second mention"}],
agent="test-agent",
),
),
],
)
by_id = {
c.id: c for c in conversation_store.list_conversations(search_query="deployment").data
}
snippet = by_id[conv.id].search_snippet
assert snippet is not None
assert "first mention" in snippet
assert "second mention" not in snippet
def test_list_conversations_excludes_archived_by_default(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``list_conversations`` hides archived rows unless
``include_archived=True``.
Two conversations, one archived: the default listing returns only
the active one; ``include_archived=True`` returns both. This is the
exact contract the sidebar's default view and "Show archived"
toggle depend on. A failure means archived sessions either leak
into the default sidebar (filter not applied) or can never be
surfaced (toggle has no effect).
"""
active = conversation_store.create_conversation()
archived = conversation_store.create_conversation()
conversation_store.update_conversation(archived.id, archived=True)
default_ids = {c.id for c in conversation_store.list_conversations().data}
assert active.id in default_ids, "active session must appear in the default listing"
assert archived.id not in default_ids, "archived session must be hidden by default"
all_ids = {c.id for c in conversation_store.list_conversations(include_archived=True).data}
assert all_ids >= {active.id, archived.id}, (
f"include_archived=True must return both active and archived sessions; got {all_ids}"
)
# ── Delete ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_delete_conversation(
conversation_store: SqlAlchemyConversationStore,
) -> None:
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_del",
data=MessageData(role="user", content=[{"type": "input_text", "text": "bye"}]),
),
],
)
assert await conversation_store.delete_conversation(conv.id) is True
assert conversation_store.get_conversation(conv.id) is None
assert conversation_store.list_items(conv.id).data == []
assert await conversation_store.delete_conversation(conv.id) is False
@pytest.mark.asyncio
async def test_delete_conversation_with_items(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Deleting a conversation with items removes the conversation and all its items.
:param conversation_store: The conversation store fixture.
"""
conv = conversation_store.create_conversation()
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_x",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "hi"}],
),
),
],
)
assert await conversation_store.delete_conversation(conv.id) is True
assert conversation_store.get_conversation(conv.id) is None
# ── List conversations pagination ────────────────────
def test_list_conversations_pagination(
conversation_store: SqlAlchemyConversationStore,
) -> None:
for _ in range(4):
conversation_store.create_conversation()
page1 = conversation_store.list_conversations(limit=2)
assert len(page1.data) == 2
assert page1.has_more is True
page2 = conversation_store.list_conversations(limit=2, after=page1.last_id)
assert len(page2.data) == 2
assert page2.has_more is False
def test_list_conversations_order_asc(
conversation_store: SqlAlchemyConversationStore,
) -> None:
for _ in range(3):
conversation_store.create_conversation()
page_desc = conversation_store.list_conversations(order="desc")
page_asc = conversation_store.list_conversations(order="asc")
assert [c.id for c in page_asc.data] == list(reversed([c.id for c in page_desc.data]))
def test_list_conversations_asc_with_after_cursor(
conversation_store: SqlAlchemyConversationStore,
) -> None:
for _ in range(5):
conversation_store.create_conversation()
page1 = conversation_store.list_conversations(limit=2, order="asc")
page2 = conversation_store.list_conversations(limit=2, order="asc", after=page1.last_id)
page3 = conversation_store.list_conversations(limit=2, order="asc", after=page2.last_id)
all_ids = [c.id for c in page1.data + page2.data + page3.data]
full_asc = conversation_store.list_conversations(limit=100, order="asc")
assert all_ids == [c.id for c in full_asc.data]
# ── list_items type filter ────────────────────────────
def test_list_items_type_filter_returns_only_matching_type(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
list_items(type=...) returns only items of the specified type,
while list_items() without a filter returns all types.
"""
from omnigent.entities import CompactionData
conv = conversation_store.create_conversation()
# Append a mix of message and compaction items
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_001",
data=MessageData(role="user", content=[{"type": "input_text", "text": "hi"}]),
),
],
)
conversation_store.append(
conv.id,
[
NewConversationItem(
type="compaction",
response_id="resp_001",
data=CompactionData(
summary="Summary text",
last_item_id="msg_001",
model="openai/gpt-4o",
token_count=50,
),
),
],
)
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_002",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "hello"}],
agent="test-agent",
),
),
],
)
compaction_items = conversation_store.list_items(conv.id, type="compaction")
message_items = conversation_store.list_items(conv.id, type="message")
all_items = conversation_store.list_items(conv.id)
# Only the one compaction item must be returned.
assert len(compaction_items.data) == 1, (
f"Expected 1 compaction item, got {len(compaction_items.data)}. "
"Failure means type filter did not exclude message items."
)
assert compaction_items.data[0].type == "compaction"
# Only message items (2) must be returned.
assert len(message_items.data) == 2, (
f"Expected 2 message items, got {len(message_items.data)}. "
"Failure means type filter did not exclude the compaction item."
)
assert all(i.type == "message" for i in message_items.data)
# No filter returns all 3 items.
assert len(all_items.data) == 3, (
f"Expected 3 total items (2 message + 1 compaction), got {len(all_items.data)}."
)
def test_list_items_type_filter_with_order_and_limit(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
list_items(type="compaction", order="desc", limit=1) returns only
the most recently appended compaction item.
"""
from omnigent.entities import CompactionData
conv = conversation_store.create_conversation()
# Append two compaction items
conversation_store.append(
conv.id,
[
NewConversationItem(
type="compaction",
response_id="resp_001",
data=CompactionData(
summary="First summary",
last_item_id="msg_010",
model="openai/gpt-4o",
token_count=100,
),
),
],
)
conversation_store.append(
conv.id,
[
NewConversationItem(
type="compaction",
response_id="resp_002",
data=CompactionData(
summary="Second summary",
last_item_id="msg_020",
model="openai/gpt-4o",
token_count=120,
),
),
],
)
result = conversation_store.list_items(conv.id, type="compaction", order="desc", limit=1)
# Only one item returned (limit=1).
assert len(result.data) == 1, f"Expected 1 item with limit=1, got {len(result.data)}."
# The most recent compaction item (second) should be returned (order=desc).
assert result.data[0].data.summary == "Second summary", (
f"Expected the latest compaction item with 'Second summary', "
f"got: {result.data[0].data.summary!r}. "
"Failure means order=desc with limit=1 did not return the newest item."
)
# ── Sub-agent conversation isolation ────────────────
def test_subagent_conversations_are_isolated(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Two sub-agent conversations created independently must have
fully isolated item sets. list_items on one must never return
items belonging to the other.
This is the foundational invariant that prevents sub-agent
"pollution": each sub-agent writes to its own conversation,
and the agent loop loads history via
``list_items(conversation_id)`` — so items from sibling
sub-agents are structurally invisible.
A failure here means the WHERE clause on ``conversation_id``
in ``list_items`` is broken, which would cause sub-agents to
see each other's messages and produce incoherent LLM prompts.
"""
conv_a = conversation_store.create_conversation(kind="sub_agent")
conv_b = conversation_store.create_conversation(kind="sub_agent")
# Append distinct items to each conversation.
conversation_store.append(
conv_a.id,
[
NewConversationItem(
type="message",
response_id="task_a",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "alpha input"}],
),
),
NewConversationItem(
type="message",
response_id="task_a",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "alpha output"}],
agent="researcher",
),
),
],
)
conversation_store.append(
conv_b.id,
[
NewConversationItem(
type="message",
response_id="task_b",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "bravo input"}],
),
),
NewConversationItem(
type="message",
response_id="task_b",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "bravo output"}],
agent="researcher",
),
),
],
)
# List items for conv_a — must contain only alpha items.
page_a = conversation_store.list_items(conv_a.id)
# 2 items: user + assistant for the alpha sub-agent.
assert len(page_a.data) == 2, (
f"Expected 2 items in conv_a, got {len(page_a.data)}. "
"If > 2, items from conv_b leaked into conv_a's listing."
)
texts_a = [item.data.content[0]["text"] for item in page_a.data]
assert texts_a == ["alpha input", "alpha output"], (
f"Expected alpha items only in conv_a, got {texts_a}. "
"If bravo items appear, the conversation_id filter is broken."
)
# Every item must carry the correct response_id.
for item in page_a.data:
assert item.response_id == "task_a", (
f"Item {item.id} in conv_a has response_id {item.response_id!r}, expected 'task_a'."
)
# List items for conv_b — must contain only bravo items.
page_b = conversation_store.list_items(conv_b.id)
# 2 items: user + assistant for the bravo sub-agent.
assert len(page_b.data) == 2, (
f"Expected 2 items in conv_b, got {len(page_b.data)}. "
"If > 2, items from conv_a leaked into conv_b's listing."
)
texts_b = [item.data.content[0]["text"] for item in page_b.data]
assert texts_b == ["bravo input", "bravo output"], (
f"Expected bravo items only in conv_b, got {texts_b}. "
"If alpha items appear, the conversation_id filter is broken."
)
for item in page_b.data:
assert item.response_id == "task_b", (
f"Item {item.id} in conv_b has response_id {item.response_id!r}, expected 'task_b'."
)
# Cross-check: item IDs must be disjoint.
ids_a = {item.id for item in page_a.data}
ids_b = {item.id for item in page_b.data}
assert ids_a.isdisjoint(ids_b), (
f"Item IDs overlap between conversations: "
f"{ids_a & ids_b}. Each conversation must have "
"unique item IDs."
)
# ── updated_at ─────────────────────────────────────────
def test_create_sets_updated_at_equal_to_created_at(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
A newly created conversation has updated_at == created_at.
"""
conv = conversation_store.create_conversation()
assert conv.updated_at == conv.created_at, (
f"Expected updated_at ({conv.updated_at}) to equal "
f"created_at ({conv.created_at}) on a brand-new conversation."
)
def test_append_bumps_updated_at(
conversation_store: SqlAlchemyConversationStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Appending items to a conversation advances updated_at
to the current time.
"""
import omnigent.stores.conversation_store.sqlalchemy_store as store_mod
# Freeze time at creation
monkeypatch.setattr(store_mod, "now_epoch", lambda: 1000)
conv = conversation_store.create_conversation()
assert conv.updated_at == 1000
# Advance time, then append
monkeypatch.setattr(store_mod, "now_epoch", lambda: 2000)
conversation_store.append(
conv.id,
[
NewConversationItem(
type="message",
response_id="resp_bump",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "hi"}],
),
),
],
)
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.updated_at == 2000, (
f"Expected updated_at to advance to 2000 after append, got {fetched.updated_at}."
)
def test_update_title_bumps_updated_at(
conversation_store: SqlAlchemyConversationStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Updating the title of a conversation advances updated_at.
"""
import omnigent.stores.conversation_store.sqlalchemy_store as store_mod
monkeypatch.setattr(store_mod, "now_epoch", lambda: 1000)
conv = conversation_store.create_conversation()
assert conv.updated_at == 1000
monkeypatch.setattr(store_mod, "now_epoch", lambda: 3000)
updated = conversation_store.update_conversation(conv.id, title="New title")
assert updated is not None
assert updated.updated_at == 3000, (
f"Expected updated_at to advance to 3000 after title update, got {updated.updated_at}."
)
# ── sort_by=updated_at ────────────────────────────────
def test_list_conversations_sort_by_updated_at(
conversation_store: SqlAlchemyConversationStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Sorting by updated_at returns conversations in order of
last activity, not creation order.
"""
import omnigent.stores.conversation_store.sqlalchemy_store as store_mod
# Create conv_a at t=100, conv_b at t=200
monkeypatch.setattr(store_mod, "now_epoch", lambda: 100)
conv_a = conversation_store.create_conversation()
monkeypatch.setattr(store_mod, "now_epoch", lambda: 200)
conv_b = conversation_store.create_conversation()
# Append to conv_a at t=300, making it the most recently updated
monkeypatch.setattr(store_mod, "now_epoch", lambda: 300)
conversation_store.append(
conv_a.id,
[
NewConversationItem(
type="message",
response_id="resp_sort",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "hello"}],
),
),
],
)
# sort_by=created_at desc → conv_b first (created later)
by_created = conversation_store.list_conversations(
sort_by="created_at",
order="desc",
kind=None,
)
assert by_created.data[0].id == conv_b.id, (
"Expected conv_b first when sorting by created_at desc."
)
# sort_by=updated_at desc → conv_a first (updated more recently)
by_updated = conversation_store.list_conversations(
sort_by="updated_at",
order="desc",
kind=None,
)
assert by_updated.data[0].id == conv_a.id, (
"Expected conv_a first when sorting by updated_at desc, "
"because it was updated at t=300 vs conv_b at t=200."
)
def test_list_conversations_sort_by_updated_at_with_pagination(
conversation_store: SqlAlchemyConversationStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Cursor-based pagination works correctly when sorting
by updated_at.
"""
import omnigent.stores.conversation_store.sqlalchemy_store as store_mod
# Create 3 conversations with distinct updated_at values
ids = []
for t in (100, 200, 300):
monkeypatch.setattr(store_mod, "now_epoch", lambda _t=t: _t)
conv = conversation_store.create_conversation()
ids.append(conv.id)
# Reverse the update order: bump the oldest conversation last
monkeypatch.setattr(store_mod, "now_epoch", lambda: 400)
conversation_store.append(
ids[0],
[
NewConversationItem(
type="message",
response_id="resp_pg",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "pg"}],
),
),
],
)
# sort_by=updated_at desc: ids[0] (400), ids[2] (300), ids[1] (200)
page1 = conversation_store.list_conversations(
limit=2,
sort_by="updated_at",
order="desc",
kind=None,
)
# 2 results with has_more=True
assert len(page1.data) == 2
assert page1.has_more is True
assert page1.data[0].id == ids[0]
assert page1.data[1].id == ids[2]
page2 = conversation_store.list_conversations(
limit=2,
sort_by="updated_at",
order="desc",
after=page1.last_id,
kind=None,
)
# 1 result remaining
assert len(page2.data) == 1
assert page2.has_more is False
assert page2.data[0].id == ids[1]
# ─── Phase 4: parent_conversation_id + name uniqueness ──────
def test_create_conversation_with_parent_pointer_and_title(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Setting ``parent_conversation_id`` + ``title`` round-trips through the row."""
parent = conversation_store.create_conversation()
child = conversation_store.create_conversation(
kind="sub_agent",
title="coder:auth",
parent_conversation_id=parent.id,
)
# Both fields surface on the entity — proves the row was
# populated AND the converter pulls the column. Without the
# converter update, parent_conversation_id would always be
# None on the returned entity even after the row stores it.
fetched = conversation_store.get_conversation(child.id)
assert fetched is not None
assert fetched.title == "coder:auth"
assert fetched.parent_conversation_id == parent.id
assert fetched.kind == "sub_agent"
def test_create_duplicate_title_under_same_parent_raises(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""G36: partial unique index rejects ``(parent_id, title)`` duplicates."""
from omnigent.stores.conversation_store import NameAlreadyExistsError
parent = conversation_store.create_conversation()
conversation_store.create_conversation(
kind="sub_agent",
title="coder:auth",
parent_conversation_id=parent.id,
)
# Without the partial unique index + IntegrityError-to-
# NameAlreadyExistsError translation, the second create
# would either succeed silently (creating a duplicate row)
# or raise a raw sqlalchemy IntegrityError that would leak
# through to the LLM as an opaque error.
with pytest.raises(NameAlreadyExistsError):
conversation_store.create_conversation(
kind="sub_agent",
title="coder:auth",
parent_conversation_id=parent.id,
)
def test_create_same_title_under_different_parents_succeeds(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""The unique constraint is per-parent — ``(p1, "auth")`` and ``(p2, "auth")`` coexist."""
p1 = conversation_store.create_conversation()
p2 = conversation_store.create_conversation()
conversation_store.create_conversation(
kind="sub_agent", title="coder:auth", parent_conversation_id=p1.id
)
# Same title, different parent — no conflict.
conversation_store.create_conversation(
kind="sub_agent", title="coder:auth", parent_conversation_id=p2.id
)
# Both children must exist; if the unique constraint were
# global (not partial-by-parent), the second create would
# raise.
p1_children = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=p1.id,
)
p2_children = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=p2.id,
)
assert len(p1_children.data) == 1
assert len(p2_children.data) == 1
assert p1_children.data[0].title == "coder:auth"
assert p2_children.data[0].title == "coder:auth"
def test_create_null_parent_allows_duplicate_titles(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Top-level conversations (NULL parent) are NOT subject to the unique constraint."""
# Both conversations share title="" and parent=None. The unique index on
# (parent_conversation_id, title) still allows this: a NULL in any indexed
# column makes the key distinct, so top-level rows never collide even
# without a WHERE predicate.
a = conversation_store.create_conversation()
b = conversation_store.create_conversation()
assert a.id != b.id
assert a.parent_conversation_id is None
assert b.parent_conversation_id is None
def test_list_conversations_filtered_by_parent_returns_children_only(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``parent_conversation_id`` filter scopes results to one parent's sub-tree."""
parent_a = conversation_store.create_conversation()
parent_b = conversation_store.create_conversation()
conversation_store.create_conversation(
kind="sub_agent", title="coder:auth", parent_conversation_id=parent_a.id
)
conversation_store.create_conversation(
kind="sub_agent", title="coder:payments", parent_conversation_id=parent_a.id
)
conversation_store.create_conversation(
kind="sub_agent", title="coder:other", parent_conversation_id=parent_b.id
)
page = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=parent_a.id,
)
# Exactly 2 children for parent_a — proves the WHERE clause
# excludes parent_b's child. If the filter were a no-op,
# all 3 sub-agent rows would appear.
titles = sorted(c.title for c in page.data if c.title)
assert titles == ["coder:auth", "coder:payments"]
def test_list_conversations_filtered_by_title(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``title`` filter returns only children with an exact title match."""
parent = conversation_store.create_conversation()
conversation_store.create_conversation(
kind="sub_agent", title="coder:auth", parent_conversation_id=parent.id
)
conversation_store.create_conversation(
kind="sub_agent", title="coder:payments", parent_conversation_id=parent.id
)
page = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=parent.id,
title="coder:auth",
)
assert len(page.data) == 1
assert page.data[0].title == "coder:auth"
empty = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=parent.id,
title="coder:nonexistent",
)
assert len(empty.data) == 0
def test_list_child_conversation_ids_by_parent_groups_direct_subagents(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``list_child_conversation_ids_by_parent`` groups direct sub-agent children.
The sessions list uses this helper to roll child runner status onto
parent rows without issuing one ``child_sessions`` query per sidebar
row. This test proves the helper returns every requested parent key,
excludes other parents and non-sub-agent children, and does not widen
from direct children to nested descendants.
"""
parent_a = conversation_store.create_conversation()
parent_b = conversation_store.create_conversation()
child_a1 = conversation_store.create_conversation(
kind="sub_agent", title="coder:auth", parent_conversation_id=parent_a.id
)
child_a2 = conversation_store.create_conversation(
kind="sub_agent", title="coder:payments", parent_conversation_id=parent_a.id
)
child_b = conversation_store.create_conversation(
kind="sub_agent", title="coder:other", parent_conversation_id=parent_b.id
)
default_child = conversation_store.create_conversation(
kind="default", title="default:child", parent_conversation_id=parent_a.id
)
nested = conversation_store.create_conversation(
kind="sub_agent", title="reviewer:nested", parent_conversation_id=child_a1.id
)
result = conversation_store.list_child_conversation_ids_by_parent(
[parent_a.id, parent_b.id, "conv_missing", parent_a.id]
)
assert set(result) == {parent_a.id, parent_b.id, "conv_missing"}
assert len(result[parent_a.id]) == 2
assert sorted(result[parent_a.id]) == sorted([child_a1.id, child_a2.id])
assert result[parent_b.id] == [child_b.id]
assert result["conv_missing"] == []
assert default_child.id not in result[parent_a.id]
assert nested.id not in result[parent_a.id]
# ── agent_id filter on list_conversations ──────────────
def test_list_conversations_filtered_by_agent_id_returns_matching_only(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""
Powers Omnigent mode ``--continue`` (resume the most-recent
conversation for *this agent*). Two agents, three
conversations: agent_alpha owns convs 1+2, agent_beta
owns conv 3. Filtering by agent_alpha returns exactly the
two conversations agent_alpha touched.
The filter uses ``conversations.agent_id`` directly
(the tasks table has been removed).
What breaks if this fails: ``--continue`` either resumes a
conversation belonging to the wrong agent (privacy /
correctness regression) or returns nothing when prior
history exists.
:param conversation_store: The conversation store fixture.
:param agent_store: The agent store fixture.
"""
alpha = agent_store.create(agent_id="ag_alpha", name="alpha", bundle_location="ag_alpha/h")
beta = agent_store.create(agent_id="ag_beta", name="beta", bundle_location="ag_beta/h")
conv1 = conversation_store.create_conversation(agent_id=alpha.id)
conv2 = conversation_store.create_conversation(agent_id=alpha.id)
conv3 = conversation_store.create_conversation(agent_id=beta.id)
_ = conv3 # ensure conv3 exists but is not returned
page = conversation_store.list_conversations(agent_id=alpha.id)
returned_ids = {c.id for c in page.data}
# Exactly two conversations, both owned by alpha — beta's
# conversation must be excluded.
assert returned_ids == {conv1.id, conv2.id}
def test_list_conversations_agent_id_none_disables_filter(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""
The default (``agent_id=None``) returns every conversation,
including ones without an agent binding. Pinning this so the filter
stays opt-in and existing callers (the ``/switch`` slash
command, list endpoint without filter) keep their
cross-agent visibility.
:param conversation_store: The conversation store fixture.
:param agent_store: The agent store fixture.
"""
alpha = agent_store.create(agent_id="ag_alpha2", name="alpha2", bundle_location="ag_alpha2/h")
conv_with_agent = conversation_store.create_conversation(agent_id=alpha.id)
conv_without_agent = conversation_store.create_conversation()
page = conversation_store.list_conversations()
returned_ids = {c.id for c in page.data}
# Both conversations present — no agent_id filter applied.
assert conv_with_agent.id in returned_ids
assert conv_without_agent.id in returned_ids
def test_list_conversations_filter_distinct_by_agent_id(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""
A conversation bound to an agent appears exactly once in the
result when filtered by that agent's id.
The filter now uses ``conversations.agent_id`` directly
(the tasks table has been removed) — each conversation
appears at most once by definition.
:param conversation_store: The conversation store fixture.
:param agent_store: The agent store fixture.
"""
alpha = agent_store.create(agent_id="ag_alpha3", name="alpha3", bundle_location="ag_alpha3/h")
conv = conversation_store.create_conversation(agent_id=alpha.id)
page = conversation_store.list_conversations(agent_id=alpha.id)
assert [c.id for c in page.data] == [conv.id]
def test_list_conversations_filter_orders_by_sort_by(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
With ``agent_id`` AND ``sort_by="updated_at"``, the result
is ordered by the conversation's updated_at. This is what
``--continue`` relies on: "the conversation I most recently
*did anything in*", which is reflected in the conversation's
own ``updated_at`` (bumped on every append).
The filter uses ``conversations.agent_id`` directly
(the tasks table has been removed).
:param conversation_store: The conversation store fixture.
:param agent_store: The agent store fixture.
:param monkeypatch: Pytest monkeypatch for time control.
"""
import omnigent.stores.conversation_store.sqlalchemy_store as store_mod
alpha = agent_store.create(agent_id="ag_alpha4", name="alpha4", bundle_location="ag_alpha4/h")
# Create two conversations at distinct timestamps so
# ``updated_at`` differs.
monkeypatch.setattr(store_mod, "now_epoch", lambda: 100)
older = conversation_store.create_conversation(agent_id=alpha.id)
monkeypatch.setattr(store_mod, "now_epoch", lambda: 200)
newer = conversation_store.create_conversation(agent_id=alpha.id)
_ = newer # both created; newer has later created_at
# Bump older's updated_at so it becomes the most recently updated.
monkeypatch.setattr(store_mod, "now_epoch", lambda: 300)
conversation_store.append(
older.id,
[
NewConversationItem(
type="message",
response_id="resp_recency",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "bump"}],
),
),
],
)
page = conversation_store.list_conversations(
agent_id=alpha.id,
sort_by="updated_at",
order="desc",
limit=1,
)
# ``older`` is now the most-recently-updated conversation
# despite being created earlier.
assert [c.id for c in page.data] == [older.id]
def test_cascade_delete_removes_descendants(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Deleting a parent recursively removes children + grandchildren (FK CASCADE)."""
import asyncio
parent = conversation_store.create_conversation()
child = conversation_store.create_conversation(
kind="sub_agent", title="coder:auth", parent_conversation_id=parent.id
)
grandchild = conversation_store.create_conversation(
kind="sub_agent", title="reviewer:nested", parent_conversation_id=child.id
)
# Delete the root — both descendants must vanish via the
# ON DELETE CASCADE on parent_conversation_id.
asyncio.run(conversation_store.delete_conversation(parent.id))
assert conversation_store.get_conversation(parent.id) is None
assert conversation_store.get_conversation(child.id) is None, (
"Child not cascaded — FK ondelete=CASCADE missing or migration didn't apply it"
)
assert conversation_store.get_conversation(grandchild.id) is None, (
"Grandchild not cascaded — recursive FK cascade missing"
)
# ── Runner pinning (designs/RUNNER.md §5) ─────────────
def test_runner_id_default_null(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Fresh conversations have no runner pin until first dispatch claims one."""
conv = conversation_store.create_conversation()
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
# ``runner_id`` is the load-bearing assertion — proves the column flowed
# all the way from the DB row to the entity. A non-None default would
# mean the entity dataclass is masking the SQL NULL.
assert fetched.runner_id is None
def test_replace_runner_id_allows_internal_non_session_conversation(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Internal sub-agent conversations can inherit runner bindings."""
conv = conversation_store.create_conversation()
updated = conversation_store.replace_runner_id(conv.id, "runner-uuid-1")
assert updated.runner_id == "runner-uuid-1"
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.runner_id == "runner-uuid-1"
def test_list_conversations_by_runner_id_filters(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Only conversations pinned to the queried runner are returned.
The runner tunnel's connect/disconnect callbacks use this lookup to
find the sessions whose ``create_session`` handshake (and the
claude-native terminal bootstrap behind it) must be driven on
reconnect — a wrong or empty result here means the terminal is
silently never created.
"""
bound = conversation_store.create_conversation(runner_id="runner_token_a")
conversation_store.create_conversation(runner_id="runner_token_b")
conversation_store.create_conversation() # unbound
result = conversation_store.list_conversations_by_runner_id("runner_token_a")
# Exactly the bound conversation — proves the filter matched on the
# column rather than returning a superset the caller must re-filter.
assert [c.id for c in result] == [bound.id]
assert result[0].runner_id == "runner_token_a"
# ── Host id ─────────────────────────────────────────
def _register_host(db_uri: str, host_id: str) -> None:
"""
Insert a ``hosts`` row so a conversation can reference ``host_id``.
``conversations.host_id`` is an FK to ``hosts.host_id`` (enforced
under ``PRAGMA foreign_keys=ON``), so the referenced host must
exist before a conversation can be bound to it. Shares ``db_uri``
with the ``conversation_store`` fixture so both hit the same DB.
:param db_uri: SQLite URI shared with the conversation store.
:param host_id: Host identifier to register, e.g. ``"host_abc123"``.
"""
HostStore(db_uri).upsert_on_connect(host_id, f"laptop-{host_id}", RESERVED_USER_LOCAL)
def test_host_id_defaults_to_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
A freshly created conversation has ``host_id=None``.
If not None, the entity dataclass or the row→entity converter
is fabricating a default instead of reflecting the SQL NULL.
"""
conv = conversation_store.create_conversation()
assert conv.host_id is None
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.host_id is None
def test_create_conversation_with_host_id(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""
Verify that host_id passed to create_conversation is persisted
and survives a round-trip through the DB.
Pass ``workspace`` alongside ``host_id`` because the schema's
``ck_conversations_workspace_required_for_host`` constraint
forbids the (host_id NOT NULL, workspace NULL) combination —
sessions targeting a host always need a path to launch in.
If the fetched host_id doesn't match, either the INSERT is
dropping the column or the row→entity converter is skipping it.
"""
# host_id is an FK to hosts.host_id, so the host must exist first.
_register_host(db_uri, "host_abc123")
conv = conversation_store.create_conversation(
host_id="host_abc123",
workspace="/Users/corey/projects/myapp",
)
assert conv.host_id == "host_abc123"
assert conv.workspace == "/Users/corey/projects/myapp"
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.host_id == "host_abc123"
assert fetched.workspace == "/Users/corey/projects/myapp"
def test_create_conversation_with_git_branch(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify git_branch passed to create_conversation persists and
round-trips through the DB.
If the fetched git_branch doesn't match, either the INSERT drops
the column (bad migration / model) or the row→entity converter
skips it — both would break the delete-dialog cleanup gate, which
keys off ``git_branch IS NOT NULL``.
"""
conv = conversation_store.create_conversation(git_branch="feature/login")
assert conv.git_branch == "feature/login"
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.git_branch == "feature/login"
def test_create_conversation_git_branch_defaults_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify git_branch defaults to None for sessions with no worktree.
A non-None default would make every session look like a worktree
session and wrongly surface the "delete local branch" checkbox.
"""
conv = conversation_store.create_conversation()
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.git_branch is None
def test_set_host_id(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""
Verify that set_host_id updates the column and persists.
The conversation is created with ``workspace`` already set so
that the post-update row satisfies
``ck_conversations_workspace_required_for_host``. Without a
pre-existing workspace, the UPDATE that introduces ``host_id``
would be blocked by the check constraint.
If the fetched value is still None after set_host_id, the UPDATE
statement is not executing or not committing.
"""
# host_id is an FK to hosts.host_id, so the host must exist first.
_register_host(db_uri, "host_def456")
# Pre-set workspace so the host_id update doesn't violate the
# workspace-required-for-host check constraint.
conv = conversation_store.create_conversation(
workspace="/Users/corey/projects/myapp",
)
assert conv.host_id is None
assert conv.workspace == "/Users/corey/projects/myapp"
updated = conversation_store.set_host_id(conv.id, "host_def456")
assert updated.host_id == "host_def456"
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.host_id == "host_def456"
assert fetched.workspace == "/Users/corey/projects/myapp"
def test_set_host_id_missing_conversation_raises(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify that set_host_id raises ConversationNotFoundError for
a nonexistent conversation.
If it silently succeeds, the guard clause is missing and a
stale host_id could be written to a phantom row.
"""
from omnigent.stores.conversation_store import ConversationNotFoundError
with pytest.raises(ConversationNotFoundError):
conversation_store.set_host_id("conv_nonexistent", "host_xyz")
def test_set_host_id_with_workspace_satisfies_constraint(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""
Verify set_host_id(host_id, workspace) writes both columns so
the row satisfies ck_conversations_workspace_required_for_host.
The launch-runner endpoint binds host_id post-create on rows
that may have NULL workspace; without the workspace argument,
the UPDATE would violate the check constraint and 500 the
request.
"""
# host_id is an FK to hosts.host_id, so the host must exist first.
_register_host(db_uri, "host_with_workspace")
conv = conversation_store.create_conversation()
assert conv.host_id is None
assert conv.workspace is None
updated = conversation_store.set_host_id(
conv.id,
"host_with_workspace",
workspace="/Users/corey/projects/myapp",
)
assert updated.host_id == "host_with_workspace"
assert updated.workspace == "/Users/corey/projects/myapp"
def test_clear_host_binding_nulls_all_binding_fields(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""
clear_host_binding NULLs host_id/workspace/git_branch/runner_id together.
This is the failed-bind rollback primitive: after a worktree launch
fails and the worktree is removed, the session must not keep pointing
at the deleted worktree/branch or stay runner-bound. Unlike set_host_id
(None = leave untouched, so it can't clear git_branch), this fully
reverts to unbound. A leftover git_branch here would wrongly satisfy
worktree-cleanup paths (git_branch IS NOT NULL); a leftover runner_id
would block the picker's retry on the atomic set_runner_id CAS.
"""
_register_host(db_uri, "host_to_unbind")
conv = conversation_store.create_conversation()
# Fully bind it the way a worktree launch would: host + worktree path
# + branch, then a runner.
conversation_store.set_host_id(
conv.id,
"host_to_unbind",
workspace="/Users/corey/repo-worktrees/feature-x",
git_branch="feature/x",
)
assert conversation_store.set_runner_id(conv.id, "runner_token_abc") is True
bound = conversation_store.get_conversation(conv.id)
assert bound is not None
# Precondition: every binding field is set (so the assertions below
# prove clearing, not a no-op on already-empty fields).
assert bound.host_id == "host_to_unbind"
assert bound.workspace == "/Users/corey/repo-worktrees/feature-x"
assert bound.git_branch == "feature/x"
assert bound.runner_id == "runner_token_abc"
cleared = conversation_store.clear_host_binding(conv.id)
assert cleared.host_id is None
assert cleared.workspace is None
assert cleared.git_branch is None
assert cleared.runner_id is None
# Persisted, not just returned: a re-fetch must agree.
refetched = conversation_store.get_conversation(conv.id)
assert refetched is not None
assert refetched.host_id is None
assert refetched.workspace is None
assert refetched.git_branch is None
assert refetched.runner_id is None
# And the session can be re-bound (the CAS sees runner_id IS NULL).
assert conversation_store.set_runner_id(conv.id, "runner_token_retry") is True
def test_clear_host_binding_missing_conversation_raises(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""clear_host_binding raises for an unknown conversation id."""
from omnigent.stores.conversation_store import ConversationNotFoundError
with pytest.raises(ConversationNotFoundError):
conversation_store.clear_host_binding("conv_nonexistent")
def test_create_session_with_agent_records_workspace(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify create_session_with_agent stores workspace=<value> on the
conversation row.
The CLI calls this path with ``workspace=os.getcwd()`` so the
Web UI can show "running locally in <workspace>" for sessions
started outside the Web UI. If the column isn't populated, the
Web UI shows "(no workspace)" and the user can't tell where the
session is running.
"""
created = conversation_store.create_session_with_agent(
agent_id="ag_session_ws",
agent_name="cli-test-agent",
agent_bundle_location="ag_session_ws/bundle1",
agent_description=None,
workspace="/Users/corey/projects/cli-launch",
)
fetched = conversation_store.get_conversation(created.conversation.id)
assert fetched is not None
assert fetched.workspace == "/Users/corey/projects/cli-launch"
assert fetched.host_id is None
def test_create_session_with_agent_workspace_defaults_to_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify create_session_with_agent leaves workspace NULL when no
value is passed.
Headless API callers (no host, no terminal cwd) shouldn't be
forced to invent a workspace. The check constraint allows
NULL workspace when host_id is also NULL — this test pins
that path through the public method.
"""
created = conversation_store.create_session_with_agent(
agent_id="ag_session_no_ws",
agent_name="no-ws-agent",
agent_bundle_location="ag_session_no_ws/bundle1",
agent_description=None,
)
fetched = conversation_store.get_conversation(created.conversation.id)
assert fetched is not None
assert fetched.workspace is None
def test_create_session_with_agent_records_terminal_launch_args(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify create_session_with_agent persists terminal_launch_args as
a JSON list that round-trips back to a real ``list[str]``.
The native wrappers set these at create-time so a daemon- or
server-launched runner can reconstruct the terminal command. If
the JSON encode (row builder) or decode (converter) is broken,
the fetched value would be a raw JSON string or ``None`` instead
of the list, and the runner would launch with the wrong args.
"""
created = conversation_store.create_session_with_agent(
agent_id="ag_session_tla",
agent_name="cli-test-agent",
agent_bundle_location="ag_session_tla/bundle1",
agent_description=None,
terminal_launch_args=["--dangerously-skip-permissions", "--model", "opus"],
)
fetched = conversation_store.get_conversation(created.conversation.id)
assert fetched is not None
# Exact list (order + content) proves the value traversed
# encode→DB→decode intact, not just "something non-null".
assert fetched.terminal_launch_args == [
"--dangerously-skip-permissions",
"--model",
"opus",
]
def test_create_session_with_agent_terminal_launch_args_defaults_to_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify create_session_with_agent leaves terminal_launch_args NULL
when no value is passed.
Non-native sessions (the common case) must not carry launch args.
A NULL column must decode to ``None`` — not ``[]`` or ``"null"`` —
so downstream code can distinguish "no native launch" from "native
launch with zero extra args".
"""
created = conversation_store.create_session_with_agent(
agent_id="ag_session_no_tla",
agent_name="no-tla-agent",
agent_bundle_location="ag_session_no_tla/bundle1",
agent_description=None,
)
fetched = conversation_store.get_conversation(created.conversation.id)
assert fetched is not None
assert fetched.terminal_launch_args is None
def test_create_session_with_agent_links_parent_and_inherits_root(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify create_session_with_agent with parent_conversation_id creates
a sub-agent child that inherits the parent's spawn-tree root and
runner binding.
This is the bundle-mode ``sys_session_create`` path: the multipart
create must produce the same parent/kind/root/runner shape as the
JSON child create, or tree-scoped reads (``sys_session_get_history``
matches on ``root_conversation_id``) and runner co-location would
silently break for bundle-created children.
"""
parent = conversation_store.create_conversation(runner_id="runner_swa1")
created = conversation_store.create_session_with_agent(
agent_id="ag_session_child",
agent_name="bundle-child-agent",
agent_bundle_location="ag_session_child/bundle1",
agent_description=None,
parent_conversation_id=parent.id,
runner_id=parent.runner_id,
)
fetched = conversation_store.get_conversation(created.conversation.id)
assert fetched is not None
assert fetched.parent_conversation_id == parent.id
# Child rows are sub-agent kind — sys_session_list filters on this.
assert fetched.kind == "sub_agent"
# Root must be the PARENT's root (== parent.id for a top-level
# parent), not the child's own id — otherwise the child lands in
# its own one-row tree and tree-scoped tools can't see it.
assert fetched.root_conversation_id == parent.root_conversation_id
assert fetched.runner_id == "runner_swa1"
def test_create_session_with_agent_top_level_unchanged(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify the no-parent path still creates a top-level default row.
The parent support must not disturb the existing multipart create
contract (CLI ``omnigent run`` sessions): kind stays "default",
no parent link, and the row roots its own tree.
"""
created = conversation_store.create_session_with_agent(
agent_id="ag_session_top",
agent_name="top-level-agent",
agent_bundle_location="ag_session_top/bundle1",
agent_description=None,
)
fetched = conversation_store.get_conversation(created.conversation.id)
assert fetched is not None
assert fetched.parent_conversation_id is None
assert fetched.kind == "default"
assert fetched.root_conversation_id == created.conversation.id
assert fetched.runner_id is None
def test_create_session_with_agent_missing_parent_fails_loud(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify a nonexistent parent_conversation_id raises
ConversationNotFoundError instead of silently creating an orphan.
The route authorizes the parent before calling the store, but the
parent can be deleted between the check and the insert — the store
must fail loud so no half-linked child row (and no orphaned agent
row) is committed.
"""
from omnigent.stores.conversation_store import ConversationNotFoundError
with pytest.raises(ConversationNotFoundError):
conversation_store.create_session_with_agent(
agent_id="ag_session_orphan",
agent_name="orphan-agent",
agent_bundle_location="ag_session_orphan/bundle1",
agent_description=None,
parent_conversation_id="conv_does_not_exist",
)
# The transaction rolled back: no agent row leaked either.
assert conversation_store.get_conversation("conv_does_not_exist") is None
def test_create_conversation_records_terminal_launch_args(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify create_conversation persists terminal_launch_args as a JSON
list that round-trips back to a real ``list[str]``.
This is the JSON ``POST /v1/sessions`` path (web permission-mode
selector), distinct from create_session_with_agent (multipart). If
the row builder skipped the JSON encode or the converter the decode,
the fetched value would be ``None`` or a raw string and the runner
would launch claude without the chosen --permission-mode.
"""
created = conversation_store.create_conversation(
terminal_launch_args=["--permission-mode", "bypassPermissions"],
)
fetched = conversation_store.get_conversation(created.id)
assert fetched is not None
# Exact list (order preserved) proves the value traversed
# encode→DB→decode intact.
assert fetched.terminal_launch_args == ["--permission-mode", "bypassPermissions"]
def test_create_conversation_terminal_launch_args_defaults_to_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify create_conversation leaves terminal_launch_args NULL when no
value is passed.
Non-native / auto-mode-off sessions (the common case) must not
carry launch args; a NULL column must decode to ``None``, not
``[]``, so the runner distinguishes "no native launch" from
"native launch with zero extra args".
"""
created = conversation_store.create_conversation()
fetched = conversation_store.get_conversation(created.id)
assert fetched is not None
assert fetched.terminal_launch_args is None
def test_update_conversation_replaces_terminal_launch_args(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify update_conversation replaces terminal_launch_args wholesale
(last-write-wins) and that ``None`` leaves the stored value
unchanged.
This pins the resume semantics from
designs/NATIVE_RUNNER_SERVER_LAUNCH.md: a cold resume with new
flags overwrites the prior set rather than appending to it. If the
store appended instead of replacing, the second assertion would
see the concatenation and fail — which is exactly the bug that
would make repeated resumes accumulate stale/conflicting flags.
"""
created = conversation_store.create_session_with_agent(
agent_id="ag_session_tla_update",
agent_name="update-agent",
agent_bundle_location="ag_session_tla_update/bundle1",
agent_description=None,
terminal_launch_args=["--model", "opus"],
)
conv_id = created.conversation.id
replaced = conversation_store.update_conversation(
conv_id,
terminal_launch_args=["--verbose"],
)
assert replaced is not None
# Replaced, NOT appended: ["--model", "opus", "--verbose"] would
# mean the store concatenated across launches (the bug).
assert replaced.terminal_launch_args == ["--verbose"]
# None must leave the prior value intact (matches the
# reasoning_effort / model_override "None = unchanged" contract).
unchanged = conversation_store.update_conversation(conv_id, title="renamed")
assert unchanged is not None
assert unchanged.terminal_launch_args == ["--verbose"]
def test_update_conversation_terminal_launch_args_empty_list_distinct_from_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify an explicitly-empty arg list round-trips as ``[]`` and stays
distinct from the NULL/None "leave unchanged" sentinel.
The store uses ``None`` to mean "leave unchanged", so an empty
list must be storable as a real, retrievable ``[]`` — otherwise a
caller clearing args back to none-extra would be silently ignored.
"""
created = conversation_store.create_session_with_agent(
agent_id="ag_session_tla_empty",
agent_name="empty-agent",
agent_bundle_location="ag_session_tla_empty/bundle1",
agent_description=None,
terminal_launch_args=["--model", "opus"],
)
updated = conversation_store.update_conversation(
created.conversation.id,
terminal_launch_args=[],
)
assert updated is not None
# "[]" is a truthy JSON string, so the converter must decode it to
# an empty list — not collapse it to None the way a NULL column does.
assert updated.terminal_launch_args == []
def test_set_host_id_no_workspace_fails_when_row_has_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Verify that calling set_host_id without a workspace argument
on a row whose workspace is NULL raises IntegrityError.
Pins the contract that set_host_id can't bypass the check
constraint by accident. Callers must pass a workspace when
binding to a host on a row that doesn't already have one.
"""
from sqlalchemy.exc import IntegrityError, OperationalError
conv = conversation_store.create_conversation()
assert conv.workspace is None
with pytest.raises((IntegrityError, OperationalError)):
conversation_store.set_host_id(conv.id, "host_no_ws")
# ── Workspace ───────────────────────────────────────
def test_workspace_defaults_to_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
A freshly created conversation has ``workspace=None`` when no
workspace is passed.
Load-bearing because the entity must mirror the DB's NULL state
rather than substituting an empty string — empty-string defaults
on path columns mask the "never set" case and would launch the
runner in the host daemon's process cwd, which is rarely correct.
"""
conv = conversation_store.create_conversation()
assert conv.workspace is None
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.workspace is None
def test_workspace_persists_for_cli_session_without_host_id(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
A CLI session can record its starting cwd without a host_id.
Pairs with the schema-level test in ``tests/db/test_migration_workspace.py``
that proves the check constraint is one-way (host_id requires
workspace, not the reverse). This test exercises the same path
through the store interface to catch regressions where the store
layer might add a stricter rule than the schema enforces.
"""
conv = conversation_store.create_conversation(
workspace="/Users/corey/projects/cli-launched",
)
assert conv.host_id is None
assert conv.workspace == "/Users/corey/projects/cli-launched"
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.host_id is None
assert fetched.workspace == "/Users/corey/projects/cli-launched"
def test_create_conversation_with_host_id_no_workspace_raises(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Creating a conversation with host_id but no workspace raises
IntegrityError from the check constraint.
The store deliberately does NOT add a Python-side guard here:
the check constraint is the canonical enforcement, and adding a
duplicate guard at the store would diverge from the schema in
confusing ways. This test pins the contract: violating the
constraint surfaces as IntegrityError, callers can catch it,
and the row is never written.
"""
from sqlalchemy.exc import IntegrityError, OperationalError
with pytest.raises((IntegrityError, OperationalError)):
conversation_store.create_conversation(host_id="host_abc")
# ── External session id ─────────────────────────────
def test_external_session_id_defaults_to_none(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
A freshly created conversation has ``external_session_id=None``.
Load-bearing because a non-None default would mean the entity
dataclass is masking the SQL NULL — the wrapper bridge wouldn't
be able to tell "not yet observed" from "set to empty".
"""
conv = conversation_store.create_conversation()
assert conv.external_session_id is None
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
# Read-back through get_conversation goes through the row→entity
# converter; this proves the column flows from DB row to entity.
assert fetched.external_session_id is None
def test_set_external_session_id_first_call_persists(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""First write transitions NULL → value and is visible on read-back."""
conv = conversation_store.create_conversation()
updated = conversation_store.set_external_session_id(
conv.id,
"a1b2c3d4-1234-5678-9abc-def012345678",
)
# The returned entity reflects the write — the route's
# response builder reads this snapshot rather than issuing a
# follow-up GET.
assert updated.external_session_id == "a1b2c3d4-1234-5678-9abc-def012345678"
# Independent read-back proves the column was actually committed
# (not just held in the returned dataclass).
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.external_session_id == "a1b2c3d4-1234-5678-9abc-def012345678"
def test_set_external_session_id_same_value_is_idempotent(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Re-writing the same value is a no-op and does not raise.
The wrapper bridge may observe the Claude session id across many
hook events; the latch in the forwarder limits this to one PATCH
per process, but a second forwarder process for the same conv
(server bounce, hot reload) would race-write the same value. The
store must accept that without surfacing a spurious error.
"""
conv = conversation_store.create_conversation()
first = conversation_store.set_external_session_id(conv.id, "sid-1")
second = conversation_store.set_external_session_id(conv.id, "sid-1")
assert first.external_session_id == "sid-1"
assert second.external_session_id == "sid-1"
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.external_session_id == "sid-1"
def test_set_external_session_id_rejects_overwrite_with_different_value(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Attempting to overwrite an existing value raises ValueError.
A divergent write signals a real bug (the bridge captured a
different Claude session id than was previously recorded). The
store surfaces it loudly so the route can return 400 instead of
silently destroying the prior mapping.
"""
conv = conversation_store.create_conversation()
conversation_store.set_external_session_id(conv.id, "sid-1")
with pytest.raises(ValueError, match="already has external_session_id"):
conversation_store.set_external_session_id(conv.id, "sid-2")
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
# First-writer-wins — the rejected second call must not have
# mutated the row. If this assertion fails, the store silently
# overwrote on conflict (the very bug the ValueError exists to
# prevent).
assert fetched.external_session_id == "sid-1"
def test_set_external_session_id_missing_conversation_raises(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
Writing to a nonexistent conversation raises ConversationNotFoundError.
Mirrors replace_runner_id / clear_runner_id — the public PATCH
routes translate this into a 404, so silently no-oping here would
let the route return 200 for a write that never happened.
"""
from omnigent.stores.conversation_store import ConversationNotFoundError
with pytest.raises(ConversationNotFoundError):
conversation_store.set_external_session_id(
"conv_does_not_exist",
"sid-1",
)
# ── Fork conversation ────────────────────────────────
def test_fork_conversation_copies_items(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""Fork creates a new conversation with deep-copied items.
Items in the fork must have fresh IDs but identical data. The
source conversation must be untouched — no items removed or
mutated.
"""
agent_store.create(
agent_id="ag_fork_test",
name="fork-test",
bundle_location="ag_fork_test/fakehash",
)
source = conversation_store.create_conversation(
agent_id="ag_fork_test",
title="Original",
)
conversation_store.append(
source.id,
[
NewConversationItem(
type="message",
response_id="resp_001",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "Hello"}],
),
),
NewConversationItem(
type="message",
response_id="resp_001",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "Hi there!"}],
agent="test-agent",
),
),
],
)
fork = conversation_store.fork_conversation(source.id, title="My Fork")
# The fork is a new conversation with a different ID.
assert fork.id != source.id
assert fork.id.startswith("conv_")
assert fork.title == "My Fork"
# Agent binding is copied from the source.
assert fork.agent_id == "ag_fork_test"
# Items are deep-copied — same count, different IDs, same data.
fork_items = conversation_store.list_items(fork.id)
source_items = conversation_store.list_items(source.id)
# Both conversations have 2 items.
assert len(fork_items.data) == 2, (
f"Fork should have 2 items (same as source), got {len(fork_items.data)}"
)
assert len(source_items.data) == 2, (
f"Source should still have 2 items after fork, got {len(source_items.data)}"
)
for src_item, fork_item in zip(source_items.data, fork_items.data, strict=True):
# IDs must differ (fresh generation).
assert fork_item.id != src_item.id, "Fork item IDs must be fresh, not reused from source"
# Type and response_id are preserved.
assert fork_item.type == src_item.type
assert fork_item.response_id == src_item.response_id
# Data content is identical.
assert fork_item.data == src_item.data
def test_fork_conversation_preserves_created_by(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""Forking carries per-item actor attribution into the fork.
Attribution history travels with the items; cloning the
conversation does not blank out who authored each message.
"""
agent_store.create(
agent_id="ag_fork_attr",
name="fork-attr",
bundle_location="ag_fork_attr/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_attr")
conversation_store.append(
source.id,
[
NewConversationItem(
type="message",
response_id="resp_001",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": "Hello"}],
),
created_by="alice@example.com",
),
NewConversationItem(
type="message",
response_id="resp_001",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "Hi!"}],
agent="test-agent",
),
),
],
)
fork = conversation_store.fork_conversation(source.id)
fork_items = conversation_store.list_items(fork.id).data
assert fork_items[0].created_by == "alice@example.com"
assert fork_items[1].created_by is None
def test_fork_conversation_default_title(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""When no title is given, fork derives one from the source title."""
agent_store.create(
agent_id="ag_fork_title",
name="fork-title",
bundle_location="ag_fork_title/fakehash",
)
source = conversation_store.create_conversation(
agent_id="ag_fork_title",
title="Chat about Python",
)
fork = conversation_store.fork_conversation(source.id)
assert fork.title == "Fork of Chat about Python"
def test_fork_conversation_empty_source(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""Forking a conversation with no items produces an empty fork."""
agent_store.create(
agent_id="ag_fork_empty",
name="fork-empty",
bundle_location="ag_fork_empty/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_empty")
fork = conversation_store.fork_conversation(source.id)
fork_items = conversation_store.list_items(fork.id)
assert fork_items.data == [], "Fork of an empty conversation should have no items"
assert fork.agent_id == "ag_fork_empty"
def test_fork_conversation_nonexistent_raises(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Forking a non-existent conversation raises LookupError."""
with pytest.raises(LookupError, match="conversation not found"):
conversation_store.fork_conversation("conv_does_not_exist")
def test_fork_conversation_copies_labels(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""Labels on the source conversation are copied to the fork."""
agent_store.create(
agent_id="ag_fork_labels",
name="fork-labels",
bundle_location="ag_fork_labels/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_labels")
conversation_store.set_labels(source.id, {"sensitivity": "high", "dept": "eng"})
fork = conversation_store.fork_conversation(source.id)
# Both labels must be copied — a mismatch means the store's fork
# skipped the label-copy step or only copied partial keys.
assert fork.labels == {"sensitivity": "high", "dept": "eng"}, (
"Fork should inherit all labels from the source conversation"
)
def test_fork_conversation_drops_instance_scoped_labels(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""
Instance-scoped labels are NOT copied to the fork.
The native bridge-id labels and the context metrics belong to the
source's running instance. Copying the bridge-id in particular
pointed a forked claude-native session at the SOURCE's bridge — the
launched terminal + web injection keyed off it and hit "session no
longer active after /clear" because the bridge's active-session
marker wasn't the clone. The fork must drop them (and re-bind its
own runtime), while ordinary labels still copy.
The DANGEROUS codex full-bypass directive is in the same set for a
different reason: a fork is a new session + workspace, so re-arming
``--dangerously-bypass-approvals-and-sandbox`` there with no typed
re-confirmation would violate the "impossible to enable accidentally"
contract (#657). It must be dropped so the clone opts in afresh.
"""
agent_store.create(
agent_id="ag_fork_instance",
name="fork-instance",
bundle_location="ag_fork_instance/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_instance")
conversation_store.set_labels(
source.id,
{
"omnigent.claude_native.bridge_id": source.id,
"omnigent.codex_native.bridge_id": source.id,
"omnigent.last_context_tokens": "39903",
"omnigent.last_context_window": "1000000",
# The dangerous bypass opt-in must NOT ride into the fork.
"omnigent.codex_native.bypass_sandbox": "1",
# An ordinary, non-instance label that SHOULD carry over.
"omnigent.wrapper": "claude-code-native-ui",
},
)
fork = conversation_store.fork_conversation(source.id)
# The clone keeps the harness identity (wrapper) but none of the
# source's per-instance state. A bridge-id here would re-introduce
# the cross-bridge bug; the metrics would show the source's stale
# usage.
assert fork.labels == {"omnigent.wrapper": "claude-code-native-ui"}, (
f"Fork must drop instance-scoped labels, kept {fork.labels!r}"
)
def test_fork_conversation_stamps_source_external_session_id(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""
The source's native session id is stamped on the fork as a one-shot
resume directive, while the clone's own ``external_session_id`` stays
NULL.
A native harness launching the clone uses
``omnigent.fork.source_external_session_id`` to resume + branch the
source's local transcript (Claude Code ``--fork-session``), so the
clone opens with prior history. The clone is NOT that session, so its
own ``external_session_id`` must remain unset until it captures its
own on first launch. Sources without a native session get no label.
"""
agent_store.create(
agent_id="ag_fork_ext",
name="fork-ext",
bundle_location="ag_fork_ext/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_ext")
conversation_store.set_external_session_id(source.id, "claude-uuid-abc")
fork = conversation_store.fork_conversation(source.id)
# Directive carries the SOURCE's claude uuid for the resume+fork launch.
assert fork.labels.get("omnigent.fork.source_external_session_id") == "claude-uuid-abc", (
f"Fork should carry the source's external session id, got {fork.labels!r}"
)
# The clone is a fresh session — it has no native session of its own
# yet. Copying external_session_id would make two Omnigent sessions claim
# the same Claude session.
reloaded = conversation_store.get_conversation(fork.id)
assert reloaded is not None
assert reloaded.external_session_id is None, (
"Fork must not inherit the source's external_session_id"
)
def test_fork_conversation_no_external_session_id_no_directive(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""A source with no native session id stamps no fork directive."""
agent_store.create(
agent_id="ag_fork_noext",
name="fork-noext",
bundle_location="ag_fork_noext/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_noext")
fork = conversation_store.fork_conversation(source.id)
assert "omnigent.fork.source_external_session_id" not in fork.labels, (
f"No source native session → no resume directive, got {fork.labels!r}"
)
def _append_three_responses(
conversation_store: SqlAlchemyConversationStore,
conversation_id: str,
) -> None:
"""
Append three user/assistant turns under distinct response ids.
Builds the fixture history for the fork-truncation tests: response
``resp_001`` ("Q1"/"A1"), ``resp_002`` ("Q2"/"A2"), and
``resp_003`` ("Q3"/"A3"), six items total in chronological order.
:param conversation_store: Store to append into.
:param conversation_id: Target conversation id, e.g.
``"conv_abc123"``.
"""
for index in (1, 2, 3):
conversation_store.append(
conversation_id,
[
NewConversationItem(
type="message",
response_id=f"resp_00{index}",
data=MessageData(
role="user",
content=[{"type": "input_text", "text": f"Q{index}"}],
),
),
NewConversationItem(
type="message",
response_id=f"resp_00{index}",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": f"A{index}"}],
agent="test-agent",
),
),
],
)
def test_fork_conversation_up_to_response_truncates_items(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""``up_to_response_id`` copies history through that response's last item.
Forking at the middle response must copy every item up to and
including the LAST item of that response (never ending mid-turn)
and drop everything after it, while leaving the source untouched.
"""
agent_store.create(
agent_id="ag_fork_trunc",
name="fork-trunc",
bundle_location="ag_fork_trunc/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_trunc")
_append_three_responses(conversation_store, source.id)
fork = conversation_store.fork_conversation(source.id, up_to_response_id="resp_002")
fork_texts = [
part["text"]
for item in conversation_store.list_items(fork.id).data
for part in item.data.content # type: ignore[union-attr]
]
# Both items of resp_002 are included (the cutoff is the response's
# LAST item) and resp_003 is dropped. A1-only would mean the cutoff
# used the response's FIRST item; any Q3/A3 means no truncation.
assert fork_texts == ["Q1", "A1", "Q2", "A2"], (
f"Fork should contain history through resp_002 only, got {fork_texts}"
)
# The source keeps its full 6-item history — fork must never mutate it.
assert len(conversation_store.list_items(source.id).data) == 6
def test_fork_conversation_truncated_drops_external_session_directive(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""A truncated fork omits the native resume directive but keeps carry-history.
If ``omnigent.fork.source_external_session_id`` were stamped, the
runner would clone the source's FULL native transcript and resume
it — resurrecting the truncated turns. The directive must be
omitted so the runner's carry-history fork-rebuild path
synthesizes the transcript from the truncated items instead.
"""
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
)
agent_store.create(
agent_id="ag_fork_trunc_ext",
name="fork-trunc-ext",
bundle_location="ag_fork_trunc_ext/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_trunc_ext")
conversation_store.set_external_session_id(source.id, "claude-uuid-trunc")
_append_three_responses(conversation_store, source.id)
fork = conversation_store.fork_conversation(
source.id,
carry_history_into_native=True,
up_to_response_id="resp_001",
)
assert FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY not in fork.labels, (
f"Truncated fork must not carry the full-transcript resume directive, got {fork.labels!r}"
)
# Carry-history still stamps: the runner needs it to rebuild the
# native transcript from the truncated items rather than launch fresh.
assert fork.labels.get(FORK_CARRY_HISTORY_LABEL_KEY) == "1"
def test_fork_conversation_cross_family_drops_external_session_directive(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""``resume_source_native_session=False`` omits the native resume directive.
A cross-family fork (e.g. codex-native source → claude-native target)
must not stamp ``omnigent.fork.source_external_session_id``: the
source's native transcript is the wrong format for the target harness,
and the runner's clone path launches FRESH when its clone attempt
fails — silently losing history. Omitting the directive routes the
runner to the carry-history rebuild path (native transcript built from
the copied Omnigent items) instead.
"""
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
)
agent_store.create(
agent_id="ag_fork_xfam",
name="fork-xfam",
bundle_location="ag_fork_xfam/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_xfam")
# A codex thread id on the source: resumable only by a codex target.
conversation_store.set_external_session_id(source.id, "codex-thread-xfam")
_append_three_responses(conversation_store, source.id)
fork = conversation_store.fork_conversation(
source.id,
carry_history_into_native=True,
resume_source_native_session=False,
)
# Despite a FULL (untruncated) fork of a source with a native session,
# the directive must be absent — present would mean the cross-family
# gate regressed and the runner will attempt a doomed transcript clone.
assert FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY not in fork.labels, (
f"Cross-family fork must not stamp the source's native session id, got {fork.labels!r}"
)
# Carry-history still stamps so the runner rebuilds from the copied items.
assert fork.labels.get(FORK_CARRY_HISTORY_LABEL_KEY) == "1", (
f"cross-family fork must stamp carry-history for rebuild, got {fork.labels!r}"
)
def test_fork_conversation_up_to_last_response_keeps_external_directive(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""Truncating at the LAST response is treated as a full fork.
The copy is equivalent to a full fork, so the resume directive is
kept — the runner can still clone the source's native transcript
verbatim (full fidelity) instead of rebuilding from items.
"""
from omnigent.stores.conversation_store import FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY
agent_store.create(
agent_id="ag_fork_trunc_last",
name="fork-trunc-last",
bundle_location="ag_fork_trunc_last/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_trunc_last")
conversation_store.set_external_session_id(source.id, "claude-uuid-last")
_append_three_responses(conversation_store, source.id)
fork = conversation_store.fork_conversation(source.id, up_to_response_id="resp_003")
# All 6 items copied — the cutoff at the last response drops nothing.
assert len(conversation_store.list_items(fork.id).data) == 6
assert fork.labels.get(FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY) == "claude-uuid-last", (
f"Cutoff at the last response is a full fork and must keep the resume "
f"directive, got {fork.labels!r}"
)
def test_fork_conversation_up_to_unknown_response_raises(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""An ``up_to_response_id`` matching no item raises ValueError.
Silently copying the full history would fork far more context than
the user selected (stale client state); the store must fail loud
and create nothing.
"""
agent_store.create(
agent_id="ag_fork_trunc_bad",
name="fork-trunc-bad",
bundle_location="ag_fork_trunc_bad/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_trunc_bad")
_append_three_responses(conversation_store, source.id)
with pytest.raises(ValueError, match="resp_nope"):
conversation_store.fork_conversation(source.id, up_to_response_id="resp_nope")
def test_fork_clone_agent_is_session_scoped(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""A fork that clones an agent creates a session-scoped row, not a built-in.
The clone must be born with ``kind='session'`` so it never appears in
the built-in agent list (``kind='template'``) that backs the fork
picker — the regression that surfaced as duplicate "Claude Code" /
"Codex" entries in the fork dialog.
"""
agent_store.create(
agent_id="ag_fork_src",
name="claude-native-ui",
bundle_location="ag_fork_src/hash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_src")
fork = conversation_store.fork_conversation(
source.id,
agent_id="ag_clone_ok",
cloned_agent_name="claude-native-ui (fork ag_clone_o)",
cloned_agent_bundle_location="ag_fork_src/hash",
cloned_agent_description=None,
)
assert fork.agent_id == "ag_clone_ok"
cloned = agent_store.get("ag_clone_ok")
assert cloned is not None
assert cloned.session_id == fork.id, "clone must be bound to the fork session"
# The clone is session-scoped, so it must NOT leak into the built-in
# list (the source built-in is the only template-name row).
builtin_ids = {a.id for a in agent_store.list(limit=100).data}
assert "ag_clone_ok" not in builtin_ids
assert "ag_fork_src" in builtin_ids
def test_fork_clone_agent_failure_leaves_no_orphan(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""A failed clone-fork rolls the agent row back — no orphaned built-in.
Pre-fix the route pre-created the clone in its own committed
transaction, so a fork failure (here a stale ``up_to_response_id``)
orphaned a ``session_id IS NULL`` row that polluted the built-in agent
catalog. Creating the clone inside the fork transaction means the
failure rolls it back too.
"""
agent_store.create(
agent_id="ag_fork_src2",
name="codex-native-ui",
bundle_location="ag_fork_src2/hash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_src2")
_append_three_responses(conversation_store, source.id)
before = {a.id for a in agent_store.list(limit=100).data}
with pytest.raises(ValueError, match="resp_nope"):
conversation_store.fork_conversation(
source.id,
agent_id="ag_clone_orphan",
cloned_agent_name="codex-native-ui (fork ag_clone_o)",
cloned_agent_bundle_location="ag_fork_src2/hash",
up_to_response_id="resp_nope",
)
# The clone must not exist at all, and the built-in list is unchanged.
assert agent_store.get("ag_clone_orphan") is None
after = {a.id for a in agent_store.list(limit=100).data}
assert after == before
def test_instance_scoped_label_keys_match_harness_constants() -> None:
"""
The store's instance-scoped denylist matches the harness label keys.
The store hard-codes the bridge-id literals (to avoid importing
harness modules into the persistence layer). This guards against
drift: if a harness renames its bridge-id label key, the literal in
:data:`_INSTANCE_SCOPED_LABEL_KEYS` would silently stop matching and
forks would re-inherit the source's bridge. Importing the real
constants here makes that rename fail loudly at test time.
"""
from omnigent.claude_native_bridge import BRIDGE_ID_LABEL_KEY
from omnigent.codex_native_bridge import CODEX_NATIVE_BRIDGE_ID_LABEL_KEY
from omnigent.stores.conversation_store import _INSTANCE_SCOPED_LABEL_KEYS
# Each harness's canonical bridge-id key must be in the denylist; a
# miss means a rename slipped past the store's hard-coded literal.
assert BRIDGE_ID_LABEL_KEY in _INSTANCE_SCOPED_LABEL_KEYS
assert CODEX_NATIVE_BRIDGE_ID_LABEL_KEY in _INSTANCE_SCOPED_LABEL_KEYS
def test_fork_conversation_copies_reasoning_effort(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""Fork inherits the source's reasoning_effort setting."""
agent_store.create(
agent_id="ag_fork_re",
name="fork-reasoning",
bundle_location="ag_fork_re/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_re")
conversation_store.update_conversation(source.id, reasoning_effort="high")
fork = conversation_store.fork_conversation(source.id)
# A wrong value means fork_conversation didn't copy the
# reasoning_effort column — the fork would silently use the
# default effort level instead of the source's setting.
assert fork.reasoning_effort == "high", "Fork should inherit reasoning_effort from the source"
def test_fork_conversation_copies_terminal_launch_args(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""Fork inherits the source's terminal_launch_args setting."""
agent_store.create(
agent_id="ag_fork_tla",
name="fork-tla",
bundle_location="ag_fork_tla/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_tla")
conversation_store.update_conversation(
source.id,
terminal_launch_args=["--dangerously-skip-permissions"],
)
fork = conversation_store.fork_conversation(source.id)
# A wrong value means fork_conversation didn't copy the
# terminal_launch_args column — a forked native session would
# silently lose its launch flags (consistent with how the fork
# copies reasoning_effort / model_override).
assert fork.terminal_launch_args == ["--dangerously-skip-permissions"], (
"Fork should inherit terminal_launch_args from the source"
)
def test_fork_conversation_copy_model_settings_false_resets(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""``copy_model_settings=False`` drops the source's model settings.
A model id is provider-bound, so a fork that switches to a different
provider family must NOT inherit the source's ``model_override`` /
``reasoning_effort`` — they'd name a model the new provider can't
serve. Both must reset to ``None`` (the bound agent's defaults), while
the default (``True``) still copies them.
"""
agent_store.create(
agent_id="ag_fork_cms",
name="fork-cms",
bundle_location="ag_fork_cms/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_cms")
conversation_store.update_conversation(
source.id, reasoning_effort="high", model_override="claude-opus-4"
)
fork = conversation_store.fork_conversation(source.id, copy_model_settings=False)
reloaded = conversation_store.get_conversation(fork.id)
assert reloaded is not None
# Both reset — a non-None value means the cross-family reset didn't
# apply and the fork would launch pointing at an incompatible model.
assert reloaded.model_override is None, (
"copy_model_settings=False must drop the source's model_override"
)
assert reloaded.reasoning_effort is None, (
"copy_model_settings=False must drop the source's reasoning_effort"
)
# Control: the default still copies, proving the reset is gated on the
# flag and not a blanket drop.
fork_default = conversation_store.fork_conversation(source.id)
reloaded_default = conversation_store.get_conversation(fork_default.id)
assert reloaded_default is not None
assert reloaded_default.model_override == "claude-opus-4"
assert reloaded_default.reasoning_effort == "high"
def test_fork_conversation_carry_history_into_native_stamps_label(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""``carry_history_into_native=True`` stamps the carry-history directive.
The runner reads ``omnigent.fork.carry_history`` to decide whether a
native target rebuilds its transcript (vs launching fresh). The label
must be set only when the flag is passed; the default leaves it off so
a normal fork into a native target doesn't trigger a rebuild from the
wrong items.
"""
from omnigent.stores.conversation_store import FORK_CARRY_HISTORY_LABEL_KEY
agent_store.create(
agent_id="ag_fork_carry",
name="fork-carry",
bundle_location="ag_fork_carry/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_carry")
carried = conversation_store.fork_conversation(source.id, carry_history_into_native=True)
assert carried.labels.get(FORK_CARRY_HISTORY_LABEL_KEY) == "1", (
f"carry_history_into_native=True must stamp the directive, got {carried.labels!r}"
)
# Default (False): no directive, so a native target launches fresh.
plain = conversation_store.fork_conversation(source.id)
assert FORK_CARRY_HISTORY_LABEL_KEY not in plain.labels, (
f"Default fork must not stamp the carry-history directive, got {plain.labels!r}"
)
def test_fork_conversation_agent_id_override(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""When agent_id is passed, the fork binds to the override
instead of the source's agent.
"""
agent_store.create(
agent_id="ag_original",
name="original-agent",
bundle_location="ag_original/fakehash",
)
agent_store.create(
agent_id="ag_cloned",
name="cloned-agent",
bundle_location="ag_original/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_original")
fork = conversation_store.fork_conversation(
source.id,
agent_id="ag_cloned",
)
assert fork.agent_id == "ag_cloned", (
"Fork should use the overridden agent_id, not the source's"
)
def test_switch_conversation_agent_cross_family_resets_and_relabels(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""In-place switch deletes the old agent, binds the new, and on a
cross-family switch resets model settings, clears the native session
id, and replaces the harness-presentation labels.
"""
from omnigent._wrapper_labels import (
CODEX_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
SWITCH_PREVIOUS_BUILTIN_LABEL_KEY,
)
# An instance-scoped label (belongs to the running instance, dropped on a
# switch). Uses a literal still in _INSTANCE_SCOPED_LABEL_KEYS — the old
# omnigent.stopped marker was retired upstream.
instance_label = "omnigent.last_context_tokens"
# A real session binds a session-scoped agent (agent.session_id == conv).
created = conversation_store.create_session_with_agent(
agent_id="ag_switch_old",
agent_name="claude (switch src)",
agent_bundle_location="ag_switch_old/hash",
agent_description="old",
)
conv_id = created.conversation.id
# Give the session model settings, a native session id, and labels that a
# switch must touch (instance-scoped stopped marker + the old harness's
# ui/wrapper) so we can assert they're handled correctly.
conversation_store.update_conversation(
conv_id, model_override="claude-opus-4-7", reasoning_effort="high"
)
conversation_store.set_external_session_id(conv_id, "old-native-uuid")
conversation_store.set_labels(
conv_id,
{
instance_label: "1",
# DANGEROUS codex bypass opt-in: in the instance-scoped set so a
# switch (a new agent/harness context) drops it rather than
# silently re-arming bypass without a fresh typed confirmation.
"omnigent.codex_native.bypass_sandbox": "1",
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: "claude-code-native-ui",
},
)
conversation_store.append(
conv_id,
[
NewConversationItem(
type="message",
response_id="resp_1",
data=MessageData(role="user", content=[{"type": "input_text", "text": "hi"}]),
)
],
)
target_labels = {
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: CODEX_NATIVE_WRAPPER_VALUE,
}
updated = conversation_store.switch_conversation_agent(
conv_id,
new_agent_id="ag_switch_new",
new_agent_name="codex (switch new)",
new_agent_bundle_location="ag_switch_new/hash",
new_agent_description="new",
copy_model_settings=False, # cross-family
carry_history_into_native=True, # native target
presentation_labels=target_labels,
previous_builtin_id="ag_builtin_claude",
)
# New agent bound; old session-scoped agent deleted (unique session_id
# index would otherwise be violated by leaving both).
assert updated.agent_id == "ag_switch_new"
assert agent_store.get("ag_switch_old") is None, (
"old session-scoped agent must be deleted on switch"
)
new_agent = agent_store.get("ag_switch_new")
assert new_agent is not None and new_agent.session_id == conv_id, (
"new agent must be session-scoped to this conversation"
)
# Cross-family → provider-bound model id is meaningless, so both reset.
assert updated.model_override is None
assert updated.reasoning_effort is None
# Native runtime state belongs to the old harness → cleared so the next
# turn cold-starts and rebuilds from items.
assert updated.external_session_id is None
# Labels: target ui/wrapper applied, carry-history + previous-builtin
# stamped, and the old instance-scoped stopped marker dropped.
assert updated.labels[UI_MODE_LABEL_KEY] == UI_MODE_TERMINAL_VALUE
assert updated.labels[WRAPPER_LABEL_KEY] == CODEX_NATIVE_WRAPPER_VALUE
assert updated.labels[FORK_CARRY_HISTORY_LABEL_KEY] == "1"
assert updated.labels[SWITCH_PREVIOUS_BUILTIN_LABEL_KEY] == "ag_builtin_claude"
assert instance_label not in updated.labels, "instance-scoped labels must not survive a switch"
assert "omnigent.codex_native.bypass_sandbox" not in updated.labels, (
"the dangerous bypass opt-in must not survive a switch (re-confirm per context)"
)
# Transcript is untouched (in place, not copied).
assert len(conversation_store.list_items(conv_id).data) == 1
def test_switch_conversation_agent_same_family_keeps_model_settings(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""A same-family switch keeps model settings; an SDK target (empty
presentation labels) drops the old ui/wrapper labels and does not stamp
the carry-history directive.
"""
from omnigent._wrapper_labels import (
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY,
)
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
SWITCH_PREVIOUS_BUILTIN_LABEL_KEY,
)
created = conversation_store.create_session_with_agent(
agent_id="ag_switch_old2",
agent_name="claude-native-ui",
agent_bundle_location="ag_switch_old2/hash",
agent_description=None,
)
conv_id = created.conversation.id
conversation_store.update_conversation(
conv_id, model_override="claude-opus-4-7", reasoning_effort="high"
)
conversation_store.set_labels(
conv_id,
{
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
WRAPPER_LABEL_KEY: "claude-code-native-ui",
# A stale previous-builtin pointer from an earlier switch.
SWITCH_PREVIOUS_BUILTIN_LABEL_KEY: "ag_stale_builtin",
},
)
updated = conversation_store.switch_conversation_agent(
conv_id,
new_agent_id="ag_switch_new2",
new_agent_name="claude (switch new)",
new_agent_bundle_location="ag_switch_new2/hash",
new_agent_description=None,
copy_model_settings=True, # same family (anthropic native → sdk)
carry_history_into_native=False, # SDK target rebuilds nothing
presentation_labels={}, # SDK → chat mode (drop ui/wrapper)
previous_builtin_id=None,
)
# Same family → model settings carry over unchanged.
assert updated.model_override == "claude-opus-4-7"
assert updated.reasoning_effort == "high"
# SDK target → terminal-first ui/wrapper labels removed (chat mode).
assert UI_MODE_LABEL_KEY not in updated.labels
assert WRAPPER_LABEL_KEY not in updated.labels
# No native rebuild for an SDK target.
assert FORK_CARRY_HISTORY_LABEL_KEY not in updated.labels
# Stale previous-builtin pointer dropped (None passed → not re-stamped),
# so a later "switch back" can't offer a wrong target.
assert SWITCH_PREVIOUS_BUILTIN_LABEL_KEY not in updated.labels
def test_get_session_connectivity_batches_runner_and_host(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""
``get_session_connectivity`` returns runner/host per id.
This is the bulk read powering the ``/health`` online-dot path: it
replaced an N+1 fan-out of ``get_conversation`` (plus a labels query
each). Liveness is now purely "is the tunnel up / is the host
fresh" — the retired ``omnigent.stopped`` marker is no longer a
field on the result. The test pins both binding fields the dot
decision needs, across a mix of bindings in one call:
- a runner-bound session reports its ``runner_id`` and no host;
- a host-bound session reports its ``host_id`` and no runner;
- an unknown id is absent from the result (callers treat that as
reachable, matching the legacy single-row path).
"""
_register_host(db_uri, "host_conn")
runner_bound = conversation_store.create_conversation(runner_id="runner_xyz")
host_bound = conversation_store.create_conversation(host_id="host_conn", workspace="/tmp/ws")
result = conversation_store.get_session_connectivity(
[runner_bound.id, host_bound.id, "conv_unknown"]
)
assert set(result) == {runner_bound.id, host_bound.id}
assert result[runner_bound.id].runner_id == "runner_xyz"
assert result[runner_bound.id].host_id is None
assert result[host_bound.id].host_id == "host_conn"
assert result[host_bound.id].runner_id is None
# None of these are forks of a coding session, so needs_workspace is
# off across the board — a True here would wrongly force the online
# dot off for a normally-bound session.
assert result[runner_bound.id].needs_workspace is False
assert result[host_bound.id].needs_workspace is False
def test_get_session_connectivity_reports_needs_workspace_for_fork(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
The fork-source label surfaces as ``needs_workspace=True``.
A fork of a session that had a working directory carries the
``omnigent.fork.source_id`` label (set by ``fork_conversation``).
``get_session_connectivity`` must report ``needs_workspace=True`` for
it — that flag is what makes ``_bulk_session_liveness`` mark the
unbound clone offline so the UI prompts for a directory instead of
dropping the first message.
"""
fork = conversation_store.create_conversation()
conversation_store.set_labels(fork.id, {"omnigent.fork.source_id": "conv_src"})
plain = conversation_store.create_conversation()
result = conversation_store.get_session_connectivity([fork.id, plain.id])
# Fork-source label present → needs_workspace on. A False here means
# the label SELECT or the flag computation dropped the fork key.
assert result[fork.id].needs_workspace is True
# No label → in-process resumable, needs_workspace off. This is the
# CUJ-2 chat-only fork path that must stay reachable.
assert result[plain.id].needs_workspace is False
def test_get_session_connectivity_empty_input_skips_query(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""
``get_session_connectivity([])`` returns ``{}`` without a query.
The single-session ``/health`` variant and an empty sidebar both
hit this; it must short-circuit rather than issue an ``IN ()``.
"""
assert conversation_store.get_session_connectivity([]) == {}
# ── Per-user daily cost rollup ────────────────────────
def test_get_daily_cost_missing_returns_zero(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""A (user, day) with no recorded spend reads as ``0.0``."""
assert conversation_store.get_daily_cost("alice@example.com", "2026-06-05") == 0.0
def test_add_daily_cost_accumulates(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Repeated adds for the same (user, day) sum into one total."""
conversation_store.add_daily_cost("alice@example.com", "2026-06-05", 1.25)
conversation_store.add_daily_cost("alice@example.com", "2026-06-05", 0.75)
conversation_store.add_daily_cost("alice@example.com", "2026-06-05", 2.00)
# 1.25 + 0.75 + 2.00 = 4.00 proves each delta was added, not overwritten.
assert conversation_store.get_daily_cost("alice@example.com", "2026-06-05") == pytest.approx(
4.00
)
def test_add_daily_cost_isolated_by_user_and_day(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Spend is partitioned by both user and UTC day; no cross-bleed."""
conversation_store.add_daily_cost("alice@example.com", "2026-06-05", 5.0)
conversation_store.add_daily_cost("alice@example.com", "2026-06-06", 3.0)
conversation_store.add_daily_cost("bob@example.com", "2026-06-05", 9.0)
# Same user, different day: only that day's delta.
assert conversation_store.get_daily_cost("alice@example.com", "2026-06-05") == pytest.approx(
5.0
)
assert conversation_store.get_daily_cost("alice@example.com", "2026-06-06") == pytest.approx(
3.0
)
# Different user, same day: isolated from alice.
assert conversation_store.get_daily_cost("bob@example.com", "2026-06-05") == pytest.approx(9.0)
def test_add_daily_cost_nonpositive_is_noop(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``delta <= 0`` never creates or mutates a row."""
conversation_store.add_daily_cost("alice@example.com", "2026-06-05", 0.0)
conversation_store.add_daily_cost("alice@example.com", "2026-06-05", -1.0)
assert conversation_store.get_daily_cost("alice@example.com", "2026-06-05") == 0.0
# A subsequent positive add still works and isn't polluted by the no-ops.
conversation_store.add_daily_cost("alice@example.com", "2026-06-05", 2.5)
assert conversation_store.get_daily_cost("alice@example.com", "2026-06-05") == pytest.approx(
2.5
)
def test_get_session_owner_returns_highest_level_grantee(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""The owner is the max-``level`` grantee, regardless of grant order."""
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
conv = conversation_store.create_conversation()
perms = SqlAlchemyPermissionStore(db_uri)
for user in ("reader@example.com", "alice@example.com", "editor@example.com"):
# session_permissions.user_id is an FK to users.id.
perms.ensure_user(user)
perms.grant("reader@example.com", conv.id, 1) # read
perms.grant("alice@example.com", conv.id, 4) # owner (LEVEL_OWNER)
perms.grant("editor@example.com", conv.id, 2) # edit
# Owner (level 4) outranks the read/edit grants; if this returned a
# lower-level grantee the ORDER BY level DESC ranking would be wrong.
assert conversation_store.get_session_owner(conv.id) == "alice@example.com"
def test_get_session_owner_none_when_no_grants(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""A session with no permission grants (single-user mode) has no owner."""
conv = conversation_store.create_conversation()
assert conversation_store.get_session_owner(conv.id) is None
def test_get_session_owner_excludes_public_sentinel(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""A session with only a public grant (no real owner) returns None."""
from omnigent.server.auth import RESERVED_USER_PUBLIC
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
conv = conversation_store.create_conversation()
perms = SqlAlchemyPermissionStore(db_uri)
perms.ensure_user(RESERVED_USER_PUBLIC)
perms.grant(RESERVED_USER_PUBLIC, conv.id, 1) # public read, no owner grant
# Without the public-sentinel filter this would return "__public__"
# (the only — hence highest-level — grant); the filter makes a
# session with no real owner read as None instead.
assert conversation_store.get_session_owner(conv.id) is None
# ── Per-user daily cost: ask-approved state ───────────
def test_get_daily_cost_state_missing_returns_zeros(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""A (user, day) with no row reads as zeros for both fields."""
state = conversation_store.get_daily_cost_state("alice@example.com", "2026-06-06")
assert state == {"cost_usd": 0.0, "ask_approved_usd": 0.0}
def test_set_daily_ask_approved_does_not_clobber_cost(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Recording an approved checkpoint leaves accumulated cost intact."""
conversation_store.add_daily_cost("alice@example.com", "2026-06-06", 3.0)
conversation_store.set_daily_ask_approved("alice@example.com", "2026-06-06", 2.0)
state = conversation_store.get_daily_cost_state("alice@example.com", "2026-06-06")
# cost must survive the approval write (set touches only ask_approved_usd);
# if it dropped to 0 the UPSERT wrongly overwrote cost_usd.
assert state["cost_usd"] == pytest.approx(3.0)
assert state["ask_approved_usd"] == pytest.approx(2.0)
def test_add_daily_cost_does_not_clobber_ask_approved(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Accumulating cost after an approval leaves the approval intact."""
conversation_store.set_daily_ask_approved("alice@example.com", "2026-06-06", 2.0)
conversation_store.add_daily_cost("alice@example.com", "2026-06-06", 1.5)
state = conversation_store.get_daily_cost_state("alice@example.com", "2026-06-06")
# The increment UPSERT must only touch cost_usd; ask_approved survives.
assert state["cost_usd"] == pytest.approx(1.5)
assert state["ask_approved_usd"] == pytest.approx(2.0)
def test_set_daily_ask_approved_creates_row_when_absent(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Approving with no prior row inserts a cost=0 row carrying the approval."""
conversation_store.set_daily_ask_approved("alice@example.com", "2026-06-06", 0.05)
state = conversation_store.get_daily_cost_state("alice@example.com", "2026-06-06")
assert state["cost_usd"] == 0.0
assert state["ask_approved_usd"] == pytest.approx(0.05)
def test_add_daily_cost_stacks_after_ask_approved(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Cost increments stack (not overwrite) even after an approval is set."""
conversation_store.set_daily_ask_approved("alice@example.com", "2026-06-06", 2.0)
conversation_store.add_daily_cost("alice@example.com", "2026-06-06", 1.5)
conversation_store.add_daily_cost("alice@example.com", "2026-06-06", 1.0)
state = conversation_store.get_daily_cost_state("alice@example.com", "2026-06-06")
# 1.5 + 1.0 proves the second add incremented rather than overwrote;
# ask_approved untouched throughout.
assert state["cost_usd"] == pytest.approx(2.5)
assert state["ask_approved_usd"] == pytest.approx(2.0)
# ── set_session_state ─────────────────────────────────────────────────────
def test_set_session_state_persists(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""set_session_state writes a JSON-serializable dict to the conversation."""
conv = conversation_store.create_conversation()
state = {"cursor": 42, "flags": ["a", "b"]}
conversation_store.set_session_state(conv.id, state)
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.session_state == state
def test_set_session_state_overwrites(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""set_session_state replaces the entire state dict."""
conv = conversation_store.create_conversation()
conversation_store.set_session_state(conv.id, {"v": 1})
conversation_store.set_session_state(conv.id, {"v": 2, "new_key": True})
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.session_state == {"v": 2, "new_key": True}
def test_set_session_state_empty_dict(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""set_session_state with empty dict clears state."""
conv = conversation_store.create_conversation()
conversation_store.set_session_state(conv.id, {"old": True})
conversation_store.set_session_state(conv.id, {})
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.session_state == {}
# ── set_session_usage ─────────────────────────────────────────────────────
def test_set_session_usage_persists(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""set_session_usage writes token usage to the conversation."""
conv = conversation_store.create_conversation()
usage = {"input_tokens": 1500, "output_tokens": 350, "total_tokens": 1850}
conversation_store.set_session_usage(conv.id, usage)
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.session_usage == usage
def test_set_session_usage_overwrites(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""set_session_usage replaces the entire usage dict."""
conv = conversation_store.create_conversation()
conversation_store.set_session_usage(conv.id, {"input_tokens": 100})
conversation_store.set_session_usage(conv.id, {"input_tokens": 200, "output_tokens": 50})
fetched = conversation_store.get_conversation(conv.id)
assert fetched is not None
assert fetched.session_usage == {"input_tokens": 200, "output_tokens": 50}
# ── next_position counter (write-path MAX(position) scan removal) ──────
def _user_message(text: str, response_id: str = "resp_pos") -> NewConversationItem:
"""A minimal user message item for position-counter tests."""
return NewConversationItem(
type="message",
response_id=response_id,
data=MessageData(role="user", content=[{"type": "input_text", "text": text}]),
)
def _stored_next_position(
conversation_store: SqlAlchemyConversationStore, conversation_id: str
) -> int | None:
"""Read the raw ``conversations.next_position`` counter for assertions."""
from omnigent.db.db_models import SqlConversation
with conversation_store._session() as session:
row = session.get(SqlConversation, (0, conversation_id))
assert row is not None
return row.next_position
def _stored_positions(
conversation_store: SqlAlchemyConversationStore, conversation_id: str
) -> list[int]:
"""Raw item positions for a conversation, ascending — the source of
truth ``list_items`` (which hides ``position``) cannot assert on."""
from sqlalchemy import select
from omnigent.db.db_models import SqlConversationItem
with conversation_store._session() as session:
return sorted(
session.execute(
select(SqlConversationItem.position).where(
SqlConversationItem.conversation_id == conversation_id
)
)
.scalars()
.all()
)
def test_new_conversation_seeds_next_position_zero(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""A freshly created conversation starts its position allocator at 0, so
the first append reads the counter rather than scanning MAX(position)."""
conv = conversation_store.create_conversation()
assert _stored_next_position(conversation_store, conv.id) == 0
@pytest.mark.parametrize("batch_sizes", [[1], [1, 1, 1], [3], [2, 1, 4]])
def test_append_allocates_dense_positions_and_advances_counter(
conversation_store: SqlAlchemyConversationStore,
batch_sizes: list[int],
) -> None:
"""append() assigns contiguous positions from next_position and advances
the counter by the batch size, so the stored counter always equals the
total items appended — across single- and multi-item batches.
Real store, real SQLite; asserts on the raw position column and counter,
no mocks.
"""
conv = conversation_store.create_conversation()
total = 0
for batch in batch_sizes:
conversation_store.append(conv.id, [_user_message(f"m{total + i}") for i in range(batch)])
total += batch
assert _stored_positions(conversation_store, conv.id) == list(range(total))
# The counter points one past the last item — the next position to hand out.
assert _stored_next_position(conversation_store, conv.id) == total
def test_append_reads_counter_not_max_scan(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""append() allocates from the maintained counter, not a MAX(position)
scan: advancing the counter past the real max makes the next item land at
the counter value, which a scan-based implementation could never produce.
"""
from omnigent.db.db_models import SqlConversation
conv = conversation_store.create_conversation()
conversation_store.append(conv.id, [_user_message("a"), _user_message("b")])
# Real max position is 1; jump the counter ahead to 100.
with conversation_store._session() as session:
session.get(SqlConversation, (0, conv.id)).next_position = 100
conversation_store.append(conv.id, [_user_message("c")])
# Position 100 (counter), not 2 (max + 1) — proves the scan path is unused.
assert _stored_positions(conversation_store, conv.id) == [0, 1, 100]
assert _stored_next_position(conversation_store, conv.id) == 101
@pytest.mark.parametrize("preexisting", [0, 1, 3])
def test_append_falls_back_to_scan_when_counter_null(
conversation_store: SqlAlchemyConversationStore,
preexisting: int,
) -> None:
"""A conversation written before the counter existed has
next_position = NULL. The next append falls back to a one-time
MAX(position) scan to place items correctly, then persists the advanced
counter so subsequent appends are scan-free.
"""
from omnigent.db.db_models import SqlConversation
conv = conversation_store.create_conversation()
if preexisting:
conversation_store.append(conv.id, [_user_message(f"pre{i}") for i in range(preexisting)])
# Simulate a pre-counter row: clear the maintained counter.
with conversation_store._session() as session:
session.get(SqlConversation, (0, conv.id)).next_position = None
assert _stored_next_position(conversation_store, conv.id) is None
conversation_store.append(conv.id, [_user_message("new")])
# The fallback scan placed the new item right after the existing max...
assert _stored_positions(conversation_store, conv.id) == list(range(preexisting + 1))
# ...and the counter is now backfilled, so the next append won't scan.
assert _stored_next_position(conversation_store, conv.id) == preexisting + 1
def test_fork_seeds_next_position_from_copied_items(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""A full fork seeds the clone's allocator from the number of copied items,
so the clone's first append is scan-free and collision-free."""
agent_store.create(agent_id="ag_fork_pos", name="fork-pos", bundle_location="ag_fork_pos/h")
source = conversation_store.create_conversation(agent_id="ag_fork_pos")
conversation_store.append(
source.id, [_user_message(f"s{i}", response_id="resp_1") for i in range(3)]
)
fork = conversation_store.fork_conversation(source.id, title="fork")
# 3 items copied (dense positions 0..2) → allocator starts at 3.
assert _stored_next_position(conversation_store, fork.id) == 3
conversation_store.append(fork.id, [_user_message("after")])
assert _stored_positions(conversation_store, fork.id) == [0, 1, 2, 3]
def test_truncated_fork_seeds_next_position_from_copied_items(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""A truncated fork seeds the allocator from the count of the *copied*
items, not the source length, so the shorter clone stays collision-free."""
agent_store.create(
agent_id="ag_fork_trunc", name="fork-trunc", bundle_location="ag_fork_trunc/h"
)
source = conversation_store.create_conversation(agent_id="ag_fork_trunc")
conversation_store.append(
source.id,
[_user_message("a", "resp_1"), _user_message("b", "resp_1")],
)
conversation_store.append(
source.id,
[_user_message("c", "resp_2"), _user_message("d", "resp_2")],
)
fork = conversation_store.fork_conversation(source.id, up_to_response_id="resp_1")
# Only resp_1's 2 items are copied → allocator starts at 2.
assert _stored_positions(conversation_store, fork.id) == [0, 1]
assert _stored_next_position(conversation_store, fork.id) == 2
conversation_store.append(fork.id, [_user_message("after")])
assert _stored_positions(conversation_store, fork.id) == [0, 1, 2]
def test_append_many_batches_stay_contiguous(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""End-to-end: many sequential appends produce a contiguous, gap-free
position sequence and a counter equal to the item count — the invariant
the maintained allocator must preserve across a long session (the
scan-per-write pattern this replaces grew with that length)."""
conv = conversation_store.create_conversation()
total = 0
for turn in range(25):
conversation_store.append(
conv.id,
[_user_message(f"t{turn}-{i}", response_id=f"resp_{turn}") for i in range(3)],
)
total += 3
assert _stored_positions(conversation_store, conv.id) == list(range(total))
assert _stored_next_position(conversation_store, conv.id) == total
listed = conversation_store.list_items(conv.id, limit=total)
assert len(listed.data) == total
# ── Projects (conversation_labels key="omni_project") ───────
def test_list_projects_returns_distinct_names_sorted(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``list_projects`` returns each distinct project name once, ordered
alphabetically. Sessions with no project label don't create phantom
projects, and a project shared by two sessions appears a single time."""
a1 = conversation_store.create_conversation()
a2 = conversation_store.create_conversation()
b1 = conversation_store.create_conversation()
conversation_store.create_conversation() # unfiled — must not appear
conversation_store.set_labels(a1.id, {"omni_project": "Sprint 42"})
conversation_store.set_labels(a2.id, {"omni_project": "Sprint 42"})
conversation_store.set_labels(b1.id, {"omni_project": "Customer X"})
# Alphabetical, de-duplicated. A missing DISTINCT would list "Sprint 42"
# twice.
assert conversation_store.list_projects() == ["Customer X", "Sprint 42"]
def test_list_projects_empty_when_no_project_labels(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Non-project labels (e.g. guardrail keys) never surface as projects."""
conv = conversation_store.create_conversation()
conversation_store.set_labels(conv.id, {"integrity": "1", "sensitivity": "public"})
assert conversation_store.list_projects() == []
def test_list_projects_excludes_all_archived_projects(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""A project whose every member is archived drops out of the list (this is
what makes "Delete project" — which archives all members — remove the
folder), while the label is preserved so unarchiving restores it.
A project with a mix of archived and active members still appears."""
solo = conversation_store.create_conversation()
mix_archived = conversation_store.create_conversation()
mix_active = conversation_store.create_conversation()
conversation_store.set_labels(solo.id, {"omni_project": "Gone"})
conversation_store.set_labels(mix_archived.id, {"omni_project": "Mixed"})
conversation_store.set_labels(mix_active.id, {"omni_project": "Mixed"})
# "Gone" has one member; archiving it empties the project. "Mixed" keeps a
# live member, so it stays.
conversation_store.update_conversation(solo.id, archived=True)
conversation_store.update_conversation(mix_archived.id, archived=True)
assert conversation_store.list_projects() == ["Mixed"]
# Unarchiving the lone member brings its project back — the label was kept.
conversation_store.update_conversation(solo.id, archived=False)
assert conversation_store.list_projects() == ["Gone", "Mixed"]
def test_list_projects_scoped_by_accessible_by(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""When ``accessible_by`` is set, only projects on sessions the user has a
permission row for are returned — mirroring the list_conversations ACL."""
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
mine = conversation_store.create_conversation()
theirs = conversation_store.create_conversation()
conversation_store.set_labels(mine.id, {"omni_project": "Mine"})
conversation_store.set_labels(theirs.id, {"omni_project": "Theirs"})
perms = SqlAlchemyPermissionStore(db_uri)
for user in ("alice@example.com", "bob@example.com"):
perms.ensure_user(user)
perms.grant("alice@example.com", mine.id, 4)
perms.grant("bob@example.com", theirs.id, 4)
# Alice only sees her project; Theirs is invisible to her.
assert conversation_store.list_projects(accessible_by="alice@example.com") == ["Mine"]
def test_delete_label_removes_only_target_key(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``delete_label`` drops the named key and leaves siblings intact — so
removing a session from its project doesn't wipe guardrail labels."""
conv = conversation_store.create_conversation()
conversation_store.set_labels(conv.id, {"omni_project": "X", "integrity": "1"})
conversation_store.delete_label(conv.id, "omni_project")
got = conversation_store.get_conversation(conv.id)
assert got is not None
assert got.labels == {"integrity": "1"}
def test_delete_label_is_noop_when_absent(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""Deleting a label that doesn't exist is a no-op, not an error."""
conv = conversation_store.create_conversation()
conversation_store.delete_label(conv.id, "omni_project") # must not raise
got = conversation_store.get_conversation(conv.id)
assert got is not None
assert got.labels == {}
def test_list_conversations_filters_by_project(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``project="X"`` returns only sessions carrying that exact project label."""
filed = conversation_store.create_conversation()
other = conversation_store.create_conversation()
conversation_store.create_conversation() # unfiled
conversation_store.set_labels(filed.id, {"omni_project": "X"})
conversation_store.set_labels(other.id, {"omni_project": "Y"})
ids = {c.id for c in conversation_store.list_conversations(project="X").data}
assert ids == {filed.id}
def test_list_conversations_empty_project_returns_unfiled(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``project=""`` returns only sessions with NO project label (Unfiled)."""
filed = conversation_store.create_conversation()
unfiled = conversation_store.create_conversation()
conversation_store.set_labels(filed.id, {"omni_project": "X"})
ids = {c.id for c in conversation_store.list_conversations(project="").data}
assert unfiled.id in ids
assert filed.id not in ids
def test_list_conversations_project_none_disables_filter(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""``project=None`` (the default) returns filed and unfiled alike."""
filed = conversation_store.create_conversation()
unfiled = conversation_store.create_conversation()
conversation_store.set_labels(filed.id, {"omni_project": "X"})
ids = {c.id for c in conversation_store.list_conversations().data}
assert ids >= {filed.id, unfiled.id}
def test_list_projects_owned_by_excludes_shared_only_projects(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""``owned_by`` restricts to projects the user OWNS, not ones merely shared
with them — so a project whose sessions are only shared to the user (owned
by someone else) does not surface as one of their own sidebar folders."""
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
mine = conversation_store.create_conversation()
shared = conversation_store.create_conversation()
conversation_store.set_labels(mine.id, {"omni_project": "Mine"})
conversation_store.set_labels(shared.id, {"omni_project": "Shared"})
perms = SqlAlchemyPermissionStore(db_uri)
for user in ("alice@example.com", "bob@example.com"):
perms.ensure_user(user)
# Bob owns both; Alice only gets a read (level 1) grant on the shared one.
perms.grant("bob@example.com", mine.id, 4)
perms.grant("alice@example.com", mine.id, 4)
perms.grant("bob@example.com", shared.id, 4)
perms.grant("alice@example.com", shared.id, 1)
# accessible_by would leak "Shared" — Alice can access it. owned_by must not.
assert conversation_store.list_projects(accessible_by="alice@example.com") == [
"Mine",
"Shared",
]
assert conversation_store.list_projects(owned_by="alice@example.com") == ["Mine"]
def test_list_conversations_owned_by_excludes_shared_sessions(
conversation_store: SqlAlchemyConversationStore,
db_uri: str,
) -> None:
"""``owned_by`` on a project filter returns only sessions the user owns; a
session shared with them (read grant) under the same project is excluded so
it stays out of the owner-only project folder."""
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
mine = conversation_store.create_conversation()
shared = conversation_store.create_conversation()
conversation_store.set_labels(mine.id, {"omni_project": "X"})
conversation_store.set_labels(shared.id, {"omni_project": "X"})
perms = SqlAlchemyPermissionStore(db_uri)
for user in ("alice@example.com", "bob@example.com"):
perms.ensure_user(user)
perms.grant("alice@example.com", mine.id, 4)
perms.grant("bob@example.com", shared.id, 4)
perms.grant("alice@example.com", shared.id, 1)
ids = {
c.id
for c in conversation_store.list_conversations(
project="X", owned_by="alice@example.com"
).data
}
assert ids == {mine.id}