From 25f9e75e53a1ca7e65d28bc49913359b21ee1173 Mon Sep 17 00:00:00 2001 From: "yuechen.wyh" <300878504+moonyue-w@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:04:54 +0800 Subject: [PATCH] feat(forward): align contracts and complete resource APIs --- CHANGELOG.md | 15 + CONTRIBUTING.md | 4 +- README.md | 2 + docs/forward-api-coverage.md | 53 +- docs/forward-parity.md | 117 ++++ docs/sessions.md | 4 +- examples/README.md | 4 + .../examples/ForwardCapabilitiesExample.java | 97 +++ .../ForwardCapabilitiesConsumerTest.java | 162 +++++ .../qoder/cloudagents/AsyncForwardClient.java | 14 + .../com/qoder/cloudagents/ForwardClient.java | 14 + .../com/qoder/cloudagents/core/JsonValue.java | 4 + .../core/internal/ClientEngine.java | 16 + .../core/internal/SearchRequests.java | 77 ++ .../core/internal/SessionMetadata.java | 32 + .../forward/AsyncChannelPairings.java | 99 +++ .../cloudagents/forward/AsyncDecisions.java | 112 +++ .../cloudagents/forward/AsyncDreams.java | 157 +++++ .../forward/AsyncEnvironments.java | 43 ++ .../qoder/cloudagents/forward/AsyncFiles.java | 42 ++ .../forward/AsyncIdentityMemoryStores.java | 46 +- .../cloudagents/forward/AsyncSessions.java | 2 +- .../cloudagents/forward/AsyncSkills.java | 42 ++ .../cloudagents/forward/AsyncVaults.java | 42 ++ .../forward/ChannelPairingListParams.java | 76 ++ .../forward/ChannelPairingListResponse.java | 29 + .../forward/ChannelPairingUpdateParams.java | 56 ++ .../cloudagents/forward/ChannelPairings.java | 67 ++ .../qoder/cloudagents/forward/Decision.java | 51 ++ .../cloudagents/forward/DecisionAnswer.java | 32 + .../cloudagents/forward/DecisionChoice.java | 58 ++ .../cloudagents/forward/DecisionContext.java | 39 ++ .../forward/DecisionCreateParams.java | 65 ++ .../cloudagents/forward/DecisionError.java | 23 + .../cloudagents/forward/DecisionLevel.java | 53 ++ .../forward/DecisionListParams.java | 56 ++ .../forward/DecisionListResponse.java | 28 + .../cloudagents/forward/DecisionQuestion.java | 78 +++ .../qoder/cloudagents/forward/Decisions.java | 84 +++ .../com/qoder/cloudagents/forward/Dream.java | 59 ++ .../forward/DreamCreateParams.java | 63 ++ .../qoder/cloudagents/forward/DreamError.java | 27 + .../qoder/cloudagents/forward/DreamInput.java | 77 ++ .../cloudagents/forward/DreamListParams.java | 56 ++ .../forward/DreamListResponse.java | 27 + .../cloudagents/forward/DreamModelConfig.java | 86 +++ .../cloudagents/forward/DreamOutput.java | 27 + .../qoder/cloudagents/forward/DreamUsage.java | 31 + .../com/qoder/cloudagents/forward/Dreams.java | 115 +++ .../forward/EnvironmentSearchParams.java | 118 ++++ .../cloudagents/forward/Environments.java | 33 + .../cloudagents/forward/FileSearchParams.java | 102 +++ .../com/qoder/cloudagents/forward/Files.java | 32 + .../qoder/cloudagents/forward/Identity.java | 4 + .../forward/IdentityCreateParams.java | 8 + .../IdentityMemoryStoreListParams.java | 52 ++ .../IdentityMemoryStoreListResponse.java | 92 ++- .../IdentityMemoryStoreMountParams.java | 8 + .../forward/IdentityMemoryStores.java | 26 + .../forward/IdentityUpdateParams.java | 8 + .../cloudagents/forward/OutcomeRubric.java | 20 +- .../qoder/cloudagents/forward/Schedule.java | 4 + .../forward/ScheduleCreateParams.java | 8 + .../cloudagents/forward/ScheduleRun.java | 4 + .../forward/ScheduleRunRetrieveParams.java | 4 + .../forward/ScheduleUpdateParams.java | 8 + .../cloudagents/forward/SessionEvent.java | 25 + .../forward/SkillSearchParams.java | 102 +++ .../com/qoder/cloudagents/forward/Skills.java | 32 + .../forward/VaultSearchParams.java | 102 +++ .../com/qoder/cloudagents/forward/Vaults.java | 32 + .../ForwardPaginationParityTest.java | 351 ++++++++++ .../qoder/cloudagents/ForwardParityTest.java | 658 ++++++++++++++++++ .../com/qoder/cloudagents/SessionsTest.java | 13 +- src/test/resources/forward-parity-routes.json | 232 ++++++ src/test/resources/session-input-events.json | 2 +- 76 files changed, 4600 insertions(+), 43 deletions(-) create mode 100644 docs/forward-parity.md create mode 100644 examples/src/main/java/com/qoder/cloudagents/examples/ForwardCapabilitiesExample.java create mode 100644 examples/src/test/java/com/qoder/cloudagents/examples/ForwardCapabilitiesConsumerTest.java create mode 100644 src/main/java/com/qoder/cloudagents/core/internal/SearchRequests.java create mode 100644 src/main/java/com/qoder/cloudagents/core/internal/SessionMetadata.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/AsyncDecisions.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/AsyncDreams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/ChannelPairingListParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/ChannelPairingListResponse.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/ChannelPairingUpdateParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/Decision.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionAnswer.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionChoice.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionContext.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionCreateParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionError.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionLevel.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionListParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionListResponse.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DecisionQuestion.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/Decisions.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/Dream.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamCreateParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamError.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamInput.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamListParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamListResponse.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamModelConfig.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamOutput.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/DreamUsage.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/Dreams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/EnvironmentSearchParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/FileSearchParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/SkillSearchParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/VaultSearchParams.java create mode 100644 src/test/java/com/qoder/cloudagents/ForwardPaginationParityTest.java create mode 100644 src/test/java/com/qoder/cloudagents/ForwardParityTest.java create mode 100644 src/test/resources/forward-parity-routes.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b35bee5..61c0b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +- Add typed synchronous/asynchronous Forward resource Search for Environments, Files, Skills and Vaults, retaining body filters, Identity query scope and Beta selectors across cursor pages. +- Add Forward Dream create/list/retrieve/cancel/archive, Channel Pairing list/retrieve/update, and Decision create/retrieve/list with typed input, output and precise score values. +- Add Identity auto-memory configuration, independent mount access and pagination, Schedule file resources, optional Run input snapshots, and Credits accessors for Session events. +- Mark typed Session creation with `__qca_sdk_created`, `__qca_sdk_language` and the actual package version; preserve custom metadata and reject conflicting reserved values before HTTP. + + - Add Forward Webhook endpoint creation, listing, retrieval, partial update, deletion, enable/disable and synthetic test delivery. - Add raw-body webhook signature verification with HMAC-SHA256, multiple v1 signatures and a 300-second timestamp window; include receiver examples and offline contracts. - Public Java 8 HTTP transport and per-attempt interceptor contracts for custom networking and tracing, without exposing OkHttp types. Cancellation reaches downstream transport futures; SSE bodies remain streaming. Storage downloads use the transport without API credentials or interceptors. WebSockets and SA Key exchange retain their existing transports. @@ -12,8 +18,17 @@ ### Fixed +- Serialize text Outcome rubrics with the documented `content` field. Keep `text()` and `Builder.text(...)` as deprecated aliases for content. + + - Add typed image `file_id`, Memory Store creation-mount `access`, Schedule/Run `initial_events_version` and Pairing `created_at` / `updated_at` fields. Missing versions remain unknown rather than becoming zero. +### Migration + +- Prefer `OutcomeRubric.content()` / `Builder.content(...)`; raw JSON callers must replace the old rubric `text` key with `content`. +- Typed Session creation now adds three metadata entries. Do not use different values/types for these exact reserved keys, and allow for them within the service metadata limit. Other metadata, including future prefixed keys, remains unchanged. + + ## [0.1.0] - 2026-10-21 First stable release of the Java SDK for the Forward API, using `com.qoder.sdk:qoder-cloud-agents-sdk:0.1.0`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fef02e8..3addee3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,9 +42,9 @@ When changing transport dependencies, recheck hidden retries, redirects, cancell 4. Add independent tests; update coverage, examples and `CHANGELOG.md`. 5. Verify the packaged consumer. Live tests need a selected environment and isolated resources. -Template, Identity/Config, Identity Memory Store mounts, Session HTTP operations, Memory Store/Memory/Version, Environment/File, Skill/Version, Vault/Credential, Schedule/Run, Batch/Task and baseline Channel/Pairing/QR are implemented. The additional Pairing list/retrieve/update operations are outside the current shared SDK baseline; keep the scope recorded in the coverage document. Usage, Forward SAT management and Drive are now implemented. Session/Thread SSE and Realtime are implemented; use the opt-in live runner to verify behavior in a selected environment. +Template, Identity/Config, Identity Memory Store mounts, Session HTTP operations, Memory Store/Memory/Version, Environment/File, Skill/Version, Vault/Credential, Schedule/Run, Batch/Task and baseline Channel/Pairing/QR are implemented. Pairing list/retrieve/update, resource Search, Dreams and Decisions are also implemented in the development SDK; keep the operation inventory and official contract links current. Usage, Forward SAT management and Drive are now implemented. Session/Thread SSE and Realtime are implemented; use the opt-in live runner to verify behavior in a selected environment. -Nested configuration objects implement `JsonSerializable`; do not rely on Jackson bean conventions to serialize them. Request builders snapshot supplied collections and distinguish omitted fields from explicit null. Identity overrides also preserve null entries inside sparse maps. Reuse `CursorRequests` / `CursorPage` for cursor endpoints; do not apply this scheme to endpoints with different pagination contracts. Mount listing is a single response even though it contains `has_more`. Session and Thread histories have different allowed selectors. Keep header-only options (such as idempotency and beta headers) in `RequestOptions`. Fixtures include deliberately unknown values to verify forward compatibility; they are not a claim that the service accepts those values. +Nested configuration objects implement `JsonSerializable`; do not rely on Jackson bean conventions to serialize them. Request builders snapshot supplied collections and distinguish omitted fields from explicit null. Identity overrides also preserve null entries inside sparse maps. Reuse `CursorRequests` / `CursorPage` for cursor endpoints; do not apply this scheme to endpoints with different pagination contracts. Mount listing uses cursor pagination while retaining its existing boxed `hasMore()` signature. POST Search uses `SearchRequests` to retain filters in the JSON body; its Identity selector stays in query. Session and Thread histories have different allowed selectors. Keep header-only options (such as idempotency and beta headers) in `RequestOptions`. Fixtures include deliberately unknown values to verify forward compatibility; they are not a claim that the service accepts those values. Memory Store creation requires an Idempotency-Key supplied in request options. Memory updates preserve the supplied content hash and whole-map metadata replacement; a 409 must not trigger an automatic overwrite. Memory content can be an empty string, while list responses omit it and redacted versions return null. Keep version redaction, Memory deletion, Store archive/deletion and mount detachment as distinct operations. diff --git a/README.md b/README.md index fdc8eae..53e83aa 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Version **0.1.0** supports the **Forward API**. **Managed API support is planned See [API coverage and resource guides](docs/forward-api-coverage.md) for the supported operations. +The development branch also includes [Forward Search, Dreams, Decisions, pairing management and contract updates](docs/forward-parity.md), alongside Webhooks and client extensions. These additions are unreleased and are not included in the published `0.1.0` JAR. + - [Installation](#installation) - [Quick start](#quick-start) - [PAT and Service Account authentication](#pat-and-service-account-authentication) diff --git a/docs/forward-api-coverage.md b/docs/forward-api-coverage.md index fe067ef..ed7fa7b 100644 --- a/docs/forward-api-coverage.md +++ b/docs/forward-api-coverage.md @@ -1,6 +1,6 @@ # Forward API 覆盖基线 -更新:2026-10-18。状态:**已实现并离线验证 Model 列表、Template、Identity / Config / Memory Store 挂载,以及 Session 普通 HTTP、Memory Store / Memory / Version、Environment / File、Skill / Version、Vault / Credential、Schedule / Run、Batch / Task、基线 Channel / Pairing / QR 接口,以及 Usage、Forward SAT 管理、Drive、Session/Thread SSE、Realtime 创建和 History,共 123 个 HTTP 操作及 Realtime WebSocket**。这是一份首版范围清单;真实环境验收方式和覆盖边界见 [真实环境测试](live-testing.md)。 +更新:2026-10-28。开发分支覆盖 **146 个 HTTP 操作及 Realtime WebSocket**:首版 123 项、未发布 Webhook 8 项、本轮 Forward Search / Dream / Pairing / Decision 15 项。公共 Maven Central 仍为 0.1.0;新增内容尚未发布。离线验证与真实环境验收分别记录,不能把操作覆盖数当作线上通过率。 HTTP BaseURL 为 CN `https://api.qoder.com.cn/api/v1/forward` 或 Global `https://api.qoder.com/api/v1/forward`;表内路径均相对于 BaseURL。 @@ -166,10 +166,9 @@ HTTP BaseURL 为 CN `https://api.qoder.com.cn/api/v1/forward` 或 Global `https: | SA Credential | `ServiceAccountCredential` | 平台 Token 置换、缓存、按需续期,与 Forward SAT 管理 API 分开 | 已实现 | | 同步/异步与响应元信息 | `ForwardClient / AsyncForwardClient / withResponse` | 同一协议核心;取消和资源所有权明确;保留 request ID | 已实现 | -## 当前版本未提供 +## 当前开发分支未提供 - Managed API:计划在后续版本支持,当前 0.1.0 尚未提供。 -- 资源 Search、Forward Dream。 - Realtime Template 专用配置 GET/POST;普通 Template 的 managed_tool_config 已实现。 - Agent 运行时内部工具调用和免 Token 的内部服务认证模式。 - 新增 Drive 本地文件同步框架、采集/播放音频设备实现。 @@ -182,18 +181,36 @@ HTTP BaseURL 为 CN `https://api.qoder.com.cn/api/v1/forward` 或 Global `https: 维护检查见[维护指南](maintaining.md),线上验证入口见[真实环境测试](live-testing.md)。 -## 本轮发现的基线外补充项 - -基线核对时的官网文档还包含以下 3 个 Pairing 管理接口;固定 SDK inventory 的 123 项中没有它们。核对 Go SDK GitHub main `735f13c8bf5888bf70b5a93b9ba89bd0e078d4b9` 也未提供这三个接口。Java 首版暂不增加,仅保留差异记录,不计入当前待实现范围。当前基线为 123/123,另已实现 Realtime WebSocket;该比例不表示官网全部接口的覆盖率。 - -| HTTP | 相对路径 | 建议 Java 入口 | 状态 | -| --- | --- | --- | --- | -| GET | `/channel_pairings` | `channelPairings().list(...)` | 暂不增加,当前范围外 | -| GET | `/channel_pairings/{pairing_id}` | `channelPairings().retrieve(...)` | 暂不增加,当前范围外 | -| POST | `/channel_pairings/{pairing_id}` | `channelPairings().update(...)` | 暂不增加,当前范围外 | - -后续若决定纳入,再重新核对参数、权限和分页并更新总基线;不能将“完成配对”视为已实现以上三个管理操作。 - -## Next release: Webhooks - -Eight Webhook endpoint operations extend the original 123-operation baseline to **131 HTTP operations**. Sync/async methods, request metadata, cursor pagination and raw-body signature verification are implemented. See [Webhook guide](webhooks.md) for paths, authentication and validation boundaries. This is separate from the three deferred Pairing operations. +## 未发布的 Webhooks 与 Forward 补齐 + +Webhook 的 8 个操作已实现同步/异步、分页、原始请求体验签及示例,详见 [Webhook 指南](webhooks.md)。本轮补齐之前记录为范围外的三个 Pairing 操作,以及 Search、Dream 和 Decision。 + +| # | HTTP | 相对路径 | Java 入口 | +| ---: | --- | --- | --- | +| 124 | `POST` | `/webhook/endpoints` | `webhooks().endpoints().create(...)` | +| 125 | `GET` | `/webhook/endpoints` | `webhooks().endpoints().list(...)` | +| 126 | `GET` | `/webhook/endpoints/{endpoint_id}` | `webhooks().endpoints().retrieve(...)` | +| 127 | `PUT` | `/webhook/endpoints/{endpoint_id}` | `webhooks().endpoints().update(...)` | +| 128 | `DELETE` | `/webhook/endpoints/{endpoint_id}` | `webhooks().endpoints().delete(...)` | +| 129 | `POST` | `/webhook/endpoints/{endpoint_id}/enable` | `webhooks().endpoints().enable(...)` | +| 130 | `POST` | `/webhook/endpoints/{endpoint_id}/disable` | `webhooks().endpoints().disable(...)` | +| 131 | `POST` | `/webhook/endpoints/{endpoint_id}/test` | `webhooks().endpoints().test(...)` | +| 132 | `POST` | `/environments/search` | `environments().search(...)` | +| 133 | `POST` | `/skills/search` | `skills().search(...)` | +| 134 | `POST` | `/vaults/search` | `vaults().search(...)` | +| 135 | `POST` | `/files/search` | `files().search(...)` | +| 136 | `POST` | `/dreams` | `dreams().create(...)` | +| 137 | `GET` | `/dreams` | `dreams().list(...)` | +| 138 | `GET` | `/dreams/{id}` | `dreams().retrieve(...)` | +| 139 | `POST` | `/dreams/{id}/cancel` | `dreams().cancel(...)` | +| 140 | `POST` | `/dreams/{id}/archive` | `dreams().archive(...)` | +| 141 | `GET` | `/channel_pairings` | `channelPairings().list(...)` | +| 142 | `GET` | `/channel_pairings/{pairing_id}` | `channelPairings().retrieve(...)` | +| 143 | `POST` | `/channel_pairings/{pairing_id}` | `channelPairings().update(...)` | +| 144 | `POST` | `/decisions` | `decisions().create(...)` | +| 145 | `GET` | `/decisions` | `decisions().list(...)` | +| 146 | `GET` | `/decisions/{decision_id}` | `decisions().retrieve(...)` | + +最新公开契约及字段迁移说明见 [Forward 补齐指南](forward-parity.md)。固定官方来源为 2026-10-28 文档快照;Go/Python/TS 0.13.0 作为行为对照,不把其扩展字段自动视为官方契约。 + +本轮独立请求 fixture 位于 `src/test/resources/forward-parity-routes.json`,覆盖 15 个操作的同步/异步及 withResponse 入口。`ForwardParityTest`、`ForwardPaginationParityTest` 验证字段、metadata 冲突、精度、分页、幂等重试与取消。`ForwardCapabilitiesConsumerTest` 通过独立 examples 工程消费实际 JAR。真实环境和 QA 包尚须按 [验收指南](live-testing.md) 执行;目前没有这些新增功能的线上通过声明。 diff --git a/docs/forward-parity.md b/docs/forward-parity.md new file mode 100644 index 0000000..2fca12d --- /dev/null +++ b/docs/forward-parity.md @@ -0,0 +1,117 @@ +# Forward API additions (unreleased) + +This guide describes the development SDK after `0.1.0`, checked against the official Forward documentation on 2026-10-28. These additions are not available from the published `0.1.0` JAR. Both `ForwardClient` and `AsyncForwardClient` expose the same resources, including `withResponse()` for status, headers and request IDs. Managed remains a separate future scope. + +## Search resources + +Environment, File, Skill and Vault services provide `search()` and `search(params, options)`. Search uses POST with a JSON object, including `{}` when no filters are specified. The SDK adds `x-qoder-beta: search-2026-08-31`, preserving other effective Beta selectors. + +```java +FileListResponse page = client.files().search(FileSearchParams.builder() + .name("report") + .metadata(java.util.Collections.singletonMap("project", "demo")) + .limit(20) + .build()); +for (FileMetadata file : page) { // Lazy iteration across pages. + System.out.println(file.id()); +} +``` + +Filters stay in the body when paging. The SDK replays `next_page` unchanged and detects repeated cursors. Request options, authentication and the selected Identity scope are retained; asynchronous continuation can be cancelled. + +| Service | Additional typed filters | +| --- | --- | +| `environments()` | `name`, `includeArchived`, `createdAtGte`, `createdAtLte` (RFC 3339 strings) | +| `files()` | `name`, `scopeId` | +| `skills()` | `source`, `displayTitle` | +| `vaults()` | `name`, `includeArchived` | + +All four support `metadata`, `limit` and `page`. The Environment timestamp keys on the wire are literally `created_at[gte]` and `created_at[lte]`. Search does not use the GET list method's query filters. + +For PAT access to an Identity-owned resource, set `params.identityId(id)`; it goes into the **query**, never the JSON body. A per-call `RequestOptions.query("identity_id", id)` overrides this value. For any SAT, omit the selector and use the token's ownership scope. The service enforces permissions; the SDK does not infer ownership or change authentication. + +Official contracts: [Environment](https://docs.qoder.com/cloud-agents/api/forward/environments/search), [File](https://docs.qoder.com/cloud-agents/api/forward/files/search), [Skill](https://docs.qoder.com/cloud-agents/api/forward/skills/search), [Vault](https://docs.qoder.com/cloud-agents/api/forward/vaults/search). + +## Dreams + +`client.dreams()` exposes `create`, `list`, `retrieve`, `cancel` and `archive`. Creation accepts PAT or administrator SAT, not Identity SAT. + +```java +Dream dream = client.dreams().create(DreamCreateParams.builder() + .inputs(java.util.Collections.singletonList(DreamInput.builder() + .type("memory_store").memoryStoreId("memstore_example").build())) + .build()); +Dream current = client.dreams().retrieve(dream.id()); +``` + +Inputs contain exactly one primary `memory_store` or `identity_template` input; additional `sessions` inputs can focus the consolidation. A Memory Store input creates a new output Store and does not replace the original. An Identity/Template input may replace that pair's default Store when it completes. Creation executes work and can consume credits. + +Omitting `model`, passing null, or using `DreamModelConfig.of("")` leaves the documented automatic model routing to the server. Use `DreamModelConfig.of("auto")` or a structured builder with `id`, `effort` and `contextWindow`; Dream models do not expose Template's `speed`. Responses expose structured model configuration, inputs, output Store IDs and touched paths, token usage, errors and lifecycle timestamps. Canceling execution and archiving a record are separate operations. + +See [Dream creation](https://docs.qoder.com/cloud-agents/api/forward/dreams/create) and [schemas](https://docs.qoder.com/cloud-agents/api/forward/dreams/schemas). + +## Pairing management + +`channelPairings().list(...)`, `.retrieve(pairingId)` and `.update(pairingId, params)` supplement the existing create/delete methods. Lists support Channel, status, target type/external ID, Identity/Template, limit and bidirectional cursors. Target filters use `user` / `group`; response scope types use `direct` / `room`. + +```java +ChannelPairing pairing = client.channelPairings().update("pair_example", + ChannelPairingUpdateParams.builder().templateId("tmpl_new").build()); +``` + +Update changes the Identity and/or Template of an **active** binding. At least one nonblank field is required; omitted fields stay unchanged and null is not supported. Use existing `create` with a code or pairing ID to complete a pending pairing. Updating a binding changes how subsequent channel messages execute; default examples only list existing bindings. + +See [list](https://docs.qoder.com/cloud-agents/api/forward/channels/list-channel-pairings), [retrieve](https://docs.qoder.com/cloud-agents/api/forward/channels/get-channel-pairing), and [update](https://docs.qoder.com/cloud-agents/api/forward/channels/update-channel-pairing). + +## Decisions (Beta) + +`decisions().create(...)` evaluates questions synchronously and stores the result; `retrieve` and cursor-based `list` read records. Supply exactly one of a nonempty `state` object or a `sessionId`, and at least one question. `state.session_context` is reserved. + +```java +Decision result = client.decisions().create(DecisionCreateParams.builder() + .state(java.util.Collections.singletonMap("resolved", JsonValue.of(true))) + .questions(java.util.Collections.singletonList(DecisionQuestion.builder() + .type("choice").instructions("Is resolved true in the supplied state?") + .choices(java.util.Arrays.asList( + DecisionChoice.builder().value(true).build(), + DecisionChoice.builder().value(false).build())) + .build())) + .build(), RequestOptions.builder().idempotencyKey("one-logical-decision").build()); +``` + +Choice values preserve their string or boolean JSON type. Score levels and answer scores use `BigDecimal`. Response context, errors and unknown fields remain readable; optional fields may be absent on running or failed records. `completed` means judgment finished, not that the business objective succeeded; inspect the answers. + +The service binds an idempotency key to the exact request bytes. SDK retries retain those bytes. Reuse the original key and immutable parameters when reconciling an uncertain result; use a new key for a new judgment. HTTP 409 conflicts are not automatically retried. Without a key, a failed POST is not automatically retried on connection/5xx errors. See [official Decision semantics](https://docs.qoder.com/cloud-agents/api/forward/decisions/create). + +## Existing contract updates + +- Outcome text rubrics use `OutcomeRubric.content(...)` / `content()`, serialized as `{"type":"text","content":"..."}`. The deprecated `text(...)` / `text()` methods delegate to content; raw `fromJson` remains literal, so migrate raw JSON's old `text` key yourself. File rubrics continue to use `fileId`. The server generates the Outcome ID. +- Identity create/update/response expose `autoMemoryStore`. Create omission/null means the server default `true`; update omission/null leaves the value unchanged. `false` does not delete existing stores or mounts. +- Independent Memory Store mounting accepts `access`. Omission retains the permission on an existing mount and defaults a new mount to `read_only`. Mount lists support `IdentityMemoryStoreListParams.limit/afterId/beforeId` and lazy iteration / asynchronous paging. The original boxed `Boolean hasMore()` signature is preserved. The default Store remains first, followed by server-ordered explicit mounts. +- Schedule create/update/read expose `resources` using `SessionResourceSpec`. Update omission preserves the configuration; null or `[]` clears it according to the service contract. Resources are separate from inherited Template/Identity files and do not change `initial_events_version`. +- `ScheduleRunRetrieveParams.includeInitialEvents(true)` requests the bound input snapshot. `ScheduleRun.initialEvents()` is null when absent and an empty list when the server explicitly returns `[]`; `field("initial_events")` distinguishes missing from explicit null. +- `SessionEvent.modelCredits/sandboxRuntimeCredits/totalCredits` read top-level cumulative `session.usage` credits; `modelUsageCredits()` reads incremental model request credits and `usageCredits()` reads compaction credits. These are exact decimals. Replace cumulative snapshots; do not add compaction credits to the total again. Existing raw Map accessors remain available. + +## Session creation metadata + +Typed `sessions().create(...)` automatically merges three string-valued metadata fields: + +| Key | Value | +| --- | --- | +| `__qca_sdk_created` | `"true"` | +| `__qca_sdk_language` | `"java"` | +| `__qca_sdk_version` | Version from the actual Java SDK JAR | + +This applies to synchronous, asynchronous and `withResponse()` creation. Missing or null metadata becomes an object containing the markers. An already-present marker with the same string value is allowed; a different value or type throws `ConfigurationException` before sending HTTP. Other keys, including unrecognized `__qca_sdk_` keys, are preserved and caller maps are not modified. These three entries count toward the service metadata limit. Metadata updates, generic `request(...)` calls, Realtime creation and Forward's downstream Managed calls are not rewritten. + +## Verification and executable example + +[ForwardCapabilitiesExample](../examples/src/main/java/com/qoder/cloudagents/examples/ForwardCapabilitiesExample.java) consumes the installed JAR. Its default `search` mode reads one page of files; `pairings` only reads. `decision` and `dream` require both `QODER_ALLOW_WRITE=true` and `QODER_ALLOW_EXECUTION=true`. Dream also requires `QODER_MEMORY_STORE_ID`; its task/output are retained for inspection. For SAT searches, leave `QODER_IDENTITY_ID` unset. + +```sh +./mvnw -B -ntp -f examples/pom.xml exec:java \ + -Dexec.mainClass=com.qoder.cloudagents.examples.ForwardCapabilitiesExample \ + -Dexec.args='search report' +``` + +Offline request fixtures cover all 15 new operations through sync/async and response wrappers, plus pagination, metadata conflicts, exact numeric values and cancellation. The independent consumer tests verify the packaged API surface. Real CN/Global PAT/SA verification and QA-package acceptance remain separate gates; offline mocks do not establish real channel pairing, Dream execution or service availability. See [live testing](live-testing.md). diff --git a/docs/sessions.md b/docs/sessions.md index 0c0db1f..5b040dd 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -68,7 +68,7 @@ System.out.println(accepted.requestId()); The development SDK adds `ImageSource.builder().type("file").fileId(fileId)` and `fileId()` for images referenced by file ID. These typed methods are not included in the published `0.1.0` JAR; the service validates the image source and file permissions. -`SessionEventParam` also exposes tool confirmation/result fields, custom tool result IDs, interrupt reason and outcome definition. `OutcomeRubric` is an object with a type and text/file ID, matching the documented outcome contract. Event-specific validation and permissions remain server-side. +`SessionEventParam` also exposes tool confirmation/result fields, custom tool result IDs, interrupt reason and outcome definition. `OutcomeRubric` uses `content` for `type="text"` and `fileId` for `type="file"`. The deprecated `text` setter now writes `content`; migrate raw JSON explicitly. Event-specific validation and permissions remain server-side. A successful send acknowledges accepted input; it does not mean the model finished. Reading history returns persisted events, not a live subscription: @@ -146,3 +146,5 @@ See the [isolated lifecycle example](../examples/README.md#session-lifecycle-and Implementation follows the [pinned SDK baseline](forward-api-coverage.md). Newer documented fields such as Session search, usage breakdown, outcomes and Thread stats are included without changing wire routes. Default tests cover local protocol behavior and independent JAR consumers. Session/Thread SSE and resumable subscriptions are described in [streaming](session-streaming.md); the [live runner](live-testing.md) separately checks actual Session execution. + +See [Forward contract updates](forward-parity.md) for automatic Session metadata markers, Credits event accessors, and Memory Store mount pagination. diff --git a/examples/README.md b/examples/README.md index 93517a6..e894ca2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -244,3 +244,7 @@ Default verification uses mock services. The opt-in [live runner](../docs/live-t See [the opt-in live runner](../docs/live-testing.md) for the `read`, `resources`, `session`, `realtime-text`, `realtime-pcm`, `schedule`, `batch`, `usage`, `sa-tokens` and `realtime-work` groups. The dedicated Schedule/Batch checks wait for completion and validate output and Session history; the smaller lifecycle examples above intentionally return earlier. This runner consumes the installed JAR and is never launched by the default Maven test lifecycle. Forward Webhook listing and receiver verification: see [Webhook guide](../docs/webhooks.md). The example does not create subscriptions or emit test deliveries. + +## Forward additions (development JAR) + +[ForwardCapabilitiesExample](src/main/java/com/qoder/cloudagents/examples/ForwardCapabilitiesExample.java) demonstrates resource Search, Decision creation, Dream creation and Pairing listing. See [parameters, opt-in flags and side effects](../docs/forward-parity.md#verification-and-executable-example). It uses the installed development JAR; these APIs are not in Maven Central `0.1.0`. diff --git a/examples/src/main/java/com/qoder/cloudagents/examples/ForwardCapabilitiesExample.java b/examples/src/main/java/com/qoder/cloudagents/examples/ForwardCapabilitiesExample.java new file mode 100644 index 0000000..3772437 --- /dev/null +++ b/examples/src/main/java/com/qoder/cloudagents/examples/ForwardCapabilitiesExample.java @@ -0,0 +1,97 @@ +package com.qoder.cloudagents.examples; + +import com.qoder.cloudagents.ForwardClient; +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.forward.*; +import java.util.*; + +/** Search is read-only; Decision and Dream creation require explicit execution opt-in. */ +public final class ForwardCapabilitiesExample { + private ForwardCapabilitiesExample() {} + + public static FileListResponse searchFiles(ForwardClient client, String name, String identityId) { + FileSearchParams.Builder params = FileSearchParams.builder().name(name).limit(5); + if (identityId != null) params.identityId(identityId); + return client.files().search(params.build()); + } + + public static Decision decide(ForwardClient client, String idempotencyKey) { + return client + .decisions() + .create( + DecisionCreateParams.builder() + .state(Collections.singletonMap("resolved", JsonValue.of(true))) + .questions( + Collections.singletonList( + DecisionQuestion.builder() + .type("choice") + .instructions("Is resolved true in the supplied state?") + .choices( + Arrays.asList( + DecisionChoice.builder().value(true).build(), + DecisionChoice.builder().value(false).build())) + .build())) + .build(), + RequestOptions.builder().idempotencyKey(idempotencyKey).build()); + } + + public static Dream createDream( + ForwardClient client, String memoryStoreId, String idempotencyKey) { + return client + .dreams() + .create( + DreamCreateParams.builder() + .inputs( + Collections.singletonList( + DreamInput.builder() + .type("memory_store") + .memoryStoreId(memoryStoreId) + .build())) + .build(), + RequestOptions.builder().idempotencyKey(idempotencyKey).build()); + } + + public static void main(String[] args) { + String action = args.length == 0 ? "search" : args[0]; + if (!Arrays.asList("search", "pairings", "decision", "dream").contains(action)) + throw new IllegalArgumentException("Use search, pairings, decision or dream"); + if (action.equals("decision") || action.equals("dream")) { + if (!"true".equals(System.getenv("QODER_ALLOW_WRITE")) + || !"true".equals(System.getenv("QODER_ALLOW_EXECUTION"))) + throw new IllegalArgumentException( + "Set QODER_ALLOW_WRITE=true and QODER_ALLOW_EXECUTION=true"); + } + String store = System.getenv("QODER_MEMORY_STORE_ID"); + if (action.equals("dream") && (store == null || store.isEmpty())) + throw new IllegalArgumentException("Set QODER_MEMORY_STORE_ID"); + try (ExampleClient setup = ExampleClient.fromEnvironment()) { + ForwardClient client = setup.client(); + if (action.equals("search")) { + for (FileMetadata file : + searchFiles( + client, + args.length > 1 ? args[1] : "report", + System.getenv("QODER_IDENTITY_ID")) + .data()) System.out.println("file=" + file.id()); + } else if (action.equals("pairings")) { + for (ChannelPairing pairing : + client + .channelPairings() + .list(ChannelPairingListParams.builder().limit(5).build()) + .data()) + System.out.println("pairing=" + pairing.id() + " status=" + pairing.status()); + } else { + String key = UUID.randomUUID().toString(); + System.out.println("idempotency_key=" + key); + if (action.equals("decision")) { + Decision decision = decide(client, key); + System.out.println("decision=" + decision.id() + " status=" + decision.status()); + } else { + Dream dream = createDream(client, store, key); + System.out.println("dream=" + dream.id() + " status=" + dream.status()); + // Keep the task and its output for inspection; do not delete or replace the input Store. + } + } + } + } +} diff --git a/examples/src/test/java/com/qoder/cloudagents/examples/ForwardCapabilitiesConsumerTest.java b/examples/src/test/java/com/qoder/cloudagents/examples/ForwardCapabilitiesConsumerTest.java new file mode 100644 index 0000000..68a5030 --- /dev/null +++ b/examples/src/test/java/com/qoder/cloudagents/examples/ForwardCapabilitiesConsumerTest.java @@ -0,0 +1,162 @@ +package com.qoder.cloudagents.examples; + +import static org.junit.jupiter.api.Assertions.*; + +import com.qoder.cloudagents.ForwardClient; +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.forward.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.*; +import org.junit.jupiter.api.*; + +@Timeout(15) +class ForwardCapabilitiesConsumerTest { + private MockWebServer server; + private ForwardClient client; + + @BeforeEach + void setup() throws Exception { + assertTrue( + ForwardClient.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .getPath() + .endsWith(".jar")); + server = new MockWebServer(); + server.start(); + client = + ForwardClient.builder() + .pat("test-pat") + .baseUrl(server.url("/api/v1/forward").toString()) + .maxRetries(0) + .build(); + } + + @AfterEach + void close() throws Exception { + client.close(); + server.close(); + } + + private void respond(String value) { + server.enqueue(new MockResponse().setBody(value)); + } + + private RecordedRequest request() throws Exception { + RecordedRequest r = server.takeRequest(2, TimeUnit.SECONDS); + assertNotNull(r); + return r; + } + + @Test + void searchAndDecisionExamplesUseTheInstalledJar() throws Exception { + respond("{\"data\":[{\"id\":\"file_test\"}],\"has_more\":false}"); + assertEquals( + "file_test", + ForwardCapabilitiesExample.searchFiles(client, "report", "idn_test").data().get(0).id()); + RecordedRequest search = request(); + assertEquals("/api/v1/forward/files/search?identity_id=idn_test", search.getPath()); + assertEquals("{\"name\":\"report\",\"limit\":5}", search.getBody().readUtf8()); + assertEquals("search-2026-08-31", search.getHeader("x-qoder-beta")); + respond( + "{\"id\":\"dec_test\",\"status\":\"completed\",\"answers\":[{\"type\":\"choice\",\"value\":true}]}"); + Decision decision = ForwardCapabilitiesExample.decide(client, "one-decision"); + assertTrue(decision.answers().get(0).value().asBoolean()); + RecordedRequest create = request(); + assertEquals("/api/v1/forward/decisions", create.getPath()); + assertEquals("one-decision", create.getHeader("Idempotency-Key")); + assertTrue( + JsonValue.parse(create.getBody().readUtf8()) + .get("questions") + .asArray() + .get(0) + .get("choices") + .asArray() + .get(0) + .get("value") + .isBoolean()); + } + + @Test + void dreamExampleAndAsyncLifecycleRemainSeparateFromTheInputStore() throws Exception { + respond("{\"id\":\"drm_test\",\"status\":\"pending\",\"model\":{\"id\":\"auto\"}}"); + Dream dream = ForwardCapabilitiesExample.createDream(client, "memstore_input", "dream-key"); + assertEquals("auto", dream.model().id()); + assertEquals( + "{\"inputs\":[{\"type\":\"memory_store\",\"memory_store_id\":\"memstore_input\"}]}", + request().getBody().readUtf8()); + respond("{\"id\":\"drm_test\",\"status\":\"canceled\"}"); + assertEquals( + "canceled", client.async().dreams().cancel(dream.id()).get(3, TimeUnit.SECONDS).status()); + assertEquals("/api/v1/forward/dreams/drm_test/cancel", request().getPath()); + respond( + "{\"id\":\"drm_test\",\"status\":\"canceled\",\"archived_at\":\"2026-10-28T00:00:00Z\"}"); + assertNotNull(client.dreams().archive(dream.id()).archivedAt()); + assertEquals("/api/v1/forward/dreams/drm_test/archive", request().getPath()); + assertEquals(3, server.getRequestCount()); + } + + @Test + void sessionProvenanceAndContractFieldsReachTheWireFromTheJar() throws Exception { + Map metadata = new LinkedHashMap<>(); + metadata.put("ticket", JsonValue.of("consumer")); + SessionCreateParams params = + SessionCreateParams.builder().identityId("i").templateId("t").metadata(metadata).build(); + respond("{\"id\":\"sess_test\"}"); + client.async().sessions().withResponse().create(params).get(3, TimeUnit.SECONDS); + RecordedRequest created = request(); + JsonValue markers = JsonValue.parse(created.getBody().readUtf8()).get("metadata"); + assertEquals("java", markers.get("__qca_sdk_language").asString()); + assertEquals("true", markers.get("__qca_sdk_created").asString()); + assertEquals( + created.getHeader("X-Qoder-Package-Version"), markers.get("__qca_sdk_version").asString()); + assertEquals(1, metadata.size()); + assertEquals(1, params.metadata().size()); + respond("{\"data\":[]}"); + client + .sessions() + .events() + .send( + "sess_test", + SessionEventSendParams.builder() + .events( + Collections.singletonList( + SessionEventParam.builder() + .type("user.define_outcome") + .description("Return done") + .rubric( + OutcomeRubric.builder() + .type("text") + .content("Reply is done") + .build()) + .build())) + .build()); + JsonValue rubric = + JsonValue.parse(request().getBody().readUtf8()) + .get("events") + .asArray() + .get(0) + .get("rubric"); + assertEquals("Reply is done", rubric.get("content").asString()); + assertTrue(rubric.get("text").isMissing()); + respond("{\"auto_memory_store\":false}"); + assertFalse( + client + .identities() + .update("i", IdentityUpdateParams.builder().autoMemoryStore(false).build()) + .autoMemoryStore()); + assertEquals("{\"auto_memory_store\":false}", request().getBody().readUtf8()); + respond("{\"resources\":[]}"); + assertTrue( + client + .schedules() + .update( + "schedule_test", + ScheduleUpdateParams.builder().resources(Collections.emptyList()).build()) + .resources() + .isEmpty()); + assertEquals("{\"resources\":[]}", request().getBody().readUtf8()); + } +} diff --git a/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java b/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java index 7b26947..fced7da 100644 --- a/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java +++ b/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java @@ -6,6 +6,8 @@ import com.qoder.cloudagents.forward.AsyncBatches; import com.qoder.cloudagents.forward.AsyncChannelPairings; import com.qoder.cloudagents.forward.AsyncChannels; +import com.qoder.cloudagents.forward.AsyncDecisions; +import com.qoder.cloudagents.forward.AsyncDreams; import com.qoder.cloudagents.forward.AsyncDrives; import com.qoder.cloudagents.forward.AsyncEnvironments; import com.qoder.cloudagents.forward.AsyncFiles; @@ -28,6 +30,8 @@ /** Native asynchronous Forward client. Build with ForwardClient.builder().buildAsync(). */ public final class AsyncForwardClient implements AutoCloseable { + private final AsyncDecisions decisions; + private final AsyncDreams dreams; private final AsyncWebhooks webhooks; private final AsyncUsage usage; private final AsyncServiceAccountTokens serviceAccountTokens; @@ -60,6 +64,8 @@ public final class AsyncForwardClient implements AutoCloseable { usage = new AsyncUsage(engine); webhooks = new AsyncWebhooks(engine); this.ownsEngine = ownsEngine; + dreams = new AsyncDreams(engine); + decisions = new AsyncDecisions(engine); models = new AsyncModels(engine); files = new AsyncFiles(engine); channelPairings = new AsyncChannelPairings(engine); @@ -76,6 +82,14 @@ public final class AsyncForwardClient implements AutoCloseable { memoryStores = new AsyncMemoryStores(engine); } + public AsyncDecisions decisions() { + return decisions; + } + + public AsyncDreams dreams() { + return dreams; + } + public AsyncWebhooks webhooks() { return webhooks; } diff --git a/src/main/java/com/qoder/cloudagents/ForwardClient.java b/src/main/java/com/qoder/cloudagents/ForwardClient.java index 7f9f667..934af3b 100644 --- a/src/main/java/com/qoder/cloudagents/ForwardClient.java +++ b/src/main/java/com/qoder/cloudagents/ForwardClient.java @@ -15,6 +15,8 @@ import com.qoder.cloudagents.forward.Batches; import com.qoder.cloudagents.forward.ChannelPairings; import com.qoder.cloudagents.forward.Channels; +import com.qoder.cloudagents.forward.Decisions; +import com.qoder.cloudagents.forward.Dreams; import com.qoder.cloudagents.forward.Drives; import com.qoder.cloudagents.forward.Environments; import com.qoder.cloudagents.forward.Files; @@ -43,6 +45,8 @@ * Thread-safe Forward client. Close it after its synchronous and asynchronous views are finished. */ public final class ForwardClient implements AutoCloseable { + private final Decisions decisions; + private final Dreams dreams; private final Webhooks webhooks; private final Usage usage; private final ServiceAccountTokens serviceAccountTokens; @@ -71,6 +75,8 @@ private ForwardClient(ClientEngine engine, Builder configuration) { this.engine = engine; realtime = new Realtime(engine); async = new AsyncForwardClient(engine, false, configuration); + dreams = new Dreams(async.dreams()); + decisions = new Decisions(async.decisions()); models = new Models(async.models()); drives = new Drives(async.drives()); serviceAccountTokens = new ServiceAccountTokens(async.serviceAccountTokens()); @@ -91,6 +97,14 @@ private ForwardClient(ClientEngine engine, Builder configuration) { memoryStores = new MemoryStores(async.memoryStores()); } + public Decisions decisions() { + return decisions; + } + + public Dreams dreams() { + return dreams; + } + public Webhooks webhooks() { return webhooks; } diff --git a/src/main/java/com/qoder/cloudagents/core/JsonValue.java b/src/main/java/com/qoder/cloudagents/core/JsonValue.java index 16e6da4..d17b9fa 100644 --- a/src/main/java/com/qoder/cloudagents/core/JsonValue.java +++ b/src/main/java/com/qoder/cloudagents/core/JsonValue.java @@ -67,6 +67,10 @@ public static JsonValue of(Object value) { return new JsonValue(MAPPER.valueToTree(value)); } + public boolean isBoolean() { + return node.isBoolean(); + } + public boolean isString() { return node.isTextual(); } diff --git a/src/main/java/com/qoder/cloudagents/core/internal/ClientEngine.java b/src/main/java/com/qoder/cloudagents/core/internal/ClientEngine.java index d919458..cd8b28b 100644 --- a/src/main/java/com/qoder/cloudagents/core/internal/ClientEngine.java +++ b/src/main/java/com/qoder/cloudagents/core/internal/ClientEngine.java @@ -19,6 +19,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -79,6 +80,21 @@ public CompletableFuture> get( return request("GET", path, query, null, true, options); } + /** Add a required beta without losing the effective caller beta selectors. */ + public RequestOptions withBeta(RequestOptions options, String required) { + if (options == null) throw new ConfigurationException("Request options must not be null"); + String current = null; + for (Map.Entry entry : defaultHeaders.entrySet()) + if (entry.getKey().equalsIgnoreCase("x-qoder-beta")) current = entry.getValue(); + for (Map.Entry entry : options.headers().entrySet()) + if (entry.getKey().equalsIgnoreCase("x-qoder-beta")) current = entry.getValue(); + Set values = new LinkedHashSet<>(); + if (current != null) + for (String value : current.split(",")) if (!value.trim().isEmpty()) values.add(value.trim()); + values.add(required); + return options.toBuilder().header("x-qoder-beta", String.join(",", values)).build(); + } + /** * Request bodies are explicit about replay safety; non-repeatable uploads must set * replayable=false. diff --git a/src/main/java/com/qoder/cloudagents/core/internal/SearchRequests.java b/src/main/java/com/qoder/cloudagents/core/internal/SearchRequests.java new file mode 100644 index 0000000..11a6559 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/core/internal/SearchRequests.java @@ -0,0 +1,77 @@ +package com.qoder.cloudagents.core.internal; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +/** POST search pagination: only the opaque page token changes in the JSON body. */ +public final class SearchRequests { + private SearchRequests() {} + + public static

