Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion plugin/src/claude_smart/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@

_TOOL_DATA_FIELD_MAX_LEN = 256

# Fields the server's ``InteractionData`` accepts. Declared literally rather
# than imported so this module keeps no runtime dependency on ``reflexio``;
# ``tests/test_state.py`` pins it against the real model when that package is
# installed. Anything not listed here is buffer-internal bookkeeping and must
# not reach the wire.
_INTERACTION_DATA_FIELDS = frozenset(
{
"created_at",
"role",
"content",
"shadow_content",
"expert_content",
"user_action",
"user_action_description",
"interacted_image_url",
"image_encoding",
"tools_used",
"citations",
"retrieved_learnings",
}
)

_VALID_CITATION_KINDS = frozenset(
{"playbook", "profile", "user_playbook", "agent_playbook"}
)
Expand Down Expand Up @@ -404,12 +426,31 @@ def unpublished_slice(
if role not in {"User", "Assistant"}:
continue

# Allowlist, not denylist. This used to drop four known keys and pass
# everything else through, so buffer-internal bookkeeping (``user_id``,
# ``synthesised_by``, ``id``, ``kind``, ...) rode along onto the wire.
# The server discards unknown keys, and now reports them: it treats
# `user_id` as a benign request-level key, but `synthesised_by` was
# warned about on every session-end anchor. More importantly a denylist
# rots every time a hook adds a record key, and a stricter server would
# turn that rot into a publish failure this plugin's adapter swallows
# without advancing its watermark.
turn = {
key: value
for key, value in record.items()
if key not in {"role", "ts", "cited_items", "host"}
if key in _INTERACTION_DATA_FIELDS
}
turn["role"] = role
# NOTE: deliberately does NOT send `created_at`. Carrying the buffer's
Comment on lines 438 to +444

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep buffered created_at off the wire and test that exact case.

The allowlist contains created_at, and the serializer filters against it without an exclusion. The current test only provides ts, so this regression remains undetected.

  • plugin/src/claude_smart/state.py#L438-L444: exclude created_at from emitted turns, preferably via a separate wire-field set.
  • tests/test_state.py#L547-L560: include "created_at" in the input fixture and assert it is omitted.
📍 Affects 2 files
  • plugin/src/claude_smart/state.py#L438-L444 (this comment)
  • tests/test_state.py#L547-L560
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/src/claude_smart/state.py` around lines 438 - 444, The turn serializer
in state.py must exclude buffered created_at from emitted wire data; introduce
or use a separate wire-field allowlist excluding created_at, while preserving
other interaction fields. In tests/test_state.py, add created_at to the input
fixture and assert the serialized turn omits it.

# `ts` across looks like an obvious improvement — the server otherwise
# stamps drain time — but the extractor's bookmark is keyed on
# interaction `created_at` (`last_processed_timestamp`, compared with
# `created_at >= ?`). A batch recovered after the bookmark has moved
# would be stored and then never seen by the extractor: reproduced as
# permanent, silent loss of learning data on exactly the offline path
# this buffer exists to protect. Cosmetic timestamps are the lesser
# problem. Revisit only once ingest ordering no longer depends on a
# caller-supplied event time.
if role == "Assistant":
citations = _to_wire_citations(record.get("cited_items"))
if citations:
Expand Down
4 changes: 3 additions & 1 deletion tests/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ def append_next_turn() -> None:
{
"role": "Assistant",
"content": "second",
"user_id": "user",
# `user_id` is buffer bookkeeping and is no longer put on the
# wire (it is sent once at the request level). `created_at` is
# deliberately not sent either — see state.unpublished_slice.
"retrieved_learnings": [
{"kind": "profile", "learning_id": "p2"}
],
Expand Down
71 changes: 71 additions & 0 deletions tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import multiprocessing as mp
import os

import pytest

from claude_smart import state


Expand Down Expand Up @@ -487,3 +489,72 @@ def test_append_concurrent_writes_do_not_corrupt_jsonl(session_dir) -> None:
for line in raw_lines:
record = json.loads(line) # must parse — no interleaving
assert record == {"role": "User", "content": big}


class TestWirePayloadContract:
"""The wire dict must contain only fields the server's model accepts.

``unpublished_slice`` used to build the payload with a denylist, so buffer
bookkeeping rode along. ``user_id`` was harmless (the server treats it as a
benign request-level key and does not warn), but ``synthesised_by`` was
reported, and a denylist rots every time a hook adds a record key.
"""

def test_bookkeeping_keys_never_reach_the_wire(self):
_, turns = state.unpublished_slice(
[
{
"ts": 1,
"role": "User",
"content": "x",
"user_id": "p",
"host": "h",
"synthesised_by": "s",
}
]
)
assert turns, "expected one wire turn"
# Assert a LITERAL set, not `<= _INTERACTION_DATA_FIELDS`: that is the
# same constant the slicer filters by, so it could never fail while the
# comprehension exists. Proven tautological by runtime mutation.
assert set(turns[0]) == {"role", "content"}, turns[0]
assert turns[0]["content"] == "x"
Comment on lines +503 to +521

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert bookkeeping keys are absent directly.

This assertion only checks that output keys belong to _INTERACTION_DATA_FIELDS; if a bookkeeping key is accidentally added to that allowlist, the test still passes. Add id and kind to the fixture and assert that the forbidden-key set is disjoint from the emitted turn.

Suggested test adjustment
                 {
                     "ts": 1,
                     "role": "User",
                     "content": "x",
+                    "id": "i",
+                    "kind": "turn",
                     "user_id": "p",
                     "host": "h",
                     "synthesised_by": "s",
                 }
             ]
         )
         assert turns, "expected one wire turn"
+        bookkeeping = {"id", "kind", "user_id", "host", "synthesised_by"}
+        assert not bookkeeping.intersection(turns[0])
         assert set(turns[0]) <= state._INTERACTION_DATA_FIELDS
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_bookkeeping_keys_never_reach_the_wire(self):
_, turns = state.unpublished_slice(
[
{
"ts": 1,
"role": "User",
"content": "x",
"user_id": "p",
"host": "h",
"synthesised_by": "s",
}
]
)
assert turns, "expected one wire turn"
assert set(turns[0]) <= state._INTERACTION_DATA_FIELDS
assert turns[0]["content"] == "x"
def test_bookkeeping_keys_never_reach_the_wire(self):
_, turns = state.unpublished_slice(
[
{
"ts": 1,
"role": "User",
"content": "x",
"id": "i",
"kind": "turn",
"user_id": "p",
"host": "h",
"synthesised_by": "s",
}
]
)
assert turns, "expected one wire turn"
bookkeeping = {"id", "kind", "user_id", "host", "synthesised_by"}
assert not bookkeeping.intersection(turns[0])
assert set(turns[0]) <= state._INTERACTION_DATA_FIELDS
assert turns[0]["content"] == "x"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_state.py` around lines 503 - 518, Update
test_bookkeeping_keys_never_reach_the_wire to include id and kind in the input
fixture, then explicitly assert that the forbidden bookkeeping-key set is
disjoint from turns[0]. Retain the existing nonempty and content assertions
while ensuring the test directly verifies those keys are absent from emitted
wire data.


def test_allowlist_covers_every_installed_model_field(self):
"""The allowlist must not be MISSING anything the model declares.

Subset, not equality. The plugin is deliberately forward-compatible:
it talks to a deployed server that can be newer than the pinned
``reflexio-ai`` release, so knowing a field the installed library has
not caught up to yet is correct — the server accepts it, and an older
server merely reports it as unrecognised.

Equality broke CI for exactly that reason: the pinned PyPI 0.2.28 has
no ``retrieved_learnings``, which this plugin has been sending (and the
server accepting) for some time. What would be a real bug is the other
direction — a field the installed model declares that the allowlist
omits, because the slicer would then silently drop it.
"""
interaction_data = pytest.importorskip(
"reflexio.models.api_schema.domain.entities"
).InteractionData
missing = set(interaction_data.model_fields) - state._INTERACTION_DATA_FIELDS
assert not missing, (
f"allowlist omits InteractionData field(s) {sorted(missing)};"
" the slicer would silently drop them"
)

def test_buffer_timestamp_is_not_sent(self):
"""`created_at` must stay off the wire.

Carrying the buffer's `ts` across was implemented and reverted: the
extractor bookmark is keyed on interaction `created_at`, so a batch
recovered after the bookmark moved was stored and then never extracted
— permanent silent loss of learning data on the offline-recovery path
this buffer exists for. The server stamping drain time is the lesser
evil until ingest ordering stops depending on caller-supplied time.
"""
_, turns = state.unpublished_slice(
[{"ts": 1700000000, "role": "User", "content": "x", "user_id": "p"}]
)
assert "created_at" not in turns[0], turns[0]
Comment on lines +547 to +560

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test an existing created_at value, not only ts.

This fixture passes even if unpublished_slice forwards a pre-existing created_at. Include that key in the input record and assert it is absent from the emitted turn.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_state.py` around lines 547 - 560, The
test_buffer_timestamp_is_not_sent fixture must verify removal of an input
record’s existing created_at, not just omission of ts-derived output. Add a
created_at value to the unpublished_slice input record and retain the assertion
that the emitted turn lacks created_at.

Loading