From 0fb9234a0f71ae4b63392b8cc75d34136b9693e8 Mon Sep 17 00:00:00 2001 From: "yuechen.wyh" <300878504+moonyue-w@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:17:23 +0800 Subject: [PATCH] fix: align session contracts and stop terminated thread streams --- CHANGELOG.md | 20 ++ docs/environments-files.md | 4 + docs/forward-api-coverage.md | 2 + docs/managed-api-coverage.md | 2 + docs/session-streaming.md | 4 + docs/sessions.md | 17 ++ .../examples/PackagedConsumerTest.java | 44 ++++ .../forward/EnvironmentConfig.java | 10 + .../qoder/cloudagents/forward/Session.java | 5 + .../cloudagents/forward/SessionBudget.java | 53 ++++ .../cloudagents/forward/SessionConfig.java | 9 + .../forward/SessionCreateParams.java | 10 + .../cloudagents/forward/SessionEvent.java | 14 +- .../cloudagents/forward/SessionStreams.java | 3 +- .../forward/SessionUpdateParams.java | 10 + .../forward/ToolApprovalDecision.java | 22 ++ .../forward/ToolApprovalEvaluation.java | 22 ++ .../managed/EnvironmentConfig.java | 10 + .../cloudagents/managed/SessionEvent.java | 5 +- .../cloudagents/managed/SessionStreams.java | 3 +- .../DocumentationAlignmentTest.java | 241 ++++++++++++++++++ 21 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/qoder/cloudagents/forward/SessionBudget.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/ToolApprovalDecision.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/ToolApprovalEvaluation.java create mode 100644 src/test/java/com/qoder/cloudagents/DocumentationAlignmentTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f2103..a08d284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +### Added + +- Add Forward Session budget builders/getters with decimal-string limits and preserved omission/null clearing, Session model overrides and typed approval evaluation details. +- Add `startupScript()` and builder setters to Forward/Managed Environment configurations. Scripts run on sandbox start/resume; self-hosted execution is controlled by the external worker. + +### Fixed + +- Read event-level `evaluatedPermission()` as a string in Forward and Managed. +- Stop Forward/Managed Thread resumable streams after `session.thread_status_terminated` in synchronous and asynchronous clients. Idle events keep the subscription open, and Session streams continue after a child Thread ends. + +### Breaking changes + +- `SessionEvent.evaluatedPermission()` returns `String` instead of `Map` in both layers. + +### Migration + +- Compare `event.evaluatedPermission()` to `"allow"`, `"ask"` or `"deny"`; optional Forward automatic decision details are under `event.evaluation().evaluatedPermission()`. +- Set `budget(SessionBudget.builder().type("limit").maxCreditCost("100.00").build())` to limit Credits. Omit the setter to keep the default/current limit; `budget(null)` clears it. +- Use `SessionConfig.builder().model(ModelConfig.of("ultimate"))` to override the model. Omit on create to inherit; `.model(null)` on update restores inheritance. `field(...)` distinguishes absent and null responses. + ### Changed - Remove resource-specific sections and inventories from the README to keep it focused on SDK setup and shared client behavior. diff --git a/docs/environments-files.md b/docs/environments-files.md index 41cb5a9..5cee759 100644 --- a/docs/environments-files.md +++ b/docs/environments-files.md @@ -126,3 +126,7 @@ Treat signed URLs as credentials; do not log them. This helper does not fetch co [EnvironmentFileExample](../examples/src/main/java/com/qoder/cloudagents/examples/EnvironmentFileExample.java) creates an isolated Environment and text File, compares downloaded bytes in bounded chunks, then deletes only its own resources. It retains cleanup errors without hiding the original failure. It does not start Sessions or execute a setup script. See [example commands](../examples/README.md) and [opt-in live testing](live-testing.md). Default tests use mock services and the installed SDK JAR. + +## Startup scripts + +Both Forward and Managed `EnvironmentConfig` expose `.startupScript("...")` and `startupScript()`. Cloud sandboxes run this shell script after setup on the first start and again on each resume. Put persistent installation work in `setupScript`, and restart transient services in `startupScript`. The service limits scripts to 64 KB and five minutes; startup failures surface as `environment_startup_failed_error`. Self-hosted environments store and return the script, while the external worker decides whether to execute it. The SDK transports the configuration and does not execute shell commands locally. diff --git a/docs/forward-api-coverage.md b/docs/forward-api-coverage.md index 97bb9d7..a2bd5e2 100644 --- a/docs/forward-api-coverage.md +++ b/docs/forward-api-coverage.md @@ -1,5 +1,7 @@ # Forward API 覆盖基线 +开发中契约复核(2026-11-03):补齐 Session budget(省略/null/对象)、config.model、审批 evaluation 和 Environment startup_script;修正顶层 evaluatedPermission 字符串与 Thread 终止后的续订行为。本轮没有新增 HTTP 操作;验证见 `DocumentationAlignmentTest`,真实环境结果单独记录。 + 契约核对:2026-10-28。**0.2.0** 覆盖 **146 个 HTTP 操作及 Realtime WebSocket**:首版 123 项、Webhook 8 项、Forward Search / Dream / Pairing / Decision 15 项。同一版本还提供 [107 个 Managed 操作](managed-api-coverage.md)。离线验证与真实环境验收分别记录,不能把操作覆盖数当作线上通过率。 HTTP BaseURL 为 CN `https://api.qoder.com.cn/api/v1/forward` 或 Global `https://api.qoder.com/api/v1/forward`;表内路径均相对于 BaseURL。 diff --git a/docs/managed-api-coverage.md b/docs/managed-api-coverage.md index 9743b64..9284c3b 100644 --- a/docs/managed-api-coverage.md +++ b/docs/managed-api-coverage.md @@ -1,5 +1,7 @@ # Managed API 覆盖基线 +开发中契约复核(2026-11-03):修正顶层 evaluatedPermission 字符串和 Thread 终止后的续订行为,补齐 Environment startup_script。Memory version/redacted、Vault credentials 和 Session Credits 已有正式访问器。本轮没有新增 HTTP 操作;验证见 `DocumentationAlignmentTest`,真实环境结果单独记录。 + 契约核对:2026-10-28;接口清单更新:2026-10-29。**0.2.0** 与 Forward 更新一起提供 **107 个 Managed 协议操作:105 个普通 HTTP 操作、2 个 SSE 操作**。同步/异步/响应封装只计一个操作;0.1.0 不包含本表能力。 Base URL:CN `https://api.qoder.com.cn/api/v1/cloud`,Global `https://api.qoder.com/api/v1/cloud`。使用方法见 [Managed 指南](managed.md);验收范围见 [真实环境测试](managed-live-testing.md)。接口存在与离线通过不等于线上权限或执行结果已验证。 diff --git a/docs/session-streaming.md b/docs/session-streaming.md index 17bfc9c..34c2b3c 100644 --- a/docs/session-streaming.md +++ b/docs/session-streaming.md @@ -94,3 +94,7 @@ To cancel the actual Session execution, use the existing `client.sessions().canc [SessionStreamingExample](../examples/src/main/java/com/qoder/cloudagents/examples/SessionStreamingExample.java) supports existing Session or Thread, shared PAT/SA setup, sync/async consumption, a 20-event limit and a two-minute total deadline. It logs only event IDs/types, checkpoint and request ID. Tests run it as an independent consumer of the installed JAR. Protocol behavior follows the [shared SDK baseline](forward-api-coverage.md). Offline checks do not establish CN/Global production compatibility; use [live testing](live-testing.md) to exercise Session execution and streaming in a selected environment. + +Thread `resumableStream` stops after yielding `session.thread_status_terminated`. Idle events do not end a Thread subscription. A Session subscription continues after one child Thread terminates. This applies to both Forward and Managed, including asynchronous clients. + +In both layers, `event.evaluatedPermission()` returns an optional `String` (`allow`, `ask`, or `deny`). Forward additionally exposes `event.evaluation()` for policy source and optional automatic decision details; check for null before reading `evaluatedPermission().reasonCode()`. The nested decision is an object and is separate from the event-level result. diff --git a/docs/sessions.md b/docs/sessions.md index 84bae7c..5e048c7 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -148,3 +148,20 @@ Implementation follows the [pinned SDK baseline](forward-api-coverage.md). Newer 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. + +## Session budget and model overrides + +```java +SessionCreateParams create = SessionCreateParams.builder() + .identityId("idn_existing").templateId("tmpl_existing") + .budget(SessionBudget.builder().type("limit").maxCreditCost("100.00").build()) + .config(SessionConfig.builder().model(ModelConfig.builder() + .id("ultimate").speed("high").build()).build()) + .build(); +SessionUpdateParams clear = SessionUpdateParams.builder().budget(null) + .config(SessionConfig.builder().model(null).build()).build(); +``` + +Omitting `budget` keeps the current limit on update; `null` clears it and an object replaces it. It limits cumulative model and sandbox Credits; an active turn can finish, but the server rejects the next user message once the budget is exhausted. A Session model override accepts a model ID string (`ModelConfig.of`) or an object. Omit it on create to inherit; `null` is only valid on update to restore inheritance. Query model capabilities before selecting speed or context windows. + +`session.updated` carries only changed fields. Use `event.field("budget").isMissing()` versus `.isNull()` to distinguish an unchanged budget from a cleared limit. `session.usage` provides cumulative `modelCredits()`, `sandboxRuntimeCredits()` and `totalCredits()`; replace the previous snapshot rather than summing successive events. diff --git a/examples/src/test/java/com/qoder/cloudagents/examples/PackagedConsumerTest.java b/examples/src/test/java/com/qoder/cloudagents/examples/PackagedConsumerTest.java index 0b5e1a0..e73e194 100644 --- a/examples/src/test/java/com/qoder/cloudagents/examples/PackagedConsumerTest.java +++ b/examples/src/test/java/com/qoder/cloudagents/examples/PackagedConsumerTest.java @@ -52,4 +52,48 @@ void examplesRunAgainstInstalledJarAndPreserveMetadata() throws Exception { assertEquals("/api/v1/forward/models", server.takeRequest().getPath()); } } + + @Test + void newSessionContractsAreAvailableFromThePackagedJar() { + com.qoder.cloudagents.forward.SessionBudget budget = + com.qoder.cloudagents.forward.SessionBudget.builder() + .type("limit") + .maxCreditCost("100.00") + .build(); + com.qoder.cloudagents.forward.SessionConfig config = + com.qoder.cloudagents.forward.SessionConfig.builder() + .model(com.qoder.cloudagents.forward.ModelConfig.of("ultimate")) + .build(); + com.qoder.cloudagents.forward.SessionUpdateParams params = + com.qoder.cloudagents.forward.SessionUpdateParams.builder() + .budget(budget) + .config(config) + .build(); + assertEquals("100.00", params.budget().maxCreditCost()); + assertEquals("ultimate", params.config().model().id()); + com.qoder.cloudagents.core.JsonValue payload = + com.qoder.cloudagents.core.JsonValue.parse( + "{\"evaluated_permission\":\"deny\",\"evaluation\":{\"type\":\"auto\",\"evaluated_permission\":{\"type\":\"deny\",\"reason_code\":\"high_risk\"}}}"); + com.qoder.cloudagents.forward.SessionEvent event = + new com.qoder.cloudagents.forward.SessionEvent(payload, null); + assertEquals("deny", event.evaluatedPermission()); + assertEquals("high_risk", event.evaluation().evaluatedPermission().reasonCode()); + assertEquals( + "deny", + new com.qoder.cloudagents.managed.SessionEvent(payload, null).evaluatedPermission()); + assertEquals( + "echo ready", + com.qoder.cloudagents.forward.EnvironmentConfig.builder() + .type("cloud") + .startupScript("echo ready") + .build() + .startupScript()); + assertEquals( + "echo ready", + com.qoder.cloudagents.managed.EnvironmentConfig.builder() + .type("cloud") + .startupScript("echo ready") + .build() + .startupScript()); + } } diff --git a/src/main/java/com/qoder/cloudagents/forward/EnvironmentConfig.java b/src/main/java/com/qoder/cloudagents/forward/EnvironmentConfig.java index 2fc729c..16b1f8b 100644 --- a/src/main/java/com/qoder/cloudagents/forward/EnvironmentConfig.java +++ b/src/main/java/com/qoder/cloudagents/forward/EnvironmentConfig.java @@ -17,6 +17,11 @@ public String type() { return optional("type", JsonValue::asString); } + /** Runs on each sandbox start or resume; self-hosted execution depends on the worker. */ + public String startupScript() { + return optional("startup_script", JsonValue::asString); + } + public String setupScript() { return optional("setup_script", JsonValue::asString); } @@ -36,6 +41,11 @@ public Builder type(String value) { return put("type", value); } + /** Configure a startup script; the SDK does not execute it. */ + public Builder startupScript(String value) { + return put("startup_script", value); + } + public Builder setupScript(String value) { return put("setup_script", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/Session.java b/src/main/java/com/qoder/cloudagents/forward/Session.java index b33fd04..8ee8509 100644 --- a/src/main/java/com/qoder/cloudagents/forward/Session.java +++ b/src/main/java/com/qoder/cloudagents/forward/Session.java @@ -37,6 +37,11 @@ public String title() { return optional("title", JsonValue::asString); } + /** Null means no limit; field("budget") distinguishes omission from explicit null. */ + public SessionBudget budget() { + return optional("budget", SessionBudget::fromJson); + } + public Map metadata() { return optionalMap("metadata", item -> item); } diff --git a/src/main/java/com/qoder/cloudagents/forward/SessionBudget.java b/src/main/java/com/qoder/cloudagents/forward/SessionBudget.java new file mode 100644 index 0000000..0f385fd --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/SessionBudget.java @@ -0,0 +1,53 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.math.BigDecimal; +import java.util.*; + +/** Immutable JSON configuration; unknown fields are preserved. */ +public final class SessionBudget extends JsonObject { + private SessionBudget(JsonValue value) { + super(value); + } + + public static SessionBudget fromJson(JsonValue value) { + return new SessionBudget(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String maxCreditCost() { + return optional("max_credit_cost", 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 maxCreditCost(String value) { + return put("max_credit_cost", value); + } + + public SessionBudget build() { + require("type", "max_credit_cost"); + if (!"limit".equals(json().get("type").asString())) + throw new ConfigurationException("Budget type must be limit"); + String cost = json().get("max_credit_cost").asString(); + if (!cost.matches("[0-9]{1,12}(\\.[0-9]{1,2})?") || new BigDecimal(cost).signum() <= 0) + throw new ConfigurationException( + "maxCreditCost must be a positive decimal string with at most 12 integer and 2" + + " fractional digits"); + return new SessionBudget(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/SessionConfig.java b/src/main/java/com/qoder/cloudagents/forward/SessionConfig.java index 386b8ca..91700ed 100644 --- a/src/main/java/com/qoder/cloudagents/forward/SessionConfig.java +++ b/src/main/java/com/qoder/cloudagents/forward/SessionConfig.java @@ -13,6 +13,10 @@ public static SessionConfig fromJson(JsonValue value) { return new SessionConfig(value); } + public ModelConfig model() { + return optional("model", ModelConfig::fromJson); + } + public Map environmentVariables() { return optionalMap("environment_variables", JsonValue::asString); } @@ -24,6 +28,11 @@ public static Builder builder() { public static final class Builder extends JsonObjectBuilder { private Builder() {} + /** Omit on create to inherit; null on update restores inheritance. */ + public Builder model(ModelConfig value) { + return put("model", value); + } + public Builder environmentVariables(Map value) { return put("environment_variables", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/SessionCreateParams.java b/src/main/java/com/qoder/cloudagents/forward/SessionCreateParams.java index 1506a32..b5c8ff6 100644 --- a/src/main/java/com/qoder/cloudagents/forward/SessionCreateParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/SessionCreateParams.java @@ -25,6 +25,11 @@ public String title() { return optional("title", JsonValue::asString); } + /** Null means no limit; field("budget") distinguishes omission from explicit null. */ + public SessionBudget budget() { + return optional("budget", SessionBudget::fromJson); + } + public Map metadata() { return optionalMap("metadata", item -> item); } @@ -56,6 +61,11 @@ public Builder title(String value) { return put("title", value); } + /** Omit to keep the default/current budget; null clears it. */ + public Builder budget(SessionBudget value) { + return put("budget", value); + } + public Builder metadata(Map value) { return put("metadata", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java b/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java index ffa4c6b..bd6f946 100644 --- a/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java +++ b/src/main/java/com/qoder/cloudagents/forward/SessionEvent.java @@ -182,14 +182,24 @@ public Map error() { return optionalMap("error", item -> item); } - public Map evaluatedPermission() { - return optionalMap("evaluated_permission", item -> item); + /** Approval result: allow, ask, or deny. */ + public String evaluatedPermission() { + return optional("evaluated_permission", JsonValue::asString); + } + + public ToolApprovalEvaluation evaluation() { + return optional("evaluation", ToolApprovalEvaluation::fromJson); } public Map agent() { return optionalMap("agent", item -> item); } + /** Null means no limit; field("budget") distinguishes omission from explicit null. */ + public SessionBudget budget() { + return optional("budget", SessionBudget::fromJson); + } + public Map metadata() { return optionalMap("metadata", item -> item); } diff --git a/src/main/java/com/qoder/cloudagents/forward/SessionStreams.java b/src/main/java/com/qoder/cloudagents/forward/SessionStreams.java index 8283ac9..2d6aa06 100644 --- a/src/main/java/com/qoder/cloudagents/forward/SessionStreams.java +++ b/src/main/java/com/qoder/cloudagents/forward/SessionStreams.java @@ -35,7 +35,8 @@ static EventStream create( SessionEvent::new, event -> "session.status_terminated".equals(event.type()) - || "session.deleted".equals(event.type()), + || "session.deleted".equals(event.type()) + || (thread && "session.thread_status_terminated".equals(event.type())), resumable); } } diff --git a/src/main/java/com/qoder/cloudagents/forward/SessionUpdateParams.java b/src/main/java/com/qoder/cloudagents/forward/SessionUpdateParams.java index 98e88c4..1b05e11 100644 --- a/src/main/java/com/qoder/cloudagents/forward/SessionUpdateParams.java +++ b/src/main/java/com/qoder/cloudagents/forward/SessionUpdateParams.java @@ -17,6 +17,11 @@ public String title() { return optional("title", JsonValue::asString); } + /** Null means no limit; field("budget") distinguishes omission from explicit null. */ + public SessionBudget budget() { + return optional("budget", SessionBudget::fromJson); + } + public Map metadata() { return optionalMap("metadata", item -> item); } @@ -36,6 +41,11 @@ public Builder title(String value) { return put("title", value); } + /** Omit to keep the default/current budget; null clears it. */ + public Builder budget(SessionBudget value) { + return put("budget", value); + } + public Builder metadata(Map value) { return put("metadata", value); } diff --git a/src/main/java/com/qoder/cloudagents/forward/ToolApprovalDecision.java b/src/main/java/com/qoder/cloudagents/forward/ToolApprovalDecision.java new file mode 100644 index 0000000..a474d8b --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/ToolApprovalDecision.java @@ -0,0 +1,22 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; + +/** Automatic approval decision; reasonCode may be absent. Unknown fields are preserved. */ +public final class ToolApprovalDecision extends JsonObject { + private ToolApprovalDecision(JsonValue value) { + super(value); + } + + public static ToolApprovalDecision fromJson(JsonValue value) { + return new ToolApprovalDecision(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public String reasonCode() { + return optional("reason_code", JsonValue::asString); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/ToolApprovalEvaluation.java b/src/main/java/com/qoder/cloudagents/forward/ToolApprovalEvaluation.java new file mode 100644 index 0000000..b90570d --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/ToolApprovalEvaluation.java @@ -0,0 +1,22 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; + +/** Optional policy evaluation details on tool-use events. Unknown fields are preserved. */ +public final class ToolApprovalEvaluation extends JsonObject { + private ToolApprovalEvaluation(JsonValue value) { + super(value); + } + + public static ToolApprovalEvaluation fromJson(JsonValue value) { + return new ToolApprovalEvaluation(value); + } + + public String type() { + return optional("type", JsonValue::asString); + } + + public ToolApprovalDecision evaluatedPermission() { + return optional("evaluated_permission", ToolApprovalDecision::fromJson); + } +} diff --git a/src/main/java/com/qoder/cloudagents/managed/EnvironmentConfig.java b/src/main/java/com/qoder/cloudagents/managed/EnvironmentConfig.java index 113bc48..c864645 100644 --- a/src/main/java/com/qoder/cloudagents/managed/EnvironmentConfig.java +++ b/src/main/java/com/qoder/cloudagents/managed/EnvironmentConfig.java @@ -17,6 +17,11 @@ public String type() { return optional("type", JsonValue::asString); } + /** Runs on each sandbox start or resume; self-hosted execution depends on the worker. */ + public String startupScript() { + return optional("startup_script", JsonValue::asString); + } + public String setupScript() { return optional("setup_script", JsonValue::asString); } @@ -40,6 +45,11 @@ public Builder type(String value) { return put("type", value); } + /** Configure a startup script; the SDK does not execute it. */ + public Builder startupScript(String value) { + return put("startup_script", value); + } + public Builder setupScript(String value) { return put("setup_script", value); } diff --git a/src/main/java/com/qoder/cloudagents/managed/SessionEvent.java b/src/main/java/com/qoder/cloudagents/managed/SessionEvent.java index 137a491..3483075 100644 --- a/src/main/java/com/qoder/cloudagents/managed/SessionEvent.java +++ b/src/main/java/com/qoder/cloudagents/managed/SessionEvent.java @@ -182,8 +182,9 @@ public Map error() { return optionalMap("error", item -> item); } - public Map evaluatedPermission() { - return optionalMap("evaluated_permission", item -> item); + /** Approval result: allow, ask, or deny. */ + public String evaluatedPermission() { + return optional("evaluated_permission", JsonValue::asString); } public Agent agent() { diff --git a/src/main/java/com/qoder/cloudagents/managed/SessionStreams.java b/src/main/java/com/qoder/cloudagents/managed/SessionStreams.java index f0774e6..1531cb7 100644 --- a/src/main/java/com/qoder/cloudagents/managed/SessionStreams.java +++ b/src/main/java/com/qoder/cloudagents/managed/SessionStreams.java @@ -35,7 +35,8 @@ static EventStream create( SessionEvent::new, event -> "session.status_terminated".equals(event.type()) - || "session.deleted".equals(event.type()), + || "session.deleted".equals(event.type()) + || (thread && "session.thread_status_terminated".equals(event.type())), resumable); } } diff --git a/src/test/java/com/qoder/cloudagents/DocumentationAlignmentTest.java b/src/test/java/com/qoder/cloudagents/DocumentationAlignmentTest.java new file mode 100644 index 0000000..f83ab0e --- /dev/null +++ b/src/test/java/com/qoder/cloudagents/DocumentationAlignmentTest.java @@ -0,0 +1,241 @@ +package com.qoder.cloudagents; + +import static org.junit.jupiter.api.Assertions.*; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.forward.*; +import com.qoder.cloudagents.streaming.EventStream; +import java.math.BigDecimal; +import java.util.*; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.*; + +/** Official Session/Environment schemas retrieved 2026-11-03; loopback only. */ +@Timeout(15) +class DocumentationAlignmentTest { + @ParameterizedTest + @CsvSource({ + "allow,always_allow,", + "ask,always_ask,", + "allow,auto,", + "ask,auto,indeterminate", + "deny,auto,high_risk" + }) + void approvalsKeepRootStringAndNestedDecision(String result, String policy, String reason) { + String detail = + "auto".equals(policy) + ? ",\"evaluated_permission\":{\"type\":\"" + + result + + "\"" + + (reason == null ? "" : ",\"reason_code\":\"" + reason + "\"") + + "}" + : ""; + JsonValue json = + JsonValue.parse( + "{\"evaluated_permission\":\"" + + result + + "\",\"evaluation\":{\"type\":\"" + + policy + + "\"" + + detail + + "},\"future\":17}"); + SessionEvent event = new SessionEvent(json, "request"); + assertEquals(result, event.evaluatedPermission()); + assertEquals(policy, event.evaluation().type()); + if ("auto".equals(policy)) { + assertEquals(result, event.evaluation().evaluatedPermission().type()); + assertEquals(reason, event.evaluation().evaluatedPermission().reasonCode()); + } else assertNull(event.evaluation().evaluatedPermission()); + assertEquals(17L, event.field("future").asLong()); + assertEquals( + result, + new com.qoder.cloudagents.managed.SessionEvent(json, "request").evaluatedPermission()); + assertNull(new SessionEvent(JsonValue.parse("{}"), "request").evaluation()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void forwardBudgetAndModelPreserveWireStates(boolean async) throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.start(); + try (ForwardClient client = + ForwardClient.builder() + .baseUrl(server.url("/forward").toString()) + .pat("test") + .maxRetries(0) + .build()) { + SessionBudget budget = + SessionBudget.builder().type("limit").maxCreditCost("100.00").build(); + SessionConfig config = + SessionConfig.builder() + .model( + ModelConfig.builder() + .id("ultimate") + .speed("high") + .contextWindow(200000L) + .build()) + .build(); + String payload = + "{\"id\":\"sess_test\",\"budget\":" + + budget.rawJson() + + ",\"config\":" + + config.rawJson() + + "}"; + server.enqueue(new MockResponse().setBody(payload)); + SessionCreateParams create = + SessionCreateParams.builder() + .identityId("idn_test") + .templateId("tmpl_test") + .budget(budget) + .config(config) + .build(); + Session response = + async + ? client.async().sessions().create(create).get() + : client.sessions().create(create); + JsonValue wire = JsonValue.parse(server.takeRequest().getBody().readUtf8()); + assertEquals("100.00", wire.get("budget").get("max_credit_cost").asString()); + assertEquals("true", wire.get("metadata").get("__qca_sdk_created").asString()); + assertEquals("high", response.config().model().speed()); + assertEquals("100.00", response.budget().maxCreditCost()); + for (int state = 0; state < 3; state++) { + SessionUpdateParams.Builder b = SessionUpdateParams.builder(); + if (state == 1) b.budget(null).config(SessionConfig.builder().model(null).build()); + if (state == 2) + b.budget(budget) + .config(SessionConfig.builder().model(ModelConfig.of("ultimate")).build()); + SessionUpdateParams params = b.build(); + server.enqueue(new MockResponse().setBody(params.rawJson())); + if (async) client.async().sessions().update("sess_test", params).get(); + else client.sessions().update("sess_test", params); + wire = JsonValue.parse(server.takeRequest().getBody().readUtf8()); + assertEquals(state == 0, wire.get("budget").isMissing()); + assertEquals(state == 1, wire.get("budget").isNull()); + if (state == 1) assertTrue(wire.get("config").get("model").isNull()); + if (state == 2) assertEquals("ultimate", wire.get("config").get("model").asString()); + } + assertTrue(create.toJsonValue().get("metadata").isMissing()); + } + } + } + + @Test + void startupScriptsAndBudgetEventsRetainPresence() { + String script = "#!/bin/sh\necho ready\n"; + EnvironmentConfig f = EnvironmentConfig.builder().type("cloud").startupScript(script).build(); + assertEquals(script, EnvironmentConfig.fromJson(f.toJsonValue()).startupScript()); + for (String type : Arrays.asList("cloud", "self_hosted")) { + com.qoder.cloudagents.managed.EnvironmentConfig m = + com.qoder.cloudagents.managed.EnvironmentConfig.builder() + .type(type) + .startupScript(script) + .build(); + assertEquals( + script, + com.qoder.cloudagents.managed.EnvironmentConfig.fromJson(m.toJsonValue()) + .startupScript()); + } + for (String payload : + Arrays.asList( + "{}", + "{\"budget\":null}", + "{\"budget\":{\"type\":\"limit\",\"max_credit_cost\":\"0.01\"}}")) { + SessionEvent event = new SessionEvent(JsonValue.parse(payload), null); + assertEquals(payload.contains("budget"), !event.field("budget").isMissing()); + if (payload.contains("0.01")) assertEquals("0.01", event.budget().maxCreditCost()); + else assertNull(event.budget()); + } + SessionEvent usage = + new SessionEvent( + JsonValue.parse( + "{\"model_credits\":0.68,\"sandbox_runtime_credits\":0.04,\"total_credits\":0.72}"), + null); + assertEquals(new BigDecimal("0.72"), usage.totalCredits()); + } + + @ParameterizedTest + @CsvSource({ + "false,false,false", + "false,false,true", + "false,true,false", + "false,true,true", + "true,false,false", + "true,false,true", + "true,true,false", + "true,true,true" + }) + void threadTerminationStopsOnlyThreadSubscriptions(boolean managed, boolean async, boolean thread) + throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.start(); + server.enqueue( + sse("first", "session.thread_status_idle") // Idle is not terminal. + .setBody( + frame("idle", "session.thread_status_idle") + + frame("end", "session.thread_status_terminated"))); + server.enqueue(sse("session-end", "session.status_terminated")); + List ids = new ArrayList<>(); + if (managed) { + try (ManagedClient c = + ManagedClient.builder() + .baseUrl(server.url("/cloud").toString()) + .pat("test") + .maxRetries(0) + .build(); + EventStream stream = + thread + ? (async + ? c.async().sessions().threads().events().resumableStream("s", "t") + : c.sessions().threads().events().resumableStream("s", "t")) + : (async + ? c.async().sessions().events().resumableStream("s") + : c.sessions().events().resumableStream("s"))) { + for (com.qoder.cloudagents.managed.SessionEvent event : stream) ids.add(event.id()); + } + } else { + try (ForwardClient c = + ForwardClient.builder() + .baseUrl(server.url("/forward").toString()) + .pat("test") + .maxRetries(0) + .build(); + EventStream stream = + thread + ? (async + ? c.async().sessions().threads().events().resumableStream("s", "t") + : c.sessions().threads().events().resumableStream("s", "t")) + : (async + ? c.async().sessions().events().resumableStream("s") + : c.sessions().events().resumableStream("s"))) { + for (SessionEvent event : stream) ids.add(event.id()); + } + } + assertEquals( + thread ? Arrays.asList("idle", "end") : Arrays.asList("idle", "end", "session-end"), ids); + assertEquals(thread ? 1 : 2, server.getRequestCount()); + assertNotNull(server.takeRequest(1, TimeUnit.SECONDS)); + if (!thread) assertEquals("end", server.takeRequest().getHeader("Last-Event-ID")); + } + } + + private static String frame(String id, String type) { + return "id: " + + id + + "\nevent: " + + type + + "\ndata: {\"id\":\"" + + id + + "\",\"type\":\"" + + type + + "\"}\n\n"; + } + + private static MockResponse sse(String id, String type) { + return new MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody(frame(id, type)); + } +}