Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ existing `0.1.0` release; earlier development prereleases are not listed.

## [Unreleased]

## [0.8.0]

### Fixed

- Align existing Managed Session and Deployment resource types with generic HTTP(S) `git_repository`, including write-only credentials, branch/commit checkout, and optional GitHub authorization for public repositories.
- Add Forward Template `max_tool_rounds`, Batch `ignore_idle_window` / `queue_reason`, and Session list `search`; preserve omitted, null, false, and zero values according to each API contract.
- Expose Forward Session outcome evaluations and Thread statistics/Credits, Schedule and Run source/input-version metadata, and trigger time boundaries.
- Support Forward MemoryStore creation with a native `mount` (Identity, Template, and optional access), preserving the create request's idempotency key for retries.
- Expose self-hosted Environment setup scripts, Forward Environment binding metadata, Managed MemoryStore status/count/size, and Dream output file paths.
- Decode flat `agent.artifact_delivered` fields and diagnostic `session.error.error.qoder_error_code` in Managed event history, Session SSE, and Thread SSE. No additional API endpoints are introduced.

### Migration

- Omitting `max_tool_rounds` on update preserves the configured limit; null clears it. `ignore_idle_window` accepts a boolean, with false as the server default.
- Treat an absent Schedule Run input version or Thread usage as unknown, not zero. Use `retry_status` rather than `qoder_error_code` to decide whether execution can retry.

## [0.7.0]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion convention/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ package convention
// X-Qoder-Package-Version. It must match the released module tag, otherwise the
// server-side SDK version statistics describe a version that was never shipped.
// `make check-version VERSION=<version>` validates it before release.
const packageVersion = "0.7.0"
const packageVersion = "0.8.0"
1,000 changes: 863 additions & 137 deletions docs/api/reference.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions forward/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ func (r *BatchService) New(ctx context.Context, params BatchNewParams, opts ...o
}

type BatchNewParams struct {
// Ignore the scheduling time window; does not change capacity or FIFO priority.
IgnoreIdleWindow param.Opt[bool] `json:"ignore_idle_window,omitzero"`
// ID of a JSONL file uploaded through the Files API.
InputFileID string `json:"input_file_id" api:"required"`
// Completion window: `24h`, `48h` or `72h`. The Batch moves to `expired` when it elapses.
Expand Down Expand Up @@ -172,6 +174,9 @@ func (r BatchFile) RawJSON() string { return r.JSON.raw }
func (r *BatchFile) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }

