Skip to content

Make InMemorySessionStore.append idempotent by uuid - #1216

Open
shashvat-singham wants to merge 3 commits into
anthropics:mainfrom
shashvat-singham:fix/inmemory-store-uuid-idempotency
Open

Make InMemorySessionStore.append idempotent by uuid#1216
shashvat-singham wants to merge 3 commits into
anthropics:mainfrom
shashvat-singham:fix/inmemory-store-uuid-idempotency

Conversation

@shashvat-singham

Copy link
Copy Markdown

Problem

SessionStore.append documents uuid as an idempotency key:

Most entries carry a stable uuid that adapters should treat as an idempotency key (upsert / ignore-duplicate). Entries without a uuid (e.g. titles, tags, mode markers) should be appended without dedup.

InMemorySessionStore.append extends unconditionally, so a re-appended entry is stored twice:

await store.append(KEY, [{"type": "user", "uuid": "a"}, {"type": "user", "uuid": "b"}])
await store.append(KEY, [{"type": "user", "uuid": "b"}, {"type": "user", "uuid": "c"}])
await store.load(KEY)
# uuids: ['a', 'b', 'b', 'c']   -- 'b' duplicated

This is reachable rather than theoretical. TranscriptMirrorBatcher retries failed batches (3 attempts), and its own docstring says a retried batch "may partially overlap a prior partial write" — which is precisely the case the idempotency clause exists for. The overlapping entries then show up twice in the resumed transcript.

Change

Skip entries whose uuid is already stored; append entries without a uuid verbatim, as the contract requires.

uuids: ['a', 'b', 'c']              # after the two overlapping appends
len(...) == 2                       # two no-uuid entries, still both kept

On the conformance suite (deliberately not changed)

I first added an idempotency check to run_session_store_conformance, then backed it out. It fails the MinimalStore fixtures in tests/test_session_store_conformance.py, which use a plain .extend() — so the contract's "should" reads as advisory guidance for adapter authors rather than a mandatory conformance requirement, and enforcing it would be a breaking change for existing adapters.

That does leave the clause untested, so an adapter can pass conformance either way. If you'd like it enforced, I'm happy to send that as a separate PR (it would need the in-tree MinimalStore fixtures updated too) — but it seemed like your call, not mine.

Tests

test_append_is_idempotent_by_uuid and test_append_does_not_dedup_entries_without_uuid. The first fails on main.

$ pytest tests/test_session_store_conformance.py -q -k asyncio
10 passed

(The [trio] parametrisations fail identically before and after on my machine — no trio backend installed — so I've filtered to asyncio here.)

parse_message wraps malformed input in MessageParseError -- non-dict
data, a missing type, missing required fields all get the parser's own
error type. But a "message" field that is not a dict escaped as a bare
TypeError from indexing into it:

    parse_message({"type": "user", "message": "hi"})
    # TypeError: string indices must be integers, not 'str'

Same for the assistant branch. The existing handlers only catch
KeyError, so TypeError/AttributeError from indexing a non-dict fell
through, and a single malformed line from the CLI stream would surface
as an unrelated-looking TypeError instead of the documented parse error.

Catch TypeError/AttributeError alongside KeyError in both branches and
raise MessageParseError with the offending data attached, like every
other malformation.
_store_implements looked the method up on type(store), so it only saw
class-level definitions. SessionStore is a structural Protocol, though,
so an implementation assigned on the instance satisfies it just as well
-- and those stores were rejected before the subprocess even spawned:

    class DelegatingStore(SessionStore):
        def __init__(self, inner):
            self.list_sessions = inner.list_sessions

    validate_session_store_options(
        ClaudeAgentOptions(session_store=DelegatingStore(inner),
                           continue_conversation=True)
    )
    # ValueError: continue_conversation with session_store requires the
    # store to implement list_sessions()

even though calling list_sessions() on that store works fine. The same
applies to a store whose method is a functools.partial, and to a test
double patched with AsyncMock -- arguably the most common way to hit
this, since it fails only under continue_conversation.

Look the attribute up on the instance and compare the underlying
function against the Protocol default, so a bound method is still
matched against the default while a plain callable assigned on the
instance counts as an implementation.
SessionStore.append documents uuid as an idempotency key: "Most entries
carry a stable uuid that adapters should treat as an idempotency key
(upsert / ignore-duplicate). Entries without a uuid (e.g. titles, tags,
mode markers) should be appended without dedup."

InMemorySessionStore extended unconditionally, so a re-appended entry
was stored twice. That is reachable rather than theoretical: the mirror
batcher retries failed batches (3 attempts), and its own docstring notes
a retried batch "may partially overlap a prior partial write" -- the
overlapping entries then appear twice in the resumed transcript.

Skip entries whose uuid is already present, and keep appending entries
without a uuid verbatim as the contract requires.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant