diff --git a/AGENTS.md b/AGENTS.md index 9f630f5..bd27ec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,4 +448,4 @@ One-time PyPI setup (must match the workflow exactly, or PyPI rejects the token) Note: `release.yml` builds + `twine check`s but does **not** run the test suite — tests run in `ci.yml` on push/PR to `main`/`dev`. Only merge to `main` through green CI so a Release -never ships untested code. +never ships untested code. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 25725c2..1f85d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `client.memory` — full async CRUD for per-user memory files: `list()`, + `create()`, `get()`, `update()`, `delete()` (`GET/POST /memory`, + `GET/PUT/DELETE /memory/file`). New models `MemoryFileCreate`, + `MemoryFileUpdate`, `MemoryFileOut`, `MemoryFileSummaryOut`. `update()` is a + partial update — pass only the fields you want to change; the API does not + support clearing `content`/`purpose` once set (a `null` is silently ignored + server-side), so passing `content=None`/`purpose=None` raises + `InvalidParams` locally instead of sending a request that looks like it + succeeded but did nothing. `path` may have at most one folder segment + (`"folder/file.md"`, not `"a/b/file.md"`) — checked locally, also raising + `InvalidParams`, since the API only enforces this after a round trip. + `content` may be an empty string (no minimum length). Guards against + concurrent writes via an opaque `version` token, raising `ConflictError` + (409) on a stale value — a malformed `version` raises `APIError` (422) + instead. `delete()` is not idempotent: deleting an already-deleted path + raises `NotFoundError` (404). See `examples/09_memory.py`. + ### Changed - `__version__` is now resolved at runtime from installed package metadata (`importlib.metadata.version("cominty-sdk")`) instead of the removed @@ -43,4 +61,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/cominty/python-sdk/compare/v0.1.1...HEAD [0.1.1]: https://github.com/cominty/python-sdk/compare/v0.1.0...v0.1.1 -[0.1.0]: https://github.com/cominty/python-sdk/releases/tag/v0.1.0 +[0.1.0]: https://github.com/cominty/python-sdk/releases/tag/v0.1.0 \ No newline at end of file diff --git a/README.md b/README.md index 3a249ce..eabeb98 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,50 @@ await client.threads.update(thread_id, name="Renamed", starred=True) await client.threads.archive(thread_id) ``` +### Memory files + +`client.memory` stores per-user files an agent can read back later — scoped to +the client's `user_id` automatically. + +```python +# Create a file +file = await client.memory.create( + path="preferences/tone.md", purpose="writing style", content="Keep it casual." +) + +# List files (summaries — no content) +for f in await client.memory.list(): + print(f.path, f.purpose, f.version) + +# Read one file's content +file = await client.memory.get("preferences/tone.md") + +# Partial update — only the fields you pass change. `version` guards against +# overwriting a concurrent change: pass back the value from your last read, +# and a stale one raises ConflictError (409). +file = await client.memory.update( + "preferences/tone.md", version=file.version, content="Keep it upbeat." +) + +# Delete +await client.memory.delete("preferences/tone.md") +``` + +`version` is an opaque token — never parse or compare it, just round-trip +whatever the API last gave you. + +There's currently no way to clear `content` or `purpose` once set — the API +ignores an explicit `null` (leaves the existing value untouched), so +`memory.update(..., content=None)` raises `InvalidParams` locally rather than +sending a request that looks like it succeeded but did nothing. + +A few other things worth knowing: +- `path` may have at most one folder segment — `"preferences/tone.md"` is + fine, `"a/b/tone.md"` isn't (raises `InvalidParams` locally). +- `content` may be an empty string; there's no minimum length. +- `memory.delete()` is not idempotent — deleting an already-deleted path + raises `NotFoundError`, not a repeated success. + ## Examples Runnable scripts for each scenario live in [`examples/`](examples/): @@ -294,4 +338,4 @@ A local rehearsal to TestPyPI is available via `uv run invoke publish-test`. ## License -MIT +MIT \ No newline at end of file diff --git a/examples/09_memory.py b/examples/09_memory.py new file mode 100644 index 0000000..8cb3754 --- /dev/null +++ b/examples/09_memory.py @@ -0,0 +1,66 @@ +"""Create, list, read, update, and delete a memory file. + + python examples/09_memory.py + +Demonstrates the full memory resource lifecycle. ``update`` is partial: only +the fields you pass are changed, and ``version`` (an opaque token from the +previous read) guards against overwriting a concurrent change — a stale +``version`` raises ``ConflictError``. The file created here is always deleted +before the script exits, even on error. +""" + +from __future__ import annotations + +import asyncio +from uuid import uuid4 + +import _pretty as pretty +from _shared import make_client + +from cominty_sdk import ConflictError + + +async def main() -> None: + async with make_client() as client: + path = f"sdk-examples/{uuid4()}.md" + + # create() -> the new file, with its initial version token. + created = await client.memory.create( + path=path, + purpose="scratch note for the memory example", + content="Remember to buy milk.", + ) + pretty.console.print(f" [bold]create[/] path={created.path!r}") + + try: + # list() -> lightweight summaries (no content) for every file. + summaries = await client.memory.list() + pretty.console.print(f" [bold]list[/] {len(summaries)} file(s)") + + # get() -> the full file, including content. + fetched = await client.memory.get(path) + pretty.console.print(f" [bold]get[/] content={fetched.content!r}") + + # update() is partial: only content changes here, purpose is untouched. + # version must match the file's current version or this raises + # ConflictError (409) — the API's optimistic-concurrency guard. + updated = await client.memory.update( + path, version=fetched.version, content="Buy oat milk instead." + ) + pretty.console.print(f" [bold]update[/] content={updated.content!r}") + + # Reusing the now-stale version demonstrates the 409 guard. + try: + await client.memory.update( + path, version=fetched.version, content="stale write" + ) + except ConflictError: + pretty.console.print(" [bold]conflict[/] [yellow]stale version rejected[/]") + finally: + # Always clean up the file this example created. + await client.memory.delete(path) + pretty.console.print(" [bold]delete[/] [green]done[/]") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index 9219da5..e2955b3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,5 +34,6 @@ python examples/01_stream_events.py | [`06_manage_thread.py`](06_manage_thread.py) | Get, rename/star, and archive a thread | | [`07_custom_agent.py`](07_custom_agent.py) | Call a custom managed agent (needs `COMINTY_CUSTOM_AGENT_ID`) | | [`08_mcp_linear.py`](08_mcp_linear.py) | Custom agent pulls live context from the Linear MCP server | +| [`09_memory.py`](09_memory.py) | Create, list, read, update, and delete a memory file | -> Shared client setup lives in [`_shared.py`](_shared.py). +> Shared client setup lives in [`_shared.py`](_shared.py). \ No newline at end of file diff --git a/src/cominty_sdk/__init__.py b/src/cominty_sdk/__init__.py index 313c369..ac3c328 100644 --- a/src/cominty_sdk/__init__.py +++ b/src/cominty_sdk/__init__.py @@ -38,6 +38,12 @@ ThreadSummary, UpdateThreadParams, ) +from .models.memory import ( + MemoryFileCreate, + MemoryFileOut, + MemoryFileSummaryOut, + MemoryFileUpdate, +) from .streaming import AssistantRun, StartedChat try: @@ -81,4 +87,8 @@ "Thread", "ThreadSummary", "UpdateThreadParams", + "MemoryFileCreate", + "MemoryFileOut", + "MemoryFileSummaryOut", + "MemoryFileUpdate", ] diff --git a/src/cominty_sdk/client.py b/src/cominty_sdk/client.py index 566e5dd..07329c1 100644 --- a/src/cominty_sdk/client.py +++ b/src/cominty_sdk/client.py @@ -10,6 +10,7 @@ from ._transport import AsyncTransport from .models.chat import validate_user_id from .resources.chat import ChatResource +from .resources.memory import MemoryResource from .resources.threads import ThreadsResource __all__ = ["AsyncCominty"] @@ -53,6 +54,7 @@ def __init__( self._transport = AsyncTransport(self._config) self.chat = ChatResource(self._transport, user_id=self._config.user_id) self.threads = ThreadsResource(self._transport, user_id=self._config.user_id) + self.memory = MemoryResource(self._transport, user_id=self._config.user_id) @property def user_id(self) -> str: @@ -76,4 +78,4 @@ async def __aexit__( await self.close() async def close(self) -> None: - await self._transport.aclose() + await self._transport.aclose() \ No newline at end of file diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index 805426a..273efb1 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -18,6 +18,7 @@ Thread, ThreadSummary, ) +from .memory import MemoryFileCreate, MemoryFileOut, MemoryFileSummaryOut, MemoryFileUpdate __all__ = [ "Agent", @@ -28,10 +29,14 @@ "Message", "MessageRole", "MessageStatus", + "MemoryFileCreate", + "MemoryFileOut", + "MemoryFileSummaryOut", + "MemoryFileUpdate", "Question", "ShareLink", "StartChatOptions", "StartChatParams", "Thread", "ThreadSummary", -] +] \ No newline at end of file diff --git a/src/cominty_sdk/models/memory.py b/src/cominty_sdk/models/memory.py new file mode 100644 index 0000000..1f90006 --- /dev/null +++ b/src/cominty_sdk/models/memory.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, ConfigDict, model_validator +from typing_extensions import TypeAlias + +from .chat import UserId + +__all__ = [ + "MemoryFileCreate", + "MemoryFileUpdate", + "MemoryFileOut", + "MemoryFileSummaryOut", + "MemoryPath", + "MemoryPathParam", + "validate_memory_path", +] + + +# A path with more than one folder segment (e.g. "a/b/file.md") is rejected with a +# 422 "Maximum folder depth is 1". +_MAX_PATH_DEPTH = 1 + + +def validate_memory_path(value: str) -> str: + """Return ``value`` if it's within the API's folder-depth limit, else raise.""" + depth = value.count("/") + if depth > _MAX_PATH_DEPTH: + raise ValueError( + f"path {value!r} has {depth} folder levels; the API allows at most " + f"{_MAX_PATH_DEPTH} (e.g. 'folder/file.md' is fine, 'a/b/file.md' isn't)" + ) + return value + + +MemoryPath: TypeAlias = Annotated[str, AfterValidator(validate_memory_path)] +"""A memory file path, folder-depth-checked before any request is sent.""" + + +class MemoryPathParam(BaseModel): + """Validates a bare ``path`` argument (``get``/``update``/``delete``, + which don't otherwise go through a request-body model).""" + + model_config = ConfigDict(strict=True) + + path: MemoryPath + + +class MemoryFileCreate(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + + path: MemoryPath + purpose: str + content: str + user_id: UserId + """Unlike the other memory endpoints, ``POST /memory`` takes ``user_id`` in + the request body rather than as a query parameter.""" + + +class MemoryFileUpdate(BaseModel): + """Partial update body for ``PUT /memory/file``. + + Dumped with ``exclude_unset=True`` so only explicitly-passed fields are + sent. The API does not currently support clearing ``content``/``purpose`` + once set — a ``null`` is silently ignored server-side (200, value + unchanged) rather than clearing the field. To avoid that confusing + silent-no-op, this model rejects an explicit ``None`` locally instead of + forwarding it. + """ + + model_config = ConfigDict(strict=True, extra="forbid") + + content: str | None = None + purpose: str | None = None + + @model_validator(mode="after") + def _require_at_least_one_field(self) -> MemoryFileUpdate: + if not self.model_fields_set: + raise ValueError("at least one of `content` or `purpose` must be provided") + return self + + @model_validator(mode="after") + def _reject_explicit_none(self) -> MemoryFileUpdate: + nulled = sorted(name for name in self.model_fields_set if getattr(self, name) is None) + if nulled: + fields = " and ".join(nulled) + raise ValueError( + f"{fields} cannot be set to None: the API does not support " + "clearing a field once set (it's currently a silent no-op) — " + "omit the argument instead of passing None" + ) + return self + + +class MemoryFileOut(BaseModel): + model_config = ConfigDict(extra="ignore") + + path: str + purpose: str + content: str + created_at: datetime + updated_at: datetime + version: str + """Opaque concurrency token — pass it back unchanged to + :meth:`~.resources.memory.MemoryResource.update`.""" + + +class MemoryFileSummaryOut(BaseModel): + model_config = ConfigDict(extra="ignore") + + path: str + purpose: str + created_at: datetime + updated_at: datetime + version: str \ No newline at end of file diff --git a/src/cominty_sdk/resources/__init__.py b/src/cominty_sdk/resources/__init__.py index ab7f6b8..5a473ec 100644 --- a/src/cominty_sdk/resources/__init__.py +++ b/src/cominty_sdk/resources/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .chat import ChatResource +from .memory import MemoryResource from .threads import ThreadsResource -__all__ = ["ChatResource", "ThreadsResource"] +__all__ = ["ChatResource", "ThreadsResource", "MemoryResource"] \ No newline at end of file diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py new file mode 100644 index 0000000..a997a4a --- /dev/null +++ b/src/cominty_sdk/resources/memory.py @@ -0,0 +1,138 @@ +"""The memory resource: list, create, read, update, and delete memory files.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Union + +from pydantic import ValidationError + +from ..exceptions import InvalidParams +from ..models.memory import ( + MemoryFileCreate, + MemoryFileOut, + MemoryFileSummaryOut, + MemoryFileUpdate, + MemoryPathParam, +) + +if TYPE_CHECKING: + from .._transport import AsyncTransport + +__all__ = ["MemoryResource"] + + +class _Unset: + """Sentinel default distinguishing "not passed" from "passed as ``None``" + for :meth:`MemoryResource.update`'s optional fields.""" + + def __repr__(self) -> str: + return "UNSET" + + +_UNSET = _Unset() +_OptionalField = Union[str, None, _Unset] + + +class MemoryResource: + def __init__(self, transport: AsyncTransport, *, user_id: str) -> None: + self._transport = transport + self._user_id = user_id + + @staticmethod + def _validate_path(path: str, *, context: str) -> str: + try: + return MemoryPathParam(path=path).path + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context=context) from None + + async def list(self) -> list[MemoryFileSummaryOut]: + """List the current user's memory files (``GET /memory``).""" + raw = await self._transport.request( + "GET", "/memory", params={"user_id": self._user_id} + ) + return [MemoryFileSummaryOut.model_validate(item) for item in raw] + + async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOut: + """Create a memory file (``POST /memory``, 201 Created). + + Unlike every other memory endpoint, ``user_id`` is injected into the + request body here rather than sent as a query param. ``path`` may have + at most one folder segment (``"folder/file.md"``, not + ``"a/b/file.md"``); a deeper path raises + :class:`~.exceptions.InvalidParams` locally. ``content`` may be an + empty string — the API doesn't enforce a minimum length. Creating at a + ``path`` that already exists raises + :class:`~.exceptions.ConflictError` (409). + """ + try: + params = MemoryFileCreate( + path=path, purpose=purpose, content=content, user_id=self._user_id + ) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="memory.create") from None + raw = await self._transport.request( + "POST", "/memory", json_body=params.model_dump(mode="json") + ) + return MemoryFileOut.model_validate(raw) + + async def get(self, path: str) -> MemoryFileOut: + """Fetch a single memory file (``GET /memory/file``).""" + path = self._validate_path(path, context="memory.get") + raw = await self._transport.request( + "GET", "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/memory/file", params={"path": path, "user_id": self._user_id} + ) + return MemoryFileOut.model_validate(raw) + + async def update( + self, + path: str, + *, + version: str, + content: _OptionalField = _UNSET, + purpose: _OptionalField = _UNSET, + ) -> MemoryFileOut: + """Update a memory file's content and/or purpose (``PUT /memory/file``). + + Partial: only the fields you pass are sent. ``version`` is the opaque + token from a previous read; a stale one raises + :class:`~.exceptions.ConflictError` (409). + + The API does not currently support clearing ``content``/``purpose`` + once set — passing ``content=None`` or ``purpose=None`` raises + :class:`~.exceptions.InvalidParams` locally rather than silently + sending a ``null`` the server would ignore. Omit the argument to + leave a field untouched. + + ``version`` must be a real version token from a previous read, not an + arbitrary string — a well-formed but stale one raises + :class:`~.exceptions.ConflictError` (409), a malformed one raises + :class:`~.exceptions.APIError` (422). + """ + path = self._validate_path(path, context="memory.update") + fields: dict[str, object] = {} + if not isinstance(content, _Unset): + fields["content"] = content + if not isinstance(purpose, _Unset): + fields["purpose"] = purpose + try: + body_model = MemoryFileUpdate.model_validate(fields) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="memory.update") from None + body = body_model.model_dump(mode="json", exclude_unset=True) + params = {"path": path, "version": version, "user_id": self._user_id} + raw = await self._transport.request( + "PUT", "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/memory/file", params=params, json_body=body + ) + return MemoryFileOut.model_validate(raw) + + async def delete(self, path: str) -> None: + """Delete a memory file (``DELETE /memory/file``, 204 No Content). + + Not idempotent: deleting an already-deleted (or never-existing) path + raises :class:`~.exceptions.NotFoundError` (404) rather than + succeeding again. + """ + path = self._validate_path(path, context="memory.delete") + await self._transport.request( + "DELETE", "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/memory/file", params={"path": path, "user_id": self._user_id} + ) \ No newline at end of file diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index b9f4b52..7b27e73 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -9,10 +9,11 @@ from __future__ import annotations import os +from uuid import uuid4 import pytest -from cominty_sdk import AsyncCominty +from cominty_sdk import AsyncCominty, ConflictError, NotFoundError pytestmark = pytest.mark.integration @@ -52,3 +53,37 @@ async def test_start_and_get_reply(creds: tuple[str, str], agent_id: str) -> Non reply = await run.result() assert reply.content assert str(reply.thread_id) == str(run.thread.id) + + +@pytest.mark.asyncio +async def test_memory_lifecycle(creds: tuple[str, str]) -> None: + api_key, user_id = creds + async with AsyncCominty(api_token=api_key, user_id=user_id) as client: + path = f"sdk-integration-tests/{uuid4()}.md" + created = await client.memory.create( + path=path, purpose="integration test", content="buy milk" + ) + try: + assert created.path == path + assert created.content == "buy milk" + + summaries = await client.memory.list() + assert any(f.path == path for f in summaries) + + fetched = await client.memory.get(path) + assert fetched.content == "buy milk" + + updated = await client.memory.update( + path, version=fetched.version, content="buy oat milk" + ) + assert updated.content == "buy oat milk" + + with pytest.raises(ConflictError): + await client.memory.update( + path, version=fetched.version, content="stale write" + ) + finally: + await client.memory.delete(path) + + with pytest.raises(NotFoundError): + await client.memory.get(path) diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py new file mode 100644 index 0000000..da351fc --- /dev/null +++ b/tests/unit/test_memory.py @@ -0,0 +1,305 @@ +"""Unit tests for the memory resource: list, create, get, update, delete. + +user_id is sourced from the client (set once at construction). Every memory +endpoint takes it as a query param except POST /memory, which takes it in the +request body instead. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from cominty_sdk import ( + AsyncCominty, + ConflictError, + InvalidParams, + MemoryFileOut, + MemoryFileSummaryOut, +) + +USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" + +# The live API rejects a path with more than one folder segment (422 "Maximum +# folder depth is 1"). +TOO_DEEP_PATH = "a/b/c.md" + + +def _file( + path: str = "notes/todo.md", + *, + purpose: str = "scratch notes", + content: str = "buy milk", + version: str = "v1", +) -> dict[str, object]: + return { + "path": path, + "purpose": purpose, + "content": content, + "created_at": "2026-06-28T10:00:00Z", + "updated_at": "2026-06-28T10:00:00Z", + "version": version, + } + + +def _summary( + path: str = "notes/todo.md", *, purpose: str = "scratch notes", version: str = "v1" +) -> dict[str, object]: + return { + "path": path, + "purpose": purpose, + "created_at": "2026-06-28T10:00:00Z", + "updated_at": "2026-06-28T10:00:00Z", + "version": version, + } + + +# --------------------------------------------------------------------------- # +# list +# --------------------------------------------------------------------------- # +async def test_list_scopes_to_client_user_id( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.get("/memory").mock( + return_value=httpx.Response(200, json=[_summary("a"), _summary("b")]) + ) + + files = await client.memory.list() + + assert [f.path for f in files] == ["a", "b"] + assert all(isinstance(f, MemoryFileSummaryOut) for f in files) + params = route.calls.last.request.url.params + assert params["user_id"] == USER_ID + assert route.calls.last.request.method == "GET" + + +# --------------------------------------------------------------------------- # +# create +# --------------------------------------------------------------------------- # +async def test_create_sends_user_id_in_body_not_query( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.post("/memory").mock( + return_value=httpx.Response(201, json=_file()) + ) + + result = await client.memory.create( + path="notes/todo.md", purpose="scratch notes", content="buy milk" + ) + + request = route.calls.last.request + assert request.method == "POST" + assert "user_id" not in request.url.params + body = json.loads(request.content) + assert body == { + "path": "notes/todo.md", + "purpose": "scratch notes", + "content": "buy milk", + "user_id": USER_ID, + } + assert isinstance(result, MemoryFileOut) + assert result.path == "notes/todo.md" + + +async def test_create_conflict_raises_conflict_error( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.post("/memory").mock( + return_value=httpx.Response( + 409, json={"detail": "A memory file already exists at 'notes/todo.md'."} + ) + ) + + with pytest.raises(ConflictError): + await client.memory.create( + path="notes/todo.md", purpose="scratch notes", content="buy milk" + ) + + +async def test_create_empty_content_is_allowed( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + # The API has no minimum-length constraint on content. + mock_api.post("/memory").mock(return_value=httpx.Response(201, json=_file(content=""))) + + result = await client.memory.create(path="notes/todo.md", purpose="scratch notes", content="") + + assert result.content == "" + + +async def test_create_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.create(path=TOO_DEEP_PATH, purpose="x", content="y") + + assert mock_api.calls.call_count == 0 + + +# --------------------------------------------------------------------------- # +# get +# --------------------------------------------------------------------------- # +async def test_get_sends_path_and_user_id_as_query( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.get("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/memory/file").mock( + return_value=httpx.Response(200, json=_file()) + ) + + result = await client.memory.get("notes/todo.md") + + params = route.calls.last.request.url.params + assert params["path"] == "notes/todo.md" + assert params["user_id"] == USER_ID + assert isinstance(result, MemoryFileOut) + assert result.content == "buy milk" + + +async def test_get_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.get(TOO_DEEP_PATH) + + assert mock_api.calls.call_count == 0 + + +# --------------------------------------------------------------------------- # +# update +# --------------------------------------------------------------------------- # +async def test_update_omitted_field_is_excluded_from_body( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.put("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/memory/file").mock( + return_value=httpx.Response(200, json=_file(content="new content", version="v2")) + ) + + result = await client.memory.update("notes/todo.md", version="v1", content="new content") + + request = route.calls.last.request + assert json.loads(request.content) == {"content": "new content"} + params = request.url.params + assert params["path"] == "notes/todo.md" + assert params["version"] == "v1" + assert params["user_id"] == USER_ID + assert isinstance(result, MemoryFileOut) + assert result.version == "v2" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"purpose": None}, + {"content": None}, + {"content": "new content", "purpose": None}, + ], +) +async def test_update_explicit_none_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter, kwargs: dict[str, object] +) -> None: + # The API silently ignores an explicit null (200, value unchanged) instead + # of clearing the field, so the SDK rejects it client-side rather than + # sending a request that looks like it succeeded but did nothing. + with pytest.raises(InvalidParams): + await client.memory.update("notes/todo.md", version="v1", **kwargs) + + assert mock_api.calls.call_count == 0 + + +async def test_update_no_fields_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.update("notes/todo.md", version="v1") + + assert mock_api.calls.call_count == 0 + + +async def test_update_version_round_trips_unchanged( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.put("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/memory/file").mock(return_value=httpx.Response(200, json=_file())) + + opaque_version = "W/\"2026-06-28T10:00:00Z-xyz\"" + await client.memory.update("notes/todo.md", version=opaque_version, content="x") + + assert route.calls.last.request.url.params["version"] == opaque_version + + +async def test_update_conflict_raises_conflict_error( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.put("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/memory/file").mock( + return_value=httpx.Response(409, json={"detail": "version mismatch"}) + ) + + with pytest.raises(ConflictError): + await client.memory.update("notes/todo.md", version="stale", content="x") + + +async def test_update_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.update(TOO_DEEP_PATH, version="v1", content="x") + + assert mock_api.calls.call_count == 0 + + +# --------------------------------------------------------------------------- # +# delete +# --------------------------------------------------------------------------- # +async def test_delete_sends_query_and_returns_none( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.delete("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/memory/file").mock(return_value=httpx.Response(204)) + + result = await client.memory.delete("notes/todo.md") + + assert result is None + request = route.calls.last.request + assert request.method == "DELETE" + assert request.url.params["path"] == "notes/todo.md" + assert request.url.params["user_id"] == USER_ID + + +async def test_delete_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.delete(TOO_DEEP_PATH) + + assert mock_api.calls.call_count == 0 + + +# --------------------------------------------------------------------------- # +# lifecycle +# --------------------------------------------------------------------------- # +async def test_full_lifecycle(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + mock_api.post("/memory").mock(return_value=httpx.Response(201, json=_file(version="v1"))) + mock_api.get("/memory").mock(return_value=httpx.Response(200, json=[_summary()])) + mock_api.get("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/memory/file").mock(return_value=httpx.Response(200, json=_file(version="v1"))) + mock_api.put("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/memory/file").mock( + return_value=httpx.Response(200, json=_file(content="updated", version="v2")) + ) + mock_api.delete("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/memory/file").mock(return_value=httpx.Response(204)) + + created = await client.memory.create( + path="notes/todo.md", purpose="scratch notes", content="buy milk" + ) + listed = await client.memory.list() + fetched = await client.memory.get(created.path) + updated = await client.memory.update( + fetched.path, version=fetched.version, content="updated" + ) + deleted = await client.memory.delete(updated.path) + + assert created.path == "notes/todo.md" + assert listed[0].path == "notes/todo.md" + assert fetched.version == "v1" + assert updated.content == "updated" + assert updated.version == "v2" + assert deleted is None \ No newline at end of file