type Batch struct {
IgnoreIdleWindow bool `json:"ignore_idle_window"`
// Dynamic queue reason, present only when applicable to validating or queued Batches.
QueueReason string `json:"queue_reason"`
// Batch ID, prefixed with `batch_`.
ID string `json:"id"`
// Always `batch`.
Expand Down Expand Up @@ -214,6 +219,8 @@ type Batch struct {
// Batch-level error description; present only in the `failed` status.
ErrorMessage string `json:"error_message"`
JSON struct {
IgnoreIdleWindow respjson.Field
QueueReason respjson.Field
ID respjson.Field
Object respjson.Field
Status respjson.Field
Expand Down
158 changes: 158 additions & 0 deletions forward/contract_alignment_080_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package forward_test

import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"reflect"
"testing"

"github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
"github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
)

// Existing API contracts from docs.qoder.com/cloud-agents/api, 2026-10-20.
func alignmentFixture(t *testing.T) map[string]json.RawMessage {
t.Helper()
data, err := os.ReadFile("testdata/contract-alignment-0.8.json")
if err != nil {
t.Fatal(err)
}
var fixture map[string]json.RawMessage
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatal(err)
}
return fixture
}

func TestForwardContractAlignment080Requests(t *testing.T) {
ctx := context.Background()
var body map[string]any
var requests int
client := testClient(func(r *http.Request) (*http.Response, error) {
requests++
if r.Method == "GET" {
if r.URL.Query().Get("search") != "report & 中文" {
t.Fatal(r.URL)
}
return reply(r, 200, `{"data":[],"has_more":false}`), nil
}
data, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
body = nil
if err := json.Unmarshal(data, &body); err != nil {
t.Fatal(err)
}
if r.URL.Path == "/api/v1/forward/memory_stores" && r.Header.Get("Idempotency-Key") != "mount-one" {
t.Fatal("missing idempotency key")
}
return reply(r, 200, `{"id":"contract_one"}`), nil
})
for _, value := range []param.Opt[int64]{{}, param.Null[int64](), forward.Int(3)} {
_, err := client.Templates.New(ctx, forward.TemplateNewParams{Name: "sdk-contract", EnvironmentID: "env_one", Model: forward.ModelConfigUnionParam{OfString: forward.String("model")}, MaxToolRounds: value})
if err != nil {
t.Fatal(err)
}
got, present := body["max_tool_rounds"]
if value.Valid() && got != float64(3) {
t.Fatal(body)
}
if param.IsNull(value) && (!present || got != nil) {
t.Fatal(body)
}
if param.IsOmitted(value) && present {
t.Fatal(body)
}
_, err = client.Templates.Update(ctx, "tmpl_one", forward.TemplateUpdateParams{MaxToolRounds: value})
if err != nil {
t.Fatal(err)
}
got, present = body["max_tool_rounds"]
if value.Valid() && got != float64(3) || param.IsNull(value) && (!present || got != nil) || param.IsOmitted(value) && present {
t.Fatal(body)
}
}
for _, value := range []param.Opt[bool]{{}, forward.Bool(false), forward.Bool(true)} {
_, err := client.Batches.New(ctx, forward.BatchNewParams{InputFileID: "file_one", CompletionWindow: "24h", IgnoreIdleWindow: value})
if err != nil {
t.Fatal(err)
}
got, present := body["ignore_idle_window"]
if present != value.Valid() || present && got != value.Value {
t.Fatal(body)
}
}
if _, err := client.Sessions.List(ctx, forward.SessionListParams{Search: forward.String("report & 中文")}); err != nil {
t.Fatal(err)
}
before := requests
for i := 0; i < 2; i++ {
_, err := client.MemoryStores.New(ctx, forward.MemoryStoreNewParams{Name: "sdk-contract", IdempotencyKey: "mount-one", Mount: forward.MemoryStoreNewParamsMount{IdentityID: "idn_one", TemplateID: "tmpl_one", Access: forward.String("read_write")}})
if err != nil {
t.Fatal(err)
}
var mount any
if err := json.Unmarshal(alignmentFixture(t)["mount"], &mount); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(body, map[string]any{"name": "sdk-contract", "mount": mount}) {
t.Fatal(body)
}
}
if requests != before+2 {
t.Fatal("mount must use a single native operation")
}
}

func TestForwardContractAlignment080Responses(t *testing.T) {
f := alignmentFixture(t)
decode := func(key string, dst any) {
t.Helper()
if err := json.Unmarshal(f[key], dst); err != nil {
t.Fatal(err)
}
}
var session forward.Session
decode("session", &session)
if len(session.OutcomeEvaluations) != 1 || !session.OutcomeEvaluations[0].JSON.Iteration.Valid() || session.OutcomeEvaluations[0].JSON.Explanation.Raw() != "null" {
t.Fatal(session)
}
var thread forward.SessionThread
decode("thread", &thread)
if !thread.Usage.JSON.TotalCredits.Valid() || thread.Usage.TotalCredits != 0 || !thread.Stats.JSON.StartupSeconds.Valid() {
t.Fatal(thread)
}
var absent forward.SessionThread
if err := json.Unmarshal([]byte(`{"id":"empty"}`), &absent); err != nil {
t.Fatal(err)
}
if absent.JSON.Usage.Valid() || absent.JSON.Stats.Valid() {
t.Fatal("absent must remain unknown")
}
var schedule forward.Schedule
decode("schedule", &schedule)
if schedule.Source != "api" || schedule.JSON.SourceSessionID.Raw() != "null" || schedule.InitialEventsVersion != 2 || schedule.TriggerPolicy.StartAt.Day() != 20 || schedule.TriggerPolicy.StopAt.Day() != 21 || schedule.TriggerPolicy.JSON.LastRunAt.Raw() != "null" {
t.Fatal(schedule)
}
var run forward.ScheduleRun
decode("run", &run)
if run.Source != "tool" || run.SourceSessionID != "sess_one" || run.JSON.InitialEventsVersion.Valid() {
t.Fatal(run)
}
var env forward.Environment
decode("environment", &env)
if env.Config.SetupScript != "echo sdk-contract" || !env.BindingInfo.JSON.AgentTemplateCount.Valid() {
t.Fatal(env)
}
var batch forward.Batch
if err := json.Unmarshal([]byte(`{"ignore_idle_window":false,"queue_reason":"capacity"}`), &batch); err != nil {
t.Fatal(err)
}
if batch.IgnoreIdleWindow || !batch.JSON.IgnoreIdleWindow.Valid() || batch.QueueReason != "capacity" {
t.Fatal(batch)
}
}
46 changes: 33 additions & 13 deletions forward/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,21 @@ func (r *EnvironmentService) Delete(ctx context.Context, id string, opts ...opti
}

type Environment struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Config EnvironmentConfig `json:"config"`
Metadata map[string]any `json:"metadata"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
IdentityID string `json:"identity_id" api:"nullable"`
ArchivedAt time.Time `json:"archived_at" api:"nullable" format:"date-time"`
IconURL string `json:"icon_url" api:"nullable"`
BindingInfo EnvironmentBindingInfo `json:"binding_info"`
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Config EnvironmentConfig `json:"config"`
Metadata map[string]any `json:"metadata"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
IdentityID string `json:"identity_id" api:"nullable"`
ArchivedAt time.Time `json:"archived_at" api:"nullable" format:"date-time"`
JSON struct {
IconURL respjson.Field
BindingInfo respjson.Field
ID respjson.Field
Type respjson.Field
Name respjson.Field
Expand All @@ -207,9 +211,11 @@ func (r Environment) RawJSON() string { return r.JSON.raw }
func (r *Environment) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }

type EnvironmentConfig struct {
Type string `json:"type"`
Packages EnvironmentConfigPackages `json:"packages"`
JSON struct {
SetupScript string `json:"setup_script"`
Type string `json:"type"`
Packages EnvironmentConfigPackages `json:"packages"`
JSON struct {
SetupScript respjson.Field
Type respjson.Field
Packages respjson.Field
ExtraFields map[string]respjson.Field
Expand Down Expand Up @@ -253,3 +259,17 @@ func (r EnvironmentNewParams) URLQuery() (url.Values, error) {
func (r EnvironmentUpdateParams) URLQuery() (url.Values, error) {
return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
}

type EnvironmentBindingInfo struct {
AgentTemplateCount int64 `json:"agent_template_count"`
JSON struct {
AgentTemplateCount respjson.Field
ExtraFields map[string]respjson.Field
raw string
} `json:"-"`
}

func (r EnvironmentBindingInfo) RawJSON() string { return r.JSON.raw }
func (r *EnvironmentBindingInfo) UnmarshalJSON(data []byte) error {
return apijson.UnmarshalRoot(data, r)
}
18 changes: 18 additions & 0 deletions forward/memorystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ func (r *MemoryStoreService) New(ctx context.Context, params MemoryStoreNewParam
}

type MemoryStoreNewParams struct {
// Establish this mount before returning. Retry with the same key and body to resume creation.
Mount MemoryStoreNewParamsMount `json:"mount,omitzero"`
// Display name for the store, non-empty. Non-printable control characters
// (`U+0000`–`U+001F`, `U+007F`) are not allowed, except newline `\n`, carriage return
// `\r` and tab `\t`.
Expand Down Expand Up @@ -225,3 +227,19 @@ type MemoryStore struct {

func (r MemoryStore) RawJSON() string { return r.JSON.raw }
func (r *MemoryStore) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }

type MemoryStoreNewParamsMount struct {
IdentityID string `json:"identity_id" api:"required"`
TemplateID string `json:"template_id" api:"required"`
// read_only (default) or read_write.
Access param.Opt[string] `json:"access,omitzero"`
paramObj
}

func (r MemoryStoreNewParamsMount) MarshalJSON() ([]byte, error) {
type shadow MemoryStoreNewParamsMount
return param.MarshalObject(r, (*shadow)(&r))
}
func (r *MemoryStoreNewParamsMount) UnmarshalJSON(data []byte) error {
return apijson.UnmarshalRoot(data, r)
}
Loading
Loading