Skip to content

Commit 06f4337

Browse files
authored
fix: align session contracts and stop terminated thread streams (#13)
1 parent 1b32dff commit 06f4337

21 files changed

Lines changed: 504 additions & 6 deletions

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Add Forward Session budget builders/getters with decimal-string limits and preserved omission/null clearing, Session model overrides and typed approval evaluation details.
8+
- 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.
9+
10+
### Fixed
11+
12+
- Read event-level `evaluatedPermission()` as a string in Forward and Managed.
13+
- 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.
14+
15+
### Breaking changes
16+
17+
- `SessionEvent.evaluatedPermission()` returns `String` instead of `Map<String, JsonValue>` in both layers.
18+
19+
### Migration
20+
21+
- Compare `event.evaluatedPermission()` to `"allow"`, `"ask"` or `"deny"`; optional Forward automatic decision details are under `event.evaluation().evaluatedPermission()`.
22+
- 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.
23+
- 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.
24+
525
### Changed
626

727
- Remove resource-specific sections and inventories from the README to keep it focused on SDK setup and shared client behavior.

‎docs/environments-files.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,7 @@ Treat signed URLs as credentials; do not log them. This helper does not fetch co
126126
[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.
127127

128128
See [example commands](../examples/README.md) and [opt-in live testing](live-testing.md). Default tests use mock services and the installed SDK JAR.
129+
130+
## Startup scripts
131+
132+
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.

‎docs/forward-api-coverage.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Forward API 覆盖基线
22

3+
开发中契约复核(2026-11-03):补齐 Session budget(省略/null/对象)、config.model、审批 evaluation 和 Environment startup_script;修正顶层 evaluatedPermission 字符串与 Thread 终止后的续订行为。本轮没有新增 HTTP 操作;验证见 `DocumentationAlignmentTest`,真实环境结果单独记录。
4+
35
契约核对: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)。离线验证与真实环境验收分别记录,不能把操作覆盖数当作线上通过率。
46

57
HTTP BaseURL 为 CN `https://api.qoder.com.cn/api/v1/forward` 或 Global `https://api.qoder.com/api/v1/forward`;表内路径均相对于 BaseURL。

‎docs/managed-api-coverage.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Managed API 覆盖基线
22

3+
开发中契约复核(2026-11-03):修正顶层 evaluatedPermission 字符串和 Thread 终止后的续订行为,补齐 Environment startup_script。Memory version/redacted、Vault credentials 和 Session Credits 已有正式访问器。本轮没有新增 HTTP 操作;验证见 `DocumentationAlignmentTest`,真实环境结果单独记录。
4+
35
契约核对:2026-10-28;接口清单更新:2026-10-29。**0.2.0** 与 Forward 更新一起提供 **107 个 Managed 协议操作:105 个普通 HTTP 操作、2 个 SSE 操作**。同步/异步/响应封装只计一个操作;0.1.0 不包含本表能力。
46

57
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)。接口存在与离线通过不等于线上权限或执行结果已验证。

‎docs/session-streaming.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,7 @@ To cancel the actual Session execution, use the existing `client.sessions().canc
9494
[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.
9595

9696
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.
97+
98+
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.
99+
100+
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.

‎docs/sessions.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,20 @@ Implementation follows the [pinned SDK baseline](forward-api-coverage.md). Newer
148148
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.
149149

150150
See [Forward contract updates](forward-parity.md) for automatic Session metadata markers, Credits event accessors, and Memory Store mount pagination.
151+
152+
## Session budget and model overrides
153+
154+
```java
155+
SessionCreateParams create = SessionCreateParams.builder()
156+
.identityId("idn_existing").templateId("tmpl_existing")
157+
.budget(SessionBudget.builder().type("limit").maxCreditCost("100.00").build())
158+
.config(SessionConfig.builder().model(ModelConfig.builder()
159+
.id("ultimate").speed("high").build()).build())
160+
.build();
161+
SessionUpdateParams clear = SessionUpdateParams.builder().budget(null)
162+
.config(SessionConfig.builder().model(null).build()).build();
163+
```
164+
165+
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.
166+
167+
`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.