CompletableFuture> search( + ClientEngine engine, + String[] path, + JsonValue body, + Map params, + RequestOptions options, + CursorRequests.PageFactory

factory) { + if (body == null || options == null) + throw new ConfigurationException("Search parameters and options must not be null"); + if (!body.get("identity_id").isMissing()) + throw new ConfigurationException("identityId belongs in the query, not the search body"); + Map query = new LinkedHashMap<>(params); + query.putAll(options.query()); + Map cursor = new LinkedHashMap<>(); + Set seen = new HashSet<>(); + JsonValue page = body.get("page"); + if (!page.isMissing() && !page.isNull()) { + cursor.put("page", page.asString()); + seen.add(page.asString()); + } + return fetch( + engine, + path.clone(), + body, + query, + engine.withBeta(options, "search-2026-08-31").toBuilder().clearQuery().build(), + cursor, + seen, + factory); + } + + private static

CompletableFuture> fetch( + ClientEngine engine, + String[] path, + JsonValue body, + Map query, + RequestOptions options, + Map cursor, + Set seen, + CursorRequests.PageFactory

factory) { + Map fields = new LinkedHashMap<>(body.asObject()); + if (cursor.containsKey("page")) fields.put("page", JsonValue.of(cursor.get("page"))); + return Futures.map( + engine.request("POST", path, query, JsonValue.of(fields), true, options), + response -> + response.map( + value -> + factory.create( + value, + response.requestId(), + cursor, + seen, + (nextCursor, nextSeen) -> + Futures.map( + fetch( + engine, + path, + body, + query, + options, + nextCursor, + nextSeen, + factory), + ApiResponse::data)))); + } +} diff --git a/src/main/java/com/qoder/cloudagents/core/internal/SessionMetadata.java b/src/main/java/com/qoder/cloudagents/core/internal/SessionMetadata.java new file mode 100644 index 0000000..1fcbb1a --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/core/internal/SessionMetadata.java @@ -0,0 +1,32 @@ +package com.qoder.cloudagents.core.internal; + +import com.qoder.cloudagents.core.JsonValue; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Reserved provenance fields for typed Session creation, without mutating caller data. */ +public final class SessionMetadata { + private SessionMetadata() {} + + public static JsonValue created(JsonValue body) { + JsonValue supplied = body.get("metadata"); + if (!supplied.isMissing() && !supplied.isNull() && !supplied.isObject()) + throw new ConfigurationException("Session metadata must be an object or null"); + Map metadata = new LinkedHashMap<>(); + if (supplied.isObject()) metadata.putAll(supplied.asObject()); + marker(metadata, "__qca_sdk_created", "true"); + marker(metadata, "__qca_sdk_language", "java"); + marker(metadata, "__qca_sdk_version", Version.CURRENT); + Map request = new LinkedHashMap<>(body.asObject()); + request.put("metadata", JsonValue.of(metadata)); + return JsonValue.of(request); + } + + private static void marker(Map metadata, String key, String expected) { + JsonValue value = metadata.get(key); + if (value != null && (!value.isString() || !expected.equals(value.asString()))) + throw new ConfigurationException("Conflicting reserved Session metadata field: " + key); + metadata.put(key, JsonValue.of(expected)); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncChannelPairings.java b/src/main/java/com/qoder/cloudagents/forward/AsyncChannelPairings.java index 92029f9..96ebf0e 100644 --- a/src/main/java/com/qoder/cloudagents/forward/AsyncChannelPairings.java +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncChannelPairings.java @@ -30,11 +30,110 @@ public CompletableFuture delete(String pairingId, Request return Futures.map(withResponse().delete(pairingId, options), ApiResponse::data); } + public CompletableFuture list(ChannelPairingListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture list( + ChannelPairingListParams params, RequestOptions options) { + return Futures.map(withResponse().list(params, options), ApiResponse::data); + } + + public CompletableFuture retrieve(String pairingId) { + return retrieve(pairingId, RequestOptions.DEFAULT); + } + + public CompletableFuture retrieve(String pairingId, RequestOptions options) { + return Futures.map(withResponse().retrieve(pairingId, options), ApiResponse::data); + } + + public CompletableFuture update( + String pairingId, ChannelPairingUpdateParams params) { + return update(pairingId, params, RequestOptions.DEFAULT); + } + + public CompletableFuture update( + String pairingId, ChannelPairingUpdateParams params, RequestOptions options) { + return Futures.map(withResponse().update(pairingId, params, options), ApiResponse::data); + } + + public CompletableFuture list() { + return list(ChannelPairingListParams.builder().build()); + } + + public CompletableFuture list(RequestOptions options) { + return list(ChannelPairingListParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public CompletableFuture> list() { + return list(ChannelPairingListParams.builder().build()); + } + + public CompletableFuture> list(RequestOptions options) { + return list(ChannelPairingListParams.builder().build(), options); + } + + public CompletableFuture> list( + ChannelPairingListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> list( + ChannelPairingListParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return CursorRequests.list( + engine, + new String[] {"channel_pairings"}, + params.query(), + options, + ChannelPairingListResponse::new); + } + + public CompletableFuture> retrieve(String pairingId) { + return retrieve(pairingId, RequestOptions.DEFAULT); + } + + public CompletableFuture> retrieve( + String pairingId, RequestOptions options) { + if (options == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "GET", + new String[] {"channel_pairings", pairingId}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new ChannelPairing(v, r.requestId()))); + } + + public CompletableFuture> update( + String pairingId, ChannelPairingUpdateParams params) { + return update(pairingId, params, RequestOptions.DEFAULT); + } + + public CompletableFuture> update( + String pairingId, ChannelPairingUpdateParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "POST", + new String[] {"channel_pairings", pairingId}, + Collections.emptyMap(), + params.toJsonValue(), + true, + options), + r -> r.map(v -> new ChannelPairing(v, r.requestId()))); + } + public CompletableFuture> create( ChannelPairingCreateParams params) { return create(params, RequestOptions.DEFAULT); diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncDecisions.java b/src/main/java/com/qoder/cloudagents/forward/AsyncDecisions.java new file mode 100644 index 0000000..850867e --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncDecisions.java @@ -0,0 +1,112 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public final class AsyncDecisions { + private final ClientEngine engine; + + public AsyncDecisions(ClientEngine engine) { + this.engine = engine; + } + + public CompletableFuture create(DecisionCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public CompletableFuture create(DecisionCreateParams params, RequestOptions options) { + return Futures.map(withResponse().create(params, options), ApiResponse::data); + } + + public CompletableFuture retrieve(String decisionId) { + return retrieve(decisionId, RequestOptions.DEFAULT); + } + + public CompletableFuture retrieve(String decisionId, RequestOptions options) { + return Futures.map(withResponse().retrieve(decisionId, options), ApiResponse::data); + } + + public CompletableFuture list(DecisionListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture list( + DecisionListParams params, RequestOptions options) { + return Futures.map(withResponse().list(params, options), ApiResponse::data); + } + + public CompletableFuture list() { + return list(DecisionListParams.builder().build()); + } + + public CompletableFuture list(RequestOptions options) { + return list(DecisionListParams.builder().build(), options); + } + + public WithResponse withResponse() { + return new WithResponse(); + } + + public final class WithResponse { + public CompletableFuture> list() { + return list(DecisionListParams.builder().build()); + } + + public CompletableFuture> list(RequestOptions options) { + return list(DecisionListParams.builder().build(), options); + } + + public CompletableFuture> create(DecisionCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> create( + DecisionCreateParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "POST", + new String[] {"decisions"}, + Collections.emptyMap(), + params.toJsonValue(), + true, + options), + r -> r.map(v -> new Decision(v, r.requestId()))); + } + + public CompletableFuture> retrieve(String decisionId) { + return retrieve(decisionId, RequestOptions.DEFAULT); + } + + public CompletableFuture> retrieve( + String decisionId, RequestOptions options) { + if (options == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "GET", + new String[] {"decisions", decisionId}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new Decision(v, r.requestId()))); + } + + public CompletableFuture> list(DecisionListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> list( + DecisionListParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return CursorRequests.list( + engine, new String[] {"decisions"}, params.query(), options, DecisionListResponse::new); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncDreams.java b/src/main/java/com/qoder/cloudagents/forward/AsyncDreams.java new file mode 100644 index 0000000..29789c4 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncDreams.java @@ -0,0 +1,157 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public final class AsyncDreams { + private final ClientEngine engine; + + public AsyncDreams(ClientEngine engine) { + this.engine = engine; + } + + public CompletableFuture create(DreamCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public CompletableFuture create(DreamCreateParams params, RequestOptions options) { + return Futures.map(withResponse().create(params, options), ApiResponse::data); + } + + public CompletableFuture list(DreamListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture list(DreamListParams params, RequestOptions options) { + return Futures.map(withResponse().list(params, options), ApiResponse::data); + } + + public CompletableFuture retrieve(String dreamId) { + return retrieve(dreamId, RequestOptions.DEFAULT); + } + + public CompletableFuture retrieve(String dreamId, RequestOptions options) { + return Futures.map(withResponse().retrieve(dreamId, options), ApiResponse::data); + } + + public CompletableFuture cancel(String dreamId) { + return cancel(dreamId, RequestOptions.DEFAULT); + } + + public CompletableFuture cancel(String dreamId, RequestOptions options) { + return Futures.map(withResponse().cancel(dreamId, options), ApiResponse::data); + } + + public CompletableFuture archive(String dreamId) { + return archive(dreamId, RequestOptions.DEFAULT); + } + + public CompletableFuture archive(String dreamId, RequestOptions options) { + return Futures.map(withResponse().archive(dreamId, options), ApiResponse::data); + } + + public CompletableFuture list() { + return list(DreamListParams.builder().build()); + } + + public CompletableFuture list(RequestOptions options) { + return list(DreamListParams.builder().build(), options); + } + + public WithResponse withResponse() { + return new WithResponse(); + } + + public final class WithResponse { + public CompletableFuture> list() { + return list(DreamListParams.builder().build()); + } + + public CompletableFuture> list(RequestOptions options) { + return list(DreamListParams.builder().build(), options); + } + + public CompletableFuture> create(DreamCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> create( + DreamCreateParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "POST", + new String[] {"dreams"}, + Collections.emptyMap(), + params.toJsonValue(), + true, + options), + r -> r.map(v -> new Dream(v, r.requestId()))); + } + + public CompletableFuture> list(DreamListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> list( + DreamListParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return CursorRequests.list( + engine, new String[] {"dreams"}, params.query(), options, DreamListResponse::new); + } + + public CompletableFuture> retrieve(String dreamId) { + return retrieve(dreamId, RequestOptions.DEFAULT); + } + + public CompletableFuture> retrieve(String dreamId, RequestOptions options) { + if (options == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "GET", new String[] {"dreams", dreamId}, Collections.emptyMap(), null, true, options), + r -> r.map(v -> new Dream(v, r.requestId()))); + } + + public CompletableFuture> cancel(String dreamId) { + return cancel(dreamId, RequestOptions.DEFAULT); + } + + public CompletableFuture> cancel(String dreamId, RequestOptions options) { + if (options == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "POST", + new String[] {"dreams", dreamId, "cancel"}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new Dream(v, r.requestId()))); + } + + public CompletableFuture> archive(String dreamId) { + return archive(dreamId, RequestOptions.DEFAULT); + } + + public CompletableFuture> archive(String dreamId, RequestOptions options) { + if (options == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return Futures.map( + engine.request( + "POST", + new String[] {"dreams", dreamId, "archive"}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new Dream(v, r.requestId()))); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncEnvironments.java b/src/main/java/com/qoder/cloudagents/forward/AsyncEnvironments.java index ec9fdb2..5e2eb39 100644 --- a/src/main/java/com/qoder/cloudagents/forward/AsyncEnvironments.java +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncEnvironments.java @@ -70,11 +70,54 @@ public CompletableFuture delete( return Futures.map(withResponse().delete(environmentId, options), ApiResponse::data); } + public CompletableFuture search(EnvironmentSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture search( + EnvironmentSearchParams params, RequestOptions options) { + return Futures.map(withResponse().search(params, options), ApiResponse::data); + } + + public CompletableFuture search() { + return search(EnvironmentSearchParams.builder().build()); + } + + public CompletableFuture search(RequestOptions options) { + return search(EnvironmentSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public CompletableFuture> search() { + return search(EnvironmentSearchParams.builder().build()); + } + + public CompletableFuture> search(RequestOptions options) { + return search(EnvironmentSearchParams.builder().build(), options); + } + + public CompletableFuture> search( + EnvironmentSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> search( + EnvironmentSearchParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return SearchRequests.search( + engine, + new String[] {"environments", "search"}, + params.toJsonValue(), + params.query(), + options, + EnvironmentListResponse::new); + } + public CompletableFuture> list() { return list(EnvironmentListParams.builder().build()); } diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncFiles.java b/src/main/java/com/qoder/cloudagents/forward/AsyncFiles.java index c2ecb94..e23b790 100644 --- a/src/main/java/com/qoder/cloudagents/forward/AsyncFiles.java +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncFiles.java @@ -66,11 +66,53 @@ public CompletableFuture download(String fileId, RequestOptions op withResponse().download(fileId, options), ApiResponse::data, r -> r.data().close()); } + public CompletableFuture search(FileSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture search( + FileSearchParams params, RequestOptions options) { + return Futures.map(withResponse().search(params, options), ApiResponse::data); + } + + public CompletableFuture search() { + return search(FileSearchParams.builder().build()); + } + + public CompletableFuture search(RequestOptions options) { + return search(FileSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public CompletableFuture> search() { + return search(FileSearchParams.builder().build()); + } + + public CompletableFuture> search(RequestOptions options) { + return search(FileSearchParams.builder().build(), options); + } + + public CompletableFuture> search(FileSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> search( + FileSearchParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return SearchRequests.search( + engine, + new String[] {"files", "search"}, + params.toJsonValue(), + params.query(), + options, + FileListResponse::new); + } + public CompletableFuture> list() { return list(FileListParams.builder().build()); } diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncIdentityMemoryStores.java b/src/main/java/com/qoder/cloudagents/forward/AsyncIdentityMemoryStores.java index cfed332..9b4171b 100644 --- a/src/main/java/com/qoder/cloudagents/forward/AsyncIdentityMemoryStores.java +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncIdentityMemoryStores.java @@ -48,11 +48,45 @@ public CompletableFuture detach( withResponse().detach(identityId, templateId, memoryStoreId, options), ApiResponse::data); } + public CompletableFuture list( + String identityId, String templateId, IdentityMemoryStoreListParams params) { + return list(identityId, templateId, params, RequestOptions.DEFAULT); + } + + public CompletableFuture list( + String identityId, + String templateId, + IdentityMemoryStoreListParams params, + RequestOptions options) { + return Futures.map( + withResponse().list(identityId, templateId, params, options), ApiResponse::data); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public CompletableFuture> list( + String identityId, String templateId, IdentityMemoryStoreListParams params) { + return list(identityId, templateId, params, RequestOptions.DEFAULT); + } + + public CompletableFuture> list( + String identityId, + String templateId, + IdentityMemoryStoreListParams params, + RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return CursorRequests.list( + engine, + new String[] {"identities", identityId, "templates", templateId, "memory_stores"}, + params.query(), + options, + IdentityMemoryStoreListResponse::new); + } + public CompletableFuture> list( String identityId, String templateId) { return list(identityId, templateId, RequestOptions.DEFAULT); @@ -60,17 +94,7 @@ public CompletableFuture> list( public CompletableFuture> list( String identityId, String templateId, RequestOptions options) { - return Futures.map( - engine.request( - "GET", - new String[] {"identities", identityId, "templates", templateId, "memory_stores"}, - Collections.emptyMap(), - null, - true, - options), - response -> - response.map( - value -> new IdentityMemoryStoreListResponse(value, response.requestId()))); + return list(identityId, templateId, IdentityMemoryStoreListParams.builder().build(), options); } public CompletableFuture> mount( diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncSessions.java b/src/main/java/com/qoder/cloudagents/forward/AsyncSessions.java index 82708e1..9ec3a13 100644 --- a/src/main/java/com/qoder/cloudagents/forward/AsyncSessions.java +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncSessions.java @@ -117,7 +117,7 @@ public CompletableFuture> create( "POST", new String[] {"sessions"}, Collections.emptyMap(), - params.toJsonValue(), + SessionMetadata.created(params.toJsonValue()), true, options), response -> response.map(value -> new Session(value, response.requestId()))); diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncSkills.java b/src/main/java/com/qoder/cloudagents/forward/AsyncSkills.java index 91d5d25..b63330b 100644 --- a/src/main/java/com/qoder/cloudagents/forward/AsyncSkills.java +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncSkills.java @@ -83,11 +83,53 @@ public CompletableFuture delete(String skillId, RequestOptions opt return Futures.map(withResponse().delete(skillId, options), ApiResponse::data); } + public CompletableFuture search(SkillSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture search( + SkillSearchParams params, RequestOptions options) { + return Futures.map(withResponse().search(params, options), ApiResponse::data); + } + + public CompletableFuture search() { + return search(SkillSearchParams.builder().build()); + } + + public CompletableFuture search(RequestOptions options) { + return search(SkillSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public CompletableFuture> search() { + return search(SkillSearchParams.builder().build()); + } + + public CompletableFuture> search(RequestOptions options) { + return search(SkillSearchParams.builder().build(), options); + } + + public CompletableFuture> search(SkillSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> search( + SkillSearchParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return SearchRequests.search( + engine, + new String[] {"skills", "search"}, + params.toJsonValue(), + params.query(), + options, + SkillListResponse::new); + } + public CompletableFuture> list() { return list(SkillListParams.builder().build()); } diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncVaults.java b/src/main/java/com/qoder/cloudagents/forward/AsyncVaults.java index e4a44ae..d0f4d77 100644 --- a/src/main/java/com/qoder/cloudagents/forward/AsyncVaults.java +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncVaults.java @@ -57,11 +57,53 @@ public CompletableFuture delete(String vaultId, RequestOptions opt return Futures.map(withResponse().delete(vaultId, options), ApiResponse::data); } + public CompletableFuture search(VaultSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture search( + VaultSearchParams params, RequestOptions options) { + return Futures.map(withResponse().search(params, options), ApiResponse::data); + } + + public CompletableFuture search() { + return search(VaultSearchParams.builder().build()); + } + + public CompletableFuture search(RequestOptions options) { + return search(VaultSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public CompletableFuture> search() { + return search(VaultSearchParams.builder().build()); + } + + public CompletableFuture> search(RequestOptions options) { + return search(VaultSearchParams.builder().build(), options); + } + + public CompletableFuture> search(VaultSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> search( + VaultSearchParams params, RequestOptions options) { + if (options == null || params == null) + throw new ConfigurationException("Request parameters and options must not be null"); + return SearchRequests.search( + engine, + new String[] {"vaults", "search"}, + params.toJsonValue(), + params.query(), + options, + VaultListResponse::new); + } + public CompletableFuture> list() { return list(VaultListParams.builder().build()); } diff --git a/src/main/java/com/qoder/cloudagents/forward/ChannelPairingListParams.java b/src/main/java/com/qoder/cloudagents/forward/ChannelPairingListParams.java new file mode 100644 index 0000000..a80bdd1 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/ChannelPairingListParams.java @@ -0,0 +1,76 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable query parameters; filters are retained when paging. */ +public final class ChannelPairingListParams { + private final Map query; + + private ChannelPairingListParams(Builder builder) { + if (builder.values.containsKey("after_id") && builder.values.containsKey("before_id")) + throw new ConfigurationException("afterId and beforeId are mutually exclusive"); + query = Collections.unmodifiableMap(new LinkedHashMap<>(builder.values)); + } + + public static Builder builder() { + return new Builder(); + } + + Map query() { + return query; + } + + public static final class Builder { + private final Map values = new LinkedHashMap<>(); + + public Builder channelId(String value) { + return put("channel_id", value); + } + + public Builder status(String value) { + return put("status", value); + } + + public Builder targetType(String value) { + return put("target_type", value); + } + + public Builder targetExternalId(String value) { + return put("target_external_id", value); + } + + public Builder identityId(String value) { + return put("identity_id", value); + } + + public Builder templateId(String value) { + return put("template_id", value); + } + + public Builder limit(int value) { + if (value < 1 || value > 100) + throw new ConfigurationException("limit must be between 1 and 100"); + return put("limit", Integer.toString(value)); + } + + public Builder afterId(String value) { + return put("after_id", value); + } + + public Builder beforeId(String value) { + return put("before_id", value); + } + + private Builder put(String key, String value) { + if (value == null) throw new ConfigurationException("Query values cannot be null"); + values.put(key, value); + return this; + } + + public ChannelPairingListParams build() { + return new ChannelPairingListParams(this); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/ChannelPairingListResponse.java b/src/main/java/com/qoder/cloudagents/forward/ChannelPairingListResponse.java new file mode 100644 index 0000000..f4a7bf5 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/ChannelPairingListResponse.java @@ -0,0 +1,29 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +/** A cursor page with lazy iteration and cancellable asynchronous page requests. */ +public final class ChannelPairingListResponse + extends CursorPage { + public ChannelPairingListResponse(JsonValue value, String requestId) { + this(value, requestId, Collections.emptyMap(), Collections.emptySet(), null); + } + + ChannelPairingListResponse( + JsonValue value, + String requestId, + Map query, + Set seen, + BiFunction, Set, CompletableFuture> + fetcher) { + super(value, requestId, query, seen, fetcher, item -> new ChannelPairing(item, requestId)); + } + + @Override + protected ChannelPairingListResponse self() { + return this; + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/ChannelPairingUpdateParams.java b/src/main/java/com/qoder/cloudagents/forward/ChannelPairingUpdateParams.java new file mode 100644 index 0000000..3bea96e --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/ChannelPairingUpdateParams.java @@ -0,0 +1,56 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class ChannelPairingUpdateParams extends JsonObject { + private ChannelPairingUpdateParams(JsonValue value) { + super(value); + } + + public static ChannelPairingUpdateParams fromJson(JsonValue value) { + return new ChannelPairingUpdateParams(value); + } + + public String identityId() { + return optional("identity_id", JsonValue::asString); + } + + public String templateId() { + return optional("template_id", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder identityId(String value) { + return put("identity_id", value); + } + + public Builder templateId(String value) { + return put("template_id", value); + } + + public ChannelPairingUpdateParams build() { + if (json().asObject().isEmpty()) + throw new ConfigurationException("At least one binding field is required"); + for (Map.Entry entry : json().asObject().entrySet()) { + if (!entry.getKey().equals("identity_id") && !entry.getKey().equals("template_id")) + throw new ConfigurationException("Unsupported pairing update field"); + JsonValue value = entry.getValue(); + if (!value.isString() + || value.asString().trim().isEmpty() + || value.asString().chars().anyMatch(Character::isISOControl)) + throw new ConfigurationException( + "Pairing binding IDs must be nonblank strings without control characters"); + } + return new ChannelPairingUpdateParams(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/ChannelPairings.java b/src/main/java/com/qoder/cloudagents/forward/ChannelPairings.java index 4b3152d..76682a1 100644 --- a/src/main/java/com/qoder/cloudagents/forward/ChannelPairings.java +++ b/src/main/java/com/qoder/cloudagents/forward/ChannelPairings.java @@ -27,11 +27,78 @@ public DeletedChannelPairing delete(String pairingId, RequestOptions options) { return Futures.await(async.delete(pairingId, options)); } + public ChannelPairingListResponse list(ChannelPairingListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public ChannelPairingListResponse list(ChannelPairingListParams params, RequestOptions options) { + return Futures.await(async.list(params, options)); + } + + public ChannelPairing retrieve(String pairingId) { + return retrieve(pairingId, RequestOptions.DEFAULT); + } + + public ChannelPairing retrieve(String pairingId, RequestOptions options) { + return Futures.await(async.retrieve(pairingId, options)); + } + + public ChannelPairing update(String pairingId, ChannelPairingUpdateParams params) { + return update(pairingId, params, RequestOptions.DEFAULT); + } + + public ChannelPairing update( + String pairingId, ChannelPairingUpdateParams params, RequestOptions options) { + return Futures.await(async.update(pairingId, params, options)); + } + + public ChannelPairingListResponse list() { + return list(ChannelPairingListParams.builder().build()); + } + + public ChannelPairingListResponse list(RequestOptions options) { + return list(ChannelPairingListParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public ApiResponse list() { + return list(ChannelPairingListParams.builder().build()); + } + + public ApiResponse list(RequestOptions options) { + return list(ChannelPairingListParams.builder().build(), options); + } + + public ApiResponse list(ChannelPairingListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public ApiResponse list( + ChannelPairingListParams params, RequestOptions options) { + return Futures.await(async.withResponse().list(params, options)); + } + + public ApiResponse retrieve(String pairingId) { + return retrieve(pairingId, RequestOptions.DEFAULT); + } + + public ApiResponse retrieve(String pairingId, RequestOptions options) { + return Futures.await(async.withResponse().retrieve(pairingId, options)); + } + + public ApiResponse update(String pairingId, ChannelPairingUpdateParams params) { + return update(pairingId, params, RequestOptions.DEFAULT); + } + + public ApiResponse update( + String pairingId, ChannelPairingUpdateParams params, RequestOptions options) { + return Futures.await(async.withResponse().update(pairingId, params, options)); + } + public ApiResponse create(ChannelPairingCreateParams params) { return create(params, RequestOptions.DEFAULT); } diff --git a/src/main/java/com/qoder/cloudagents/forward/Decision.java b/src/main/java/com/qoder/cloudagents/forward/Decision.java new file mode 100644 index 0000000..0ed958b --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/Decision.java @@ -0,0 +1,51 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable API response; unknown fields are preserved. */ +public final class Decision extends JsonModel { + public Decision(JsonValue value, String requestId) { + super(value, requestId); + } + + public String id() { + return optional("id", JsonValue::asString); + } + + public String sessionId() { + return optional("session_id", JsonValue::asString); + } + + public String source() { + return optional("source", JsonValue::asString); + } + + public String status() { + return optional("status", JsonValue::asString); + } + + public List questions() { + return optionalList("questions", DecisionQuestion::fromJson); + } + + public List answers() { + return optionalList("answers", DecisionAnswer::fromJson); + } + + public DecisionContext context() { + return optional("context", DecisionContext::fromJson); + } + + public DecisionError error() { + return optional("error", DecisionError::fromJson); + } + + public String createdAt() { + return optional("created_at", JsonValue::asString); + } + + public String completedAt() { + return optional("completed_at", JsonValue::asString); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionAnswer.java b/src/main/java/com/qoder/cloudagents/forward/DecisionAnswer.java new file mode 100644 index 0000000..34563ac --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionAnswer.java @@ -0,0 +1,32 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.math.BigDecimal; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DecisionAnswer extends JsonObject { + private DecisionAnswer(JsonValue value) { + super(value); + } + + public static DecisionAnswer fromJson(JsonValue value) { + return new DecisionAnswer(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String name() { + return optional("name", JsonValue::asString); + } + + public JsonValue value() { + return field("value"); + } + + public BigDecimal score() { + return optional("score", JsonValue::asDecimal); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionChoice.java b/src/main/java/com/qoder/cloudagents/forward/DecisionChoice.java new file mode 100644 index 0000000..a3f45d3 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionChoice.java @@ -0,0 +1,58 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DecisionChoice extends JsonObject { + private DecisionChoice(JsonValue value) { + super(value); + } + + public static DecisionChoice fromJson(JsonValue value) { + return new DecisionChoice(value); + } + + public JsonValue value() { + return field("value"); + } + + public String description() { + return optional("description", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder value(JsonValue value) { + return put("value", value); + } + + public Builder description(String value) { + return put("description", value); + } + + public Builder value(String value) { + return put("value", value); + } + + public Builder value(boolean value) { + return put("value", value); + } + + public DecisionChoice build() { + require("value"); + JsonValue value = json().get("value"); + if (!value.isString() && !value.isBoolean()) + throw new ConfigurationException("Choice values must be strings or booleans"); + if (value.isString() && value.asString().isEmpty()) + throw new ConfigurationException("Choice values must not be empty"); + return new DecisionChoice(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionContext.java b/src/main/java/com/qoder/cloudagents/forward/DecisionContext.java new file mode 100644 index 0000000..dde65f8 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionContext.java @@ -0,0 +1,39 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DecisionContext extends JsonObject { + private DecisionContext(JsonValue value) { + super(value); + } + + public static DecisionContext fromJson(JsonValue value) { + return new DecisionContext(value); + } + + public String mode() { + return optional("mode", JsonValue::asString); + } + + public String anchorEventId() { + return optional("anchor_event_id", JsonValue::asString); + } + + public String firstEventId() { + return optional("first_event_id", JsonValue::asString); + } + + public String lastEventId() { + return optional("last_event_id", JsonValue::asString); + } + + public Long includedSegmentCount() { + return optional("included_segment_count", JsonValue::asLong); + } + + public Long includedEventCount() { + return optional("included_event_count", JsonValue::asLong); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionCreateParams.java b/src/main/java/com/qoder/cloudagents/forward/DecisionCreateParams.java new file mode 100644 index 0000000..4486ef3 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionCreateParams.java @@ -0,0 +1,65 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DecisionCreateParams extends JsonObject { + private DecisionCreateParams(JsonValue value) { + super(value); + } + + public static DecisionCreateParams fromJson(JsonValue value) { + return new DecisionCreateParams(value); + } + + public Map state() { + return optionalMap("state", value -> value); + } + + public String sessionId() { + return optional("session_id", JsonValue::asString); + } + + public List questions() { + return optionalList("questions", DecisionQuestion::fromJson); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder state(Map value) { + return put("state", value); + } + + public Builder sessionId(String value) { + return put("session_id", value); + } + + public Builder questions(List value) { + return put("questions", value); + } + + public DecisionCreateParams build() { + require("questions"); + boolean state = !json().get("state").isMissing(), + session = !json().get("session_id").isMissing(); + if (state == session) + throw new ConfigurationException("Supply exactly one of state or sessionId"); + require(state ? "state" : "session_id"); + if (state + && (json().get("state").asObject().isEmpty() + || !json().get("state").get("session_context").isMissing())) + throw new ConfigurationException( + "Decision state must be nonempty and cannot contain session_context"); + if (json().get("questions").asArray().isEmpty()) + throw new ConfigurationException("Questions must not be empty"); + return new DecisionCreateParams(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionError.java b/src/main/java/com/qoder/cloudagents/forward/DecisionError.java new file mode 100644 index 0000000..fc6d81a --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionError.java @@ -0,0 +1,23 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DecisionError extends JsonObject { + private DecisionError(JsonValue value) { + super(value); + } + + public static DecisionError fromJson(JsonValue value) { + return new DecisionError(value); + } + + public String code() { + return optional("code", JsonValue::asString); + } + + public String message() { + return optional("message", JsonValue::asString); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionLevel.java b/src/main/java/com/qoder/cloudagents/forward/DecisionLevel.java new file mode 100644 index 0000000..34262ab --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionLevel.java @@ -0,0 +1,53 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.math.BigDecimal; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DecisionLevel extends JsonObject { + private DecisionLevel(JsonValue value) { + super(value); + } + + public static DecisionLevel fromJson(JsonValue value) { + return new DecisionLevel(value); + } + + public String label() { + return optional("label", JsonValue::asString); + } + + public BigDecimal score() { + return optional("score", JsonValue::asDecimal); + } + + public String description() { + return optional("description", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder label(String value) { + return put("label", value); + } + + public Builder score(BigDecimal value) { + return put("score", value); + } + + public Builder description(String value) { + return put("description", value); + } + + public DecisionLevel build() { + require("label", "score"); + return new DecisionLevel(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionListParams.java b/src/main/java/com/qoder/cloudagents/forward/DecisionListParams.java new file mode 100644 index 0000000..fea732f --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionListParams.java @@ -0,0 +1,56 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable query parameters; filters are retained when paging. */ +public final class DecisionListParams { + private final Map query; + + private DecisionListParams(Builder builder) { + if (builder.values.containsKey("after_id") && builder.values.containsKey("before_id")) + throw new ConfigurationException("afterId and beforeId are mutually exclusive"); + query = Collections.unmodifiableMap(new LinkedHashMap<>(builder.values)); + } + + public static Builder builder() { + return new Builder(); + } + + Map query() { + return query; + } + + public static final class Builder { + private final Map values = new LinkedHashMap<>(); + + public Builder sessionId(String value) { + return put("session_id", value); + } + + public Builder limit(int value) { + if (value < 1 || value > 100) + throw new ConfigurationException("limit must be between 1 and 100"); + return put("limit", Integer.toString(value)); + } + + public Builder afterId(String value) { + return put("after_id", value); + } + + public Builder beforeId(String value) { + return put("before_id", value); + } + + private Builder put(String key, String value) { + if (value == null) throw new ConfigurationException("Query values cannot be null"); + values.put(key, value); + return this; + } + + public DecisionListParams build() { + return new DecisionListParams(this); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionListResponse.java b/src/main/java/com/qoder/cloudagents/forward/DecisionListResponse.java new file mode 100644 index 0000000..fd305e5 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionListResponse.java @@ -0,0 +1,28 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +/** A cursor page with lazy iteration and cancellable asynchronous page requests. */ +public final class DecisionListResponse extends CursorPage { + public DecisionListResponse(JsonValue value, String requestId) { + this(value, requestId, Collections.emptyMap(), Collections.emptySet(), null); + } + + DecisionListResponse( + JsonValue value, + String requestId, + Map query, + Set seen, + BiFunction, Set, CompletableFuture> + fetcher) { + super(value, requestId, query, seen, fetcher, item -> new Decision(item, requestId)); + } + + @Override + protected DecisionListResponse self() { + return this; + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DecisionQuestion.java b/src/main/java/com/qoder/cloudagents/forward/DecisionQuestion.java new file mode 100644 index 0000000..20aea98 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DecisionQuestion.java @@ -0,0 +1,78 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DecisionQuestion extends JsonObject { + private DecisionQuestion(JsonValue value) { + super(value); + } + + public static DecisionQuestion fromJson(JsonValue value) { + return new DecisionQuestion(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String name() { + return optional("name", JsonValue::asString); + } + + public String instructions() { + return optional("instructions", JsonValue::asString); + } + + public List choices() { + return optionalList("choices", DecisionChoice::fromJson); + } + + public List levels() { + return optionalList("levels", DecisionLevel::fromJson); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder type(String value) { + return put("type", value); + } + + public Builder name(String value) { + return put("name", value); + } + + public Builder instructions(String value) { + return put("instructions", value); + } + + public Builder choices(List value) { + return put("choices", value); + } + + public Builder levels(List value) { + return put("levels", value); + } + + public DecisionQuestion build() { + require("type", "instructions"); + String type = json().get("type").asString(); + if (!type.equals("choice") && !type.equals("score")) + throw new ConfigurationException("Question type must be choice or score"); + String key = type.equals("choice") ? "choices" : "levels", + other = type.equals("choice") ? "levels" : "choices"; + require(key); + int count = json().get(key).asArray().size(); + if (count < 2 || count > 32 || !json().get(other).isMissing()) + throw new ConfigurationException("Questions require 2-32 entries of their own type"); + return new DecisionQuestion(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Decisions.java b/src/main/java/com/qoder/cloudagents/forward/Decisions.java new file mode 100644 index 0000000..56eb16a --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/Decisions.java @@ -0,0 +1,84 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.*; +import java.util.*; + +public final class Decisions { + private final AsyncDecisions async; + + public Decisions(AsyncDecisions async) { + this.async = async; + } + + public Decision create(DecisionCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public Decision create(DecisionCreateParams params, RequestOptions options) { + return Futures.await(async.create(params, options)); + } + + public Decision retrieve(String decisionId) { + return retrieve(decisionId, RequestOptions.DEFAULT); + } + + public Decision retrieve(String decisionId, RequestOptions options) { + return Futures.await(async.retrieve(decisionId, options)); + } + + public DecisionListResponse list(DecisionListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public DecisionListResponse list(DecisionListParams params, RequestOptions options) { + return Futures.await(async.list(params, options)); + } + + public DecisionListResponse list() { + return list(DecisionListParams.builder().build()); + } + + public DecisionListResponse list(RequestOptions options) { + return list(DecisionListParams.builder().build(), options); + } + + public WithResponse withResponse() { + return new WithResponse(); + } + + public final class WithResponse { + public ApiResponse list() { + return list(DecisionListParams.builder().build()); + } + + public ApiResponse list(RequestOptions options) { + return list(DecisionListParams.builder().build(), options); + } + + public ApiResponse create(DecisionCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public ApiResponse create(DecisionCreateParams params, RequestOptions options) { + return Futures.await(async.withResponse().create(params, options)); + } + + public ApiResponse retrieve(String decisionId) { + return retrieve(decisionId, RequestOptions.DEFAULT); + } + + public ApiResponse retrieve(String decisionId, RequestOptions options) { + return Futures.await(async.withResponse().retrieve(decisionId, options)); + } + + public ApiResponse list(DecisionListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public ApiResponse list( + DecisionListParams params, RequestOptions options) { + return Futures.await(async.withResponse().list(params, options)); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Dream.java b/src/main/java/com/qoder/cloudagents/forward/Dream.java new file mode 100644 index 0000000..ab7e48a --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/Dream.java @@ -0,0 +1,59 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable API response; unknown fields are preserved. */ +public final class Dream extends JsonModel { + public Dream(JsonValue value, String requestId) { + super(value, requestId); + } + + public String id() { + return optional("id", JsonValue::asString); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String status() { + return optional("status", JsonValue::asString); + } + + public List inputs() { + return optionalList("inputs", DreamInput::fromJson); + } + + public List outputs() { + return optionalList("outputs", DreamOutput::fromJson); + } + + public DreamModelConfig model() { + return optional("model", DreamModelConfig::fromJson); + } + + public String instructions() { + return optional("instructions", JsonValue::asString); + } + + public DreamUsage usage() { + return optional("usage", DreamUsage::fromJson); + } + + public DreamError error() { + return optional("error", DreamError::fromJson); + } + + public String createdAt() { + return optional("created_at", JsonValue::asString); + } + + public String endedAt() { + return optional("ended_at", JsonValue::asString); + } + + public String archivedAt() { + return optional("archived_at", JsonValue::asString); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamCreateParams.java b/src/main/java/com/qoder/cloudagents/forward/DreamCreateParams.java new file mode 100644 index 0000000..c6fd14b --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamCreateParams.java @@ -0,0 +1,63 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DreamCreateParams extends JsonObject { + private DreamCreateParams(JsonValue value) { + super(value); + } + + public static DreamCreateParams fromJson(JsonValue value) { + return new DreamCreateParams(value); + } + + public List inputs() { + return optionalList("inputs", DreamInput::fromJson); + } + + public DreamModelConfig model() { + return optional("model", DreamModelConfig::fromJson); + } + + public String instructions() { + return optional("instructions", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder inputs(List value) { + return put("inputs", value); + } + + public Builder model(DreamModelConfig value) { + return put("model", value); + } + + public Builder instructions(String value) { + return put("instructions", value); + } + + public DreamCreateParams build() { + require("inputs"); + long primary = + json().get("inputs").asArray().stream() + .filter( + v -> + "memory_store".equals(v.get("type").stringOrNull()) + || "identity_template".equals(v.get("type").stringOrNull())) + .count(); + if (primary != 1) + throw new ConfigurationException( + "Dream inputs require exactly one primary memory_store or identity_template"); + return new DreamCreateParams(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamError.java b/src/main/java/com/qoder/cloudagents/forward/DreamError.java new file mode 100644 index 0000000..b93b6a8 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamError.java @@ -0,0 +1,27 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DreamError extends JsonObject { + private DreamError(JsonValue value) { + super(value); + } + + public static DreamError fromJson(JsonValue value) { + return new DreamError(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String code() { + return optional("code", JsonValue::asString); + } + + public String message() { + return optional("message", JsonValue::asString); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamInput.java b/src/main/java/com/qoder/cloudagents/forward/DreamInput.java new file mode 100644 index 0000000..972652b --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamInput.java @@ -0,0 +1,77 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DreamInput extends JsonObject { + private DreamInput(JsonValue value) { + super(value); + } + + public static DreamInput fromJson(JsonValue value) { + return new DreamInput(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String memoryStoreId() { + return optional("memory_store_id", JsonValue::asString); + } + + public String identityId() { + return optional("identity_id", JsonValue::asString); + } + + public String templateId() { + return optional("template_id", JsonValue::asString); + } + + public List sessionIds() { + return optionalList("session_ids", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder type(String value) { + return put("type", value); + } + + public Builder memoryStoreId(String value) { + return put("memory_store_id", value); + } + + public Builder identityId(String value) { + return put("identity_id", value); + } + + public Builder templateId(String value) { + return put("template_id", value); + } + + public Builder sessionIds(List value) { + return put("session_ids", value); + } + + public DreamInput build() { + require("type"); + String type = json().get("type").asString(); + if (type.equals("memory_store")) require("memory_store_id"); + if (type.equals("identity_template")) require("identity_id", "template_id"); + if (type.equals("sessions")) { + require("session_ids"); + if (json().get("session_ids").asArray().isEmpty()) + throw new ConfigurationException("Session inputs must not be empty"); + } + return new DreamInput(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamListParams.java b/src/main/java/com/qoder/cloudagents/forward/DreamListParams.java new file mode 100644 index 0000000..45b0e0a --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamListParams.java @@ -0,0 +1,56 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable query parameters; filters are retained when paging. */ +public final class DreamListParams { + private final Map query; + + private DreamListParams(Builder builder) { + if (builder.values.containsKey("after_id") && builder.values.containsKey("before_id")) + throw new ConfigurationException("afterId and beforeId are mutually exclusive"); + query = Collections.unmodifiableMap(new LinkedHashMap<>(builder.values)); + } + + public static Builder builder() { + return new Builder(); + } + + Map query() { + return query; + } + + public static final class Builder { + private final Map values = new LinkedHashMap<>(); + + public Builder includeArchived(boolean value) { + return put("include_archived", Boolean.toString(value)); + } + + public Builder limit(int value) { + if (value < 1 || value > 100) + throw new ConfigurationException("limit must be between 1 and 100"); + return put("limit", Integer.toString(value)); + } + + public Builder afterId(String value) { + return put("after_id", value); + } + + public Builder beforeId(String value) { + return put("before_id", value); + } + + private Builder put(String key, String value) { + if (value == null) throw new ConfigurationException("Query values cannot be null"); + values.put(key, value); + return this; + } + + public DreamListParams build() { + return new DreamListParams(this); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamListResponse.java b/src/main/java/com/qoder/cloudagents/forward/DreamListResponse.java new file mode 100644 index 0000000..7cb59ea --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamListResponse.java @@ -0,0 +1,27 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +/** A cursor page with lazy iteration and cancellable asynchronous page requests. */ +public final class DreamListResponse extends CursorPage { + public DreamListResponse(JsonValue value, String requestId) { + this(value, requestId, Collections.emptyMap(), Collections.emptySet(), null); + } + + DreamListResponse( + JsonValue value, + String requestId, + Map query, + Set seen, + BiFunction, Set, CompletableFuture> fetcher) { + super(value, requestId, query, seen, fetcher, item -> new Dream(item, requestId)); + } + + @Override + protected DreamListResponse self() { + return this; + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamModelConfig.java b/src/main/java/com/qoder/cloudagents/forward/DreamModelConfig.java new file mode 100644 index 0000000..4c5a935 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamModelConfig.java @@ -0,0 +1,86 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import com.qoder.cloudagents.errors.ResponseValidationException; + +/** A model ID string or a structured model configuration; wire shape is preserved. */ +public final class DreamModelConfig implements JsonSerializable { + private final JsonValue value; + + private DreamModelConfig(JsonValue value) { + if (value == null || (!value.isString() && !value.isObject())) + throw new ResponseValidationException("Expected a model string or object"); + this.value = value; + } + + public static DreamModelConfig of(String id) { + if (id == null) throw new ConfigurationException("Model ID must not be null"); + return new DreamModelConfig(JsonValue.of(id)); + } + + public static DreamModelConfig fromJson(JsonValue value) { + return new DreamModelConfig(value); + } + + public static Builder builder() { + return new Builder(); + } + + public boolean isString() { + return value.isString(); + } + + public String id() { + return isString() ? value.asString() : value.get("id").asString(); + } + + public String effort() { + return string("effort"); + } + + public Long contextWindow() { + JsonValue field = field("context_window"); + return field.isMissing() || field.isNull() ? null : field.asLong(); + } + + public JsonValue field(String name) { + return value.get(name); + } + + private String string(String name) { + JsonValue field = field(name); + return field.isMissing() || field.isNull() ? null : field.asString(); + } + + @Override + public JsonValue toJsonValue() { + return value; + } + + @Override + public String toString() { + return "DreamModelConfig"; + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder id(String value) { + return put("id", value); + } + + public Builder effort(String value) { + return put("effort", value); + } + + public Builder contextWindow(Long value) { + return put("context_window", value); + } + + public DreamModelConfig build() { + require("id"); + return new DreamModelConfig(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamOutput.java b/src/main/java/com/qoder/cloudagents/forward/DreamOutput.java new file mode 100644 index 0000000..6c958e5 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamOutput.java @@ -0,0 +1,27 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DreamOutput extends JsonObject { + private DreamOutput(JsonValue value) { + super(value); + } + + public static DreamOutput fromJson(JsonValue value) { + return new DreamOutput(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String memoryStoreId() { + return optional("memory_store_id", JsonValue::asString); + } + + public List filesTouched() { + return optionalList("files_touched", JsonValue::asString); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/DreamUsage.java b/src/main/java/com/qoder/cloudagents/forward/DreamUsage.java new file mode 100644 index 0000000..7bc7f70 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/DreamUsage.java @@ -0,0 +1,31 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class DreamUsage extends JsonObject { + private DreamUsage(JsonValue value) { + super(value); + } + + public static DreamUsage fromJson(JsonValue value) { + return new DreamUsage(value); + } + + public Long inputTokens() { + return optional("input_tokens", JsonValue::asLong); + } + + public Long outputTokens() { + return optional("output_tokens", JsonValue::asLong); + } + + public Long cacheCreationInputTokens() { + return optional("cache_creation_input_tokens", JsonValue::asLong); + } + + public Long cacheReadInputTokens() { + return optional("cache_read_input_tokens", JsonValue::asLong); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Dreams.java b/src/main/java/com/qoder/cloudagents/forward/Dreams.java new file mode 100644 index 0000000..268a1a4 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/Dreams.java @@ -0,0 +1,115 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.*; +import java.util.*; + +public final class Dreams { + private final AsyncDreams async; + + public Dreams(AsyncDreams async) { + this.async = async; + } + + public Dream create(DreamCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public Dream create(DreamCreateParams params, RequestOptions options) { + return Futures.await(async.create(params, options)); + } + + public DreamListResponse list(DreamListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public DreamListResponse list(DreamListParams params, RequestOptions options) { + return Futures.await(async.list(params, options)); + } + + public Dream retrieve(String dreamId) { + return retrieve(dreamId, RequestOptions.DEFAULT); + } + + public Dream retrieve(String dreamId, RequestOptions options) { + return Futures.await(async.retrieve(dreamId, options)); + } + + public Dream cancel(String dreamId) { + return cancel(dreamId, RequestOptions.DEFAULT); + } + + public Dream cancel(String dreamId, RequestOptions options) { + return Futures.await(async.cancel(dreamId, options)); + } + + public Dream archive(String dreamId) { + return archive(dreamId, RequestOptions.DEFAULT); + } + + public Dream archive(String dreamId, RequestOptions options) { + return Futures.await(async.archive(dreamId, options)); + } + + public DreamListResponse list() { + return list(DreamListParams.builder().build()); + } + + public DreamListResponse list(RequestOptions options) { + return list(DreamListParams.builder().build(), options); + } + + public WithResponse withResponse() { + return new WithResponse(); + } + + public final class WithResponse { + public ApiResponse list() { + return list(DreamListParams.builder().build()); + } + + public ApiResponse list(RequestOptions options) { + return list(DreamListParams.builder().build(), options); + } + + public ApiResponse create(DreamCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public ApiResponse create(DreamCreateParams params, RequestOptions options) { + return Futures.await(async.withResponse().create(params, options)); + } + + public ApiResponse list(DreamListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public ApiResponse list(DreamListParams params, RequestOptions options) { + return Futures.await(async.withResponse().list(params, options)); + } + + public ApiResponse retrieve(String dreamId) { + return retrieve(dreamId, RequestOptions.DEFAULT); + } + + public ApiResponse retrieve(String dreamId, RequestOptions options) { + return Futures.await(async.withResponse().retrieve(dreamId, options)); + } + + public ApiResponse cancel(String dreamId) { + return cancel(dreamId, RequestOptions.DEFAULT); + } + + public ApiResponse cancel(String dreamId, RequestOptions options) { + return Futures.await(async.withResponse().cancel(dreamId, options)); + } + + public ApiResponse archive(String dreamId) { + return archive(dreamId, RequestOptions.DEFAULT); + } + + public ApiResponse archive(String dreamId, RequestOptions options) { + return Futures.await(async.withResponse().archive(dreamId, options)); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/EnvironmentSearchParams.java b/src/main/java/com/qoder/cloudagents/forward/EnvironmentSearchParams.java new file mode 100644 index 0000000..cbca902 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/EnvironmentSearchParams.java @@ -0,0 +1,118 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class EnvironmentSearchParams extends JsonObject { + private final String identityId; + + private EnvironmentSearchParams(JsonValue value) { + this(value, null); + } + + private EnvironmentSearchParams(JsonValue value, String identityId) { + super(value); + this.identityId = identityId; + } + + public String identityId() { + return identityId; + } + + Map query() { + return identityId == null + ? Collections.emptyMap() + : Collections.singletonMap("identity_id", identityId); + } + + public static EnvironmentSearchParams fromJson(JsonValue value) { + return new EnvironmentSearchParams(value); + } + + public Map metadata() { + return optionalMap("metadata", JsonValue::asString); + } + + public Long limit() { + return optional("limit", JsonValue::asLong); + } + + public String page() { + return optional("page", JsonValue::asString); + } + + public String name() { + return optional("name", JsonValue::asString); + } + + public Boolean includeArchived() { + return optional("include_archived", JsonValue::asBoolean); + } + + public String createdAtGte() { + return optional("created_at[gte]", JsonValue::asString); + } + + public String createdAtLte() { + return optional("created_at[lte]", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private String identityId; + + private Builder() {} + + public Builder identityId(String value) { + if (value == null) throw new ConfigurationException("identityId must not be null"); + identityId = value; + return this; + } + + public Builder metadata(Map value) { + return put("metadata", value); + } + + public Builder limit(long value) { + return limit(Long.valueOf(value)); + } + + public Builder limit(Long value) { + return put("limit", value); + } + + public Builder page(String value) { + return put("page", value); + } + + public Builder name(String value) { + return put("name", value); + } + + public Builder includeArchived(Boolean value) { + return put("include_archived", value); + } + + public Builder createdAtGte(String value) { + return put("created_at[gte]", value); + } + + public Builder createdAtLte(String value) { + return put("created_at[lte]", value); + } + + public EnvironmentSearchParams build() { + if (!json().get("identity_id").isMissing()) + throw new ConfigurationException("identityId belongs in the query, not the search body"); + JsonValue limit = json().get("limit"); + if (!limit.isMissing() && !limit.isNull() && (limit.asLong() < 1 || limit.asLong() > 100)) + throw new ConfigurationException("limit must be between 1 and 100"); + return new EnvironmentSearchParams(json(), identityId); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Environments.java b/src/main/java/com/qoder/cloudagents/forward/Environments.java index c6c94fe..eb92377 100644 --- a/src/main/java/com/qoder/cloudagents/forward/Environments.java +++ b/src/main/java/com/qoder/cloudagents/forward/Environments.java @@ -63,11 +63,44 @@ public DeletedEnvironment delete(String environmentId, RequestOptions options) { return Futures.await(async.delete(environmentId, options)); } + public EnvironmentListResponse search(EnvironmentSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public EnvironmentListResponse search(EnvironmentSearchParams params, RequestOptions options) { + return Futures.await(async.search(params, options)); + } + + public EnvironmentListResponse search() { + return search(EnvironmentSearchParams.builder().build()); + } + + public EnvironmentListResponse search(RequestOptions options) { + return search(EnvironmentSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public ApiResponse search() { + return search(EnvironmentSearchParams.builder().build()); + } + + public ApiResponse search(RequestOptions options) { + return search(EnvironmentSearchParams.builder().build(), options); + } + + public ApiResponse search(EnvironmentSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public ApiResponse search( + EnvironmentSearchParams params, RequestOptions options) { + return Futures.await(async.withResponse().search(params, options)); + } + public ApiResponse list() { return list(EnvironmentListParams.builder().build()); } diff --git a/src/main/java/com/qoder/cloudagents/forward/FileSearchParams.java b/src/main/java/com/qoder/cloudagents/forward/FileSearchParams.java new file mode 100644 index 0000000..b65621c --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/FileSearchParams.java @@ -0,0 +1,102 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class FileSearchParams extends JsonObject { + private final String identityId; + + private FileSearchParams(JsonValue value) { + this(value, null); + } + + private FileSearchParams(JsonValue value, String identityId) { + super(value); + this.identityId = identityId; + } + + public String identityId() { + return identityId; + } + + Map query() { + return identityId == null + ? Collections.emptyMap() + : Collections.singletonMap("identity_id", identityId); + } + + public static FileSearchParams fromJson(JsonValue value) { + return new FileSearchParams(value); + } + + public Map metadata() { + return optionalMap("metadata", JsonValue::asString); + } + + public Long limit() { + return optional("limit", JsonValue::asLong); + } + + public String page() { + return optional("page", JsonValue::asString); + } + + public String name() { + return optional("name", JsonValue::asString); + } + + public String scopeId() { + return optional("scope_id", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private String identityId; + + private Builder() {} + + public Builder identityId(String value) { + if (value == null) throw new ConfigurationException("identityId must not be null"); + identityId = value; + return this; + } + + public Builder metadata(Map value) { + return put("metadata", value); + } + + public Builder limit(long value) { + return limit(Long.valueOf(value)); + } + + public Builder limit(Long value) { + return put("limit", value); + } + + public Builder page(String value) { + return put("page", value); + } + + public Builder name(String value) { + return put("name", value); + } + + public Builder scopeId(String value) { + return put("scope_id", value); + } + + public FileSearchParams build() { + if (!json().get("identity_id").isMissing()) + throw new ConfigurationException("identityId belongs in the query, not the search body"); + JsonValue limit = json().get("limit"); + if (!limit.isMissing() && !limit.isNull() && (limit.asLong() < 1 || limit.asLong() > 100)) + throw new ConfigurationException("limit must be between 1 and 100"); + return new FileSearchParams(json(), identityId); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Files.java b/src/main/java/com/qoder/cloudagents/forward/Files.java index 04cc156..91f1eb9 100644 --- a/src/main/java/com/qoder/cloudagents/forward/Files.java +++ b/src/main/java/com/qoder/cloudagents/forward/Files.java @@ -62,11 +62,43 @@ public FileDownload download(String fileId, RequestOptions options) { return Futures.await(async.download(fileId, options)); } + public FileListResponse search(FileSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public FileListResponse search(FileSearchParams params, RequestOptions options) { + return Futures.await(async.search(params, options)); + } + + public FileListResponse search() { + return search(FileSearchParams.builder().build()); + } + + public FileListResponse search(RequestOptions options) { + return search(FileSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public ApiResponse search() { + return search(FileSearchParams.builder().build()); + } + + public ApiResponse search(RequestOptions options) { + return search(FileSearchParams.builder().build(), options); + } + + public ApiResponse search(FileSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public ApiResponse search(FileSearchParams params, RequestOptions options) { + return Futures.await(async.withResponse().search(params, options)); + } + public ApiResponse list() { return list(FileListParams.builder().build()); } diff --git a/src/main/java/com/qoder/cloudagents/forward/Identity.java b/src/main/java/com/qoder/cloudagents/forward/Identity.java index 43f76e8..d4fb4b6 100644 --- a/src/main/java/com/qoder/cloudagents/forward/Identity.java +++ b/src/main/java/com/qoder/cloudagents/forward/Identity.java @@ -9,6 +9,10 @@ public Identity(JsonValue value, String requestId) { super(value, requestId); } + public Boolean autoMemoryStore() { + return optional("auto_memory_store", JsonValue::asBoolean); + } + public String id() { return field("id").asString(); } diff --git a/src/main/java/com/qoder/cloudagents/forward/IdentityCreateParams.java b/src/main/java/com/qoder/cloudagents/forward/IdentityCreateParams.java index 540e429..e9be0f4 100644 --- a/src/main/java/com/qoder/cloudagents/forward/IdentityCreateParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/IdentityCreateParams.java @@ -13,6 +13,10 @@ public static IdentityCreateParams fromJson(JsonValue value) { return new IdentityCreateParams(value); } + public Boolean autoMemoryStore() { + return optional("auto_memory_store", JsonValue::asBoolean); + } + public String externalId() { return optional("external_id", JsonValue::asString); } @@ -36,6 +40,10 @@ public static Builder builder() { public static final class Builder extends JsonObjectBuilder { private Builder() {} + public Builder autoMemoryStore(Boolean value) { + return put("auto_memory_store", value); + } + public Builder externalId(String value) { return put("external_id", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListParams.java b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListParams.java new file mode 100644 index 0000000..64c3eeb --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListParams.java @@ -0,0 +1,52 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable query parameters; filters are retained when paging. */ +public final class IdentityMemoryStoreListParams { + private final Map query; + + private IdentityMemoryStoreListParams(Builder builder) { + if (builder.values.containsKey("after_id") && builder.values.containsKey("before_id")) + throw new ConfigurationException("afterId and beforeId are mutually exclusive"); + query = Collections.unmodifiableMap(new LinkedHashMap<>(builder.values)); + } + + public static Builder builder() { + return new Builder(); + } + + Map query() { + return query; + } + + public static final class Builder { + private final Map values = new LinkedHashMap<>(); + + public Builder limit(int value) { + if (value < 1 || value > 100) + throw new ConfigurationException("limit must be between 1 and 100"); + return put("limit", Integer.toString(value)); + } + + public Builder afterId(String value) { + return put("after_id", value); + } + + public Builder beforeId(String value) { + return put("before_id", value); + } + + private Builder put(String key, String value) { + if (value == null) throw new ConfigurationException("Query values cannot be null"); + values.put(key, value); + return this; + } + + public IdentityMemoryStoreListParams build() { + return new IdentityMemoryStoreListParams(this); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListResponse.java b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListResponse.java index f057c6a..adea8eb 100644 --- a/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListResponse.java +++ b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreListResponse.java @@ -1,16 +1,34 @@ package com.qoder.cloudagents.forward; import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.Futures; import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +/** A mount page; the existing boxed hasMore signature remains source and binary compatible. */ +public final class IdentityMemoryStoreListResponse extends JsonModel + implements Iterable { + private final Page page; -/** Immutable API response; unknown fields are preserved. */ -public final class IdentityMemoryStoreListResponse extends JsonModel { public IdentityMemoryStoreListResponse(JsonValue value, String requestId) { + this(value, requestId, Collections.emptyMap(), Collections.emptySet(), null); + } + + IdentityMemoryStoreListResponse( + JsonValue value, + String requestId, + Map query, + Set seen, + BiFunction< + Map, Set, CompletableFuture> + fetcher) { super(value, requestId); + page = new Page(value, requestId, query, seen, fetcher); } public List data() { - return optionalList("data", item -> new MemoryStoreMount(item, requestId())); + return page.data(); } public Boolean hasMore() { @@ -18,10 +36,74 @@ public Boolean hasMore() { } public String firstId() { - return optional("first_id", JsonValue::asString); + return optionalString("first_id"); } public String lastId() { - return optional("last_id", JsonValue::asString); + return optionalString("last_id"); + } + + public boolean hasNextPage() { + return page.hasNextPage(); + } + + public IdentityMemoryStoreListResponse getNextPage() { + return Futures.await(getNextPageAsync()); + } + + public CompletableFuture getNextPageAsync() { + return Futures.map(page.getNextPageAsync(), Page::response); + } + + public Iterable iterPages() { + return () -> + new Iterator() { + private final Iterator pages = page.iterPages().iterator(); + + public boolean hasNext() { + return pages.hasNext(); + } + + public IdentityMemoryStoreListResponse next() { + return pages.next().response(); + } + }; + } + + @Override + public Iterator iterator() { + return page.iterator(); + } + + private final class Page extends CursorPage { + Page( + JsonValue value, + String requestId, + Map query, + Set seen, + BiFunction< + Map, + Set, + CompletableFuture> + fetcher) { + super( + value, + requestId, + query, + seen, + fetcher == null + ? null + : (q, s) -> Futures.map(fetcher.apply(q, s), result -> result.page), + item -> new MemoryStoreMount(item, requestId)); + } + + @Override + protected Page self() { + return this; + } + + IdentityMemoryStoreListResponse response() { + return IdentityMemoryStoreListResponse.this; + } } } diff --git a/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreMountParams.java b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreMountParams.java index d050092..d39ffa3 100644 --- a/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreMountParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStoreMountParams.java @@ -13,6 +13,10 @@ public static IdentityMemoryStoreMountParams fromJson(JsonValue value) { return new IdentityMemoryStoreMountParams(value); } + public String access() { + return optional("access", JsonValue::asString); + } + public String memoryStoreId() { return optional("memory_store_id", JsonValue::asString); } @@ -24,6 +28,10 @@ public static Builder builder() { public static final class Builder extends JsonObjectBuilder { private Builder() {} + public Builder access(String value) { + return put("access", value); + } + public Builder memoryStoreId(String value) { return put("memory_store_id", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStores.java b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStores.java index a4535bd..cda91aa 100644 --- a/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStores.java +++ b/src/main/java/com/qoder/cloudagents/forward/IdentityMemoryStores.java @@ -43,11 +43,37 @@ public DeletedMemoryStoreMount detach( return Futures.await(async.detach(identityId, templateId, memoryStoreId, options)); } + public IdentityMemoryStoreListResponse list( + String identityId, String templateId, IdentityMemoryStoreListParams params) { + return list(identityId, templateId, params, RequestOptions.DEFAULT); + } + + public IdentityMemoryStoreListResponse list( + String identityId, + String templateId, + IdentityMemoryStoreListParams params, + RequestOptions options) { + return Futures.await(async.list(identityId, templateId, params, options)); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public ApiResponse list( + String identityId, String templateId, IdentityMemoryStoreListParams params) { + return list(identityId, templateId, params, RequestOptions.DEFAULT); + } + + public ApiResponse list( + String identityId, + String templateId, + IdentityMemoryStoreListParams params, + RequestOptions options) { + return Futures.await(async.withResponse().list(identityId, templateId, params, options)); + } + public ApiResponse list(String identityId, String templateId) { return list(identityId, templateId, RequestOptions.DEFAULT); } diff --git a/src/main/java/com/qoder/cloudagents/forward/IdentityUpdateParams.java b/src/main/java/com/qoder/cloudagents/forward/IdentityUpdateParams.java index 13fcd2c..fa943f3 100644 --- a/src/main/java/com/qoder/cloudagents/forward/IdentityUpdateParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/IdentityUpdateParams.java @@ -13,6 +13,10 @@ public static IdentityUpdateParams fromJson(JsonValue value) { return new IdentityUpdateParams(value); } + public Boolean autoMemoryStore() { + return optional("auto_memory_store", JsonValue::asBoolean); + } + public String externalId() { return optional("external_id", JsonValue::asString); } @@ -36,6 +40,10 @@ public static Builder builder() { public static final class Builder extends JsonObjectBuilder { private Builder() {} + public Builder autoMemoryStore(Boolean value) { + return put("auto_memory_store", value); + } + public Builder externalId(String value) { return put("external_id", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/OutcomeRubric.java b/src/main/java/com/qoder/cloudagents/forward/OutcomeRubric.java index 0fc8f9d..a63e101 100644 --- a/src/main/java/com/qoder/cloudagents/forward/OutcomeRubric.java +++ b/src/main/java/com/qoder/cloudagents/forward/OutcomeRubric.java @@ -17,8 +17,16 @@ public String type() { return optional("type", JsonValue::asString); } + public String content() { + return optional("content", JsonValue::asString); + } + + /** + * @deprecated Use {@link #content()}. + */ + @Deprecated public String text() { - return optional("text", JsonValue::asString); + return content(); } public String fileId() { @@ -36,8 +44,16 @@ public Builder type(String value) { return put("type", value); } + public Builder content(String value) { + return put("content", value); + } + + /** + * @deprecated Use {@link #content(String)}; this alias also writes content. + */ + @Deprecated public Builder text(String value) { - return put("text", value); + return content(value); } public Builder fileId(String value) { diff --git a/src/main/java/com/qoder/cloudagents/forward/Schedule.java b/src/main/java/com/qoder/cloudagents/forward/Schedule.java index a02d0ad..56018bb 100644 --- a/src/main/java/com/qoder/cloudagents/forward/Schedule.java +++ b/src/main/java/com/qoder/cloudagents/forward/Schedule.java @@ -9,6 +9,10 @@ public Schedule(JsonValue value, String requestId) { super(value, requestId); } + public List resources() { + return optionalList("resources", SessionResourceSpec::fromJson); + } + public Long initialEventsVersion() { return optional("initial_events_version", JsonValue::asLong); } diff --git a/src/main/java/com/qoder/cloudagents/forward/ScheduleCreateParams.java b/src/main/java/com/qoder/cloudagents/forward/ScheduleCreateParams.java index d8a4dab..0ffc38d 100644 --- a/src/main/java/com/qoder/cloudagents/forward/ScheduleCreateParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/ScheduleCreateParams.java @@ -14,6 +14,10 @@ public static ScheduleCreateParams fromJson(JsonValue value) { return new ScheduleCreateParams(value); } + public List resources() { + return optionalList("resources", SessionResourceSpec::fromJson); + } + public String identityId() { return optional("identity_id", JsonValue::asString); } @@ -61,6 +65,10 @@ public static Builder builder() { public static final class Builder extends JsonObjectBuilder { private Builder() {} + public Builder resources(List value) { + return put("resources", value); + } + public Builder identityId(String value) { return put("identity_id", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/ScheduleRun.java b/src/main/java/com/qoder/cloudagents/forward/ScheduleRun.java index 240515f..18e5c87 100644 --- a/src/main/java/com/qoder/cloudagents/forward/ScheduleRun.java +++ b/src/main/java/com/qoder/cloudagents/forward/ScheduleRun.java @@ -9,6 +9,10 @@ public ScheduleRun(JsonValue value, String requestId) { super(value, requestId); } + public List initialEvents() { + return optionalList("initial_events", ScheduleInitialEvent::fromJson); + } + public Long initialEventsVersion() { return optional("initial_events_version", JsonValue::asLong); } diff --git a/src/main/java/com/qoder/cloudagents/forward/ScheduleRunRetrieveParams.java b/src/main/java/com/qoder/cloudagents/forward/ScheduleRunRetrieveParams.java index ffd3d19..20872ee 100644 --- a/src/main/java/com/qoder/cloudagents/forward/ScheduleRunRetrieveParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/ScheduleRunRetrieveParams.java @@ -22,6 +22,10 @@ Map query() { public static final class Builder { private final Map values = new LinkedHashMap<>(); + public Builder includeInitialEvents(boolean value) { + return put("include_initial_events", Boolean.toString(value)); + } + public Builder identityId(String value) { return put("identity_id", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/ScheduleUpdateParams.java b/src/main/java/com/qoder/cloudagents/forward/ScheduleUpdateParams.java index d049f33..6497a1c 100644 --- a/src/main/java/com/qoder/cloudagents/forward/ScheduleUpdateParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/ScheduleUpdateParams.java @@ -14,6 +14,10 @@ public static ScheduleUpdateParams fromJson(JsonValue value) { return new ScheduleUpdateParams(value); } + public List resources() { + return optionalList("resources", SessionResourceSpec::fromJson); + } + public String name() { return optional("name", JsonValue::asString); } @@ -57,6 +61,10 @@ public static Builder builder() { public static final class Builder extends JsonObjectBuilder { private Builder() {} + public Builder resources(List value) { + return put("resources", value); + } + public Builder name(String value) { return put("name", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java b/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java index 9f3ab03..ffa4c6b 100644 --- a/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java +++ b/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java @@ -1,6 +1,7 @@ package com.qoder.cloudagents.forward; import com.qoder.cloudagents.core.*; +import java.math.BigDecimal; import java.util.*; /** Immutable API response; unknown fields are preserved. */ @@ -9,6 +10,18 @@ public SessionEvent(JsonValue value, String requestId) { super(value, requestId); } + public BigDecimal totalCredits() { + return optional("total_credits", JsonValue::asDecimal); + } + + public BigDecimal sandboxRuntimeCredits() { + return optional("sandbox_runtime_credits", JsonValue::asDecimal); + } + + public BigDecimal modelCredits() { + return optional("model_credits", JsonValue::asDecimal); + } + public String id() { return optional("id", JsonValue::asString); } @@ -145,6 +158,18 @@ public Map input() { return optionalMap("input", item -> item); } + /** Credits for context compaction, when usage.credits is present. */ + public BigDecimal usageCredits() { + JsonValue value = field("usage").get("credits"); + return value.isMissing() || value.isNull() ? null : value.asDecimal(); + } + + /** Incremental credits for a model request, when model_usage.credits is present. */ + public BigDecimal modelUsageCredits() { + JsonValue value = field("model_usage").get("credits"); + return value.isMissing() || value.isNull() ? null : value.asDecimal(); + } + public Map usage() { return optionalMap("usage", item -> item); } diff --git a/src/main/java/com/qoder/cloudagents/forward/SkillSearchParams.java b/src/main/java/com/qoder/cloudagents/forward/SkillSearchParams.java new file mode 100644 index 0000000..ad1b2f2 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/SkillSearchParams.java @@ -0,0 +1,102 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class SkillSearchParams extends JsonObject { + private final String identityId; + + private SkillSearchParams(JsonValue value) { + this(value, null); + } + + private SkillSearchParams(JsonValue value, String identityId) { + super(value); + this.identityId = identityId; + } + + public String identityId() { + return identityId; + } + + Map query() { + return identityId == null + ? Collections.emptyMap() + : Collections.singletonMap("identity_id", identityId); + } + + public static SkillSearchParams fromJson(JsonValue value) { + return new SkillSearchParams(value); + } + + public Map metadata() { + return optionalMap("metadata", JsonValue::asString); + } + + public Long limit() { + return optional("limit", JsonValue::asLong); + } + + public String page() { + return optional("page", JsonValue::asString); + } + + public String source() { + return optional("source", JsonValue::asString); + } + + public String displayTitle() { + return optional("display_title", JsonValue::asString); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private String identityId; + + private Builder() {} + + public Builder identityId(String value) { + if (value == null) throw new ConfigurationException("identityId must not be null"); + identityId = value; + return this; + } + + public Builder metadata(Map value) { + return put("metadata", value); + } + + public Builder limit(long value) { + return limit(Long.valueOf(value)); + } + + public Builder limit(Long value) { + return put("limit", value); + } + + public Builder page(String value) { + return put("page", value); + } + + public Builder source(String value) { + return put("source", value); + } + + public Builder displayTitle(String value) { + return put("display_title", value); + } + + public SkillSearchParams build() { + if (!json().get("identity_id").isMissing()) + throw new ConfigurationException("identityId belongs in the query, not the search body"); + JsonValue limit = json().get("limit"); + if (!limit.isMissing() && !limit.isNull() && (limit.asLong() < 1 || limit.asLong() > 100)) + throw new ConfigurationException("limit must be between 1 and 100"); + return new SkillSearchParams(json(), identityId); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Skills.java b/src/main/java/com/qoder/cloudagents/forward/Skills.java index 598999b..498043f 100644 --- a/src/main/java/com/qoder/cloudagents/forward/Skills.java +++ b/src/main/java/com/qoder/cloudagents/forward/Skills.java @@ -79,11 +79,43 @@ public DeletedSkill delete(String skillId, RequestOptions options) { return Futures.await(async.delete(skillId, options)); } + public SkillListResponse search(SkillSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public SkillListResponse search(SkillSearchParams params, RequestOptions options) { + return Futures.await(async.search(params, options)); + } + + public SkillListResponse search() { + return search(SkillSearchParams.builder().build()); + } + + public SkillListResponse search(RequestOptions options) { + return search(SkillSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public ApiResponse search() { + return search(SkillSearchParams.builder().build()); + } + + public ApiResponse search(RequestOptions options) { + return search(SkillSearchParams.builder().build(), options); + } + + public ApiResponse search(SkillSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public ApiResponse search(SkillSearchParams params, RequestOptions options) { + return Futures.await(async.withResponse().search(params, options)); + } + public ApiResponse list() { return list(SkillListParams.builder().build()); } diff --git a/src/main/java/com/qoder/cloudagents/forward/VaultSearchParams.java b/src/main/java/com/qoder/cloudagents/forward/VaultSearchParams.java new file mode 100644 index 0000000..610ddd5 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/VaultSearchParams.java @@ -0,0 +1,102 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class VaultSearchParams extends JsonObject { + private final String identityId; + + private VaultSearchParams(JsonValue value) { + this(value, null); + } + + private VaultSearchParams(JsonValue value, String identityId) { + super(value); + this.identityId = identityId; + } + + public String identityId() { + return identityId; + } + + Map query() { + return identityId == null + ? Collections.emptyMap() + : Collections.singletonMap("identity_id", identityId); + } + + public static VaultSearchParams fromJson(JsonValue value) { + return new VaultSearchParams(value); + } + + public Map metadata() { + return optionalMap("metadata", JsonValue::asString); + } + + public Long limit() { + return optional("limit", JsonValue::asLong); + } + + public String page() { + return optional("page", JsonValue::asString); + } + + public String name() { + return optional("name", JsonValue::asString); + } + + public Boolean includeArchived() { + return optional("include_archived", JsonValue::asBoolean); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private String identityId; + + private Builder() {} + + public Builder identityId(String value) { + if (value == null) throw new ConfigurationException("identityId must not be null"); + identityId = value; + return this; + } + + public Builder metadata(Map value) { + return put("metadata", value); + } + + public Builder limit(long value) { + return limit(Long.valueOf(value)); + } + + public Builder limit(Long value) { + return put("limit", value); + } + + public Builder page(String value) { + return put("page", value); + } + + public Builder name(String value) { + return put("name", value); + } + + public Builder includeArchived(Boolean value) { + return put("include_archived", value); + } + + public VaultSearchParams build() { + if (!json().get("identity_id").isMissing()) + throw new ConfigurationException("identityId belongs in the query, not the search body"); + JsonValue limit = json().get("limit"); + if (!limit.isMissing() && !limit.isNull() && (limit.asLong() < 1 || limit.asLong() > 100)) + throw new ConfigurationException("limit must be between 1 and 100"); + return new VaultSearchParams(json(), identityId); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Vaults.java b/src/main/java/com/qoder/cloudagents/forward/Vaults.java index 3d52162..f4ff8bc 100644 --- a/src/main/java/com/qoder/cloudagents/forward/Vaults.java +++ b/src/main/java/com/qoder/cloudagents/forward/Vaults.java @@ -55,11 +55,43 @@ public DeletedVault delete(String vaultId, RequestOptions options) { return Futures.await(async.delete(vaultId, options)); } + public VaultListResponse search(VaultSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public VaultListResponse search(VaultSearchParams params, RequestOptions options) { + return Futures.await(async.search(params, options)); + } + + public VaultListResponse search() { + return search(VaultSearchParams.builder().build()); + } + + public VaultListResponse search(RequestOptions options) { + return search(VaultSearchParams.builder().build(), options); + } + public WithResponse withResponse() { return new WithResponse(); } public final class WithResponse { + public ApiResponse search() { + return search(VaultSearchParams.builder().build()); + } + + public ApiResponse search(RequestOptions options) { + return search(VaultSearchParams.builder().build(), options); + } + + public ApiResponse search(VaultSearchParams params) { + return search(params, RequestOptions.DEFAULT); + } + + public ApiResponse search(VaultSearchParams params, RequestOptions options) { + return Futures.await(async.withResponse().search(params, options)); + } + public ApiResponse list() { return list(VaultListParams.builder().build()); } diff --git a/src/test/java/com/qoder/cloudagents/ForwardPaginationParityTest.java b/src/test/java/com/qoder/cloudagents/ForwardPaginationParityTest.java new file mode 100644 index 0000000..384a26e --- /dev/null +++ b/src/test/java/com/qoder/cloudagents/ForwardPaginationParityTest.java @@ -0,0 +1,351 @@ +package com.qoder.cloudagents; + +import static org.junit.jupiter.api.Assertions.*; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.*; +import com.qoder.cloudagents.forward.*; +import java.math.BigDecimal; +import java.util.*; +import java.util.concurrent.*; +import okhttp3.mockwebserver.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@Timeout(15) +class ForwardPaginationParityTest { + private MockWebServer server; + private ForwardClient client; + + @BeforeEach + void setup() throws Exception { + server = new MockWebServer(); + server.start(); + client = + ForwardClient.builder() + .pat("pat_test") + .baseUrl(server.url("/api/v1/forward").toString()) + .defaultHeader("X-Qoder-Beta", "other-beta") + .maxRetries(0) + .build(); + } + + @AfterEach + void close() throws Exception { + client.close(); + server.close(); + } + + private void respond(String body) { + server.enqueue(new MockResponse().setBody(body)); + } + + private RecordedRequest request() throws Exception { + RecordedRequest r = server.takeRequest(2, TimeUnit.SECONDS); + assertNotNull(r); + return r; + } + + @ParameterizedTest + @ValueSource(strings = {"environments", "files", "skills", "vaults"}) + void postSearchPagesRetainBodyScopeAndOptions(String resource) throws Exception { + respond("{\"data\":[{\"id\":\"first\"}],\"has_more\":true,\"next_page\":\"opaque+/=?\"}"); + respond("{\"data\":[],\"has_more\":false,\"next_page\":null}"); + RequestOptions options = + RequestOptions.builder() + .query("identity_id", "idn_override") + .header("X-Qoder-Beta", "caller-beta") + .header("X-Trace", "trace") + .build(); + CursorPage page; + switch (resource) { + case "environments": + page = + client + .environments() + .search( + EnvironmentSearchParams.builder() + .metadata(Collections.singletonMap("team", "sdk")) + .identityId("idn_original") + .build(), + options); + break; + case "files": + page = + client + .files() + .search( + FileSearchParams.builder() + .metadata(Collections.singletonMap("team", "sdk")) + .scopeId("sess_test") + .identityId("idn_original") + .build(), + options); + break; + case "skills": + page = + client + .skills() + .search( + SkillSearchParams.builder() + .metadata(Collections.singletonMap("team", "sdk")) + .identityId("idn_original") + .build(), + options); + break; + default: + page = + client + .vaults() + .search( + VaultSearchParams.builder() + .metadata(Collections.singletonMap("team", "sdk")) + .identityId("idn_original") + .build(), + options); + } + assertTrue(page.hasNextPage()); + RecordedRequest first = request(); + CursorPage last = page.getNextPageAsync().get(3, TimeUnit.SECONDS); + RecordedRequest second = request(); + assertEquals("POST", second.getMethod()); + assertEquals( + "/api/v1/forward/" + resource + "/search?identity_id=idn_override", second.getPath()); + assertEquals("caller-beta,search-2026-08-31", second.getHeader("X-Qoder-Beta")); + assertEquals("trace", second.getHeader("X-Trace")); + Map expected = + new LinkedHashMap<>(JsonValue.parse(first.getBody().readUtf8()).asObject()); + expected.put("page", JsonValue.of("opaque+/=?")); + assertEquals( + JsonValue.of(expected).toJson(), JsonValue.parse(second.getBody().readUtf8()).toJson()); + assertFalse(last.hasNextPage()); + assertThrows(PaginationException.class, last::getNextPage); + assertEquals(2, server.getRequestCount()); + } + + @Test + void repeatedSearchCursorCannotLoopAndDefaultBetaIsKept() throws Exception { + respond("{\"data\":[{\"id\":\"f\"}],\"has_more\":true,\"next_page\":\"same\"}"); + FileListResponse page = client.files().search(FileSearchParams.builder().page("same").build()); + assertEquals("other-beta,search-2026-08-31", request().getHeader("x-qoder-beta")); + assertThrows(PaginationException.class, page::getNextPage); + assertEquals(1, server.getRequestCount()); + assertThrows( + ConfigurationException.class, + () -> + client + .files() + .search( + FileSearchParams.fromJson( + JsonValue.parse("{\"identity_id\":\"wrong-place\"}")))); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void mountPagingUsesStoreIdsAndKeepsDirection(boolean backwards) throws Exception { + respond( + "{\"data\":[{\"memory_store_id\":\"m1\",\"access\":\"read_only\"}],\"first_id\":\"m1\",\"last_id\":\"m2\",\"has_more\":true}"); + respond("{\"data\":[],\"first_id\":null,\"last_id\":null,\"has_more\":false}"); + IdentityMemoryStoreListParams.Builder params = IdentityMemoryStoreListParams.builder().limit(2); + if (backwards) params.beforeId("m0"); + IdentityMemoryStoreListResponse page = + client + .identities() + .memoryStores() + .list( + "i/a", + "t/b", + params.build(), + RequestOptions.builder().header("X-Trace", "mount").build()); + assertEquals(Boolean.TRUE, page.hasMore()); + request(); + IdentityMemoryStoreListResponse last = page.getNextPageAsync().get(3, TimeUnit.SECONDS); + RecordedRequest next = request(); + assertEquals( + "/api/v1/forward/identities/i%2Fa/templates/t%2Fb/memory_stores?limit=2&" + + (backwards ? "before_id=m1" : "after_id=m2"), + next.getPath()); + assertEquals("mount", next.getHeader("X-Trace")); + assertFalse(last.hasNextPage()); + assertThrows( + ConfigurationException.class, + () -> + client + .identities() + .memoryStores() + .list( + "i", + "t", + params.build(), + RequestOptions.builder() + .query("after_id", "x") + .query("before_id", "y") + .build())); + } + + @ParameterizedTest + @ValueSource(strings = {"dreams", "decisions", "channel_pairings"}) + void newListsUseCursorPaging(String resource) throws Exception { + respond( + "{\"data\":[{\"id\":\"first\"}],\"first_id\":\"first\",\"last_id\":\"last\",\"has_more\":true}"); + respond("{\"data\":[],\"has_more\":false}"); + CursorPage page; + if (resource.equals("dreams")) + page = client.dreams().list(DreamListParams.builder().includeArchived(true).limit(1).build()); + else if (resource.equals("decisions")) + page = + client + .decisions() + .list(DecisionListParams.builder().sessionId("sess_test").limit(1).build()); + else + page = + client + .channelPairings() + .list(ChannelPairingListParams.builder().status("active").limit(1).build()); + String first = request().getPath(); + page.getNextPageAsync().get(3, TimeUnit.SECONDS); + assertEquals(first + "&after_id=last", request().getPath()); + } + + @Test + void decisionValuesAndDreamModelsPreserveTheirWireTypes() { + Decision decision = + new Decision( + JsonValue.parse( + "{\"answers\":[{\"type\":\"choice\",\"value\":false},{\"type\":\"choice\",\"value\":\"false\"},{\"type\":\"score\",\"score\":9007199254740993.25}],\"context\":{\"mode\":\"session\",\"included_event_count\":0},\"error\":null}"), + null); + assertFalse(decision.answers().get(0).value().asBoolean()); + assertEquals("false", decision.answers().get(1).value().asString()); + assertEquals(new BigDecimal("9007199254740993.25"), decision.answers().get(2).score()); + assertEquals(Long.valueOf(0), decision.context().includedEventCount()); + assertNull(decision.error()); + assertThrows( + ConfigurationException.class, + () -> DecisionChoice.builder().value(JsonValue.of(1)).build()); + DecisionQuestion question = + DecisionQuestion.builder() + .type("score") + .instructions("grade") + .levels( + Arrays.asList( + DecisionLevel.builder().label("low").score(new BigDecimal("0.5")).build(), + DecisionLevel.builder().label("high").score(new BigDecimal("1.25")).build())) + .build(); + assertEquals(new BigDecimal("1.25"), question.levels().get(1).score()); + assertThrows( + ConfigurationException.class, + () -> + DecisionCreateParams.builder() + .state(Collections.singletonMap("x", JsonValue.of(1))) + .sessionId("s") + .questions(Collections.singletonList(question)) + .build()); + assertThrows( + ConfigurationException.class, + () -> + DecisionCreateParams.builder() + .state(Collections.singletonMap("session_context", JsonValue.of("reserved"))) + .questions(Collections.singletonList(question)) + .build()); + DreamCreateParams dream = + DreamCreateParams.builder() + .inputs( + Collections.singletonList( + DreamInput.builder() + .type("identity_template") + .identityId("i") + .templateId("t") + .build())) + .model( + DreamModelConfig.builder().id("auto").effort("low").contextWindow(32768L).build()) + .build(); + assertEquals( + "{\"id\":\"auto\",\"effort\":\"low\",\"context_window\":32768}", + dream.model().toJsonValue().toJson()); + assertEquals("", DreamModelConfig.of("").id()); + assertThrows(ConfigurationException.class, () -> ChannelPairingUpdateParams.builder().build()); + assertThrows( + ConfigurationException.class, + () -> ChannelPairingUpdateParams.builder().identityId(null).build()); + assertThrows( + ConfigurationException.class, + () -> ChannelPairingUpdateParams.builder().templateId("bad\nvalue").build()); + } + + @Test + void searchContinuationCanBeCancelled() throws Exception { + respond("{\"data\":[{\"id\":\"v\"}],\"has_more\":true,\"next_page\":\"next\"}"); + VaultListResponse first = client.vaults().search(VaultSearchParams.builder().build()); + request(); + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)); + CompletableFuture pending = first.getNextPageAsync(); + request(); + assertTrue(pending.cancel(true)); + assertTrue(pending.isCancelled()); + } + + @Test + void emptySearchSendsObjectAndPreservesRequiredBetaOnce() throws Exception { + respond("{\"data\":[],\"has_more\":false}"); + client + .files() + .search(RequestOptions.builder().header("x-qoder-beta", "search-2026-08-31,other").build()); + RecordedRequest sent = request(); + assertEquals("{}", sent.getBody().readUtf8()); + assertEquals("search-2026-08-31,other", sent.getHeader("x-qoder-beta")); + } + + @Test + void decisionRetriesKeepTheExactBodyAndDoNotRetryConflict() throws Exception { + DecisionCreateParams params = + DecisionCreateParams.builder() + .sessionId("sess_test") + .questions( + Collections.singletonList( + DecisionQuestion.builder() + .type("choice") + .instructions("Is it complete?") + .choices( + Arrays.asList( + DecisionChoice.builder().value("yes").build(), + DecisionChoice.builder().value("no").build())) + .build())) + .build(); + try (ForwardClient retrying = client.withOptions(b -> b.maxRetries(1))) { + server.enqueue( + new MockResponse() + .setResponseCode(503) + .setHeader("retry-after-ms", "1") + .setBody("{\"error\":{\"message\":\"busy\"}}")); + respond("{\"id\":\"dec_ok\",\"status\":\"completed\"}"); + assertEquals( + "dec_ok", + retrying + .decisions() + .create(params, RequestOptions.builder().idempotencyKey("decision-key").build()) + .id()); + RecordedRequest first = request(), second = request(); + assertEquals(first.getBody().readUtf8(), second.getBody().readUtf8()); + assertEquals("decision-key", second.getHeader("Idempotency-Key")); + server.enqueue( + new MockResponse() + .setResponseCode(409) + .setBody( + "{\"error\":{\"code\":\"decision_in_progress\",\"message\":\"pending\",\"decision_id\":\"dec_pending\"}}")); + assertThrows( + ApiException.class, + () -> + retrying + .decisions() + .create(params, RequestOptions.builder().idempotencyKey("pending-key").build())); + request(); + assertEquals(3, server.getRequestCount()); + server.enqueue( + new MockResponse().setResponseCode(503).setBody("{\"error\":{\"message\":\"busy\"}}")); + assertThrows(ApiException.class, () -> retrying.decisions().create(params)); + request(); + assertEquals(4, server.getRequestCount()); + } + } +} diff --git a/src/test/java/com/qoder/cloudagents/ForwardParityTest.java b/src/test/java/com/qoder/cloudagents/ForwardParityTest.java new file mode 100644 index 0000000..e6f73ca --- /dev/null +++ b/src/test/java/com/qoder/cloudagents/ForwardParityTest.java @@ -0,0 +1,658 @@ +package com.qoder.cloudagents; + +import static org.junit.jupiter.api.Assertions.*; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.*; +import com.qoder.cloudagents.forward.*; +import java.io.*; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.*; +import okhttp3.mockwebserver.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.*; + +@Timeout(15) +class ForwardParityTest { + private MockWebServer server; + private ForwardClient client; + + @BeforeEach + void setup() throws Exception { + server = new MockWebServer(); + server.start(); + client = + ForwardClient.builder() + .pat("test-pat") + .baseUrl(server.url("/api/v1/forward").toString()) + .maxRetries(0) + .build(); + } + + @AfterEach + void close() throws Exception { + client.close(); + server.close(); + } + + private void respond(JsonValue body) { + server.enqueue( + new MockResponse().setHeader("X-Request-ID", "req_parity").setBody(body.toJson())); + } + + private RecordedRequest request() throws Exception { + RecordedRequest r = server.takeRequest(2, TimeUnit.SECONDS); + assertNotNull(r); + return r; + } + + private static List fixtures() throws IOException { + try (BufferedReader r = + new BufferedReader( + new InputStreamReader( + ForwardParityTest.class.getResourceAsStream("/forward-parity-routes.json"), + StandardCharsets.UTF_8))) { + return JsonValue.parse(r.lines().collect(Collectors.joining("\n"))).asArray(); + } + } + + static Stream routes() throws Exception { + return fixtures().stream() + .flatMap( + f -> + IntStream.range(0, 4) + .mapToObj(mode -> Arguments.of(f.get("operation").asString(), mode, f))); + } + + @ParameterizedTest(name = "{0}, mode={1}") + @MethodSource("routes") + void allFifteenOperationsUseOfficialWireContracts(String operation, int mode, JsonValue fixture) + throws Exception { + respond(fixture.get("response")); + Object value = invoke(operation, mode); + if (value instanceof CompletableFuture) + value = ((CompletableFuture) value).get(3, TimeUnit.SECONDS); + if (value instanceof ApiResponse) + assertEquals("req_parity", ((ApiResponse) value).requestId()); + RecordedRequest r = request(); + assertEquals(fixture.get("method").asString(), r.getMethod()); + assertEquals("/api/v1/forward" + fixture.get("path").asString(), r.getPath()); + assertEquals("Bearer test-pat", r.getHeader("Authorization")); + assertEquals("java", r.getHeader("X-Qoder-Lang")); + if (operation.startsWith("search")) + assertEquals("search-2026-08-31", r.getHeader("x-qoder-beta")); + String body = r.getBody().readUtf8(); + if (fixture.get("body").isNull()) assertEquals("", body); + else assertEquals(fixture.get("body").toJson(), JsonValue.parse(body).toJson()); + } + + private DecisionCreateParams decisionParams() { + return DecisionCreateParams.builder() + .state(Collections.singletonMap("ready", JsonValue.of(true))) + .questions( + Collections.singletonList( + DecisionQuestion.builder() + .type("choice") + .instructions("Is it ready?") + .choices( + Arrays.asList( + DecisionChoice.builder().value(true).build(), + DecisionChoice.builder().value(false).build())) + .build())) + .build(); + } + + private Object invoke(String operation, int mode) { + switch (operation) { + case "searchEnvironment": + if (mode == 0) + return client + .environments() + .search( + EnvironmentSearchParams.builder() + .name("dev") + .metadata(Collections.singletonMap("team", "sdk")) + .createdAtGte("2026-01-01T00:00:00Z") + .limit(1) + .identityId("idn_owner") + .build()); + if (mode == 1) + return client + .async() + .environments() + .search( + EnvironmentSearchParams.builder() + .name("dev") + .metadata(Collections.singletonMap("team", "sdk")) + .createdAtGte("2026-01-01T00:00:00Z") + .limit(1) + .identityId("idn_owner") + .build()); + if (mode == 2) + return client + .environments() + .withResponse() + .search( + EnvironmentSearchParams.builder() + .name("dev") + .metadata(Collections.singletonMap("team", "sdk")) + .createdAtGte("2026-01-01T00:00:00Z") + .limit(1) + .identityId("idn_owner") + .build()); + return client + .async() + .environments() + .withResponse() + .search( + EnvironmentSearchParams.builder() + .name("dev") + .metadata(Collections.singletonMap("team", "sdk")) + .createdAtGte("2026-01-01T00:00:00Z") + .limit(1) + .identityId("idn_owner") + .build()); + case "searchFile": + if (mode == 0) + return client + .files() + .search( + FileSearchParams.builder() + .name("report") + .scopeId("sess_test") + .identityId("idn_owner") + .build()); + if (mode == 1) + return client + .async() + .files() + .search( + FileSearchParams.builder() + .name("report") + .scopeId("sess_test") + .identityId("idn_owner") + .build()); + if (mode == 2) + return client + .files() + .withResponse() + .search( + FileSearchParams.builder() + .name("report") + .scopeId("sess_test") + .identityId("idn_owner") + .build()); + return client + .async() + .files() + .withResponse() + .search( + FileSearchParams.builder() + .name("report") + .scopeId("sess_test") + .identityId("idn_owner") + .build()); + case "searchSkill": + if (mode == 0) + return client + .skills() + .search( + SkillSearchParams.builder() + .source("custom") + .displayTitle("research") + .identityId("idn_owner") + .build()); + if (mode == 1) + return client + .async() + .skills() + .search( + SkillSearchParams.builder() + .source("custom") + .displayTitle("research") + .identityId("idn_owner") + .build()); + if (mode == 2) + return client + .skills() + .withResponse() + .search( + SkillSearchParams.builder() + .source("custom") + .displayTitle("research") + .identityId("idn_owner") + .build()); + return client + .async() + .skills() + .withResponse() + .search( + SkillSearchParams.builder() + .source("custom") + .displayTitle("research") + .identityId("idn_owner") + .build()); + case "searchVault": + if (mode == 0) + return client + .vaults() + .search( + VaultSearchParams.builder() + .name("keys") + .includeArchived(true) + .identityId("idn_owner") + .build()); + if (mode == 1) + return client + .async() + .vaults() + .search( + VaultSearchParams.builder() + .name("keys") + .includeArchived(true) + .identityId("idn_owner") + .build()); + if (mode == 2) + return client + .vaults() + .withResponse() + .search( + VaultSearchParams.builder() + .name("keys") + .includeArchived(true) + .identityId("idn_owner") + .build()); + return client + .async() + .vaults() + .withResponse() + .search( + VaultSearchParams.builder() + .name("keys") + .includeArchived(true) + .identityId("idn_owner") + .build()); + case "createDream": + if (mode == 0) + return client + .dreams() + .create( + DreamCreateParams.builder() + .inputs( + Collections.singletonList( + DreamInput.builder() + .type("memory_store") + .memoryStoreId("memstore_test") + .build())) + .build()); + if (mode == 1) + return client + .async() + .dreams() + .create( + DreamCreateParams.builder() + .inputs( + Collections.singletonList( + DreamInput.builder() + .type("memory_store") + .memoryStoreId("memstore_test") + .build())) + .build()); + if (mode == 2) + return client + .dreams() + .withResponse() + .create( + DreamCreateParams.builder() + .inputs( + Collections.singletonList( + DreamInput.builder() + .type("memory_store") + .memoryStoreId("memstore_test") + .build())) + .build()); + return client + .async() + .dreams() + .withResponse() + .create( + DreamCreateParams.builder() + .inputs( + Collections.singletonList( + DreamInput.builder() + .type("memory_store") + .memoryStoreId("memstore_test") + .build())) + .build()); + case "listDream": + if (mode == 0) + return client + .dreams() + .list(DreamListParams.builder().includeArchived(true).limit(2).build()); + if (mode == 1) + return client + .async() + .dreams() + .list(DreamListParams.builder().includeArchived(true).limit(2).build()); + if (mode == 2) + return client + .dreams() + .withResponse() + .list(DreamListParams.builder().includeArchived(true).limit(2).build()); + return client + .async() + .dreams() + .withResponse() + .list(DreamListParams.builder().includeArchived(true).limit(2).build()); + case "getDream": + if (mode == 0) return client.dreams().retrieve("drm/a"); + if (mode == 1) return client.async().dreams().retrieve("drm/a"); + if (mode == 2) return client.dreams().withResponse().retrieve("drm/a"); + return client.async().dreams().withResponse().retrieve("drm/a"); + case "cancelDream": + if (mode == 0) return client.dreams().cancel("drm/a"); + if (mode == 1) return client.async().dreams().cancel("drm/a"); + if (mode == 2) return client.dreams().withResponse().cancel("drm/a"); + return client.async().dreams().withResponse().cancel("drm/a"); + case "archiveDream": + if (mode == 0) return client.dreams().archive("drm/a"); + if (mode == 1) return client.async().dreams().archive("drm/a"); + if (mode == 2) return client.dreams().withResponse().archive("drm/a"); + return client.async().dreams().withResponse().archive("drm/a"); + case "listPairing": + if (mode == 0) + return client + .channelPairings() + .list( + ChannelPairingListParams.builder() + .channelId("channel_test") + .status("active") + .targetType("group") + .targetExternalId("room") + .identityId("idn_test") + .templateId("tmpl_test") + .limit(2) + .build()); + if (mode == 1) + return client + .async() + .channelPairings() + .list( + ChannelPairingListParams.builder() + .channelId("channel_test") + .status("active") + .targetType("group") + .targetExternalId("room") + .identityId("idn_test") + .templateId("tmpl_test") + .limit(2) + .build()); + if (mode == 2) + return client + .channelPairings() + .withResponse() + .list( + ChannelPairingListParams.builder() + .channelId("channel_test") + .status("active") + .targetType("group") + .targetExternalId("room") + .identityId("idn_test") + .templateId("tmpl_test") + .limit(2) + .build()); + return client + .async() + .channelPairings() + .withResponse() + .list( + ChannelPairingListParams.builder() + .channelId("channel_test") + .status("active") + .targetType("group") + .targetExternalId("room") + .identityId("idn_test") + .templateId("tmpl_test") + .limit(2) + .build()); + case "getPairing": + if (mode == 0) return client.channelPairings().retrieve("pair/a"); + if (mode == 1) return client.async().channelPairings().retrieve("pair/a"); + if (mode == 2) return client.channelPairings().withResponse().retrieve("pair/a"); + return client.async().channelPairings().withResponse().retrieve("pair/a"); + case "updatePairing": + if (mode == 0) + return client + .channelPairings() + .update( + "pair/a", ChannelPairingUpdateParams.builder().templateId("tmpl_new").build()); + if (mode == 1) + return client + .async() + .channelPairings() + .update( + "pair/a", ChannelPairingUpdateParams.builder().templateId("tmpl_new").build()); + if (mode == 2) + return client + .channelPairings() + .withResponse() + .update( + "pair/a", ChannelPairingUpdateParams.builder().templateId("tmpl_new").build()); + return client + .async() + .channelPairings() + .withResponse() + .update("pair/a", ChannelPairingUpdateParams.builder().templateId("tmpl_new").build()); + case "createDecision": + if (mode == 0) return client.decisions().create(decisionParams()); + if (mode == 1) return client.async().decisions().create(decisionParams()); + if (mode == 2) return client.decisions().withResponse().create(decisionParams()); + return client.async().decisions().withResponse().create(decisionParams()); + case "getDecision": + if (mode == 0) return client.decisions().retrieve("dec/a"); + if (mode == 1) return client.async().decisions().retrieve("dec/a"); + if (mode == 2) return client.decisions().withResponse().retrieve("dec/a"); + return client.async().decisions().withResponse().retrieve("dec/a"); + case "listDecision": + if (mode == 0) + return client + .decisions() + .list( + DecisionListParams.builder() + .sessionId("sess_test") + .beforeId("dec_new") + .limit(2) + .build()); + if (mode == 1) + return client + .async() + .decisions() + .list( + DecisionListParams.builder() + .sessionId("sess_test") + .beforeId("dec_new") + .limit(2) + .build()); + if (mode == 2) + return client + .decisions() + .withResponse() + .list( + DecisionListParams.builder() + .sessionId("sess_test") + .beforeId("dec_new") + .limit(2) + .build()); + return client + .async() + .decisions() + .withResponse() + .list( + DecisionListParams.builder() + .sessionId("sess_test") + .beforeId("dec_new") + .limit(2) + .build()); + default: + throw new AssertionError(operation); + } + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3}) + void sessionMarkersPreserveCallerMetadataAndCoverEveryCreateEntry(int mode) throws Exception { + Map metadata = new LinkedHashMap<>(); + metadata.put("ticket", JsonValue.of("example")); + metadata.put("__qca_sdk_future", JsonValue.of("kept")); + metadata.put("__qca_sdk_created", JsonValue.of("true")); + SessionCreateParams params = + SessionCreateParams.builder().identityId("i").templateId("t").metadata(metadata).build(); + respond(JsonValue.parse("{\"id\":\"sess_test\"}")); + if (mode == 0) client.sessions().create(params); + if (mode == 1) client.async().sessions().create(params).get(3, TimeUnit.SECONDS); + if (mode == 2) client.sessions().withResponse().create(params); + if (mode == 3) client.async().sessions().withResponse().create(params).get(3, TimeUnit.SECONDS); + RecordedRequest created = request(); + JsonValue actual = JsonValue.parse(created.getBody().readUtf8()).get("metadata"); + assertEquals("true", actual.get("__qca_sdk_created").asString()); + assertEquals("java", actual.get("__qca_sdk_language").asString()); + assertEquals( + created.getHeader("X-Qoder-Package-Version"), actual.get("__qca_sdk_version").asString()); + assertEquals("example", actual.get("ticket").asString()); + assertEquals("kept", actual.get("__qca_sdk_future").asString()); + assertEquals(3, metadata.size()); + assertEquals(3, params.metadata().size()); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "{\"metadata\":null}"}) + void missingAndNullMetadataAreMarked(String json) throws Exception { + respond(JsonValue.parse("{\"id\":\"sess_test\"}")); + client.sessions().create(SessionCreateParams.fromJson(JsonValue.parse(json))); + assertEquals( + 3, JsonValue.parse(request().getBody().readUtf8()).get("metadata").asObject().size()); + } + + @Test + void conflictsFailBeforeHttpAndUpdatesAreNotMarked() throws Exception { + for (String key : + Arrays.asList("__qca_sdk_created", "__qca_sdk_language", "__qca_sdk_version")) { + for (JsonValue value : + Arrays.asList(JsonValue.of("wrong"), JsonValue.of(true), JsonValue.of(null))) { + SessionCreateParams params = + SessionCreateParams.builder() + .identityId("i") + .templateId("t") + .metadata(Collections.singletonMap(key, value)) + .build(); + assertThrows(ConfigurationException.class, () -> client.sessions().create(params)); + assertThrows( + ConfigurationException.class, + () -> client.async().sessions().withResponse().create(params)); + } + } + assertEquals(0, server.getRequestCount()); + respond(JsonValue.parse("{\"id\":\"sess_test\"}")); + client + .sessions() + .update( + "sess_test", + SessionUpdateParams.builder() + .metadata(Collections.singletonMap("ticket", JsonValue.of("new"))) + .build()); + assertEquals("{\"metadata\":{\"ticket\":\"new\"}}", request().getBody().readUtf8()); + } + + @Test + void fieldsUseCurrentContractAndRetainNullAndEmpty() throws Exception { + assertEquals( + "{\"type\":\"text\",\"content\":\"accepted\"}", + OutcomeRubric.builder().type("text").text("accepted").build().rawJson()); + assertEquals( + "accepted", + OutcomeRubric.fromJson(JsonValue.parse("{\"type\":\"text\",\"content\":\"accepted\"}")) + .content()); + assertEquals( + "{\"type\":\"file\",\"file_id\":\"file_rubric\"}", + OutcomeRubric.builder().type("file").fileId("file_rubric").build().rawJson()); + assertTrue( + IdentityCreateParams.builder() + .externalId("external") + .autoMemoryStore(null) + .build() + .field("auto_memory_store") + .isNull()); + assertEquals( + Boolean.FALSE, + IdentityUpdateParams.builder().autoMemoryStore(false).build().autoMemoryStore()); + assertEquals( + Boolean.FALSE, + new Identity(JsonValue.parse("{\"auto_memory_store\":false}"), null).autoMemoryStore()); + assertTrue(ScheduleUpdateParams.builder().resources(null).build().field("resources").isNull()); + assertEquals( + "[]", + ScheduleUpdateParams.builder() + .resources(Collections.emptyList()) + .build() + .field("resources") + .toJson()); + respond( + JsonValue.parse( + "{\"id\":\"run_test\",\"initial_events\":[],\"initial_events_version\":0}")); + ScheduleRun run = + client + .scheduleRuns() + .retrieve( + "run_test", ScheduleRunRetrieveParams.builder().includeInitialEvents(true).build()); + assertEquals( + "/api/v1/forward/schedule_runs/run_test?include_initial_events=true", request().getPath()); + assertEquals(Collections.emptyList(), run.initialEvents()); + assertEquals(Long.valueOf(0), run.initialEventsVersion()); + assertNull(new ScheduleRun(JsonValue.parse("{}"), null).initialEvents()); + respond(JsonValue.parse("{\"memory_store_id\":\"m\",\"access\":\"read_write\"}")); + client + .identities() + .memoryStores() + .mount( + "i", + "t", + IdentityMemoryStoreMountParams.builder() + .memoryStoreId("m") + .access("read_write") + .build()); + JsonValue mount = JsonValue.parse(request().getBody().readUtf8()); + assertEquals("read_write", mount.get("access").asString()); + } + + @Test + void usageEventsPreserveDecimalPrecisionAndAbsentFields() { + SessionEvent snapshot = + new SessionEvent( + JsonValue.parse( + "{\"type\":\"session.usage\",\"model_credits\":0,\"sandbox_runtime_credits\":0.25,\"total_credits\":9007199254740993.25}"), + null); + assertEquals(BigDecimal.ZERO, snapshot.modelCredits()); + assertEquals(new BigDecimal("0.25"), snapshot.sandboxRuntimeCredits()); + assertEquals(new BigDecimal("9007199254740993.25"), snapshot.totalCredits()); + SessionEvent model = + new SessionEvent( + JsonValue.parse( + "{\"type\":\"span.model_request_end\",\"model_usage\":{\"credits\":1.25}}"), + null); + assertEquals(new BigDecimal("1.25"), model.modelUsageCredits()); + SessionEvent compaction = + new SessionEvent( + JsonValue.parse( + "{\"type\":\"agent.thread_context_compacted\",\"usage\":{\"credits\":0.5}}"), + null); + assertEquals(new BigDecimal("0.5"), compaction.usageCredits()); + assertNull(model.usageCredits()); + assertNull(model.totalCredits()); + assertNull( + new SessionEvent(JsonValue.parse("{\"type\":\"future.event\"}"), null).modelUsageCredits()); + } +} diff --git a/src/test/java/com/qoder/cloudagents/SessionsTest.java b/src/test/java/com/qoder/cloudagents/SessionsTest.java index e02c9a8..d3fe3d4 100644 --- a/src/test/java/com/qoder/cloudagents/SessionsTest.java +++ b/src/test/java/com/qoder/cloudagents/SessionsTest.java @@ -198,6 +198,16 @@ void syncAndAsyncRoutes( assertEquals("Bearer test-pat", sent.getHeader("Authorization")); assertEquals("logical-operation", sent.getHeader("Idempotency-Key")); assertEquals("contract", sent.getHeader("X-Test")); + if (operation.equals("create")) { + com.fasterxml.jackson.databind.node.ObjectNode expected = + (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(body); + expected + .putObject("metadata") + .put("__qca_sdk_created", "true") + .put("__qca_sdk_language", "java") + .put("__qca_sdk_version", sent.getHeader("X-Qoder-Package-Version")); + body = expected.toString(); + } String actual = sent.getBody().readUtf8(); if (body.isEmpty()) assertEquals("", actual); else assertEquals(JSON.readTree(body), JSON.readTree(actual)); @@ -418,8 +428,7 @@ void allInputEventShapesUseArrayMessagesAndObjectRubric() throws Exception { SessionEventParam.builder() .type("user.define_outcome") .description("Tests pass") - .rubric(OutcomeRubric.builder().type("text").text("Tests and lint pass").build()) - .outcomeId("outc_1") + .rubric(OutcomeRubric.builder().type("text").content("Tests and lint pass").build()) .maxIterations(3L) .build()); SessionEventSendParams params = SessionEventSendParams.builder().events(events).build(); diff --git a/src/test/resources/forward-parity-routes.json b/src/test/resources/forward-parity-routes.json new file mode 100644 index 0000000..08384b0 --- /dev/null +++ b/src/test/resources/forward-parity-routes.json @@ -0,0 +1,232 @@ +[ + { + "operation": "searchEnvironment", + "method": "POST", + "path": "/environments/search?identity_id=idn_owner", + "body": { + "name": "dev", + "metadata": { + "team": "sdk" + }, + "created_at[gte]": "2026-01-01T00:00:00Z", + "limit": 1 + }, + "response": { + "data": [], + "has_more": false, + "first_id": null, + "last_id": null, + "next_page": null + } + }, + { + "operation": "searchFile", + "method": "POST", + "path": "/files/search?identity_id=idn_owner", + "body": { + "name": "report", + "scope_id": "sess_test" + }, + "response": { + "data": [], + "has_more": false, + "first_id": null, + "last_id": null, + "next_page": null + } + }, + { + "operation": "searchSkill", + "method": "POST", + "path": "/skills/search?identity_id=idn_owner", + "body": { + "source": "custom", + "display_title": "research" + }, + "response": { + "data": [], + "has_more": false, + "first_id": null, + "last_id": null, + "next_page": null + } + }, + { + "operation": "searchVault", + "method": "POST", + "path": "/vaults/search?identity_id=idn_owner", + "body": { + "name": "keys", + "include_archived": true + }, + "response": { + "data": [], + "has_more": false, + "first_id": null, + "last_id": null, + "next_page": null + } + }, + { + "operation": "createDream", + "method": "POST", + "path": "/dreams", + "body": { + "inputs": [ + { + "type": "memory_store", + "memory_store_id": "memstore_test" + } + ] + }, + "response": { + "id": "drm_test", + "type": "dream", + "status": "pending" + } + }, + { + "operation": "listDream", + "method": "GET", + "path": "/dreams?include_archived=true&limit=2", + "body": null, + "response": { + "data": [], + "has_more": false, + "first_id": null, + "last_id": null, + "next_page": null + } + }, + { + "operation": "getDream", + "method": "GET", + "path": "/dreams/drm%2Fa", + "body": null, + "response": { + "id": "drm_test", + "type": "dream", + "status": "pending" + } + }, + { + "operation": "cancelDream", + "method": "POST", + "path": "/dreams/drm%2Fa/cancel", + "body": null, + "response": { + "id": "drm_test", + "type": "dream", + "status": "pending" + } + }, + { + "operation": "archiveDream", + "method": "POST", + "path": "/dreams/drm%2Fa/archive", + "body": null, + "response": { + "id": "drm_test", + "type": "dream", + "status": "pending" + } + }, + { + "operation": "listPairing", + "method": "GET", + "path": "/channel_pairings?channel_id=channel_test&status=active&target_type=group&target_external_id=room&identity_id=idn_test&template_id=tmpl_test&limit=2", + "body": null, + "response": { + "data": [], + "has_more": false, + "first_id": null, + "last_id": null, + "next_page": null + } + }, + { + "operation": "getPairing", + "method": "GET", + "path": "/channel_pairings/pair%2Fa", + "body": null, + "response": { + "id": "pair_test", + "status": "active" + } + }, + { + "operation": "updatePairing", + "method": "POST", + "path": "/channel_pairings/pair%2Fa", + "body": { + "template_id": "tmpl_new" + }, + "response": { + "id": "pair_test", + "status": "active" + } + }, + { + "operation": "createDecision", + "method": "POST", + "path": "/decisions", + "body": { + "state": { + "ready": true + }, + "questions": [ + { + "type": "choice", + "instructions": "Is it ready?", + "choices": [ + { + "value": true + }, + { + "value": false + } + ] + } + ] + }, + "response": { + "id": "dec_test", + "status": "completed", + "answers": [ + { + "type": "choice", + "value": true + } + ] + } + }, + { + "operation": "getDecision", + "method": "GET", + "path": "/decisions/dec%2Fa", + "body": null, + "response": { + "id": "dec_test", + "status": "completed", + "answers": [ + { + "type": "choice", + "value": true + } + ] + } + }, + { + "operation": "listDecision", + "method": "GET", + "path": "/decisions?session_id=sess_test&before_id=dec_new&limit=2", + "body": null, + "response": { + "data": [], + "has_more": false, + "first_id": null, + "last_id": null, + "next_page": null + } + } +] diff --git a/src/test/resources/session-input-events.json b/src/test/resources/session-input-events.json index c62966f..96843fa 100644 --- a/src/test/resources/session-input-events.json +++ b/src/test/resources/session-input-events.json @@ -8,6 +8,6 @@ {"type": "user.tool_confirmation", "tool_use_id": "toolu_1", "result": "deny", "deny_message": "Not needed"}, {"type": "user.tool_result", "tool_use_id": "toolu_2", "content": "tool output", "is_error": false}, {"type": "user.custom_tool_result", "custom_tool_use_id": "custom_1", "content": [{"type": "text", "text": "Done"}], "is_error": true}, - {"type": "user.define_outcome", "description": "Tests pass", "rubric": {"type": "text", "text": "Tests and lint pass"}, "outcome_id": "outc_1", "max_iterations": 3} + {"type": "user.define_outcome", "description": "Tests pass", "rubric": {"type": "text", "content": "Tests and lint pass"}, "max_iterations": 3} ] }