Skip to content

feat(client): page get_timeline, and page from the recent end - #32

Merged
smaramwbc merged 1 commit into
mainfrom
feat/timeline-newest-first
Aug 30, 2026
Merged

feat(client): page get_timeline, and page from the recent end#32
smaramwbc merged 1 commit into
mainfrom
feat/timeline-newest-first

Conversation

@smaramwbc

Copy link
Copy Markdown
Owner

The problem

get_timeline sends subject_id and nothing else, on both clients:

return self._request(
    "GET", "/v1/timeline", params={"subject_id": subject_id}, model=Timeline,
    timeout=timeout,
)

/v1/timeline orders ascending and caps each collection, so a caller past that cap gets the subject's oldest records — the opposite of what a consumer keeping a bounded "recent activity" window needs, and the gap widens the longer the subject lives. There was no parameter to change it.

Same class of mismatch as #174, which this repo already has a test file for: the REST contract could express something the SDK signature could not.

This is the Python half. Server: statewave#362 (limit/offset + has-more flags) and statewave#363 (newest_first). TypeScript: statewave-ts#31.

The change

  • get_timeline accepts limit, offset, newest_first on the sync and async clients. Keyword-only, defaulting to None, and a parameter is sent only when supplied — so a bare get_timeline(subject_id) puts the same bytes on the wire as before.
  • Timeline gains episodes_has_more / memories_has_more as bool | None. None means the server did not report it, which is not the same as False; a missing flag must not read as "this page is complete".
  • Episode gains occurred_at — the event's own time, as against created_at, its ingest time.

Why the model changes are load-bearing rather than cosmetic

No model in statewave/models.py sets model_config, so pydantic v2's default extra="ignore" applies. Undeclared server fields are destroyed on parse — not merely untyped:

>>> Timeline.model_validate({... , "episodes_has_more": True}).model_extra
None

So declaring these fields is the feature; without it a caller cannot reach them by any route. The same mechanism is why Episode.occurred_at was being dropped on every get_timeline() call already. It belongs in this PR specifically because it is the column the timeline orders by — asking for the most recent episodes and then being unable to see what made them recent is half an answer.

bool | None = None follows the house idiom for "an older server does not send this" (Receipt.region, Receipt.receipt_signature, Receipt.policy_snapshot).

Two judgement calls worth your ruling

  1. Bool convention. search_memories hand-stringifies (if semantic: params["semantic"] = "true") and omits the key when false. I pass a real bool instead, and let httpx render it — pinned by a test that drives a real MockTransport and asserts the literal query string subject_id=subj-1&limit=20&newest_first=true. The reason to diverge: newest_first is a direction where "the caller did not say" differs meaningfully from "the caller said oldest", and a three-state None default lets limit, offset and newest_first share one convention in a single signature. Happy to switch to the search_memories style if you would rather have one convention in the file.

  2. session_id left alone. Episode also drops session_id, which the server sends, by the same extra="ignore" mechanism. It has nothing to do with ordering, so I left it out to keep the PR's story to one thing. Worth a follow-up.

Backward compatibility

Every parameter after subject_id was already keyword-only on both clients, so there is no positional slot to capture and no existing call changes shape. Unlike the TypeScript SDK, which needed overloads and a runtime check to protect getTimeline(id, {signal}), this is purely additive.

Tests

tests/test_timeline.py is new — the endpoint had no test at all. Thirteen cases:

Area Cases
Request default sends only subject_id; all three params forwarded; explicit newest_first=False survives; bool reaches the wire as lowercase true; a subject id containing &/= round-trips
Response has-more flags surfaced; both stay None when the server omits them; occurred_at surfaced; an episode without occurred_at still parses
Async default request, params forwarded, has-more flags
Parity sync and async get_timeline signatures agree — name, kind and default

Eight of the thirteen fail against the unmodified SDK (verified by reverting statewave/); the rest are regression and compatibility guards.

The parity test is there because the two clients are hand-written copies in one 1555-line file with nothing generated, and no existing test asserts their surfaces match — a kwarg added to one and forgotten on the other is the obvious way they drift.

Verification

  • pytest tests/ -q105 passed (92 existing + 13 new).
  • ruff check statewave/ tests/ — clean, on the lint.select pin from 0dc8784.
  • Branched from origin/main (0dc8784), so it includes your ruff fix.

No version bump and no uv.lock regeneration, per PUBLISHING.md's split between feature and release commits; the CHANGELOG entry goes under ## Unreleased.

Note on release

These parameters need a server that accepts them; against an older instance FastAPI ignores unknown query parameters and returns its default page. That is exactly why the has-more flags are optional — the SDK cannot tell a server that said "no more" from one that said nothing. Worth holding the release until the server PRs land, so newest_first never ships as a parameter that silently does nothing.

🤖 Generated with Claude Code

…#33)

Rebased as a delta on top of #33, which already landed get_timeline's
limit/offset/newest_first and the Timeline has_more flags: keep
Episode.occurred_at (the column the timeline orders by, previously
discarded by the model), the CHANGELOG entry, the README example and the
fuller test module, aligned to the wire convention main uses
(newest_first as "true"/"false"). Also stop tracking the committed
__pycache__ bytecode, which .gitignore already excludes.
@smaramwbc

Copy link
Copy Markdown
Owner Author

Rebased as a delta over #33 (which had landed the get_timeline params + has_more flags meanwhile): this PR now carries what #33 didn't — Episode.occurred_at, the CHANGELOG entry, the README example, and the fuller test module (aligned to the "true"/"false" wire convention main sends). Also un-tracks the committed __pycache__ bytecode. 105 tests pass locally.

@smaramwbc
smaramwbc force-pushed the feat/timeline-newest-first branch from 9b291d0 to 07e3c11 Compare August 30, 2026 11:00
@smaramwbc
smaramwbc merged commit cc61804 into main Aug 30, 2026
2 checks passed
@smaramwbc
smaramwbc deleted the feat/timeline-newest-first branch August 30, 2026 11:01
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