‎examples/src/test/java/com/qoder/cloudagents/examples/PackagedConsumerTest.java‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,48 @@ void examplesRunAgainstInstalledJarAndPreserveMetadata() throws Exception {
5252
assertEquals("/api/v1/forward/models", server.takeRequest().getPath());
5353
}
5454
}
55+
56+
@Test
57+
void newSessionContractsAreAvailableFromThePackagedJar() {
58+
com.qoder.cloudagents.forward.SessionBudget budget =
59+
com.qoder.cloudagents.forward.SessionBudget.builder()
60+
.type("limit")
61+
.maxCreditCost("100.00")
62+
.build();
63+
com.qoder.cloudagents.forward.SessionConfig config =
64+
com.qoder.cloudagents.forward.SessionConfig.builder()
65+
.model(com.qoder.cloudagents.forward.ModelConfig.of("ultimate"))
66+
.build();
67+
com.qoder.cloudagents.forward.SessionUpdateParams params =
68+
com.qoder.cloudagents.forward.SessionUpdateParams.builder()
69+
.budget(budget)
70+
.config(config)
71+
.build();
72+
assertEquals("100.00", params.budget().maxCreditCost());
73+
assertEquals("ultimate", params.config().model().id());
74+
com.qoder.cloudagents.core.JsonValue payload =
75+
com.qoder.cloudagents.core.JsonValue.parse(
76+
"{\"evaluated_permission\":\"deny\",\"evaluation\":{\"type\":\"auto\",\"evaluated_permission\":{\"type\":\"deny\",\"reason_code\":\"high_risk\"}}}");
77+
com.qoder.cloudagents.forward.SessionEvent event =
78+
new com.qoder.cloudagents.forward.SessionEvent(payload, null);
79+
assertEquals("deny", event.evaluatedPermission());
80+
assertEquals("high_risk", event.evaluation().evaluatedPermission().reasonCode());
81+
assertEquals(
82+
"deny",
83+
new com.qoder.cloudagents.managed.SessionEvent(payload, null).evaluatedPermission());
84+
assertEquals(
85+
"echo ready",
86+
com.qoder.cloudagents.forward.EnvironmentConfig.builder()
87+
.type("cloud")
88+
.startupScript("echo ready")
89+
.build()
90+
.startupScript());
91+
assertEquals(
92+
"echo ready",
93+
com.qoder.cloudagents.managed.EnvironmentConfig.builder()
94+
.type("cloud")
95+
.startupScript("echo ready")
96+
.build()
97+
.startupScript());
98+
}
5599
}

‎src/main/java/com/qoder/cloudagents/forward/EnvironmentConfig.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ public String type() {
1717
return optional("type", JsonValue::asString);
1818
}
1919

20+
/** Runs on each sandbox start or resume; self-hosted execution depends on the worker. */
21+
public String startupScript() {
22+
return optional("startup_script", JsonValue::asString);
23+
}
24+
2025
public String setupScript() {
2126
return optional("setup_script", JsonValue::asString);
2227
}
@@ -36,6 +41,11 @@ public Builder type(String value) {
3641
return put("type", value);
3742
}
3843

44+
/** Configure a startup script; the SDK does not execute it. */
45+
public Builder startupScript(String value) {
46+
return put("startup_script", value);
47+
}
48+
3949
public Builder setupScript(String value) {
4050
return put("setup_script", value);
4151
}

‎src/main/java/com/qoder/cloudagents/forward/Session.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ public String title() {
3737
return optional("title", JsonValue::asString);
3838
}
3939

40+
/** Null means no limit; field("budget") distinguishes omission from explicit null. */
41+
public SessionBudget budget() {
42+
return optional("budget", SessionBudget::fromJson);
43+
}
44+
4045
public Map<String, JsonValue> metadata() {
4146
return optionalMap("metadata", item -> item);
4247
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.qoder.cloudagents.forward;
2+
3+
import com.qoder.cloudagents.core.*;
4+
import com.qoder.cloudagents.errors.ConfigurationException;
5+
import java.math.BigDecimal;
6+
import java.util.*;
7+
8+
/** Immutable JSON configuration; unknown fields are preserved. */
9+
public final class SessionBudget extends JsonObject {
10+
private SessionBudget(JsonValue value) {
11+
super(value);
12+
}
13+
14+
public static SessionBudget fromJson(JsonValue value) {
15+
return new SessionBudget(value);
16+
}
17+
18+
public String type() {
19+
return optional("type", JsonValue::asString);
20+
}
21+
22+
public String maxCreditCost() {
23+
return optional("max_credit_cost", JsonValue::asString);
24+
}
25+
26+
public static Builder builder() {
27+
return new Builder();
28+
}
29+
30+
public static final class Builder extends JsonObjectBuilder<Builder> {
31+
private Builder() {}
32+
33+
public Builder type(String value) {
34+
return put("type", value);
35+
}
36+
37+
public Builder maxCreditCost(String value) {
38+
return put("max_credit_cost", value);
39+
}
40+
41+
public SessionBudget build() {
42+
require("type", "max_credit_cost");
43+
if (!"limit".equals(json().get("type").asString()))
44+
throw new ConfigurationException("Budget type must be limit");
45+
String cost = json().get("max_credit_cost").asString();
46+
if (!cost.matches("[0-9]{1,12}(\\.[0-9]{1,2})?") || new BigDecimal(cost).signum() <= 0)
47+
throw new ConfigurationException(
48+
"maxCreditCost must be a positive decimal string with at most 12 integer and 2"
49+
+ " fractional digits");
50+
return new SessionBudget(json());
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)