From a5423b52e8d5a538eb3a37039d27645260c77bef Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:57:12 -0400 Subject: [PATCH] ts SDK fix: adding strict checking (#421) * fix: adding strict checking and updating readme * chore: changelog and version * fix: Add strict validation to all Python and TypeScript classes * fix: Address Code Rabit Comments * fix: duplicate searchQuery param in typescript session.context() * feat: add created_at, is_active fields, and get_message method * feat: Add pagintion params to sdk * fix: Remove lazy initalization behavior from sdks * fix: Address File Upload Validation, add compatibility shims, address review comments * chore: Docs updates * fix: Convert session config from API format in Peer.sessions() Co-Authored-By: Claude Opus 4.6 (1M context) * fix: Pass all args to Session constructor in Peer.sessions() Co-Authored-By: Claude Opus 4.6 (1M context) * fix: Preserve createdAt in Peer.refresh(), pass all data in session.peers() Co-Authored-By: Claude Opus 4.6 (1M context) * docs: changelog for ts * fix: Review Comments * fix: Add createAt and to peers call --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- docs/changelog/compatibility-guide.mdx | 6 +- docs/changelog/introduction.mdx | 74 ++- .../core-concepts/configuration.mdx | 14 + .../advanced/representation-scopes.mdx | 14 + .../v3/documentation/features/get-context.mdx | 2 +- docs/v3/documentation/reference/sdk.mdx | 87 +++- sdks/python/CHANGELOG.md | 25 + sdks/python/pyproject.toml | 2 +- sdks/python/src/honcho/__init__.py | 22 +- sdks/python/src/honcho/aio.py | 267 +++++++--- sdks/python/src/honcho/api_types.py | 78 ++- sdks/python/src/honcho/client.py | 127 +++-- sdks/python/src/honcho/conclusions.py | 15 +- sdks/python/src/honcho/peer.py | 96 +++- sdks/python/src/honcho/session.py | 124 +++-- sdks/typescript/CHANGELOG.md | 32 ++ sdks/typescript/README.md | 2 +- sdks/typescript/__tests__/client.test.ts | 28 +- sdks/typescript/__tests__/helpers.ts | 16 +- sdks/typescript/__tests__/messages.test.ts | 127 ++++- sdks/typescript/__tests__/peer.test.ts | 70 ++- sdks/typescript/__tests__/session.test.ts | 60 ++- .../typescript/__tests__/session.unit.test.ts | 150 ++++++ sdks/typescript/__tests__/validation.test.ts | 406 +++++++++++++++ sdks/typescript/bun.lock | 2 +- sdks/typescript/package.json | 4 +- sdks/typescript/src/client.ts | 162 ++++-- sdks/typescript/src/conclusions.ts | 32 +- sdks/typescript/src/peer.ts | 99 +++- sdks/typescript/src/session.ts | 170 +++++-- sdks/typescript/src/types/api.ts | 3 +- sdks/typescript/src/validation.ts | 472 ++++++++++-------- tests/sdk/test_session.py | 56 +++ uv.lock | 2 +- 34 files changed, 2312 insertions(+), 534 deletions(-) create mode 100644 sdks/typescript/__tests__/session.unit.test.ts create mode 100644 sdks/typescript/__tests__/validation.test.ts diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index 61c45202..9d1a52aa 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -10,14 +10,14 @@ This guide helps you match the right SDK version to your Honcho API version. New - **Latest:** v2.0.1 + **Latest:** v2.1.0 ```bash npm install @honcho-ai/sdk ``` - **Latest:** v2.0.1 + **Latest:** v2.1.0 ```bash pip install honcho-ai @@ -30,7 +30,7 @@ This guide helps you match the right SDK version to your Honcho API version. New | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v3.0.3 (Current) | v2.0.1 | v2.0.1 | +| v3.0.3 (Current) | v2.1.0 | v2.1.0 | | v3.0.2 | v2.0.0+ | v2.0.0+ | | v3.0.1 | v2.0.0+ | v2.0.0+ | | v3.0.0 | v2.0.0+ | v2.0.0+ | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index c8ed9d60..c7b59ace 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -524,7 +524,30 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) - + + ### Added + + - `created_at` property on `Peer` and `Session` objects + - `is_active` property on `Session` objects + - `get_message(message_id)` method on `Session` (sync and async) to fetch a single message by ID + - `page`, `size`, and `reverse` pagination parameters on all list methods + + ### Changed + + - **Breaking**: `peer()` and `session()` now always make a get-or-create API call — no more lazy initialization + - Response configuration models now tolerate unknown fields from newer servers for forward compatibility + + ### Fixed + + - Sync and async `Session.get_metadata()`, `get_configuration()`, and `refresh()` now refresh cached `created_at` and `is_active` values along with metadata and configuration + - `honcho.__version__` now derives from package metadata, with a source-checkout fallback, so it stays aligned with released package versions + + + ### Changed + + - All input models now reject unknown fields via strict Pydantic validation (`extra="forbid"`). Previously, misspelled or extraneous fields were silently ignored. Now a `ValidationError` is raised with the unrecognized field name. + + ### Added - `set_peer_card` method @@ -638,7 +661,54 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) - + + ### Added + + - `createdAt` property on `Peer` and `Session` wrapper objects + - `isActive` property on `Session` wrapper objects + - `getMessage(messageId)` method on `Session` to fetch a single message by ID + - `Peer.representation()`, `Session.representation()`, and `Session.context()` now accept `Message` objects for `searchQuery` + - `page`, `size`, and `reverse` pagination controls on all list methods + + ### Changed + + - **Breaking**: `searchQuery` removed from top-level `context()` options — use `representationOptions.searchQuery` instead: + ```typescript + // Before (v2.0.x) + await session.context({ searchQuery: "..." }); + // After (v2.1.0) + await session.context({ representationOptions: { searchQuery: "..." } }); + ``` + - List methods (`peers()`, `sessions()`, `messages()`, `workspaces()`) support both the new options object and the legacy raw-filter form + - Representation search options now accept strings and content-like objects, including `Message` instances, while rejecting whitespace-only or invalid runtime inputs + - **Breaking**: `peer()` and `session()` now always make a get-or-create API call — no more lazy initialization. If you relied on constructing SDK objects without triggering a network request, note that every `peer()` and `session()` call now hits the API: + ```typescript + // Before (v2.0.x) — no API call + const session = honcho.session("my-session"); + // After (v2.1.0) — makes a get-or-create API call + const session = await honcho.session("my-session"); + ``` + - Response configuration models now tolerate unknown fields from newer servers for forward compatibility + - Moved `@types/node` from `dependencies` to `devDependencies` + + ### Fixed + + - `uploadFile()` now rejects unsupported top-level binary/object inputs and only validates inputs the serializer can actually upload + - `uploadFile()` now serializes message configuration using API field names, matching `addMessages()` + - Session fetch methods now refresh cached `createdAt` and `isActive` values alongside metadata and configuration + + + ### Changed + + - Client constructor now rejects unknown options via `.strict()` Zod validation. Previously, misspelled options (e.g., `baseUrl` instead of `baseURL`) were silently ignored, causing the SDK to fall back to defaults. Now a `ZodError` is thrown with the unrecognized key name. + - All input schemas now use `.strict()` validation to reject unknown fields. + - `FileUploadSchema.configuration` now uses `MessageConfigurationSchema` instead of open record type. + + ### Fixed + + - README example used `baseUrl` instead of `baseURL`. + + ### Added - `setPeerCard` method diff --git a/docs/v2/documentation/core-concepts/configuration.mdx b/docs/v2/documentation/core-concepts/configuration.mdx index 7edf4230..f0cad4ef 100644 --- a/docs/v2/documentation/core-concepts/configuration.mdx +++ b/docs/v2/documentation/core-concepts/configuration.mdx @@ -353,6 +353,20 @@ import { Honcho } from "@honcho-ai/sdk"; ``` +### Observation and Peer Join Order + +Reasoning tasks are scheduled at the time a message is created, based on which peers are in the session **at that moment**. Honcho does not retroactively schedule reasoning for peers that join later. + +This means: + +- If Peer C joins a session **after** messages from Peer A and Peer B have already been sent, Peer C will **not** receive reasoning tasks for those earlier messages—even if Peer C has `observe_others` enabled. +- Peer C will only begin observing new messages sent after they join the session. +- Similarly, if a peer leaves a session, they stop being included as an observer for any messages sent after their departure. + + +There is no retroactive reasoning. If your application needs an observer peer to reason about prior conversation history, add the peer to the session **before** messages are sent. Alternatively use the .chat() endpoint to include the conversation history in the agent's context, regardless of if they were reasoned against or not + + ## Full Configuration Schema Reference ### Workspace & Session Configuration diff --git a/docs/v3/documentation/features/advanced/representation-scopes.mdx b/docs/v3/documentation/features/advanced/representation-scopes.mdx index f7ae03db..d6e03867 100644 --- a/docs/v3/documentation/features/advanced/representation-scopes.mdx +++ b/docs/v3/documentation/features/advanced/representation-scopes.mdx @@ -266,6 +266,20 @@ Directional representations update automatically through the reasoning pipeline The pipeline respects scoping—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant. +### Peer Join Order Matters + +Reasoning tasks are scheduled at the time a message is created, based on which peers are in the session **at that moment**. Honcho does not retroactively schedule reasoning for peers that join later. + +This means: + +- If Peer C joins a session **after** messages from Peer A and Peer B have already been sent, Peer C will **not** receive reasoning tasks for those earlier messages—even if Peer C has `observe_others=true`. +- Peer C will only begin observing new messages sent after they join the session. +- Similarly, if a peer leaves a session, they stop being included as an observer for any messages sent after their departure. + + +There is no retroactive reasoning. If your application needs an observer peer to reason about prior conversation history, add the peer to the session **before** messages are sent. Alternatively, use `peer.chat()` to include conversation history in the agent's context whether or not those messages were previously reasoned over. + + Conclusions are cached for fast retrieval. Use `representation()` to retrieve stored conclusions for dashboards and analytics. Use `peer.chat()` when you need query-specific reasoning with natural language. diff --git a/docs/v3/documentation/features/get-context.mdx b/docs/v3/documentation/features/get-context.mdx index b28137f1..60ff45bc 100644 --- a/docs/v3/documentation/features/get-context.mdx +++ b/docs/v3/documentation/features/get-context.mdx @@ -165,8 +165,8 @@ context = session.context( const context = await session.context({ tokens: 2000, peerTarget: "user-123", - searchQuery: "What are my coding preferences?", representationOptions: { + searchQuery: "What are my coding preferences?", searchTopK: 10, // Number of relevant conclusions to fetch searchMaxDistance: 0.8, // Max semantic distance (0.0-1.0) includeMostFrequent: true, // Include most frequent conclusions diff --git a/docs/v3/documentation/reference/sdk.mdx b/docs/v3/documentation/reference/sdk.mdx index 9aa0f509..3f5cc764 100644 --- a/docs/v3/documentation/reference/sdk.mdx +++ b/docs/v3/documentation/reference/sdk.mdx @@ -218,6 +218,14 @@ const session = await honcho.session(id); // List all peers in workspace (returns Page) const peers = await honcho.peers(); +// List with pagination and filtering +const filtered = await honcho.peers({ + filters: { metadata: { role: "user" } }, + page: 1, + size: 25, + reverse: true +}); + // List all sessions in workspace (returns Page) const sessions = await honcho.sessions(); @@ -234,7 +242,7 @@ const workspaces = await honcho.workspaces(); -Peer and session creation is **lazy** - no API calls are made until you actually use the peer or session. +`peer()` and `session()` always make a get-or-create API call, returning objects with cached metadata, configuration, and timestamps. ### Peer @@ -243,7 +251,7 @@ Represents an entity that can participate in conversations: ```python Python -# Create peers (lazy creation - no API call yet) +# Create peers (get-or-create API call) alice = honcho.peer("alice") assistant = honcho.peer("assistant") @@ -254,6 +262,7 @@ alice = honcho.peer("bob", config={"role": "user", "active": True}, metadata={"l # Peer properties print(f"Peer ID: {alice.id}") print(f"Workspace: {alice.workspace_id}") +print(f"Created: {alice.created_at}") # Available after API fetch # Chat with peer's representations (supports streaming) response = alice.chat("What did I have for breakfast?") @@ -305,6 +314,7 @@ const assistant = await honcho.peer("assistant"); // Peer properties console.log(`Peer ID: ${alice.id}`); +console.log(`Created: ${alice.createdAt}`); // Available after API fetch // Chat with peer's representations (supports streaming) const response = await alice.chat("What did I have for breakfast?"); @@ -551,7 +561,7 @@ Manages multi-party conversations: ```python Python -# Create session (like peers, lazy creation) +# Create session (get-or-create API call) session = honcho.session("conversation-1") # Create with immediate configuration @@ -561,6 +571,8 @@ session = honcho.session("meeting-1", config={"type": "meeting", "max_peers": 10 # Session properties print(f"Session ID: {session.id}") print(f"Workspace: {session.workspace_id}") +print(f"Created: {session.created_at}") # Available after API fetch +print(f"Active: {session.is_active}") # Available after API fetch # Peer management session.add_peers([alice, assistant]) @@ -579,8 +591,12 @@ session.add_messages([ assistant.message("Hi Alice! How can I help today?") ]) -# Get messages +# Get messages (with optional pagination) messages = session.messages() +messages = session.messages(page=1, size=100, reverse=True) + +# Get a single message by ID +message = session.get_message("message-id") # Get conversation context context = session.context(summary=True, tokens=2000) @@ -641,6 +657,8 @@ const session = await honcho.session("conversation-1"); // Session properties console.log(`Session ID: ${session.id}`); +console.log(`Created: ${session.createdAt}`); // Available after API fetch +console.log(`Active: ${session.isActive}`); // Available after API fetch // Peer management await session.addPeers([alice, assistant]); @@ -658,8 +676,12 @@ await session.addMessages([ assistant.message("Hi Alice! How can I help today?") ]); -// Get messages +// Get messages (with optional pagination) const messages = await session.messages(); +const paged = await session.messages({ page: 1, size: 100, reverse: true }); + +// Get a single message by ID +const message = await session.getMessage("message-id"); // Get conversation context const context = await session.context({ summary: true, tokens: 2000 }); @@ -668,10 +690,10 @@ const context = await session.context({ summary: true, tokens: 2000 }); const richContext = await session.context({ tokens: 2000, peerTarget: "user", - searchQuery: "What are my preferences?", peerPerspective: "assistant", limitToSession: true, representationOptions: { + searchQuery: "What are my preferences?", searchTopK: 10, searchMaxDistance: 0.8, includeMostFrequent: true, @@ -811,12 +833,12 @@ The SessionContext object has the following structure: | `tokens` | `int` | Maximum tokens to include | | `peer_target` | `str` | Peer ID to get representation for | | `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) | -| `search_query` | `str` or `Message` | Query string or Message object for semantic search | | `limit_to_session` | `bool` | Limit representation to session only | -| `search_top_k` | `int` | Number of semantic search results (1-100) | -| `search_max_distance` | `float` | Max semantic distance (0.0-1.0) | -| `include_most_frequent` | `bool` | Include most frequent conclusions | -| `max_conclusions` | `int` | Max conclusions to include (1-100) | +| `representationOptions.searchQuery` | `str` or `Message` | Query string or Message object for semantic search | +| `representationOptions.searchTopK` | `int` | Number of semantic search results (1-100) | +| `representationOptions.searchMaxDistance` | `float` | Max semantic distance (0.0-1.0) | +| `representationOptions.includeMostFrequent` | `bool` | Include most frequent conclusions | +| `representationOptions.maxConclusions` | `int` | Max conclusions to include (1-100) | ## Advanced Usage @@ -988,23 +1010,46 @@ const actionItems = await session.messages({ ### Pagination +All list methods support `page`, `size`, and `reverse` parameters: + ```python Python -# Iterate through all sessions +# Default pagination (page 1, size 50) for session in honcho.sessions(): print(f"Session: {session.id}") - # Iterate through session messages - for message in session.messages(): - print(f" {message.peer_id}: {message.content}") +# Custom page size +for message in session.messages(size=100): + print(f" {message.peer_id}: {message.content}") + +# Start at a specific page +page3 = session.messages(page=3, size=25) + +# Reverse ordering +recent_first = session.messages(reverse=True) + +# Combine with filters +filtered = session.messages(filters={"peer_id": "alice"}, size=10) ``` ```typescript TypeScript -// Get paginated results +// Default pagination (page 1, size 50) const peersPage = await honcho.peers(); -// Iterate through all items -for await (const peer of peersPage) { +// Custom page size and filtering +const filtered = await honcho.peers({ + filters: { metadata: { role: "user" } }, + size: 25 +}); + +// Start at a specific page +const page3 = await session.messages({ page: 3, size: 25 }); + +// Reverse ordering +const recent = await session.messages({ reverse: true }); + +// Iterate through all items (auto-paginates) +for await (const peer of await honcho.peers()) { console.log(`Peer: ${peer.id}`); } @@ -1048,8 +1093,8 @@ const supportAgent = await honcho.peer(`agent-${agentId}`); ```python Python -# Lazy creation - no API calls until needed -peers = [honcho.peer(f"user-{i}") for i in range(100)] # Fast +# Create peers (each makes a get-or-create call) +peers = [honcho.peer(f"user-{i}") for i in range(100)] # Batch operations when possible session.add_messages([peer.message(f"Message {i}") for i, peer in enumerate(peers)]) @@ -1059,7 +1104,7 @@ context = session.context(tokens=1500) # Limit context size ``` ```typescript TypeScript -// Lazy creation - no API calls until needed +// Create peers (each makes a get-or-create call) const peers = await Promise.all( Array.from({ length: 100 }, (_, i) => honcho.peer(`user-${i}`)) ); diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index f2317e49..ba055bcd 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.1.0] - 2026-03-25 + +### Added + +- `created_at` property on `Peer` and `Session` objects +- `is_active` property on `Session` objects +- `get_message(message_id)` method on `Session` (sync and async) to fetch a single message by ID +- `page`, `size`, and `reverse` pagination parameters on all list methods: `peers()`, `sessions()`, `messages()`, and `conclusions.list()` + +### Changed + +- **Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call. All Peer/Session objects now have `created_at` populated immediately. +- Response configuration models (`WorkspaceConfigurationResponse`, `SessionConfigurationResponse`) now tolerate unknown fields from newer servers for forward compatibility + +### Fixed + +- Sync and async `Session.get_metadata()`, `get_configuration()`, and `refresh()` now refresh cached `created_at` and `is_active` values along with metadata and configuration. +- `honcho.__version__` now derives from package metadata, with a `pyproject.toml` fallback in source checkouts, so it stays aligned with SDK releases. + +## [2.0.2] - 2026-03-10 + +### Changed + +- All input models now reject unknown fields via `extra="forbid"` Pydantic validation. Previously, misspelled or extraneous fields were silently ignored. Now a `ValidationError` is raised with the unrecognized field name. + ## [2.0.1] - 2026-02-09 ### Added diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index dd389d96..3fda759a 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai" -version = "2.0.1" +version = "2.1.0" description = "Official DX Optimized Python SDK for Honcho" dynamic = ["readme"] license = "Apache-2.0" diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py index 35f42dce..239f35ef 100644 --- a/sdks/python/src/honcho/__init__.py +++ b/sdks/python/src/honcho/__init__.py @@ -37,6 +37,10 @@ Usage: print(p.id) """ +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +import re + from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio from .api_types import MessageCreateParams from .base import PeerBase, SessionBase @@ -66,7 +70,23 @@ from .types import ( DialecticStreamResponse, ) -__version__ = "2.0.1" + +def _detect_version() -> str: + try: + return version("honcho-ai") + except PackageNotFoundError: + try: + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + pyproject_text = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_text, re.MULTILINE) + if match: + return match.group(1) + except OSError: + pass + return "0.0.0" + + +__version__ = _detect_version() __author__ = "Plastic Labs" __email__ = "hello@plasticlabs.ai" diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index aa851889..0edcc60f 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -167,42 +167,52 @@ class HonchoAio(AsyncMetadataConfigMixin): configuration: Optional configuration to set for this peer. Returns: - A Peer object + A Peer object with cached values from the API response. """ - if configuration is not None or metadata is not None: - await self._honcho._ensure_workspace_async() - body: dict[str, Any] = {"id": id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) + await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) - data = await self._honcho._async_http_client.post( - routes.peers(self._honcho.workspace_id), body=body - ) - peer_data = PeerResponse.model_validate(data) - return Peer( - id, - self._honcho, - metadata=peer_data.metadata, - configuration=peer_data.configuration, - ) - - return Peer(id, self._honcho, metadata=metadata, configuration=configuration) + data = await self._honcho._async_http_client.post( + routes.peers(self._honcho.workspace_id), body=body + ) + peer_data = PeerResponse.model_validate(data) + return Peer( + id, + self._honcho, + metadata=peer_data.metadata, + configuration=peer_data.configuration, + created_at=peer_data.created_at, + ) async def peers( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[PeerResponse, Peer]: """ Get all peers in the current workspace asynchronously. - Returns: - An AsyncPage of Peer objects + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. """ await self._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._honcho._async_http_client.post( routes.peers_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(peer: PeerResponse) -> Peer: @@ -211,13 +221,17 @@ class HonchoAio(AsyncMetadataConfigMixin): self._honcho, metadata=peer.metadata, configuration=peer.configuration, + created_at=peer.created_at, ) - async def fetch_next(page: int) -> AsyncPage[PeerResponse, Peer]: + async def fetch_next(next_page: int) -> AsyncPage[PeerResponse, Peer]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._honcho._async_http_client.post( routes.peers_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, PeerResponse, transform, fetch_next) @@ -239,42 +253,55 @@ class HonchoAio(AsyncMetadataConfigMixin): configuration: Optional configuration to set for this session. Returns: - A Session object + A Session object with cached values from the API response. """ - if configuration is not None or metadata is not None: - await self._honcho._ensure_workspace_async() - body: dict[str, Any] = {"id": id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) + await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) - data = await self._honcho._async_http_client.post( - routes.sessions(self._honcho.workspace_id), body=body - ) - session_data = SessionResponse.model_validate(data) - return Session( - id, - self._honcho, - metadata=session_data.metadata, - configuration=session_data.configuration, - ) - - return Session(id, self._honcho, metadata=metadata, configuration=configuration) + data = await self._honcho._async_http_client.post( + routes.sessions(self._honcho.workspace_id), body=body + ) + session_data = SessionResponse.model_validate(data) + return Session( + id, + self._honcho, + metadata=session_data.metadata, + configuration=SessionConfiguration.model_validate( + session_data.configuration.model_dump() + ), + created_at=session_data.created_at, + is_active=session_data.is_active, + ) async def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[SessionResponse, Session]: """ Get all sessions in the current workspace asynchronously. - Returns: - An AsyncPage of Session objects + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. """ await self._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._honcho._async_http_client.post( routes.sessions_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: @@ -283,13 +310,18 @@ class HonchoAio(AsyncMetadataConfigMixin): self._honcho, metadata=session.metadata, configuration=session.configuration, + created_at=session.created_at, + is_active=session.is_active, ) - async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]: + async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._honcho._async_http_client.post( routes.sessions_list(self._honcho.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, SessionResponse, transform, fetch_next) @@ -457,6 +489,21 @@ class PeerAio(AsyncMetadataConfigMixin): return {} return self._peer._configuration.model_dump(exclude_none=True) + def _apply_peer_response(self, peer: PeerResponse) -> None: + self._peer._metadata = peer.metadata or {} + self._peer._configuration = peer.configuration + self._peer._created_at = peer.created_at + + async def get_metadata(self) -> dict[str, object]: + """Get metadata from the server asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) + return self._peer._metadata or {} + async def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride] """Get configuration from the server asynchronously.""" await self._peer._honcho._ensure_workspace_async() @@ -464,9 +511,17 @@ class PeerAio(AsyncMetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) peer = PeerResponse.model_validate(data) - self._peer._metadata = peer.metadata or {} - self._peer._configuration = peer.configuration - return self._peer._configuration + self._apply_peer_response(peer) + return self._peer._configuration or PeerConfig() + + async def refresh(self) -> None: + """Refresh cached metadata, configuration, and created_at asynchronously.""" + await self._peer._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) async def set_configuration(self, configuration: PeerConfig) -> None: # pyright: ignore[reportIncompatibleMethodOverride] """Set configuration on the server asynchronously.""" @@ -544,23 +599,44 @@ class PeerAio(AsyncMetadataConfigMixin): return AsyncDialecticStreamResponse(stream_response()) async def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[SessionResponse, Session]: """Get all sessions this peer is a member of asynchronously.""" await self._peer._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._peer._honcho._async_http_client.post( routes.peer_sessions_list(self._peer.workspace_id, self._peer.id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: - return Session(session.id, self._peer._honcho) + return Session( + session.id, + self._peer._honcho, + metadata=session.metadata, + configuration=SessionConfiguration.model_validate( + session.configuration.model_dump() + ), + created_at=session.created_at, + is_active=session.is_active, + ) - async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]: + async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._peer._honcho._async_http_client.post( routes.peer_sessions_list(self._peer.workspace_id, self._peer.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, SessionResponse, transform, fetch_next) @@ -777,6 +853,24 @@ class SessionAio(AsyncMetadataConfigMixin): return {} return self._session._configuration.model_dump(exclude_none=True) + def _apply_session_response(self, session: SessionResponse) -> None: + self._session._metadata = session.metadata or {} + self._session._configuration = SessionConfiguration.model_validate( + session.configuration.model_dump() + ) + self._session._created_at = session.created_at + self._session._is_active = session.is_active + + async def get_metadata(self) -> dict[str, object]: + """Get metadata from the server asynchronously.""" + await self._session._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) + return self._get_metadata() + async def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] """Get configuration from the server asynchronously.""" await self._session._honcho._ensure_workspace_async() @@ -784,9 +878,17 @@ class SessionAio(AsyncMetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) session = SessionResponse.model_validate(data) - self._session._metadata = session.metadata or {} - self._session._configuration = session.configuration - return self._session._configuration + self._apply_session_response(session) + return self._session._configuration or SessionConfiguration() + + async def refresh(self) -> None: + """Refresh cached metadata, configuration, and session status asynchronously.""" + await self._session._honcho._ensure_workspace_async() + data = await self._get_async_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) async def set_configuration(self, configuration: SessionConfiguration) -> None: # pyright: ignore[reportIncompatibleMethodOverride] """Set configuration on the server asynchronously.""" @@ -921,22 +1023,32 @@ class SessionAio(AsyncMetadataConfigMixin): self, *, filters: dict[str, object] | None = None, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> AsyncPage[MessageResponse, Message]: """Get messages from this session asynchronously.""" await self._session._honcho._ensure_workspace_async() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._session._honcho._async_http_client.post( routes.messages_list(self._session.workspace_id, self._session.id), body={"filters": filters} if filters else None, + query=query, ) def transform(response: MessageResponse) -> Message: return Message.from_api_response(response) - async def fetch_next(page: int) -> AsyncPage[MessageResponse, Message]: + async def fetch_next(next_page: int) -> AsyncPage[MessageResponse, Message]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._session._honcho._async_http_client.post( routes.messages_list(self._session.workspace_id, self._session.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return AsyncPage(next_data, MessageResponse, transform, fetch_next) @@ -966,6 +1078,8 @@ class SessionAio(AsyncMetadataConfigMixin): self._session._honcho, metadata=cloned.metadata, configuration=cloned.configuration, + created_at=cloned.created_at, + is_active=cloned.is_active, ) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -1248,6 +1362,21 @@ class SessionAio(AsyncMetadataConfigMixin): ) return QueueStatusResponse.model_validate(data) + async def get_message(self, message_id: str) -> Message: + """Get a single message by ID from this session asynchronously. + + Args: + message_id: The ID of the message to retrieve + + Returns: + The Message object + """ + await self._session._honcho._ensure_workspace_async() + data = await self._session._honcho._async_http_client.get( + routes.message(self._session.workspace_id, self._session.id, message_id) + ) + return Message.from_api_response(MessageResponse.model_validate(data)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) async def update_message( self, @@ -1288,6 +1417,8 @@ class ConclusionScopeAio: page: int = 1, size: int = 50, session: str | SessionBase | None = None, + *, + reverse: bool = False, ) -> AsyncPage[ConclusionResponse, Conclusion]: """List conclusions in this scope asynchronously.""" await self._scope._honcho._ensure_workspace_async() @@ -1299,22 +1430,28 @@ class ConclusionScopeAio: if resolved_session_id: filters["session_id"] = resolved_session_id + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = await self._scope._honcho._async_http_client.post( routes.conclusions_list(self._scope.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=query, ) def transform(response: ConclusionResponse) -> Conclusion: return Conclusion.from_api_response(response) async def fetch_next( - page: int, + next_page: int, ) -> AsyncPage[ConclusionResponse, Conclusion]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = await self._scope._honcho._async_http_client.post( routes.conclusions_list(self._scope.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=next_query, ) return AsyncPage(next_data, ConclusionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index 47d8e4da..efc39713 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -18,6 +18,8 @@ from pydantic import BaseModel, ConfigDict, Field class ReasoningConfiguration(BaseModel): """Configuration for reasoning functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + enabled: bool | None = None custom_instructions: str | None = None @@ -25,6 +27,8 @@ class ReasoningConfiguration(BaseModel): class PeerCardConfiguration(BaseModel): """Configuration for peer card functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + use: bool | None = None create: bool | None = None @@ -32,6 +36,8 @@ class PeerCardConfiguration(BaseModel): class SummaryConfiguration(BaseModel): """Configuration for summary functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + enabled: bool | None = None messages_per_short_summary: int | None = None messages_per_long_summary: int | None = None @@ -40,13 +46,15 @@ class SummaryConfiguration(BaseModel): class DreamConfiguration(BaseModel): """Configuration for dream functionality.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + enabled: bool | None = None class WorkspaceConfiguration(BaseModel): """Workspace-level configuration options.""" - model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] reasoning: ReasoningConfiguration | None = None peer_card: PeerCardConfiguration | None = None @@ -60,9 +68,23 @@ class SessionConfiguration(WorkspaceConfiguration): pass +class WorkspaceConfigurationResponse(WorkspaceConfiguration): + """Workspace configuration for response parsing — tolerates unknown fields from newer servers.""" + + model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute] + + +class SessionConfigurationResponse(SessionConfiguration): + """Session configuration for response parsing — tolerates unknown fields from newer servers.""" + + model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute] + + class MessageConfiguration(BaseModel): """Message-level configuration options.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + reasoning: ReasoningConfiguration | None = None @@ -74,6 +96,8 @@ class MessageConfiguration(BaseModel): class PeerConfig(BaseModel): """Configuration for peer-level settings.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + observe_me: bool | None = None """Whether Honcho will use reasoning to form a representation of this peer.""" @@ -81,6 +105,8 @@ class PeerConfig(BaseModel): class SessionPeerConfig(BaseModel): """Configuration for a peer within a session.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + observe_others: bool | None = Field( None, description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session", @@ -103,8 +129,8 @@ class WorkspaceResponse(BaseModel): id: str metadata: dict[str, Any] = Field(default_factory=dict) - configuration: WorkspaceConfiguration = Field( - default_factory=WorkspaceConfiguration + configuration: WorkspaceConfigurationResponse = Field( + default_factory=WorkspaceConfigurationResponse ) created_at: datetime.datetime @@ -112,6 +138,8 @@ class WorkspaceResponse(BaseModel): class WorkspaceCreateParams(BaseModel): """Parameters for creating a workspace.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + id: str = Field(min_length=1, max_length=100) metadata: dict[str, Any] = Field(default_factory=dict) configuration: WorkspaceConfiguration = Field( @@ -122,6 +150,8 @@ class WorkspaceCreateParams(BaseModel): class WorkspaceUpdateParams(BaseModel): """Parameters for updating a workspace.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None configuration: WorkspaceConfiguration | None = None @@ -129,6 +159,8 @@ class WorkspaceUpdateParams(BaseModel): class WorkspaceListParams(BaseModel): """Parameters for listing workspaces.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None @@ -152,6 +184,8 @@ class PeerResponse(BaseModel): class PeerCreateParams(BaseModel): """Parameters for creating a peer.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + id: str = Field(min_length=1, max_length=100) metadata: dict[str, Any] | None = None configuration: PeerConfig | None = None @@ -160,6 +194,8 @@ class PeerCreateParams(BaseModel): class PeerUpdateParams(BaseModel): """Parameters for updating a peer.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None configuration: PeerConfig | None = None @@ -167,12 +203,16 @@ class PeerUpdateParams(BaseModel): class PeerListParams(BaseModel): """Parameters for listing peers.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None class PeerRepresentationParams(BaseModel): """Parameters for getting peer representation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + session_id: str | None = None target: str | None = None search_query: str | None = None @@ -217,13 +257,17 @@ class SessionResponse(BaseModel): is_active: bool workspace_id: str metadata: dict[str, Any] = Field(default_factory=dict) - configuration: SessionConfiguration = Field(default_factory=SessionConfiguration) + configuration: SessionConfigurationResponse = Field( + default_factory=SessionConfigurationResponse + ) created_at: datetime.datetime class SessionCreateParams(BaseModel): """Parameters for creating a session.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + id: str = Field(min_length=1, max_length=100) metadata: dict[str, Any] | None = None peers: dict[str, SessionPeerConfig] | None = None @@ -233,6 +277,8 @@ class SessionCreateParams(BaseModel): class SessionUpdateParams(BaseModel): """Parameters for updating a session.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None configuration: SessionConfiguration | None = None @@ -240,6 +286,8 @@ class SessionUpdateParams(BaseModel): class SessionListParams(BaseModel): """Parameters for listing sessions.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None @@ -308,6 +356,8 @@ class MessageResponse(BaseModel): class MessageCreateParams(BaseModel): """Parameters for creating a message.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + content: str peer_id: str metadata: dict[str, Any] | None = None @@ -318,24 +368,32 @@ class MessageCreateParams(BaseModel): class MessageBatchCreateParams(BaseModel): """Parameters for batch message creation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + messages: list[MessageCreateParams] = Field(min_length=1, max_length=100) class MessageUpdateParams(BaseModel): """Parameters for updating a message.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + metadata: dict[str, Any] | None = None class MessageListParams(BaseModel): """Parameters for listing messages.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None class MessageSearchParams(BaseModel): """Parameters for searching messages.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + query: str filters: dict[str, Any] | None = None limit: int = Field(default=10, ge=1, le=100) @@ -362,6 +420,8 @@ class ConclusionResponse(BaseModel): class ConclusionCreateParams(BaseModel): """Parameters for creating a conclusion.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + content: str = Field(min_length=1, max_length=65535) observer_id: str observed_id: str @@ -371,18 +431,24 @@ class ConclusionCreateParams(BaseModel): class ConclusionBatchCreateParams(BaseModel): """Parameters for batch conclusion creation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + conclusions: list[ConclusionCreateParams] = Field(min_length=1, max_length=100) class ConclusionListParams(BaseModel): """Parameters for listing conclusions.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + filters: dict[str, Any] | None = None class ConclusionQueryParams(BaseModel): """Parameters for querying conclusions.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + query: str top_k: int = Field(default=10, ge=1, le=100) distance: float | None = Field(default=None, ge=0.0, le=1.0) @@ -425,6 +491,8 @@ ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] class DialecticParams(BaseModel): """Parameters for dialectic chat.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + session_id: str | None = None target: str | None = None query: str = Field(min_length=1, max_length=10000) @@ -474,6 +542,8 @@ class PageResponse(BaseModel): class MessageUploadParams(BaseModel): """Parameters for file upload message creation.""" + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + peer_id: str metadata: dict[str, Any] | None = None configuration: MessageConfiguration | None = None diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index a40f12ee..c34cc5f5 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -55,7 +55,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul recently fetched. Call get_configuration() for fresh data. """ - model_config = ConfigDict(extra="allow") # pyright: ignore + model_config = ConfigDict(extra="forbid") # pyright: ignore workspace_id: str = Field( ..., @@ -305,44 +305,62 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul """ Get or create a peer with the given ID. - Creates a Peer object that can be used to interact with the specified peer. - This method does not make an API call unless `configuration` or `metadata` is - provided. + Makes an API call to get or create the peer, then returns a Peer object + with cached values from the response. Args: - id: Unique identifier for the peer within the workspace. Should be a - stable identifier that can be used consistently across sessions. + id: Unique identifier for the peer within the workspace. metadata: Optional metadata dictionary to associate with this peer. - If set, will get/create peer immediately with metadata. configuration: Optional configuration to set for this peer. - If set, will get/create peer immediately with flags. Returns: - A Peer object that can be used to send messages, join sessions, and - query the peer's knowledge representations - - Raises: - ValidationError: If the peer ID is empty or invalid + A Peer object with cached metadata, configuration, and created_at. """ - return Peer(id, self, configuration=configuration, metadata=metadata) + self._ensure_workspace() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) + + data = self._http.post(routes.peers(self.workspace_id), body=body) + peer_data = PeerResponse.model_validate(data) + return Peer( + id, + self, + metadata=peer_data.metadata, + configuration=peer_data.configuration, + created_at=peer_data.created_at, + ) def peers( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[PeerResponse, Peer]: """ Get all peers in the current workspace. - Makes an API call to retrieve all peers that have been created or used - within the current workspace. Returns a paginated result that transforms - inner client Peer objects to SDK Peer objects as they are consumed. + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. Returns: A SyncPage of Peer objects representing all peers in the workspace """ self._ensure_workspace() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._http.post( routes.peers_list(self.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(peer: PeerResponse) -> Peer: @@ -351,13 +369,17 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul self, metadata=peer.metadata, configuration=peer.configuration, + created_at=peer.created_at, ) - def fetch_next(page: int) -> SyncPage[PeerResponse, Peer]: + def fetch_next(next_page: int) -> SyncPage[PeerResponse, Peer]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._http.post( routes.peers_list(self.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, PeerResponse, transform, fetch_next) @@ -382,45 +404,65 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul """ Get or create a session with the given ID. - Creates a Session object that can be used to manage conversations between - multiple peers. This method does not make an API call unless `configuration` or - `metadata` is provided. + Makes an API call to get or create the session, then returns a Session object + with cached values from the response. Args: - id: Unique identifier for the session within the workspace. Should be a - stable identifier that can be used consistently to reference the - same conversation + id: Unique identifier for the session within the workspace. metadata: Optional metadata dictionary to associate with this session. - If set, will get/create session immediately with metadata. configuration: Optional configuration to set for this session. - If set, will get/create session immediately with flags. Returns: - A Session object that can be used to add peers, send messages, and - manage conversation context - - Raises: - ValidationError: If the session ID is empty or invalid + A Session object with cached metadata, configuration, created_at, and is_active. """ - return Session(id, self, configuration=configuration, metadata=metadata) + self._ensure_workspace() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + if configuration is not None: + body["configuration"] = configuration.model_dump(exclude_none=True) + + data = self._http.post(routes.sessions(self.workspace_id), body=body) + session_data = SessionResponse.model_validate(data) + return Session( + id, + self, + metadata=session_data.metadata, + configuration=SessionConfiguration.model_validate( + session_data.configuration.model_dump() + ), + created_at=session_data.created_at, + is_active=session_data.is_active, + ) def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[SessionResponse, Session]: """ Get all sessions in the current workspace. - Makes an API call to retrieve all sessions that have been created within - the current workspace. + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. Returns: A SyncPage of Session objects representing all sessions in the workspace. - Returns an empty page if no sessions exist """ self._ensure_workspace() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._http.post( routes.sessions_list(self.workspace_id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: @@ -429,13 +471,18 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul self, metadata=session.metadata, configuration=session.configuration, + created_at=session.created_at, + is_active=session.is_active, ) - def fetch_next(page: int) -> SyncPage[SessionResponse, Session]: + def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._http.post( routes.sessions_list(self.workspace_id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, SessionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index 56e8f4c6..e76b1900 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -168,6 +168,8 @@ class ConclusionScope: page: int = 1, size: int = 50, session: str | SessionBase | None = None, + *, + reverse: bool = False, ) -> SyncPage[ConclusionResponse, Conclusion]: """ List conclusions in this scope. @@ -176,6 +178,7 @@ class ConclusionScope: page: Page number (1-indexed) size: Number of results per page session: Optional session (ID string or Session object) to filter by + reverse: If True, reverses the default ordering. Default: False. Returns: Paginated response containing Conclusion objects @@ -189,22 +192,28 @@ class ConclusionScope: if resolved_session_id: filters["session_id"] = resolved_session_id + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._honcho._http.post( routes.conclusions_list(self.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=query, ) def transform(response: ConclusionResponse) -> Conclusion: return Conclusion.from_api_response(response) def fetch_next( - page: int, + next_page: int, ) -> SyncPage[ConclusionResponse, Conclusion]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._honcho._http.post( routes.conclusions_list(self.workspace_id), body={"filters": filters}, - query={"page": page, "size": size}, + query=next_query, ) return SyncPage(next_data, ConclusionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index 5984737b..fc65a420 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -19,6 +19,7 @@ from .api_types import ( PeerContextResponse, PeerResponse, RepresentationResponse, + SessionConfiguration, SessionResponse, ) from .base import PeerBase, SessionBase @@ -57,6 +58,7 @@ class Peer(PeerBase, MetadataConfigMixin): _metadata: dict[str, object] | None = PrivateAttr(default=None) _configuration: PeerConfig | None = PrivateAttr(default=None) + _created_at: datetime.datetime | None = PrivateAttr(default=None) _honcho: "Honcho" = PrivateAttr() @property @@ -69,6 +71,11 @@ class Peer(PeerBase, MetadataConfigMixin): """Cached configuration for this peer. May be stale. Use get_configuration() for fresh data.""" return self._configuration + @property + def created_at(self) -> datetime.datetime | None: + """Timestamp when this peer was created. Only available if fetched from the API.""" + return self._created_at + # MetadataConfigMixin implementation def _get_http_client(self): self._honcho._ensure_workspace() @@ -90,6 +97,21 @@ class Peer(PeerBase, MetadataConfigMixin): # Return configuration as dict for mixin compatibility return peer.metadata or {}, peer.configuration.model_dump(exclude_none=True) + def _apply_peer_response(self, peer: PeerResponse) -> None: + self._metadata = peer.metadata or {} + self._configuration = peer.configuration + self._created_at = peer.created_at + + def get_metadata(self) -> dict[str, object]: + """Get metadata from the server and update the cache.""" + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) + return self._metadata or {} + def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride] """ Get configuration from the server and update the cache. @@ -102,9 +124,17 @@ class Peer(PeerBase, MetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) peer = PeerResponse.model_validate(data) - self._metadata = peer.metadata or {} - self._configuration = peer.configuration - return self._configuration + self._apply_peer_response(peer) + return self._configuration or PeerConfig() + + def refresh(self) -> None: + """Refresh cached metadata, configuration, and created_at from the server.""" + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + peer = PeerResponse.model_validate(data) + self._apply_peer_response(peer) @validate_call def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride] @@ -163,6 +193,10 @@ class Peer(PeerBase, MetadataConfigMixin): None, description="Optional configuration to set for this peer. If set, will get/create peer immediately with flags.", ), + created_at: datetime.datetime | None = Field( + None, + description="Timestamp when this peer was created.", + ), ) -> None: """ Initialize a new Peer. @@ -184,21 +218,8 @@ class Peer(PeerBase, MetadataConfigMixin): ) self._honcho = honcho self._metadata = metadata - self._configuration = configuration - - if configuration is not None or metadata is not None: - self._honcho._ensure_workspace() - body: dict[str, Any] = {"id": peer_id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) - - data = honcho._http.post(routes.peers(honcho.workspace_id), body=body) - peer_data = PeerResponse.model_validate(data) - # Update cached values with API response - self._metadata = peer_data.metadata - self._configuration = peer_data.configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._created_at = created_at @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def chat( @@ -309,35 +330,58 @@ class Peer(PeerBase, MetadataConfigMixin): return DialecticStreamResponse(stream_response()) def sessions( - self, filters: dict[str, object] | None = None + self, + filters: dict[str, object] | None = None, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[SessionResponse, "Session"]: """ Get all sessions this peer is a member of. - Makes an API call to retrieve all sessions where this peer is an active participant. - Sessions are created when peers are added to them or send messages to them. + Args: + filters: Optional filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. Returns: - A paginated list of Session objects this peer belongs to. Returns an empty - list if the peer is not a member of any sessions + A paginated list of Session objects this peer belongs to. """ self._honcho._ensure_workspace() # Import here to avoid circular import (session.py imports Peer) from .session import Session + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._honcho._http.post( routes.peer_sessions_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, + query=query, ) def transform(session: SessionResponse) -> Session: - return Session(session.id, self._honcho) + return Session( + session.id, + self._honcho, + metadata=session.metadata, + configuration=SessionConfiguration.model_validate( + session.configuration.model_dump() + ), + created_at=session.created_at, + is_active=session.is_active, + ) - def fetch_next(page: int) -> SyncPage[SessionResponse, Session]: + def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._honcho._http.post( routes.peer_sessions_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, SessionResponse, transform, fetch_next) diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index 8414cc60..8a91d788 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -62,6 +62,8 @@ class Session(SessionBase, MetadataConfigMixin): _metadata: dict[str, object] | None = PrivateAttr(default=None) _configuration: SessionConfiguration | None = PrivateAttr(default=None) + _created_at: datetime | None = PrivateAttr(default=None) + _is_active: bool | None = PrivateAttr(default=None) _honcho: "Honcho" = PrivateAttr() @property @@ -74,6 +76,16 @@ class Session(SessionBase, MetadataConfigMixin): """Cached configuration for this session. May be stale. Use get_configuration() for fresh data.""" return self._configuration + @property + def created_at(self) -> datetime | None: + """Timestamp when this session was created. Only available if fetched from the API.""" + return self._created_at + + @property + def is_active(self) -> bool | None: + """Whether this session is active. Only available if fetched from the API.""" + return self._is_active + # MetadataConfigMixin implementation def _get_http_client(self): self._honcho._ensure_workspace() @@ -97,6 +109,30 @@ class Session(SessionBase, MetadataConfigMixin): exclude_none=True ) + def _apply_session_response(self, session: SessionResponse) -> None: + self._metadata = session.metadata or {} + self._configuration = SessionConfiguration.model_validate( + session.configuration.model_dump() + ) + self._created_at = session.created_at + self._is_active = session.is_active + + def get_metadata(self) -> dict[str, object]: + """ + Get metadata from the server and update the cache. + + Returns: + A dictionary containing the metadata. Returns an empty dictionary + if no metadata is set. + """ + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) + return self._metadata or {} + def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride] """ Get configuration from the server and update the cache. @@ -109,9 +145,19 @@ class Session(SessionBase, MetadataConfigMixin): self._get_fetch_route(), body=self._get_fetch_body() ) session = SessionResponse.model_validate(data) - self._metadata = session.metadata or {} - self._configuration = session.configuration - return self._configuration + self._apply_session_response(session) + return self._configuration or SessionConfiguration() + + def refresh(self) -> None: + """ + Refresh cached metadata, configuration, and session status from the server. + """ + self._honcho._ensure_workspace() + data = self._get_http_client().post( + self._get_fetch_route(), body=self._get_fetch_body() + ) + session = SessionResponse.model_validate(data) + self._apply_session_response(session) @validate_call def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride] @@ -171,6 +217,14 @@ class Session(SessionBase, MetadataConfigMixin): None, description="Optional configuration to set for this session. If set, will get/create session immediately with flags.", ), + created_at: datetime | None = Field( + None, + description="Timestamp when this session was created.", + ), + is_active: bool | None = Field( + None, + description="Whether this session is active.", + ), ) -> None: """ Initialize a new Session. @@ -192,21 +246,9 @@ class Session(SessionBase, MetadataConfigMixin): ) self._honcho = honcho self._metadata = metadata - self._configuration = configuration - - if configuration is not None or metadata is not None: - self._honcho._ensure_workspace() - body: dict[str, Any] = {"id": session_id} - if metadata is not None: - body["metadata"] = metadata - if configuration is not None: - body["configuration"] = configuration.model_dump(exclude_none=True) - - data = honcho._http.post(routes.sessions(honcho.workspace_id), body=body) - session_data = SessionResponse.model_validate(data) - # Update cached values with API response - self._metadata = session_data.metadata - self._configuration = session_data.configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride] + self._created_at = created_at + self._is_active = is_active def add_peers( self, @@ -405,38 +447,43 @@ class Session(SessionBase, MetadataConfigMixin): filters: dict[str, object] | None = Field( None, description="Dictionary of filter criteria" ), + page: int = 1, + size: int = 50, + reverse: bool = False, ) -> SyncPage[MessageResponse, Message]: """ Get messages from this session with optional filtering. - Makes an API call to retrieve messages from this session. Results can be - filtered based on various criteria. - Args: - filters: Dictionary of filter criteria. Supported filters include: - - peer_id: Filter messages by the peer who created them - - metadata: Filter messages by metadata key-value pairs - - timestamp_start: Filter messages after a specific timestamp - - timestamp_end: Filter messages before a specific timestamp + filters: Dictionary of filter criteria. + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, returns messages in reverse chronological order. Default: False. Returns: - A list of Message objects matching the specified criteria, ordered by - creation time (most recent first) + A paginated result of Message objects. """ self._honcho._ensure_workspace() + query: dict[str, Any] = {"page": page, "size": size} + if reverse: + query["reverse"] = "true" data = self._honcho._http.post( routes.messages_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, + query=query, ) def transform(response: MessageResponse) -> Message: return Message.from_api_response(response) - def fetch_next(page: int) -> SyncPage[MessageResponse, Message]: + def fetch_next(next_page: int) -> SyncPage[MessageResponse, Message]: + next_query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + next_query["reverse"] = "true" next_data = self._honcho._http.post( routes.messages_list(self.workspace_id, self.id), body={"filters": filters} if filters else None, - query={"page": page}, + query=next_query, ) return SyncPage(next_data, MessageResponse, transform, fetch_next) @@ -503,6 +550,8 @@ class Session(SessionBase, MetadataConfigMixin): self._honcho, metadata=cloned.metadata, configuration=cloned.configuration, + created_at=cloned.created_at, + is_active=cloned.is_active, ) @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) @@ -919,6 +968,21 @@ class Session(SessionBase, MetadataConfigMixin): ) return QueueStatusResponse.model_validate(data) + def get_message(self, message_id: str) -> Message: + """Get a single message by ID from this session. + + Args: + message_id: The ID of the message to retrieve + + Returns: + The Message object + """ + self._honcho._ensure_workspace() + data = self._honcho._http.get( + routes.message(self.workspace_id, self.id, message_id) + ) + return Message.from_api_response(MessageResponse.model_validate(data)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def update_message( self, diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 87cfa8bf..ca77230a 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.1.0] - 2026-03-25 + +### Added + +- `createdAt` property on `Peer` and `Session` wrapper objects +- `isActive` property on `Session` wrapper objects +- `getMessage(messageId)` method on `Session` to fetch a single message by ID +- `Peer.representation()`, `Session.representation()`, and `Session.context()` now accept `Message` objects for `searchQuery` (extracts `.content` automatically) +- `page`, `size`, and `reverse` pagination controls on all list methods: `peers()`, `sessions()`, `messages()`, `workspaces()`, and `conclusions.list()` + +### Changed + +- **Breaking**: `searchQuery` removed from top-level `context()` options. Use `representationOptions.searchQuery` instead — this eliminates the duplicate parameter and is consistent with the API structure. +- List methods (`peers()`, `sessions()`, `messages()`, `workspaces()`) support both the new options object and the legacy raw-filter form: `peers({ filters, page, size, reverse })` and `peers(filters)`. +- Representation search options now accept strings and content-like objects (including `Message` instances) while rejecting whitespace-only or invalid runtime inputs. +- **Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call. All Peer/Session objects now have `createdAt` populated immediately. +- Response configuration models (`WorkspaceConfigurationResponse`, `SessionConfigurationResponse`) now tolerate unknown fields from newer servers for forward compatibility +- Reusable `PeerIdObjectSchema` and `SessionIdObjectSchema` helpers for union validation +- Moved `@types/node` from `dependencies` to `devDependencies` + +### Fixed + +- `uploadFile()` now rejects unsupported top-level binary/object inputs and only validates inputs the serializer can actually upload. +- `uploadFile()` now serializes message configuration using API field names, matching `addMessages()`. +- Session fetch methods now refresh cached `createdAt` and `isActive` values alongside metadata and configuration. + +## [2.0.2] - 2026-03-10 + +### Changed + +- **Breaking**: Client constructor now rejects unknown options via `.strict()` Zod validation. Previously, misspelled options (e.g., `baseUrl` instead of `baseURL`) were silently ignored, causing the SDK to fall back to defaults. Now a `ZodError` is thrown with the unrecognized key name. + ## [2.0.1] - 2026-02-09 ### Added diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index af79dd4f..c6ac05bc 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -15,7 +15,7 @@ import { Honcho } from "@honcho-ai/sdk"; const honcho = new Honcho({ apiKey: process.env.HONCHO_API_KEY, - baseUrl: "http://localhost:8000", + baseURL: "http://localhost:8000", workspaceId: "test", }); diff --git a/sdks/typescript/__tests__/client.test.ts b/sdks/typescript/__tests__/client.test.ts index 1af3d7df..715d55e4 100644 --- a/sdks/typescript/__tests__/client.test.ts +++ b/sdks/typescript/__tests__/client.test.ts @@ -143,6 +143,27 @@ describe('Honcho Client', () => { await testClient.deleteWorkspace(testClient.workspaceId) } }) + + test('workspaces with options.filters narrows results', async () => { + const uniqueValue = `filter-options-${Date.now()}` + const testClient = new Honcho({ + baseURL: TEST_CONFIG.baseURL, + apiKey: TEST_CONFIG.apiKey, + workspaceId: generateWorkspaceId('filter-options'), + }) + + try { + await testClient.setMetadata({ filterKey: uniqueValue }) + + const page = await client.workspaces({ + filters: { metadata: { filterKey: uniqueValue } }, + }) + + expect(page.items).toContain(testClient.workspaceId) + } finally { + await testClient.deleteWorkspace(testClient.workspaceId) + } + }) }) // =========================================================================== @@ -172,11 +193,12 @@ describe('Honcho Client', () => { // =========================================================================== describe('Peer access', () => { - test('peer() returns Peer instance without API call', async () => { + test('peer() returns Peer instance with cached data', async () => { const peer = await client.peer('lazy-peer') expect(peer.id).toBe('lazy-peer') expect(peer.workspaceId).toBe(client.workspaceId) + expect(peer.createdAt).toBeDefined() }) test('peer() with metadata makes API call', async () => { @@ -205,11 +227,13 @@ describe('Honcho Client', () => { }) describe('Session access', () => { - test('session() returns Session instance without API call', async () => { + test('session() returns Session instance with cached data', async () => { const session = await client.session('lazy-session', { metadata: {} }) expect(session.id).toBe('lazy-session') expect(session.workspaceId).toBe(client.workspaceId) + expect(session.createdAt).toBeDefined() + expect(session.isActive).toBe(true) }) test('session() with metadata makes API call', async () => { diff --git a/sdks/typescript/__tests__/helpers.ts b/sdks/typescript/__tests__/helpers.ts index ed66078a..83cefe24 100644 --- a/sdks/typescript/__tests__/helpers.ts +++ b/sdks/typescript/__tests__/helpers.ts @@ -14,6 +14,11 @@ import type { PageResponse, } from '../src/types/api' +function expectValidDateString(value: string): void { + const parsedDate = new Date(value) + expect(Number.isNaN(parsedDate.getTime())).toBe(false) +} + // ============================================================================= // Response Shape Assertions // ============================================================================= @@ -28,8 +33,7 @@ export function assertWorkspaceShape(workspace: WorkspaceResponse): void { expect(typeof workspace.metadata).toBe('object') expect(typeof workspace.configuration).toBe('object') expect(typeof workspace.created_at).toBe('string') - // Validate ISO 8601 date format - expect(() => new Date(workspace.created_at)).not.toThrow() + expectValidDateString(workspace.created_at) } /** @@ -43,7 +47,7 @@ export function assertPeerShape(peer: PeerResponse): void { expect(typeof peer.metadata).toBe('object') expect(typeof peer.configuration).toBe('object') expect(typeof peer.created_at).toBe('string') - expect(() => new Date(peer.created_at)).not.toThrow() + expectValidDateString(peer.created_at) } /** @@ -58,7 +62,7 @@ export function assertSessionShape(session: SessionResponse): void { expect(typeof session.metadata).toBe('object') expect(typeof session.configuration).toBe('object') expect(typeof session.created_at).toBe('string') - expect(() => new Date(session.created_at)).not.toThrow() + expectValidDateString(session.created_at) } /** @@ -76,7 +80,7 @@ export function assertMessageShape(message: Message): void { expect(typeof message.createdAt).toBe('string') expect(typeof message.tokenCount).toBe('number') expect(message.tokenCount).toBeGreaterThanOrEqual(0) - expect(() => new Date(message.createdAt)).not.toThrow() + expectValidDateString(message.createdAt) } /** @@ -91,7 +95,7 @@ export function assertConclusionShape(conclusion: ConclusionResponse): void { expect(typeof conclusion.observed_id).toBe('string') expect(typeof conclusion.session_id).toBe('string') expect(typeof conclusion.created_at).toBe('string') - expect(() => new Date(conclusion.created_at)).not.toThrow() + expectValidDateString(conclusion.created_at) } /** diff --git a/sdks/typescript/__tests__/messages.test.ts b/sdks/typescript/__tests__/messages.test.ts index 210c90c4..acb865e3 100644 --- a/sdks/typescript/__tests__/messages.test.ts +++ b/sdks/typescript/__tests__/messages.test.ts @@ -223,7 +223,7 @@ describe('Messages', () => { bob.message('From Bob'), ]) - const page = await session.messages({ peer_id: alice.id }) + const page = await session.messages({ filters: { peer_id: alice.id } }) expect(page.items.length).toBe(1) expect(page.items[0].peerId).toBe(alice.id) @@ -238,11 +238,27 @@ describe('Messages', () => { peer.message('Untagged'), ]) - const page = await session.messages({ metadata: { category: 'important' } }) + const page = await session.messages({ filters: { metadata: { category: 'important' } } }) expect(page.items.length).toBe(1) expect(page.items[0].metadata.category).toBe('important') }) + + test('accepts legacy raw filter objects', async () => { + const session = await client.session('legacy-filter-msg-session', { metadata: {} }) + const alice = await client.peer('legacy-filter-alice') + const bob = await client.peer('legacy-filter-bob') + await session.addPeers([alice.id, bob.id]) + await session.addMessages([ + alice.message('Legacy Alice'), + bob.message('Legacy Bob'), + ]) + + const page = await session.messages({ peer_id: alice.id }) + + expect(page.items.length).toBe(1) + expect(page.items[0].peerId).toBe(alice.id) + }) }) // =========================================================================== @@ -382,8 +398,8 @@ describe('Messages', () => { expect(typeof message.tokenCount).toBe('number') // Validate date format - expect(() => new Date(message.createdAt)).not.toThrow() const date = new Date(message.createdAt) + expect(Number.isNaN(date.getTime())).toBe(false) expect(date.getTime()).toBeGreaterThan(0) }) @@ -403,4 +419,109 @@ describe('Messages', () => { expect(uniqueIds.size).toBe(ids.length) }) }) + + // =========================================================================== + // Get Single Message (GET /messages/:id) + // =========================================================================== + + describe('GET /messages/:id (getMessage)', () => { + test('getMessage returns message by ID', async () => { + const session = await client.session('get-msg-session', { metadata: {} }) + const peer = await client.peer('get-msg-peer') + await session.addPeers([peer.id]) + + const [created] = await session.addMessages(peer.message('Retrievable')) + const fetched = await session.getMessage(created.id) + + expect(fetched.id).toBe(created.id) + expect(fetched.content).toBe('Retrievable') + expect(fetched.peerId).toBe(peer.id) + expect(fetched.sessionId).toBe(session.id) + }) + + test('getMessage preserves metadata', async () => { + const session = await client.session('get-msg-meta-session', { + metadata: {}, + }) + const peer = await client.peer('get-msg-meta-peer') + await session.addPeers([peer.id]) + + const [created] = await session.addMessages( + peer.message('With metadata', { metadata: { key: 'value' } }) + ) + const fetched = await session.getMessage(created.id) + + expect(fetched.metadata).toEqual({ key: 'value' }) + }) + }) + + // =========================================================================== + // Pagination Controls + // =========================================================================== + + describe('Pagination controls', () => { + test('messages with custom page size', async () => { + const session = await client.session('page-size-session', { metadata: {} }) + const peer = await client.peer('page-size-peer') + await session.addPeers([peer.id]) + + // Create 3 messages + await session.addMessages([ + peer.message('One'), + peer.message('Two'), + peer.message('Three'), + ]) + + // Fetch with size=2 + const page = await session.messages({ size: 2 }) + const items = await collectAll(page) + + // Should get all 3 via auto-pagination, but first page had only 2 + expect(items.length).toBe(3) + }) + + test('messages with explicit page number', async () => { + const session = await client.session('page-num-session', { metadata: {} }) + const peer = await client.peer('page-num-peer') + await session.addPeers([peer.id]) + + await session.addMessages([ + peer.message('A'), + peer.message('B'), + peer.message('C'), + ]) + + // Fetch page 1 with size 2 + const page1 = await session.messages({ page: 1, size: 2 }) + expect(page1.items.length).toBe(2) + + // Fetch page 2 + const page2 = await session.messages({ page: 2, size: 2 }) + expect(page2.items.length).toBe(1) + }) + + test('messages with reverse ordering', async () => { + const session = await client.session('reverse-session', { metadata: {} }) + const peer = await client.peer('reverse-peer') + await session.addPeers([peer.id]) + + await session.addMessages([ + peer.message('First'), + peer.message('Second'), + peer.message('Third'), + ]) + + const normal = await session.messages() + const reversed = await session.messages({ reverse: true }) + + const normalItems = await collectAll(normal) + const reversedItems = await collectAll(reversed) + + expect(normalItems.length).toBe(reversedItems.length) + // Reversed order should have different first element + expect(normalItems[0].content).not.toBe( + reversedItems[0].content + ) + }) + }) }) diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index 77953168..d8a3750c 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -52,6 +52,18 @@ describe('Peer', () => { expect(peer).toBeInstanceOf(Peer) expect(peer.id).toBe('simple-peer') expect(peer.workspaceId).toBe(client.workspaceId) + expect(peer.createdAt).toBeDefined() + }) + + test('peer created with metadata exposes createdAt', async () => { + const peer = await client.peer('peer-created-at', { + metadata: { test: true }, + }) + + expect(peer.createdAt).toBeDefined() + expect(typeof peer.createdAt).toBe('string') + const createdAt = new Date(peer.createdAt!) + expect(Number.isNaN(createdAt.getTime())).toBe(false) }) test('creates peer with metadata', async () => { @@ -116,12 +128,24 @@ describe('Peer', () => { metadata: { uniqueTag }, }) - const page = await client.peers({ metadata: { uniqueTag } }) + const page = await client.peers({ filters: { metadata: { uniqueTag } } }) expect(page.items.length).toBe(1) expect(page.items[0].id).toBe('filtered-peer') }) + test('peers accept legacy raw filter objects', async () => { + const uniqueTag = `legacy-tag-${Date.now()}` + await client.peer(`legacy-filtered-peer-${Date.now()}`, { + metadata: { uniqueTag }, + }) + + const page = await client.peers({ metadata: { uniqueTag } }) + + expect(page.items.length).toBe(1) + expect(page.items[0].metadata.uniqueTag).toBe(uniqueTag) + }) + test('Page is async iterable', async () => { await client.peer('iter-peer-1', { metadata: {} }) await client.peer('iter-peer-2', { metadata: {} }) @@ -213,10 +237,24 @@ describe('Peer', () => { }) await session.addPeers([peer.id]) - const sessions = await peer.sessions({ metadata: { category: 'special' } }) + const sessions = await peer.sessions({ filters: { metadata: { category: 'special' } } }) expect(sessions.items.length).toBeGreaterThanOrEqual(1) }) + + test('sessions accept legacy raw filter objects', async () => { + const category = `legacy-category-${Date.now()}` + const peer = await client.peer(`legacy-filter-sessions-peer-${Date.now()}`) + const session = await client.session(`legacy-filterable-session-${Date.now()}`, { + metadata: { category }, + }) + await session.addPeers([peer.id]) + + const sessions = await peer.sessions({ metadata: { category } }) + + expect(sessions.items.length).toBeGreaterThanOrEqual(1) + expect(sessions.items[0].metadata.category).toBe(category) + }) }) // =========================================================================== @@ -637,4 +675,32 @@ describe('Peer', () => { expect(str).toBe("Peer(id='tostring-peer')") }) }) + + // =========================================================================== + // Pagination Controls + // =========================================================================== + + describe('Pagination controls', () => { + test('peers with custom page size', async () => { + // Create peers with unique metadata + await client.peer('page-peer-1', { metadata: { group: 'page-test' } }) + await client.peer('page-peer-2', { metadata: { group: 'page-test' } }) + await client.peer('page-peer-3', { metadata: { group: 'page-test' } }) + + const page = await client.peers({ + filters: { metadata: { group: 'page-test' } }, + size: 2, + }) + expect(page.items.length).toBe(2) + }) + + test('peers with explicit page number', async () => { + const page2 = await client.peers({ + filters: { metadata: { group: 'page-test' } }, + page: 2, + size: 2, + }) + expect(page2.items.length).toBe(1) + }) + }) }) diff --git a/sdks/typescript/__tests__/session.test.ts b/sdks/typescript/__tests__/session.test.ts index 5562785f..b9f4cdeb 100644 --- a/sdks/typescript/__tests__/session.test.ts +++ b/sdks/typescript/__tests__/session.test.ts @@ -54,6 +54,20 @@ describe('Session', () => { expect(session).toBeInstanceOf(Session) expect(session.id).toBe('simple-session') expect(session.workspaceId).toBe(client.workspaceId) + expect(session.createdAt).toBeDefined() + expect(session.isActive).toBe(true) + }) + + test('session created with metadata exposes createdAt and isActive', async () => { + const session = await client.session('session-timestamps', { + metadata: { test: true }, + }) + + expect(session.createdAt).toBeDefined() + expect(typeof session.createdAt).toBe('string') + const createdAt = new Date(session.createdAt!) + expect(Number.isNaN(createdAt.getTime())).toBe(false) + expect(session.isActive).toBe(true) }) test('creates session with metadata', async () => { @@ -104,11 +118,23 @@ describe('Session', () => { const tag = `tag-${Date.now()}` await client.session('filtered-session', { metadata: { tag } }) - const page = await client.sessions({ metadata: { tag } }) + const page = await client.sessions({ filters: { metadata: { tag } } }) expect(page.items.length).toBe(1) expect(page.items[0].id).toBe('filtered-session') }) + + test('sessions accept legacy raw filter objects', async () => { + const tag = `legacy-tag-${Date.now()}` + await client.session(`legacy-filtered-session-${Date.now()}`, { + metadata: { tag }, + }) + + const page = await client.sessions({ metadata: { tag } }) + + expect(page.items.length).toBe(1) + expect(page.items[0].metadata.tag).toBe(tag) + }) }) // =========================================================================== @@ -446,7 +472,7 @@ describe('Session', () => { peer.message('Not this one'), ]) - const page = await session.messages({ metadata: { tag: 'special' } }) + const page = await session.messages({ filters: { metadata: { tag: 'special' } } }) expect(page.items.length).toBe(1) expect(page.items[0].metadata.tag).toBe('special') @@ -610,4 +636,34 @@ describe('Session', () => { expect(str).toBe("Session(id='tostring-session')") }) }) + + // =========================================================================== + // Pagination Controls + // =========================================================================== + + describe('Pagination controls', () => { + test('sessions with custom page size', async () => { + // Create 3 sessions + await client.session('page-test-1', { metadata: { group: 'page-test' } }) + await client.session('page-test-2', { metadata: { group: 'page-test' } }) + await client.session('page-test-3', { metadata: { group: 'page-test' } }) + + const page = await client.sessions({ + filters: { metadata: { group: 'page-test' } }, + size: 2, + }) + // First page should have 2 items + expect(page.items.length).toBe(2) + }) + + test('sessions with explicit page number', async () => { + const page2 = await client.sessions({ + filters: { metadata: { group: 'page-test' } }, + page: 2, + size: 2, + }) + // Page 2 should have the remaining item + expect(page2.items.length).toBe(1) + }) + }) }) diff --git a/sdks/typescript/__tests__/session.unit.test.ts b/sdks/typescript/__tests__/session.unit.test.ts new file mode 100644 index 00000000..71a440e6 --- /dev/null +++ b/sdks/typescript/__tests__/session.unit.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'bun:test' +import { ZodError } from 'zod' +import type { HonchoHTTPClient } from '../src/http/client' +import { Session } from '../src/session' +import type { SessionContextResponse, SessionResponse } from '../src/types/api' + +function createSessionResponse( + overrides: Partial = {} +): SessionResponse { + return { + id: 'session-1', + workspace_id: 'workspace-1', + is_active: true, + metadata: {}, + configuration: {}, + created_at: '2024-01-01T00:00:00Z', + ...overrides, + } +} + +function createSessionContextResponse(): SessionContextResponse { + return { + id: 'session-1', + messages: [], + summary: null, + peer_representation: null, + peer_card: null, + } +} + +describe('Session unit behavior', () => { + test('getMetadata populates createdAt and isActive from fetched session data', async () => { + const http = { + post: async () => + createSessionResponse({ + metadata: { topic: 'testing' }, + created_at: '2024-02-01T12:00:00Z', + is_active: true, + }), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + expect(session.createdAt).toBeUndefined() + expect(session.isActive).toBeUndefined() + + const metadata = await session.getMetadata() + + expect(metadata).toEqual({ topic: 'testing' }) + expect(session.createdAt).toBe('2024-02-01T12:00:00Z') + expect(session.isActive).toBe(true) + }) + + test('refresh updates cached createdAt and isActive from the latest fetch', async () => { + const responses = [ + createSessionResponse({ + metadata: { version: 1 }, + configuration: { reasoning: { enabled: true } }, + created_at: '2024-01-01T00:00:00Z', + is_active: true, + }), + createSessionResponse({ + metadata: { version: 2 }, + configuration: { reasoning: { enabled: false } }, + created_at: '2024-01-02T00:00:00Z', + is_active: false, + }), + ] + + const http = { + post: async () => responses.shift() ?? createSessionResponse(), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await session.getMetadata() + await session.refresh() + + expect(session.metadata).toEqual({ version: 2 }) + expect(session.configuration).toMatchObject({ + reasoning: { enabled: false }, + }) + expect(session.createdAt).toBe('2024-01-02T00:00:00Z') + expect(session.isActive).toBe(false) + }) + + test('uploadFile converts configuration to API field names', async () => { + let capturedFormData: FormData | undefined + + const http = { + upload: async (_path: string, formData: FormData) => { + capturedFormData = formData + return [] + }, + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await session.uploadFile(new Blob(['hello'], { type: 'text/plain' }), 'peer-1', { + configuration: { + reasoning: { + enabled: true, + customInstructions: 'Keep headings intact', + }, + }, + }) + + expect(capturedFormData).toBeDefined() + + const rawConfiguration = capturedFormData?.get('configuration') + expect(typeof rawConfiguration).toBe('string') + expect(JSON.parse(rawConfiguration as string)).toEqual({ + reasoning: { + enabled: true, + custom_instructions: 'Keep headings intact', + }, + }) + }) + + test('context rejects invalid content-like searchQuery inputs', async () => { + const http = { + get: async () => createSessionContextResponse(), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'peer-1', + representationOptions: { + searchQuery: { foo: 'bar' } as never, + }, + }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('representation rejects whitespace-only content-like searchQuery inputs', async () => { + const http = { + post: async () => ({ representation: 'ok' }), + } as unknown as HonchoHTTPClient + + const session = new Session('session-1', 'workspace-1', http) + + await expect( + session.representation('peer-1', { + searchQuery: { content: ' ' } as never, + }) + ).rejects.toBeInstanceOf(ZodError) + }) +}) diff --git a/sdks/typescript/__tests__/validation.test.ts b/sdks/typescript/__tests__/validation.test.ts new file mode 100644 index 00000000..42d03cf7 --- /dev/null +++ b/sdks/typescript/__tests__/validation.test.ts @@ -0,0 +1,406 @@ +/** + * Validation Schema Tests + * + * Unit tests for Zod validation schemas. No server required. + */ + +import { describe, test, expect } from 'bun:test' +import { ZodError } from 'zod' +import { + ChatQuerySchema, + ContextParamsSchema, + HonchoConfigSchema, + MessageInputSchema, + FileUploadSchema, + RepresentationOptionsSchema, +} from '../src/validation' + +// ============================================================================= +// ChatQuerySchema +// ============================================================================= + +describe('ChatQuerySchema', () => { + // --- Valid inputs --- + + test('minimal valid query', () => { + const result = ChatQuerySchema.parse({ query: 'hello' }) + expect(result.query).toBe('hello') + expect(result.target).toBeUndefined() + expect(result.session).toBeUndefined() + expect(result.reasoningLevel).toBeUndefined() + }) + + test('query with all optional fields', () => { + const result = ChatQuerySchema.parse({ + query: 'hello', + target: 'peer-1', + session: 'session-1', + reasoningLevel: 'high', + }) + expect(result.query).toBe('hello') + expect(result.target).toBe('peer-1') + expect(result.session).toBe('session-1') + expect(result.reasoningLevel).toBe('high') + }) + + test('target as object with id is transformed to string', () => { + const result = ChatQuerySchema.parse({ + query: 'hello', + target: { id: 'peer-1' }, + }) + expect(result.target).toBe('peer-1') + }) + + test('session as object with id is transformed to string', () => { + const result = ChatQuerySchema.parse({ + query: 'hello', + session: { id: 'session-1' }, + }) + expect(result.session).toBe('session-1') + }) + + test.each(['minimal', 'low', 'medium', 'high', 'max'] as const)( + 'reasoning level "%s" is valid', + (level) => { + const result = ChatQuerySchema.parse({ query: 'hello', reasoningLevel: level }) + expect(result.reasoningLevel).toBe(level) + } + ) + + // --- Missing required fields --- + + test('missing query throws', () => { + expect(() => ChatQuerySchema.parse({})).toThrow(ZodError) + }) + + test('empty query throws', () => { + expect(() => ChatQuerySchema.parse({ query: '' })).toThrow(ZodError) + }) + + test('whitespace-only query throws', () => { + expect(() => ChatQuerySchema.parse({ query: ' ' })).toThrow(ZodError) + }) + + // --- Invalid field values --- + + test('invalid reasoning level throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', reasoningLevel: 'ultra' }) + ).toThrow(ZodError) + }) + + test('empty target string throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', target: '' }) + ).toThrow(ZodError) + }) + + test('empty session string throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', session: '' }) + ).toThrow(ZodError) + }) + + test('target with special characters throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', target: 'peer with spaces' }) + ).toThrow(ZodError) + }) + + test('session with special characters throws', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', session: 'session/bad' }) + ).toThrow(ZodError) + }) + + // --- Strict mode: unknown fields --- + + test('unknown field throws (strict)', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', typo: 'oops' }) + ).toThrow(ZodError) + }) + + test('misspelled field throws (strict)', () => { + expect(() => + ChatQuerySchema.parse({ query: 'hello', reasoning_level: 'high' }) + ).toThrow(ZodError) + }) + + test('extra field alongside valid fields throws (strict)', () => { + expect(() => + ChatQuerySchema.parse({ + query: 'hello', + target: 'peer-1', + session: 'session-1', + reasoningLevel: 'low', + extra: true, + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// HonchoConfigSchema (strict validation) +// ============================================================================= + +describe('HonchoConfigSchema (strict)', () => { + test('baseUrl (wrong casing) throws', () => { + expect(() => + HonchoConfigSchema.parse({ + baseUrl: 'http://localhost:8000', + workspaceId: 'test', + }) + ).toThrow(ZodError) + }) + + test('baseURL (correct casing) passes', () => { + const result = HonchoConfigSchema.parse({ + baseURL: 'http://localhost:8000', + workspaceId: 'test', + }) + expect(result.baseURL).toBe('http://localhost:8000') + }) + + test('unknown option throws', () => { + expect(() => + HonchoConfigSchema.parse({ + workspaceId: 'test', + retries: 3, + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// MessageInputSchema (strict validation) +// ============================================================================= + +describe('MessageInputSchema (strict)', () => { + test('valid message input', () => { + const result = MessageInputSchema.parse({ + peerId: 'peer-1', + content: 'hello', + metadata: { key: 'value' }, + }) + expect(result.peerId).toBe('peer-1') + expect(result.content).toBe('hello') + }) + + test('snake_case peerId alias throws (strict)', () => { + expect(() => + MessageInputSchema.parse({ + peer_id: 'peer-1', + content: 'hello', + }) + ).toThrow(ZodError) + }) + + test('unknown field throws (strict)', () => { + expect(() => + MessageInputSchema.parse({ + peerId: 'peer-1', + content: 'hello', + role: 'user', + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// ContextParamsSchema (searchQuery in representationOptions) +// ============================================================================= + +describe('ContextParamsSchema', () => { + test('top-level searchQuery throws (strict)', () => { + expect(() => + ContextParamsSchema.parse({ + peerTarget: 'peer-1', + searchQuery: 'hello', + }) + ).toThrow(ZodError) + }) + + test('searchQuery inside representationOptions passes', () => { + const result = ContextParamsSchema.parse({ + peerTarget: 'peer-1', + representationOptions: { searchQuery: 'hello' }, + }) + expect(result.representationOptions?.searchQuery).toBe('hello') + }) + + test('representationOptions.searchQuery requires peerTarget', () => { + expect(() => + ContextParamsSchema.parse({ + representationOptions: { searchQuery: 'hello' }, + }) + ).toThrow(ZodError) + }) + + test('minimal valid context params', () => { + const result = ContextParamsSchema.parse({}) + expect(result.summary).toBeUndefined() + expect(result.peerTarget).toBeUndefined() + }) + + test('all valid options', () => { + const result = ContextParamsSchema.parse({ + summary: true, + tokens: 1000, + peerTarget: 'peer-1', + peerPerspective: 'peer-2', + limitToSession: true, + representationOptions: { + searchQuery: 'preferences', + searchTopK: 10, + searchMaxDistance: 0.5, + includeMostFrequent: true, + maxConclusions: 25, + }, + }) + expect(result.summary).toBe(true) + expect(result.peerTarget).toBe('peer-1') + expect(result.representationOptions?.searchQuery).toBe('preferences') + expect(result.representationOptions?.searchTopK).toBe(10) + }) + + test('peerPerspective without peerTarget throws', () => { + expect(() => + ContextParamsSchema.parse({ peerPerspective: 'peer-2' }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// RepresentationOptionsSchema +// ============================================================================= + +describe('RepresentationOptionsSchema', () => { + test('string searchQuery passes', () => { + const result = RepresentationOptionsSchema.parse({ searchQuery: 'hello' }) + expect(result.searchQuery).toBe('hello') + }) + + test('content-like object searchQuery passes', () => { + const result = RepresentationOptionsSchema.parse({ + searchQuery: { + id: 'msg-1', + content: 'hello world', + created_at: '2024-01-01T00:00:00Z', + peer_id: 'peer-1', + session_id: 'session-1', + token_count: 2, + workspace_id: 'ws-1', + metadata: {}, + }, + }) + expect(result.searchQuery).toBeDefined() + }) + + test('whitespace-only string searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ searchQuery: ' ' }) + ).toThrow(ZodError) + }) + + test('whitespace-only object content searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: { content: ' ' }, + }) + ).toThrow(ZodError) + }) + + test('invalid object searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: { foo: 'bar' }, + }) + ).toThrow(ZodError) + }) + + test('numeric searchQuery throws', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: 42, + }) + ).toThrow(ZodError) + }) + + test('unknown field throws (strict)', () => { + expect(() => + RepresentationOptionsSchema.parse({ + searchQuery: 'hello', + typo: true, + }) + ).toThrow(ZodError) + }) +}) + +// ============================================================================= +// FileUploadSchema +// ============================================================================= + +describe('FileUploadSchema', () => { + test('Blob upload passes', () => { + const result = FileUploadSchema.parse({ + file: new Blob(['hello'], { type: 'text/plain' }), + peer: 'peer-1', + }) + + expect(result.file).toBeInstanceOf(Blob) + }) + + test('custom upload object passes', () => { + const result = FileUploadSchema.parse({ + file: { + filename: 'test.txt', + content: new TextEncoder().encode('hello'), + content_type: 'text/plain', + }, + peer: 'peer-1', + }) + + expect(result.file).toBeDefined() + }) + + test('custom upload object accepts Buffer content', () => { + const result = FileUploadSchema.parse({ + file: { + filename: 'test.txt', + content: Buffer.from('hello'), + content_type: 'text/plain', + }, + peer: 'peer-1', + }) + + expect(result.file).toBeDefined() + }) + + test('top-level Uint8Array throws', () => { + expect(() => + FileUploadSchema.parse({ + file: new Uint8Array([1, 2, 3]), + peer: 'peer-1', + }) + ).toThrow(ZodError) + }) + + test('top-level Buffer throws', () => { + expect(() => + FileUploadSchema.parse({ + file: Buffer.from('hello'), + peer: 'peer-1', + }) + ).toThrow(ZodError) + }) + + test('arbitrary object throws', () => { + expect(() => + FileUploadSchema.parse({ + file: { filename: 'test.txt' }, + peer: 'peer-1', + }) + ).toThrow(ZodError) + }) +}) diff --git a/sdks/typescript/bun.lock b/sdks/typescript/bun.lock index c67af981..f7c1641a 100644 --- a/sdks/typescript/bun.lock +++ b/sdks/typescript/bun.lock @@ -5,12 +5,12 @@ "": { "name": "@honcho-ai/sdk", "dependencies": { - "@types/node": "^24.0.1", "zod": "4.0.0", }, "devDependencies": { "@biomejs/biome": "^2.1.2", "@types/bun": "latest", + "@types/node": "^24.0.1", "typescript": "^5.0.0", }, }, diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 618a0acf..29b06e85 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/sdk", - "version": "2.0.1", + "version": "2.1.0", "description": "Official DX Optimized TypeScript SDK for Honcho", "author": "Plastic Labs ", "license": "Apache-2.0", @@ -18,12 +18,12 @@ "test": "echo 'Error: Tests must be run from the monorepo root via pytest. See tests/README.md' && exit 1" }, "dependencies": { - "@types/node": "^24.0.1", "zod": "4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.1.2", "@types/bun": "latest", + "@types/node": "^24.0.1", "typescript": "^5.0.0" } } diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 53801d9d..223f3833 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -21,6 +21,7 @@ import { type HonchoConfig, HonchoConfigSchema, LimitSchema, + normalizeListOptions, type PeerConfig, PeerConfigSchema, PeerIdSchema, @@ -279,13 +280,18 @@ export class Honcho { filters?: Record page?: number size?: number + reverse?: boolean } ): Promise> { return this._http.post>( `/${API_VERSION}/workspaces/${workspaceId}/peers/list`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -310,13 +316,18 @@ export class Honcho { filters?: Record page?: number size?: number + reverse?: boolean } ): Promise> { return this._http.post>( `/${API_VERSION}/workspaces/${workspaceId}/sessions/list`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -381,29 +392,19 @@ export class Honcho { ? PeerConfigSchema.parse(options.configuration) : undefined - if (validatedConfiguration || validatedMetadata) { - const peerData = await this._getOrCreatePeer(this.workspaceId, { - id: validatedId, - configuration: peerConfigToApi(validatedConfiguration), - metadata: validatedMetadata, - }) - return new Peer( - validatedId, - this.workspaceId, - this._http, - peerData.metadata ?? undefined, - peerConfigFromApi(peerData.configuration) ?? undefined, - () => this._ensureWorkspace() - ) - } - + const peerData = await this._getOrCreatePeer(this.workspaceId, { + id: validatedId, + configuration: peerConfigToApi(validatedConfiguration), + metadata: validatedMetadata, + }) return new Peer( validatedId, this.workspaceId, this._http, - undefined, - undefined, - () => this._ensureWorkspace() + peerData.metadata ?? undefined, + peerConfigFromApi(peerData.configuration) ?? undefined, + () => this._ensureWorkspace(), + peerData.created_at ) } @@ -413,14 +414,37 @@ export class Honcho { * Makes an API call to retrieve all peers that have been created or used * within the current workspace. Returns a paginated result. * - * @param filters - Optional filter criteria for peers. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a Page of Peer objects representing all peers in the workspace */ - async peers(filters?: Filters): Promise> { + async peers( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { await this._ensureWorkspace() - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse const peersPage = await this._listPeers(this.workspaceId, { filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, }) const fetchNextPage = async ( @@ -431,6 +455,7 @@ export class Honcho { filters: validatedFilter, page, size, + reverse, }) } @@ -443,7 +468,8 @@ export class Honcho { this._http, peer.metadata ?? undefined, peerConfigFromApi(peer.configuration) ?? undefined, - () => this._ensureWorkspace() + () => this._ensureWorkspace(), + peer.created_at ), fetchNextPage ) @@ -486,29 +512,20 @@ export class Honcho { ? SessionConfigSchema.parse(options.configuration) : undefined - if (validatedConfiguration || validatedMetadata) { - const sessionData = await this._getOrCreateSession(this.workspaceId, { - id: validatedId, - configuration: validatedConfiguration, - metadata: validatedMetadata, - }) - return new Session( - validatedId, - this.workspaceId, - this._http, - sessionData.metadata ?? undefined, - sessionConfigFromApi(sessionData.configuration) ?? undefined, - () => this._ensureWorkspace() - ) - } - + const sessionData = await this._getOrCreateSession(this.workspaceId, { + id: validatedId, + configuration: validatedConfiguration, + metadata: validatedMetadata, + }) return new Session( validatedId, this.workspaceId, this._http, - undefined, - undefined, - () => this._ensureWorkspace() + sessionData.metadata ?? undefined, + sessionConfigFromApi(sessionData.configuration) ?? undefined, + () => this._ensureWorkspace(), + sessionData.created_at, + sessionData.is_active ) } @@ -518,15 +535,38 @@ export class Honcho { * Makes an API call to retrieve all sessions that have been created within * the current workspace. * - * @param filters - Optional filter criteria for sessions. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a Page of Session objects representing all sessions * in the workspace. Returns an empty page if no sessions exist */ - async sessions(filters?: Filters): Promise> { + async sessions( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { await this._ensureWorkspace() - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse const sessionsPage = await this._listSessions(this.workspaceId, { filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, }) const fetchNextPage = async ( @@ -537,6 +577,7 @@ export class Honcho { filters: validatedFilter, page, size, + reverse, }) } @@ -549,7 +590,9 @@ export class Honcho { this._http, session.metadata ?? undefined, sessionConfigFromApi(session.configuration) ?? undefined, - () => this._ensureWorkspace() + () => this._ensureWorkspace(), + session.created_at, + session.is_active ), fetchNextPage ) @@ -647,16 +690,33 @@ export class Honcho { * Makes an API call to retrieve all workspace IDs that the authenticated * user has access to. * - * @param filters - Optional filter criteria for workspaces. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, and `size`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a Page of workspace ID strings. Returns an empty * page if no workspaces are accessible or none exist */ async workspaces( - filters?: Filters + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + } ): Promise> { - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined const workspacesPage = await this._listWorkspaces({ filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, }) const fetchNextPage = async ( diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index 28cd06e9..7c854b57 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -8,6 +8,7 @@ import type { RepresentationOptions, RepresentationResponse, } from './types/api' +import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation' /** * Parameters for creating a conclusion. @@ -101,13 +102,18 @@ export class ConclusionScope { filters?: Record page?: number size?: number + reverse?: boolean }): Promise> { await this._ensureWorkspace() return this._http.post>( `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/list`, { body: { filters: params.filters }, - query: { page: params.page, size: params.size }, + query: { + page: params.page, + size: params.size, + reverse: params.reverse ? 'true' : undefined, + }, } ) } @@ -182,6 +188,7 @@ export class ConclusionScope { page?: number size?: number session?: string | Session + reverse?: boolean }): Promise> { const resolvedSessionId = options?.session ? typeof options.session === 'string' @@ -195,18 +202,20 @@ export class ConclusionScope { if (resolvedSessionId) { filters.session_id = resolvedSessionId } + const reverse = options?.reverse const response = await this._list({ filters, page: options?.page ?? 1, size: options?.size ?? 50, + reverse, }) const fetchNextPage = async ( page: number, size: number ): Promise> => { - return this._list({ filters, page, size }) + return this._list({ filters, page, size, reverse }) } return new Page( @@ -277,13 +286,22 @@ export class ConclusionScope { * Get the computed representation for this scope. */ async representation(options?: RepresentationOptions): Promise { + const searchQuery = normalizeSearchQuery(options?.searchQuery) + const validatedOptions = RepresentationOptionsSchema.parse({ + searchQuery, + searchTopK: options?.searchTopK, + searchMaxDistance: options?.searchMaxDistance, + includeMostFrequent: options?.includeMostFrequent, + maxConclusions: options?.maxConclusions, + }) + const response = await this._getRepresentation(this.observer, { target: this.observed, - search_query: options?.searchQuery, - search_top_k: options?.searchTopK, - search_max_distance: options?.searchMaxDistance, - include_most_frequent: options?.includeMostFrequent, - max_conclusions: options?.maxConclusions, + search_query: searchQuery, + search_top_k: validatedOptions.searchTopK, + search_max_distance: validatedOptions.searchMaxDistance, + include_most_frequent: validatedOptions.includeMostFrequent, + max_conclusions: validatedOptions.maxConclusions, }) return response.representation } diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index 65567ad5..bc129616 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -28,6 +28,8 @@ import { MessageConfigurationSchema, MessageContentSchema, MessageMetadataSchema, + normalizeListOptions, + normalizeSearchQuery, PeerCardContentSchema, type PeerConfig, PeerConfigSchema, @@ -35,7 +37,9 @@ import { PeerMetadataSchema, peerConfigFromApi, peerConfigToApi, + RepresentationOptionsSchema, SearchQuerySchema, + sessionConfigFromApi, } from './validation' /** @@ -120,6 +124,7 @@ export class Peer { * Private cached configuration for this peer. */ private _configuration?: PeerConfig + private _createdAt?: string /** * Cached metadata for this peer. May be stale if the peer @@ -143,6 +148,13 @@ export class Peer { return this._configuration } + /** + * Timestamp when this peer was created. Only available if fetched from the API. + */ + get createdAt(): string | undefined { + return this._createdAt + } + /** * Initialize a new Peer. **Do not call this directly, use the client.peer() method instead.** * @@ -158,7 +170,8 @@ export class Peer { http: HonchoHTTPClient, metadata?: Record, configuration?: PeerConfig, - ensureWorkspace: () => Promise = async () => undefined + ensureWorkspace: () => Promise = async () => undefined, + createdAt?: string ) { this.id = id this.workspaceId = workspaceId @@ -166,6 +179,7 @@ export class Peer { this._metadata = metadata this._configuration = configuration this._ensureWorkspace = ensureWorkspace + this._createdAt = createdAt } // =========================================================================== @@ -199,13 +213,18 @@ export class Peer { filters?: Record page?: number size?: number + reverse?: boolean }): Promise> { await this._ensureWorkspace() return this._http.post>( `/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/sessions`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -459,19 +478,49 @@ export class Peer { * Makes an API call to retrieve all sessions where this peer is an active participant. * Sessions are created when peers are added to them or send messages to them. * - * @param filters - Optional filter criteria for sessions. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See + * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a paginated list of Session objects this peer belongs to. * Returns an empty list if the peer is not a member of any sessions */ - async sessions(filters?: Filters): Promise> { - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const sessionsPage = await this._listSessions({ filters: validatedFilter }) + async sessions( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse + const sessionsPage = await this._listSessions({ + filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, + }) const fetchNextPage = async ( page: number, size: number ): Promise> => { - return this._listSessions({ filters: validatedFilter, page, size }) + return this._listSessions({ + filters: validatedFilter, + page, + size, + reverse, + }) } return new Page( @@ -482,7 +531,10 @@ export class Peer { this.workspaceId, this._http, session.metadata ?? undefined, - session.configuration ?? undefined + sessionConfigFromApi(session.configuration) ?? undefined, + () => this._ensureWorkspace(), + session.created_at, + session.is_active ), fetchNextPage ) @@ -559,6 +611,7 @@ export class Peer { async getMetadata(): Promise> { const peer = await this._getOrCreate({ id: this.id }) this._metadata = peer.metadata || {} + this._createdAt = peer.created_at return this._metadata } @@ -590,6 +643,7 @@ export class Peer { async getConfiguration(): Promise { const peer = await this._getOrCreate({ id: this.id }) this._configuration = peerConfigFromApi(peer.configuration) || {} + this._createdAt = peer.created_at return this._configuration } @@ -621,6 +675,7 @@ export class Peer { const peer = await this._getOrCreate({ id: this.id }) this._metadata = peer.metadata || {} this._configuration = peerConfigFromApi(peer.configuration) || {} + this._createdAt = peer.created_at } /** @@ -741,17 +796,18 @@ export class Peer { async representation(options?: { session?: string | Session target?: string | Peer - searchQuery?: string + searchQuery?: string | Message searchTopK?: number searchMaxDistance?: number includeMostFrequent?: boolean maxConclusions?: number }): Promise { + const searchQuery = normalizeSearchQuery(options?.searchQuery) const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({ session: options?.session, target: options?.target, options: { - searchQuery: options?.searchQuery, + searchQuery, searchTopK: options?.searchTopK, searchMaxDistance: options?.searchMaxDistance, includeMostFrequent: options?.includeMostFrequent, @@ -772,7 +828,7 @@ export class Peer { const response = await this._getRepresentation({ session_id: sessionId, target: targetId, - search_query: getRepresentationParams.options?.searchQuery, + search_query: searchQuery, search_top_k: getRepresentationParams.options?.searchTopK, search_max_distance: getRepresentationParams.options?.searchMaxDistance, include_most_frequent: @@ -827,14 +883,25 @@ export class Peer { ? options.target : options.target.id : undefined + const searchQuery = + options?.searchQuery === undefined + ? undefined + : SearchQuerySchema.parse(options.searchQuery) + const validatedOptions = RepresentationOptionsSchema.parse({ + searchQuery, + searchTopK: options?.searchTopK, + searchMaxDistance: options?.searchMaxDistance, + includeMostFrequent: options?.includeMostFrequent, + maxConclusions: options?.maxConclusions, + }) const response = await this._getContext({ target: targetId, - search_query: options?.searchQuery, - search_top_k: options?.searchTopK, - search_max_distance: options?.searchMaxDistance, - include_most_frequent: options?.includeMostFrequent, - max_conclusions: options?.maxConclusions, + search_query: searchQuery, + search_top_k: validatedOptions.searchTopK, + search_max_distance: validatedOptions.searchMaxDistance, + include_most_frequent: validatedOptions.includeMostFrequent, + max_conclusions: validatedOptions.maxConclusions, }) return PeerContext.fromApiResponse(response) diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts index 6d257f71..41fb2bba 100644 --- a/sdks/typescript/src/session.ts +++ b/sdks/typescript/src/session.ts @@ -28,10 +28,14 @@ import { type MessageAddition, MessageAdditionToApiSchema, MessageMetadataSchema, + messageConfigToApi, + normalizeListOptions, + normalizeSearchQuery, type PeerAddition, PeerAdditionToApiSchema, type PeerRemoval, PeerRemovalSchema, + peerConfigFromApi, type QueueStatusOptions, SearchQuerySchema, type SessionConfig, @@ -79,6 +83,8 @@ export class Session { private _http: HonchoHTTPClient private _metadata?: Record private _configuration?: SessionConfig + private _createdAt?: string + private _isActive?: boolean private _ensureWorkspace: () => Promise /** @@ -103,6 +109,20 @@ export class Session { return this._configuration } + /** + * Timestamp when this session was created. Only available if fetched from the API. + */ + get createdAt(): string | undefined { + return this._createdAt + } + + /** + * Whether this session is active. Only available if fetched from the API. + */ + get isActive(): boolean | undefined { + return this._isActive + } + /** * Initialize a new Session. **Do not call this directly, use the client.session() method instead.** * @@ -118,7 +138,9 @@ export class Session { http: HonchoHTTPClient, metadata?: Record, configuration?: SessionConfig, - ensureWorkspace: () => Promise = async () => undefined + ensureWorkspace: () => Promise = async () => undefined, + createdAt?: string, + isActive?: boolean ) { this.id = id this.workspaceId = workspaceId @@ -126,6 +148,15 @@ export class Session { this._metadata = metadata this._configuration = configuration this._ensureWorkspace = ensureWorkspace + this._createdAt = createdAt + this._isActive = isActive + } + + private _applySessionResponse(session: SessionResponse): void { + this._metadata = session.metadata || {} + this._configuration = sessionConfigFromApi(session.configuration) || {} + this._createdAt = session.created_at + this._isActive = session.is_active } // =========================================================================== @@ -305,13 +336,18 @@ export class Session { filters?: Record page?: number size?: number + reverse?: boolean }): Promise> { await this._ensureWorkspace() return this._http.post>( `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/list`, { body: { filters: params?.filters }, - query: { page: params?.page, size: params?.size }, + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, } ) } @@ -358,6 +394,13 @@ export class Session { ) } + private async _getMessage(messageId: string): Promise { + await this._ensureWorkspace() + return this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/${messageId}` + ) + } + private async _updateMessage( messageId: string, params: { metadata: Record } @@ -450,9 +493,10 @@ export class Session { peer.id, this.workspaceId, this._http, - undefined, - undefined, - () => this._ensureWorkspace() + peer.metadata ?? undefined, + peerConfigFromApi(peer.configuration) ?? undefined, + () => this._ensureWorkspace(), + peer.created_at ) ) } @@ -539,19 +583,48 @@ export class Session { * * Makes an API call to retrieve messages in the session, with optional filtering. * - * @param filters - Optional filter criteria for messages. See + * @param options - Either a legacy raw filter object or an options object with + * `filters`, `page`, `size`, and `reverse`. See * [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters). * @returns Promise resolving to a paginated Page of Message objects */ - async messages(filters?: Filters): Promise> { - const validatedFilter = filters ? FilterSchema.parse(filters) : undefined - const messagesPage = await this._listMessages({ filters: validatedFilter }) + async messages( + options?: + | Filters + | { + filters?: Filters + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + const normalizedOptions = normalizeListOptions(options, [ + 'filters', + 'page', + 'size', + 'reverse', + ]) + const validatedFilter = normalizedOptions.filters + ? FilterSchema.parse(normalizedOptions.filters) + : undefined + const reverse = normalizedOptions.reverse + const messagesPage = await this._listMessages({ + filters: validatedFilter, + page: normalizedOptions.page, + size: normalizedOptions.size, + reverse, + }) const fetchNextPage = async ( page: number, size: number ): Promise> => { - return this._listMessages({ filters: validatedFilter, page, size }) + return this._listMessages({ + filters: validatedFilter, + page, + size, + reverse, + }) } return new Page(messagesPage, Message.fromApiResponse, fetchNextPage) @@ -568,8 +641,8 @@ export class Session { */ async getMetadata(): Promise> { const session = await this._getOrCreate({ id: this.id }) - this._metadata = session.metadata || {} - return this._metadata + this._applySessionResponse(session) + return this._metadata ?? {} } /** @@ -599,8 +672,8 @@ export class Session { */ async getConfiguration(): Promise { const session = await this._getOrCreate({ id: this.id }) - this._configuration = sessionConfigFromApi(session.configuration) || {} - return this._configuration + this._applySessionResponse(session) + return this._configuration ?? {} } /** @@ -627,8 +700,7 @@ export class Session { */ async refresh(): Promise { const session = await this._getOrCreate({ id: this.id }) - this._metadata = session.metadata || {} - this._configuration = sessionConfigFromApi(session.configuration) || {} + this._applySessionResponse(session) } /** @@ -662,7 +734,9 @@ export class Session { this._http, clonedSessionData.metadata ?? undefined, sessionConfigFromApi(clonedSessionData.configuration) ?? undefined, - () => this._ensureWorkspace() + () => this._ensureWorkspace(), + clonedSessionData.created_at, + clonedSessionData.is_active ) } @@ -677,10 +751,9 @@ export class Session { * @param options.summary - Whether to include a summary of earlier messages * @param options.tokens - Target token count for the context window * @param options.peerTarget - The peer to get representation for - * @param options.lastUserMessage - Message text (string) or Message object whose content will be used for semantic search * @param options.peerPerspective - The peer whose perspective to use for representation * @param options.limitToSession - Whether to limit representation to this session only - * @param options.representationOptions - Options for representation retrieval + * @param options.representationOptions - Options for representation retrieval (searchQuery, searchTopK, etc.) * @returns Promise resolving to a SessionContext with messages, summary, and representation * * @example @@ -699,7 +772,6 @@ export class Session { summary?: boolean tokens?: number peerTarget?: string | Peer - searchQuery?: string | Message peerPerspective?: string | Peer limitToSession?: boolean representationOptions?: RepresentationOptions @@ -713,30 +785,29 @@ export class Session { typeof opts.peerPerspective === 'object' ? opts.peerPerspective.id : opts.peerPerspective - const searchQueryText = - typeof opts.searchQuery === 'string' - ? opts.searchQuery - : opts.searchQuery?.content + + const searchQuery = normalizeSearchQuery( + opts.representationOptions?.searchQuery + ) const contextParams = ContextParamsSchema.parse({ summary: opts.summary, tokens: opts.tokens, peerTarget: peerTargetId, - searchQuery: searchQueryText, peerPerspective: peerPerspectiveId, limitToSession: opts.limitToSession, - representationOptions: opts.representationOptions, + representationOptions: opts.representationOptions + ? { + ...opts.representationOptions, + searchQuery, + } + : undefined, }) - const searchQueryParsed = - typeof contextParams.searchQuery === 'string' - ? contextParams.searchQuery - : contextParams.searchQuery?.content - const context = await this._getContext({ tokens: contextParams.tokens, summary: contextParams.summary, - search_query: searchQueryParsed, + search_query: searchQuery, peer_target: contextParams.peerTarget, peer_perspective: contextParams.peerPerspective, limit_to_session: contextParams.limitToSession, @@ -895,14 +966,15 @@ export class Session { }) const formData = new FormData() + const uploadFile = uploadParams.file - if (file instanceof File || file instanceof Blob) { - formData.append('file', file) + if (uploadFile instanceof Blob) { + formData.append('file', uploadFile) } else { // Convert to Uint8Array for Blob compatibility - const content = new Uint8Array(file.content) - const blob = new Blob([content], { type: file.content_type }) - formData.append('file', blob, file.filename) + const content = new Uint8Array(uploadFile.content) + const blob = new Blob([content], { type: uploadFile.content_type }) + formData.append('file', blob, uploadFile.filename) } formData.append('peer_id', resolvedPeerId) @@ -913,10 +985,8 @@ export class Session { uploadParams.configuration !== undefined && uploadParams.configuration !== null ) { - formData.append( - 'configuration', - JSON.stringify(uploadParams.configuration) - ) + const apiConfiguration = messageConfigToApi(uploadParams.configuration) + formData.append('configuration', JSON.stringify(apiConfiguration)) } if ( uploadParams.createdAt !== undefined && @@ -949,18 +1019,19 @@ export class Session { peer: string | Peer, options?: { target?: string | Peer - searchQuery?: string + searchQuery?: string | Message searchTopK?: number searchMaxDistance?: number includeMostFrequent?: boolean maxConclusions?: number } ): Promise { + const searchQuery = normalizeSearchQuery(options?.searchQuery) const getRepresentationParams = GetRepresentationParamsSchema.parse({ peer, target: options?.target, options: { - searchQuery: options?.searchQuery, + searchQuery, searchTopK: options?.searchTopK, searchMaxDistance: options?.searchMaxDistance, includeMostFrequent: options?.includeMostFrequent, @@ -980,7 +1051,7 @@ export class Session { const response = await this._getRepresentation(peerId, { session_id: this.id, target: targetId, - search_query: getRepresentationParams.options?.searchQuery, + search_query: searchQuery, search_top_k: getRepresentationParams.options?.searchTopK, search_max_distance: getRepresentationParams.options?.searchMaxDistance, include_most_frequent: @@ -990,6 +1061,17 @@ export class Session { return response.representation } + /** + * Get a single message by ID from this session. + * + * @param messageId - The ID of the message to retrieve + * @returns Promise resolving to the Message object + */ + async getMessage(messageId: string): Promise { + const response = await this._getMessage(messageId) + return Message.fromApiResponse(response) + } + /** * Update the metadata of a message in this session. * diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index 9c71d558..1622c99c 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -285,8 +285,9 @@ export interface RepresentationResponse { export interface RepresentationOptions { /** * Semantic search query to filter relevant conclusions. + * Accepts a string or any object with a `content` property (e.g., a Message). */ - searchQuery?: string + searchQuery?: string | { content: string } /** * Number of semantically relevant conclusions to return. diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index 829699ca..34aa2caf 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import type { MessageResponse } from './types/api' /** * Validation schemas for the Honcho TypeScript SDK. @@ -23,23 +22,28 @@ export const WorkspaceIdSchema = z /** * Schema for Honcho client configuration options. */ -export const HonchoConfigSchema = z.object({ - apiKey: z.string().optional(), - environment: z.enum(['local', 'production']).optional(), - baseURL: z.url('Base URL must be a valid URL').optional(), - workspaceId: WorkspaceIdSchema.optional(), - timeout: z.number().positive('Timeout must be a positive number').optional(), - maxRetries: z - .number() - .int() - .min(0, 'Max retries must be a non-negative integer') - .max(3, 'Max retries must be at most 3') - .optional(), - defaultHeaders: z.record(z.string(), z.string()).optional(), - defaultQuery: z - .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) - .optional(), -}) +export const HonchoConfigSchema = z + .object({ + apiKey: z.string().optional(), + environment: z.enum(['local', 'production']).optional(), + baseURL: z.url('Base URL must be a valid URL').optional(), + workspaceId: WorkspaceIdSchema.optional(), + timeout: z + .number() + .positive('Timeout must be a positive number') + .optional(), + maxRetries: z + .number() + .int() + .min(0, 'Max retries must be a non-negative integer') + .max(3, 'Max retries must be at most 3') + .optional(), + defaultHeaders: z.record(z.string(), z.string()).optional(), + defaultQuery: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional(), + }) + .strict() /** * Schema for peer metadata. @@ -49,9 +53,11 @@ export const PeerMetadataSchema = z.record(z.string(), z.unknown()) /** * Schema for peer configuration. */ -export const PeerConfigSchema = z.object({ - observeMe: z.boolean().nullable().optional(), -}) +export const PeerConfigSchema = z + .object({ + observeMe: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for peer ID validation. @@ -65,6 +71,11 @@ export const PeerIdSchema = z ) .max(100, 'Peer ID can be at most 100 characters') +/** + * Strict helper: peer ID as object. + */ +const PeerIdObjectSchema = z.object({ id: PeerIdSchema }) + /** * Schema for session metadata. */ @@ -78,48 +89,58 @@ export const SessionMetadataSchema = z.record(z.string(), z.unknown()) * Schema for reasoning configuration. * Used in workspace, session, and message configuration. */ -export const ReasoningConfigSchema = z.object({ - enabled: z.boolean().nullable().optional(), - customInstructions: z.string().nullable().optional(), -}) +export const ReasoningConfigSchema = z + .object({ + enabled: z.boolean().nullable().optional(), + customInstructions: z.string().nullable().optional(), + }) + .strict() /** * Schema for peer card configuration. * Used in workspace and session configuration. */ -export const PeerCardConfigSchema = z.object({ - use: z.boolean().nullable().optional(), - create: z.boolean().nullable().optional(), -}) +export const PeerCardConfigSchema = z + .object({ + use: z.boolean().nullable().optional(), + create: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for summary configuration. * Used in workspace and session configuration. */ -export const SummaryConfigSchema = z.object({ - enabled: z.boolean().nullable().optional(), - messagesPerShortSummary: z.number().int().min(10).nullable().optional(), - messagesPerLongSummary: z.number().int().min(20).nullable().optional(), -}) +export const SummaryConfigSchema = z + .object({ + enabled: z.boolean().nullable().optional(), + messagesPerShortSummary: z.number().int().min(10).nullable().optional(), + messagesPerLongSummary: z.number().int().min(20).nullable().optional(), + }) + .strict() /** * Schema for dream configuration. * Used in workspace and session configuration. */ -export const DreamConfigSchema = z.object({ - enabled: z.boolean().nullable().optional(), -}) +export const DreamConfigSchema = z + .object({ + enabled: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for session configuration. * Includes reasoning, peer card, summary, and dream settings. */ -export const SessionConfigSchema = z.object({ - reasoning: ReasoningConfigSchema.nullable().optional(), - peerCard: PeerCardConfigSchema.nullable().optional(), - summary: SummaryConfigSchema.nullable().optional(), - dream: DreamConfigSchema.nullable().optional(), -}) +export const SessionConfigSchema = z + .object({ + reasoning: ReasoningConfigSchema.nullable().optional(), + peerCard: PeerCardConfigSchema.nullable().optional(), + summary: SummaryConfigSchema.nullable().optional(), + dream: DreamConfigSchema.nullable().optional(), + }) + .strict() /** * Schema for session ID validation. @@ -133,13 +154,20 @@ export const SessionIdSchema = z ) .max(100, 'Session ID can be at most 100 characters') +/** + * Strict helper: session ID as object. + */ +const SessionIdObjectSchema = z.object({ id: SessionIdSchema }) + /** * Schema for session peer configuration. */ -export const SessionPeerConfigSchema = z.object({ - observeMe: z.boolean().nullable().optional(), - observeOthers: z.boolean().nullable().optional(), -}) +export const SessionPeerConfigSchema = z + .object({ + observeMe: z.boolean().nullable().optional(), + observeOthers: z.boolean().nullable().optional(), + }) + .strict() /** * Schema for message content. @@ -166,19 +194,22 @@ export const MessageConfigurationSchema = z .object({ reasoning: ReasoningConfigSchema.nullable().optional(), }) + .strict() .nullable() .optional() /** * Schema for message input. */ -export const MessageInputSchema = z.object({ - peerId: PeerIdSchema, - content: MessageContentSchema, - metadata: MessageMetadataSchema, - configuration: MessageConfigurationSchema, - createdAt: z.string().nullable().optional(), -}) +export const MessageInputSchema = z + .object({ + peerId: PeerIdSchema, + content: MessageContentSchema, + metadata: MessageMetadataSchema, + configuration: MessageConfigurationSchema, + createdAt: z.string().nullable().optional(), + }) + .strict() /** * Schema for search query validation. @@ -191,78 +222,125 @@ export const SearchQuerySchema = z 'Search query cannot be only whitespace' ) +/** + * Schema for content-like search query objects. + * Accepts SDK Message instances and other objects with a valid content field. + */ +export const SearchQueryObjectSchema = z + .object({ + content: SearchQuerySchema, + }) + .passthrough() + +/** + * Schema for search query inputs that can be normalized to a string. + */ +export const SearchQueryLikeSchema = z.union([ + SearchQuerySchema, + SearchQueryObjectSchema, +]) + +/** + * Normalize a supported search query input to plain text. + */ +export function normalizeSearchQuery(searchQuery: unknown): string | undefined { + if (searchQuery === undefined) { + return undefined + } + + const validatedSearchQuery = SearchQueryLikeSchema.parse(searchQuery) + return typeof validatedSearchQuery === 'string' + ? validatedSearchQuery + : validatedSearchQuery.content +} + /** * Schema for filter objects. */ export const FilterSchema = z.record(z.string(), z.unknown()).optional() /** - * Schema for chat query parameters. + * Normalize list-method input so legacy raw filters and the new options object + * shape are both accepted. + * + * Discriminates on the `filters` key: if the input has a `filters` property or + * any of the pagination-only keys (`page`, `size`, `reverse`) it is treated as + * the new options object. Otherwise it is treated as a legacy raw filter. */ -export const ChatQuerySchema = z.object({ - query: SearchQuerySchema, - target: z - .union([PeerIdSchema, z.object({ id: PeerIdSchema })]) - .optional() - .transform((val) => - val ? (typeof val === 'string' ? val : val.id) : undefined - ), - session: z - .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) - .optional() - .transform((val) => - val ? (typeof val === 'string' ? val : val.id) : undefined - ), - reasoningLevel: z - .enum(['minimal', 'low', 'medium', 'high', 'max']) - .optional(), -}) +export function normalizeListOptions( + input: Filters | T | undefined, + optionKeys: string[] +): T { + if (input === undefined) { + return {} as T + } + + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return { filters: input as Filters } as T + } + + // Pagination-only keys can never appear in a raw filter object + const paginationKeys = optionKeys.filter((k) => k !== 'filters') + const hasFiltersKey = 'filters' in input + const hasPaginationKey = paginationKeys.some((key) => key in input) + + if (hasFiltersKey || hasPaginationKey) { + return input as T + } + + return { filters: input as Filters } as T +} /** - * Schema for validating Message API responses (snake_case). + * Schema for chat query parameters. */ -const MessageResponseSchema: z.ZodType = z.object({ - id: z.string(), - content: z.string(), - created_at: z.string(), - peer_id: PeerIdSchema, - session_id: SessionIdSchema, - token_count: z.number(), - workspace_id: WorkspaceIdSchema, - metadata: z.record(z.string(), z.unknown()), -}) as z.ZodType +export const ChatQuerySchema = z + .object({ + query: SearchQuerySchema, + target: z + .union([PeerIdSchema, PeerIdObjectSchema]) + .optional() + .transform((val) => + val ? (typeof val === 'string' ? val : val.id) : undefined + ), + session: z + .union([SessionIdSchema, SessionIdObjectSchema]) + .optional() + .transform((val) => + val ? (typeof val === 'string' ? val : val.id) : undefined + ), + reasoningLevel: z + .enum(['minimal', 'low', 'medium', 'high', 'max']) + .optional(), + }) + .strict() /** * Schema for representation options. */ -export const RepresentationOptionsSchema = z.object({ - searchQuery: z - .string() - .min(1, 'searchQuery must be a non-empty string') - .refine( - (query: string) => query.trim().length > 0, - 'searchQuery cannot be only whitespace' - ) - .optional(), - searchTopK: z - .number() - .int() - .min(1, 'searchTopK must be at least 1') - .max(100, 'searchTopK must be at most 100') - .optional(), - searchMaxDistance: z - .number() - .min(0.0, 'searchMaxDistance must be at least 0.0') - .max(1.0, 'searchMaxDistance must be at most 1.0') - .optional(), - includeMostFrequent: z.boolean().optional(), - maxConclusions: z - .number() - .int() - .min(1, 'maxConclusions must be at least 1') - .max(100, 'maxConclusions must be at most 100') - .optional(), -}) +export const RepresentationOptionsSchema = z + .object({ + searchQuery: SearchQueryLikeSchema.optional(), + searchTopK: z + .number() + .int() + .min(1, 'searchTopK must be at least 1') + .max(100, 'searchTopK must be at most 100') + .optional(), + searchMaxDistance: z + .number() + .min(0.0, 'searchMaxDistance must be at least 0.0') + .max(1.0, 'searchMaxDistance must be at most 1.0') + .optional(), + includeMostFrequent: z.boolean().optional(), + maxConclusions: z + .number() + .int() + .min(1, 'maxConclusions must be at least 1') + .max(100, 'maxConclusions must be at most 100') + .optional(), + }) + .strict() /** * Schema for context retrieval parameters. @@ -271,23 +349,18 @@ export const ContextParamsSchema = z .object({ summary: z.boolean().optional(), tokens: z.int('Token limit must be an integer').optional(), - searchQuery: z - .union([ - z.string().min(1, 'Search query must be a non-empty string'), - MessageResponseSchema, - ]) - .optional(), peerTarget: PeerIdSchema.optional(), peerPerspective: PeerIdSchema.optional(), limitToSession: z.boolean().optional(), representationOptions: RepresentationOptionsSchema.optional(), }) + .strict() .superRefine((data, ctx) => { - if (data.searchQuery && !data.peerTarget) { + if (data.representationOptions?.searchQuery && !data.peerTarget) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'peerTarget is required when searchQuery is provided', - path: ['searchQuery'], + path: ['representationOptions', 'searchQuery'], }) } @@ -303,74 +376,72 @@ export const ContextParamsSchema = z /** * Schema for deriver status options. */ -export const QueueStatusOptionsSchema = z.object({ - observer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - sender: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - session: z - .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) - .optional(), - timeout: z.number().positive('Timeout must be a positive number').optional(), -}) +export const QueueStatusOptionsSchema = z + .object({ + observer: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + sender: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(), + timeout: z + .number() + .positive('Timeout must be a positive number') + .optional(), + }) + .strict() /** * Schema for file upload parameters. - * Supports File objects (browser), Buffer, Uint8Array, and custom uploadable objects. + * Supports Blob/File objects and custom uploadable objects with binary content. */ -export const FileUploadSchema = z.object({ - file: z.union([ - // Browser File object - z.instanceof(File), - // Node.js Buffer - z.instanceof(Buffer), - // Uint8Array - z.instanceof(Uint8Array), - // Custom uploadable object with filename, content, and content_type - z.object({ - filename: z.string().min(1, 'Filename must be a non-empty string'), - content: z.union([z.instanceof(Buffer), z.instanceof(Uint8Array)]), - content_type: z - .string() - .min(1, 'Content type must be a non-empty string'), - }), - // Fallback for any other uploadable type - z - .any() - .refine( - (val) => val !== null && val !== undefined, - 'File must not be null or undefined' - ), - ]), - peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), - metadata: MessageMetadataSchema, - configuration: z.record(z.string(), z.unknown()).optional(), - createdAt: z.string().nullable().optional(), -}) +export const FileUploadSchema = z + .object({ + file: z.union([ + // Browser/File API objects + z.instanceof(Blob), + // Custom uploadable object with filename, content, and content_type + z + .object({ + filename: z.string().min(1, 'Filename must be a non-empty string'), + content: z.instanceof(Uint8Array), + content_type: z + .string() + .min(1, 'Content type must be a non-empty string'), + }) + .strict(), + ]), + peer: z.union([PeerIdSchema, PeerIdObjectSchema]), + metadata: MessageMetadataSchema, + configuration: MessageConfigurationSchema, + createdAt: z.string().nullable().optional(), + }) + .strict() /** * Schema for get representation parameters. */ -export const GetRepresentationParamsSchema = z.object({ - peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), - target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - options: RepresentationOptionsSchema.optional(), -}) +export const GetRepresentationParamsSchema = z + .object({ + peer: z.union([PeerIdSchema, PeerIdObjectSchema]), + target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + options: RepresentationOptionsSchema.optional(), + }) + .strict() /** * Schema for peer get representation parameters. */ -export const PeerGetRepresentationParamsSchema = z.object({ - session: z - .union([SessionIdSchema, z.object({ id: SessionIdSchema })]) - .optional(), - target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(), - options: RepresentationOptionsSchema.optional(), -}) +export const PeerGetRepresentationParamsSchema = z + .object({ + session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(), + target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), + options: RepresentationOptionsSchema.optional(), + }) + .strict() /** * Schema for peer card target parameter. */ export const CardTargetSchema = z - .union([PeerIdSchema, z.object({ id: PeerIdSchema })]) + .union([PeerIdSchema, PeerIdObjectSchema]) .optional() .transform((val) => val ? (typeof val === 'string' ? val : val.id) : undefined @@ -386,19 +457,19 @@ export const PeerCardContentSchema = z.array(z.string()) */ export const PeerAdditionSchema = z.union([ PeerIdSchema, - z.object({ id: PeerIdSchema }), + PeerIdObjectSchema, z.array( z.union([ PeerIdSchema, - z.object({ id: PeerIdSchema }), + PeerIdObjectSchema, z.tuple([ - z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), + z.union([PeerIdSchema, PeerIdObjectSchema]), SessionPeerConfigSchema, ]), ]) ), z.tuple([ - z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]), + z.union([PeerIdSchema, PeerIdObjectSchema]), SessionPeerConfigSchema, ]), ]) @@ -798,8 +869,8 @@ export const PeerAdditionToApiSchema = PeerAdditionSchema.transform( */ export const PeerRemovalSchema = z.union([ PeerIdSchema, - z.object({ id: PeerIdSchema }), - z.array(z.union([PeerIdSchema, z.object({ id: PeerIdSchema })])), + PeerIdObjectSchema, + z.array(z.union([PeerIdSchema, PeerIdObjectSchema])), ]) /** @@ -835,12 +906,14 @@ export const WorkspaceMetadataSchema = z.record(z.string(), z.unknown()) * Schema for workspace configuration. * Includes reasoning, peer card, summary, and dream settings. */ -export const WorkspaceConfigSchema = z.object({ - reasoning: ReasoningConfigSchema.nullable().optional(), - peerCard: PeerCardConfigSchema.nullable().optional(), - summary: SummaryConfigSchema.nullable().optional(), - dream: DreamConfigSchema.nullable().optional(), -}) +export const WorkspaceConfigSchema = z + .object({ + reasoning: ReasoningConfigSchema.nullable().optional(), + peerCard: PeerCardConfigSchema.nullable().optional(), + summary: SummaryConfigSchema.nullable().optional(), + dream: DreamConfigSchema.nullable().optional(), + }) + .strict() /** * Schema for limit. @@ -854,21 +927,23 @@ export const LimitSchema = z /** * Schema for conclusion query parameters. */ -export const ConclusionQueryParamsSchema = z.object({ - query: SearchQuerySchema, - top_k: z - .number() - .int() - .min(1, 'top_k must be at least 1') - .max(100, 'top_k must be at most 100') - .optional(), - distance: z - .number() - .min(0.0, 'distance must be at least 0.0') - .max(1.0, 'distance must be at most 1.0') - .optional(), - filters: FilterSchema, -}) +export const ConclusionQueryParamsSchema = z + .object({ + query: SearchQuerySchema, + top_k: z + .number() + .int() + .min(1, 'top_k must be at least 1') + .max(100, 'top_k must be at most 100') + .optional(), + distance: z + .number() + .min(0.0, 'distance must be at least 0.0') + .max(1.0, 'distance must be at most 1.0') + .optional(), + filters: FilterSchema, + }) + .strict() /** * Type exports for use throughout the SDK. @@ -883,6 +958,7 @@ export type MessageInput = z.infer export type Filters = z.infer export type ChatQuery = z.infer export type ContextParams = z.infer +export type SearchQueryLike = z.infer export type QueueStatusOptions = z.infer export type FileUpload = z.infer export type GetRepresentationParams = z.infer< diff --git a/tests/sdk/test_session.py b/tests/sdk/test_session.py index 264a5c5c..3a618891 100644 --- a/tests/sdk/test_session.py +++ b/tests/sdk/test_session.py @@ -36,6 +36,62 @@ async def test_session_metadata(client_fixture: tuple[Honcho, str]): assert metadata == {"foo": "bar"} +@pytest.mark.asyncio +async def test_session_fetch_methods_refresh_cached_status_fields( + client_fixture: tuple[Honcho, str], +): + """ + Tests that fetch-style session methods populate created_at and is_active. + """ + honcho_client, client_type = client_fixture + session_id = "test-session-refresh-cache" + + if client_type == "async": + await honcho_client.aio.session(id=session_id) + session = Session(session_id, honcho_client) + + assert session.created_at is None + assert session.is_active is None + + metadata = await session.aio.get_metadata() + assert metadata == {} + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + await session.aio.refresh() + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + configuration = await session.aio.get_configuration() + assert configuration is not None + assert session.created_at is not None + assert session.is_active is True + else: + honcho_client.session(id=session_id) + session = Session(session_id, honcho_client) + + assert session.created_at is None + assert session.is_active is None + + metadata = session.get_metadata() + assert metadata == {} + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + session.refresh() + assert session.created_at is not None + assert session.is_active is True + + session = Session(session_id, honcho_client) + configuration = session.get_configuration() + assert configuration is not None + assert session.created_at is not None + assert session.is_active is True + + @pytest.mark.asyncio async def test_session_peer_management( client_fixture: tuple[Honcho, str], diff --git a/uv.lock b/uv.lock index 29d84d26..635a9616 100644 --- a/uv.lock +++ b/uv.lock @@ -1397,7 +1397,7 @@ dev = [ [[package]] name = "honcho-ai" -version = "2.0.1" +version = "2.1.0" source = { editable = "sdks/python" } dependencies = [ { name = "httpx" },