From cdd71af0e70eac9a280f4beb3e00d06b4a8edb5b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:15:13 +0800 Subject: [PATCH 01/11] Migrate Go HTTP fixtures to required Session input --- .../internal/api/claude_admission_test.go | 14 +++++--------- .../agents-api/internal/api/claude_mcp_test.go | 4 ++-- .../agents-api/internal/api/disabled_tools_test.go | 2 +- .../internal/api/environment_creation_test.go | 2 +- .../internal/api/environment_input_test.go | 3 ++- .../internal/api/function_configuration_test.go | 7 ++++--- services/agents-api/internal/api/handler_test.go | 9 +++++---- services/agents-api/internal/api/harness_test.go | 6 ++++-- .../internal/api/hosted_environment_test.go | 10 +++++++--- .../internal/api/session_request_test.go | 5 +++-- .../internal/api/text_configuration_test.go | 7 ++++--- .../internal/store/agents_delete_public_test.go | 5 +++-- .../internal/store/agents_update_public_test.go | 5 +++-- .../internal/store/harness_onboarding_test.go | 2 +- .../store/session_agent_filter_public_test.go | 5 +++-- 15 files changed, 48 insertions(+), 38 deletions(-) diff --git a/services/agents-api/internal/api/claude_admission_test.go b/services/agents-api/internal/api/claude_admission_test.go index adb1e7344..dde53634c 100644 --- a/services/agents-api/internal/api/claude_admission_test.go +++ b/services/agents-api/internal/api/claude_admission_test.go @@ -14,7 +14,7 @@ import ( func TestClaudeSessionConfigurationAdmission(t *testing.T) { for _, stream := range []bool{false, true} { - for _, initial := range []bool{false, true} { + for _, input := range []string{`"Check the configured response."`, `[{"role":"user","content":[{"type":"input_text","text":"Check the configured response."}]}]`} { for _, test := range []struct { name, fields string accepted bool @@ -41,22 +41,18 @@ func TestClaudeSessionConfigurationAdmission(t *testing.T) { {"MCP wildcard name", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":["*"]}]`, false}, {"MCP empty fragment", `,"tools":[` + strings.Replace(publicMCP, `/tools"`, `/tools#"`, 1) + `]`, false}, } { - t.Run(fmt.Sprintf("%s/stream=%t/initial=%t", test.name, stream, initial), func(t *testing.T) { + t.Run(fmt.Sprintf("%s/stream=%t/input=%s", test.name, stream, input), func(t *testing.T) { digest := sha256.Sum256([]byte("test-api-key")) auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(digest[:]), TenantID: uuid.NewString()}}) if err != nil { t.Fatal(err) } saved := &recordingStore{} - handler, err := NewHandler(saved, auth, "claude_sdk") + handler, err := NewHandler(saved, auth, "claude_sdk", WithExecution(&inputRecorder{ResourceStore: saved})) if err != nil { t.Fatal(err) } - input := "" - if initial { - input = `,"input":"Hello"` - } - body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"%s},"environment":{"type":"none"},"stream":%t%s}`, test.fields, stream, input) + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"%s},"environment":{"type":"none"},"stream":%t,"input":%s}`, test.fields, stream, input) request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer test-api-key") request.Header.Set("OpenAI-Beta", "agents=v1") @@ -65,7 +61,7 @@ func TestClaudeSessionConfigurationAdmission(t *testing.T) { want := http.StatusBadRequest if test.accepted { want = http.StatusCreated - if stream || initial { + if stream { want = http.StatusServiceUnavailable } } diff --git a/services/agents-api/internal/api/claude_mcp_test.go b/services/agents-api/internal/api/claude_mcp_test.go index d413cb78d..e40ad308f 100644 --- a/services/agents-api/internal/api/claude_mcp_test.go +++ b/services/agents-api/internal/api/claude_mcp_test.go @@ -40,11 +40,11 @@ func TestClaudeMCPAdmitsResolvedCredentials(t *testing.T) { if err != nil { t.Fatal(err) } - h, err := NewHandler(s, auth, "claude_sdk") + h, err := NewHandler(s, auth, "claude_sdk", WithExecution(&inputRecorder{ResourceStore: s})) if err != nil { t.Fatal(err) } - body := fmt.Sprintf(`{"agent":{"model":"model","tools":[%s]},"environment":{"type":"none"},"vault_ids":[%q]}`, tool, vault) + body := fmt.Sprintf(`{"agent":{"model":"model","tools":[%s]},"environment":{"type":"none"},"vault_ids":[%q],"input":"Use the configured records server."}`, tool, vault) response := credentialRequest(h, "POST", "/v1/agents/sessions", body) if response.Code != 201 || s.calls != 1 || s.tenant == "" { t.Fatal("credential selection or admission failed", response.Code, response.Body, s.calls) diff --git a/services/agents-api/internal/api/disabled_tools_test.go b/services/agents-api/internal/api/disabled_tools_test.go index ff785affe..b64aebb86 100644 --- a/services/agents-api/internal/api/disabled_tools_test.go +++ b/services/agents-api/internal/api/disabled_tools_test.go @@ -52,7 +52,7 @@ func TestDisabledToolAdmissionPrecedesPersistence(t *testing.T) { `[{"type":"web_search","mode":"disabled"},{"type":"web_search","mode":"disabled"}]`, } { h, store, _ := testHandler(t) - req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model","tools":`+tools+`},"environment":{"type":"none"}}`)) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model","tools":`+tools+`},"environment":{"type":"none"},"input":"Use only the enabled tools."}`)) req.Header.Set("Authorization", "Bearer test-api-key") req.Header.Set("OpenAI-Beta", "agents=v1") response := httptest.NewRecorder() diff --git a/services/agents-api/internal/api/environment_creation_test.go b/services/agents-api/internal/api/environment_creation_test.go index 84e42faa8..1612d1165 100644 --- a/services/agents-api/internal/api/environment_creation_test.go +++ b/services/agents-api/internal/api/environment_creation_test.go @@ -215,7 +215,7 @@ func TestSelfHostedCreationRequiresOperatorExecution(t *testing.T) { func TestHostedCreationRequiresOperatorExecution(t *testing.T) { for _, stream := range []bool{false, true} { handler, fixture := environmentCreationHandler(t, "codex", WithExecution(&inputRecorder{}), WithEnvironmentRemoteURL(environmentOrigin)) - body := fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"stream":%t}`, stream) + body := fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"stream":%t,"input":"Initialize the hosted execution."}`, stream) request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer key") request.Header.Set("OpenAI-Beta", "agents=v1") diff --git a/services/agents-api/internal/api/environment_input_test.go b/services/agents-api/internal/api/environment_input_test.go index 6e4468dd6..25160f7ca 100644 --- a/services/agents-api/internal/api/environment_input_test.go +++ b/services/agents-api/internal/api/environment_input_test.go @@ -68,7 +68,8 @@ func TestPreparedEnvironmentInputWaitExtendsOnlyItsResponseDeadline(t *testing.T options = append(options, WithEnvironmentRemoteURL(environmentOrigin)) } handler, fixture := environmentCreationHandler(t, "codex", options...) - create := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"MiniMax-M3"},"environment":`+environmentJSON+`}`)) + waiting.InputSubmitter = &inputRecorder{ResourceStore: fixture} + create := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"MiniMax-M3"},"environment":`+environmentJSON+`,"input":"Prepare the response deadline fixture."}`)) create.Header.Set("Authorization", "Bearer key") create.Header.Set("OpenAI-Beta", "agents=v1") created := httptest.NewRecorder() diff --git a/services/agents-api/internal/api/function_configuration_test.go b/services/agents-api/internal/api/function_configuration_test.go index 6b17cee6f..791f9b52f 100644 --- a/services/agents-api/internal/api/function_configuration_test.go +++ b/services/agents-api/internal/api/function_configuration_test.go @@ -13,8 +13,9 @@ import ( func TestPublicFunctionConfiguration(t *testing.T) { tool := `{"type":"function","name":"lookup","description":"","parameters":{"const":9007199254740993}}` for _, suffix := range []string{"", `,"tools":null`, `,"tools":[]`, `,"tools":[` + tool + `]`, `,"tools":[` + strings.TrimSuffix(tool, "}") + `,"defer_loading":false}]`} { - h, s, _ := testHandler(t) - req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model"`+suffix+`},"environment":{"type":"none"}}`)) + s := &recordingStore{} + h, _, _ := testHandler(t, WithExecution(&inputRecorder{ResourceStore: s})) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model"`+suffix+`},"environment":{"type":"none"},"input":"Use the configured function when needed."}`)) req.Header.Set("Authorization", "Bearer test-api-key") req.Header.Set("OpenAI-Beta", "agents=v1") w := httptest.NewRecorder() @@ -53,7 +54,7 @@ func TestPublicFunctionConfigurationRejectsInvalidOrUnsupported(t *testing.T) { `[{` + base + `,"unexpected":true}]`, `[{` + base + `},{` + base + `}]`, } { h, s, _ := testHandler(t) - req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model","tools":`+raw+`},"environment":{"type":"none"}}`)) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"model","tools":`+raw+`},"environment":{"type":"none"},"input":"Use the configured function when needed."}`)) req.Header.Set("Authorization", "Bearer test-api-key") req.Header.Set("OpenAI-Beta", "agents=v1") w := httptest.NewRecorder() diff --git a/services/agents-api/internal/api/handler_test.go b/services/agents-api/internal/api/handler_test.go index da5ec885c..0792b1512 100644 --- a/services/agents-api/internal/api/handler_test.go +++ b/services/agents-api/internal/api/handler_test.go @@ -56,8 +56,9 @@ func testHandler(t *testing.T, options ...Option) (http.Handler, *recordingStore } func TestHTTPConfigurationAndTenantIdentity(t *testing.T) { - h, s, tenant := testHandler(t) - body := `{"agent":{"model":"requested-model","instructions":"Keep this."},"environment":{"type":"none"},"metadata":{"tenant_id":"untrusted-tenant"}}` + s := &recordingStore{} + h, _, tenant := testHandler(t, WithExecution(&inputRecorder{ResourceStore: s})) + body := `{"agent":{"model":"requested-model","instructions":"Keep this."},"environment":{"type":"none"},"metadata":{"tenant_id":"untrusted-tenant"},"input":"Follow the configured instructions."}` request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer test-api-key") request.Header.Set("OpenAI-Beta", "agents=v1") @@ -78,7 +79,7 @@ func TestHTTPConfigurationAndTenantIdentity(t *testing.T) { } func TestHTTPRejectsUntrustedOrUnsupportedRequests(t *testing.T) { - valid := `{"agent":{"model":"example"},"environment":{"type":"none"}}` + valid := `{"agent":{"model":"example"},"environment":{"type":"none"},"input":"Run the configured request."}` for _, test := range []struct { name, auth, beta, path, body string status int @@ -90,7 +91,7 @@ func TestHTTPRejectsUntrustedOrUnsupportedRequests(t *testing.T) { {"tenant body", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"tenant_id":"other","agent":`, 1), 400}, {"hosted environment without managed deployment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"openai_hosted"`, 1), 503}, {"self-hosted environment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"self_hosted"`, 1), 400}, - {"initial input", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"input":"run it","agent":`, 1), 503}, + {"initial input", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", valid, 503}, {"stream unavailable", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"stream":true,"agent":`, 1), 503}, {"unknown saved agent", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"agent_id":"saved","agent":`, 1), 404}, {"unknown agent option", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"model":`, `"tools":[{}],"model":`, 1), 400}, diff --git a/services/agents-api/internal/api/harness_test.go b/services/agents-api/internal/api/harness_test.go index fffd2f46b..7d8052029 100644 --- a/services/agents-api/internal/api/harness_test.go +++ b/services/agents-api/internal/api/harness_test.go @@ -33,8 +33,10 @@ func TestSessionHarnessAdmission(t *testing.T) { if tc.enabled { options = append(options, WithHarnesses([]string{"claude_sdk", "mcode"})) } - h, s, _ := testHandler(t, options...) - r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"fixture"`+tc.extension+tc.extra+`},"environment":`+tc.environment+`}`)) + s := &recordingStore{} + options = append(options, WithExecution(&inputRecorder{ResourceStore: s})) + h, _, _ := testHandler(t, options...) + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"fixture"`+tc.extension+tc.extra+`},"environment":`+tc.environment+`,"input":"Run on the selected harness."}`)) r.Header.Set("Authorization", "Bearer test-api-key") r.Header.Set("OpenAI-Beta", "agents=v1") w := httptest.NewRecorder() diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/agents-api/internal/api/hosted_environment_test.go index 18d905fcc..a0ced24d1 100644 --- a/services/agents-api/internal/api/hosted_environment_test.go +++ b/services/agents-api/internal/api/hosted_environment_test.go @@ -86,11 +86,15 @@ func TestHostedEnvironmentResponseHasPinnedShapeAndNoConnectionAction(t *testing // The execution owner must see idle creation as well as initial-input creation. // Resource persistence alone cannot validate the configured managed deployment. -func TestHostedCreationUsesExecutionAdmissionWithoutInitialInput(t *testing.T) { +func TestHostedCreationUsesExecutionAdmission(t *testing.T) { for _, stream := range []bool{false, true} { recorder := &hostedCreationRecorder{} handler, fixture := environmentCreationHandler(t, "codex", WithHostedEnvironments(), WithExecution(recorder)) - body := fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"stream":%t}`, stream) + input := "" + if stream { + input = `,"input":"Initialize the streamed hosted execution."` + } + body := fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"stream":%t%s}`, stream, input) request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer key") request.Header.Set("OpenAI-Beta", "agents=v1") @@ -99,7 +103,7 @@ func TestHostedCreationUsesExecutionAdmissionWithoutInitialInput(t *testing.T) { // The recorder deliberately rejects both creation methods. Its rejection // proves admission was used; the resource fixture must remain untouched. if recorder.calls != 1 || fixture.input.Engine != "" || fixture.session.ID != "" || response.Code != http.StatusBadRequest { - t.Fatal("idle hosted creation bypassed execution admission", stream, response.Code, response.Body.String()) + t.Fatal("hosted creation bypassed execution admission", stream, response.Code, response.Body.String()) } } } diff --git a/services/agents-api/internal/api/session_request_test.go b/services/agents-api/internal/api/session_request_test.go index 143f92f8e..73e739686 100644 --- a/services/agents-api/internal/api/session_request_test.go +++ b/services/agents-api/internal/api/session_request_test.go @@ -36,8 +36,9 @@ func TestSessionCreateFieldPresence(t *testing.T) { {"array metadata", `,"metadata":[]`, 400, nil}, } { t.Run(tc.name, func(t *testing.T) { - handler, saved, _ := testHandler(t) - body := `{"agent":{"model":"example"},"environment":{"type":"none"}` + tc.fields + `}` + saved := &recordingStore{} + handler, _, _ := testHandler(t, WithExecution(&inputRecorder{ResourceStore: saved})) + body := `{"agent":{"model":"example"},"environment":{"type":"none"},"input":"Confirm the session metadata."` + tc.fields + `}` request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer test-api-key") request.Header.Set("OpenAI-Beta", "agents=v1") diff --git a/services/agents-api/internal/api/text_configuration_test.go b/services/agents-api/internal/api/text_configuration_test.go index 22ff4a484..79221d2c3 100644 --- a/services/agents-api/internal/api/text_configuration_test.go +++ b/services/agents-api/internal/api/text_configuration_test.go @@ -19,8 +19,9 @@ func TestTextConfigurationHTTP(t *testing.T) { {`,"text":{"verbosity":"high","format":{"type":"text"}}`, "high"}, } { t.Run(tc.text, func(t *testing.T) { - h, s, _ := testHandler(t) - req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"example"`+tc.text+`},"environment":{"type":"none"}}`)) + s := &recordingStore{} + h, _, _ := testHandler(t, WithExecution(&inputRecorder{ResourceStore: s})) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"example"`+tc.text+`},"environment":{"type":"none"},"input":"Describe the configured response format."}`)) req.Header.Set("Authorization", "Bearer test-api-key") req.Header.Set("OpenAI-Beta", "agents=v1") response := httptest.NewRecorder() @@ -44,7 +45,7 @@ func TestTextConfigurationHTTP(t *testing.T) { } for _, invalid := range []string{`{"verbosity":""}`, `{"verbosity":"verbose"}`, `{"verbosity":4}`, `{"format":{}}`, `{"format":{"type":"json_schema","schema":{}}}`, `{"format":{"type":"text","extra":true}}`, `{"unknown":true}`} { h, s, _ := testHandler(t) - req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"example","text":`+invalid+`},"environment":{"type":"none"}}`)) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"example","text":`+invalid+`},"environment":{"type":"none"},"input":"Describe the configured response format."}`)) req.Header.Set("Authorization", "Bearer test-api-key") req.Header.Set("OpenAI-Beta", "agents=v1") response := httptest.NewRecorder() diff --git a/services/agents-api/internal/store/agents_delete_public_test.go b/services/agents-api/internal/store/agents_delete_public_test.go index a3c732baf..1b5a2016d 100644 --- a/services/agents-api/internal/store/agents_delete_public_test.go +++ b/services/agents-api/internal/store/agents_delete_public_test.go @@ -28,13 +28,14 @@ func TestAgentDeletionOfficialClient(t *testing.T) { if err != nil { t.Fatal(err) } - h, err := api.NewHandler(s, auth, "codex") + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) if err != nil { t.Fatal(err) } server := httptest.NewServer(h) defer server.Close() - h, err = api.NewHandler(store.New(pool), auth, "codex") + recoveredStore := store.New(pool) + h, err = api.NewHandler(recoveredStore, auth, "codex", api.WithExecution(recoveredStore)) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/agents_update_public_test.go b/services/agents-api/internal/store/agents_update_public_test.go index 86d4efc49..d00d2031b 100644 --- a/services/agents-api/internal/store/agents_update_public_test.go +++ b/services/agents-api/internal/store/agents_update_public_test.go @@ -28,13 +28,14 @@ func TestAgentUpdateOfficialClient(t *testing.T) { if err != nil { t.Fatal(err) } - h, err := api.NewHandler(s, auth, "codex") + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) if err != nil { t.Fatal(err) } server := httptest.NewServer(h) defer server.Close() - h, err = api.NewHandler(store.New(pool), auth, "codex") + recoveredStore := store.New(pool) + h, err = api.NewHandler(recoveredStore, auth, "codex", api.WithExecution(recoveredStore)) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/harness_onboarding_test.go b/services/agents-api/internal/store/harness_onboarding_test.go index f817d728d..a35252648 100644 --- a/services/agents-api/internal/store/harness_onboarding_test.go +++ b/services/agents-api/internal/store/harness_onboarding_test.go @@ -81,7 +81,7 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { return res } for _, fields := range []string{`,"text":{"verbosity":"high"}`, `,"tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]`} { - request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"`+fields+`},"environment":{"type":"none"}}`, 400) + request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"`+fields+`},"environment":{"type":"none"},"input":"Check the requested harness capability."}`, 400) } res := request("POST", "/v1/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"none"},"input":"hold"}`, 201) var created struct { diff --git a/services/agents-api/internal/store/session_agent_filter_public_test.go b/services/agents-api/internal/store/session_agent_filter_public_test.go index 1feb7767b..8b7733009 100644 --- a/services/agents-api/internal/store/session_agent_filter_public_test.go +++ b/services/agents-api/internal/store/session_agent_filter_public_test.go @@ -28,13 +28,14 @@ func TestSessionAgentFilterOfficialClient(t *testing.T) { if err != nil { t.Fatal(err) } - h, err := api.NewHandler(s, auth, "codex") + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) if err != nil { t.Fatal(err) } server := httptest.NewServer(h) defer server.Close() - h, err = api.NewHandler(store.New(pool), auth, "codex") + recoveredStore := store.New(pool) + h, err = api.NewHandler(recoveredStore, auth, "codex", api.WithExecution(recoveredStore)) if err != nil { t.Fatal(err) } From d69cbb83c697ead936f4a6913f7e1ca44c75fd4f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:16:38 +0800 Subject: [PATCH 02/11] Require initial Session input and explicit metadata updates --- CONTRIBUTING.md | 19 ++ contracts/agents-api/README.md | 11 +- .../official-semantics-alignment.md | 35 +++- contracts/agents-api/openapi.yaml | 82 ++++---- contracts/agents-api/operation-evidence.md | 180 ++++++++++++++++++ contracts/agents-api/v1/sessions.go | 3 +- services/agents-api/README.md | 3 +- services/agents-api/internal/api/handler.go | 10 +- .../internal/api/session_admission_test.go | 71 +++++++ .../internal/api/session_metadata.go | 5 +- 10 files changed, 366 insertions(+), 53 deletions(-) create mode 100644 contracts/agents-api/operation-evidence.md create mode 100644 services/agents-api/internal/api/session_admission_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcd5f5ee4..f5d1ab31d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,25 @@ Do not split an already active batch again just to adopt this workflow. Preserve confirmed native differences and queue nonblocking findings without expanding the milestone; stop after completing it when the user has set that boundary. +For the continuous official-semantics alignment campaign, repeat owned-resource +API/documentation comparisons, bounded implementation batches, acceptance and +rescan until reasonably addressable discovered differences are removed. Maintain +operation-level evidence, including unverified behavior and approved native +harness/daemon differences. A merged batch does not finish the campaign. Discuss +uncertain designs before expanding mechanisms; simplify repeated patch loops and +record unresolved low-ROI cases with evidence and impact. Never defer a safety or +data-consistency blocker while claiming the affected workflow passed. + +Session creation requires initial input for `none`, and for streaming creation +outside `self_hosted`. Check these conditions before creation retry lookup or +resource resolution. The parser remains shared with subsequent message admission; +non-streaming hosted and self-hosted requests may omit input. Do not retain an +idle-none creation compatibility exception. Valid requests retain their documented +local idempotency behavior; clients may use the same request/key with stream=false +to recover a lost creation response. Session metadata updates require a supplied +metadata field, with null/empty clearing it. Validate an empty update before any +resource lookup, after authentication. + Keep runtime state, test artifacts and build output under `~/.parsar/`. Require absolute user-supplied working directories. Keep credentials out of source and logs. Update this guide when architecture, ownership or generated contracts change. diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 80b8c40ff..040b5a896 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -1,5 +1,6 @@ # Agents API contract +See the [58-operation evidence inventory](operation-evidence.md) for observed official behavior, local verification and remaining unknowns. The external reference is [openai-python beta/agents](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents), pinned in `upstream.json`. Its resource methods, corresponding types, pagination and streaming helpers define the compatibility target. This directory records @@ -358,8 +359,8 @@ upgrade the protocol. `stream` nor `agent_id` permits null. Metadata omission/null defaults to an empty map; individual values must be strings, including valid empty strings. Validate these distinctions before persistence rather than coercing null to Go zero values. -- `POST /agents/sessions/{id}` updates metadata only: omission preserves it, - null or `{}` clears it, and an object replaces all pairs. Apply the same string +- `POST /agents/sessions/{id}` updates metadata only: an empty update body + rejects; `metadata: null` or `metadata: {}` clears it, and an object replaces all pairs. Apply the same string and character limits as creation. Preserve execution state, effective configuration and the original creation retry identity. Fixed SDK/raw HTTP checks cover these distinctions, tenant isolation, active Session reads and restart persistence. @@ -744,7 +745,11 @@ With `none`, this includes the first Turn and input Items. With `self_hosted`, i includes the initial reservation and connection action; preparation and Turn admission belong to the existing Worker. Creation returns while the executor is offline, and an initial deadline failure leaves a failed Session without a Turn. -Omitted/null input creates an idle Session. Execution must be enabled and the +Initial input is required for `none`, and for streamed creation outside +`self_hosted`. Non-streaming hosted and self-hosted creation may omit input or +supply null. These conditions apply before creation retry lookup; valid retries +retain the same Session and never duplicate initial work. Existing Session reads +and subsequent events are unaffected. Execution must be enabled and the configured engine must support admission before any initial work is persisted. Fixed SDK/raw HTTP and PostgreSQL tests cover the accepted forms, saved and inline diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 325de68aa..40834b292 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -38,14 +38,17 @@ credential values belong in the repository or task board. - Current documentation supports Session Agent configuration updates; the fixed `SessionUpdateParams` exposes only metadata. New fields and newer Environment status/configuration shapes are a queued baseline upgrade, as approved by the user. -- Official `none` creation rejected omitted, null and empty initial input. Core - still permits idle `none` Sessions. Changing this requires a coordinated client - and acceptance-flow migration and is separately queued. +- The Session admission batch rejects missing/null input for `none` and for + streaming creation outside `self_hosted`. September 23 official probes confirmed + these conditions and idle self-hosted creation. Existing blank-text validation + remains stricter: official whitespace-only string input returned 201. This + newly found difference is queued rather than expanding the admission batch. - Two otherwise identical official creates with the same `Idempotency-Key` returned 201 and distinct Session IDs. Core retains its durable creation retry guarantee. This is a local behavior, not evidence of official idempotency parity. -- An empty Session update body, generic validation codes/field `param`, malformed - queries, page limits and overlapping mutation behavior need separate qualification. +- Empty Session update now returns the observed 400 error; explicit metadata null + and empty-object clearing remain supported. Generic validation codes/field `param`, + malformed queries, page limits and overlapping mutation behavior need qualification. The error mapping above must not be extrapolated to every status or resource. - Template references with inline installation overrides, optional Skill version semantics and the other active board entries remain outstanding. @@ -75,3 +78,25 @@ found no blockers and independently ran API/contract tests. Rebase onto main `c96ea82` preserved every batch patch; the combined tree passed API/execution and three PostgreSQL scheduling regressions. Test resources were scoped to this batch. E2B, OAuth provider refresh and new native capability combinations were not requalified. + +## Session admission batch — September 23 + +The conditional input requirements are checked before creation lookup, credential +binding or execution. No idle-none legacy creation exception is retained; existing +Session GET and events remain available. Valid creation requests keep the local +same-key guarantee. An empty metadata update is rejected after authentication and +before resource lookup; supplied metadata still uses the existing tenant-scoped +update path. + +Core Web requires initial input for conversation-only creation. Hosted creation +without input uses JSON; input-bearing creation retains SSE. If a creation stream +fails before revealing the Session ID, the next user-initiated retry sends the +same draft and key as JSON to recover that creation. It does not replay an input +or introduce an automatic retry loop. + +The official probe made nine bounded create requests and created two owned +Sessions (self-hosted without input and none with whitespace). Both were deleted +successfully; no hosted environment was created. Metadata observations are reused +from September 22. Evidence: `~/.parsar/remediation/20260923/session-admission-alignment/official/`. +See [operation evidence](operation-evidence.md) for the wider 58-operation audit. +Implementation validation is recorded separately when this batch completes. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 44ce472ad..d29dbb991 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -175,7 +175,7 @@ definitions: input: description: |- Input accepts a string or an ordered array of user InputMessage objects. - Omission and null create an idle Session; non-text content is not supported yet. + Required for none and streamed creation outside self_hosted; otherwise optional. x-nullable: true metadata: additionalProperties: @@ -2844,40 +2844,42 @@ paths: and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on - initial timeout. Omitted or null input creates an idle Session. With stream=true, - returns live Session events starting at creation; disconnect does not cancel - execution. New Sessions retain their authenticated creator; all creation retries - require the same typed subject, including across key rotation. Saved-Agent - retries and inline requests using Vault attachments or credential references - retain caller intent independently of later resource changes; unrelated inline - retries preserve resolved/default equivalences. Unknown historical creators - reject retries; known creators without recorded intent retain resolved-snapshot - retry rules. These conflict policies are local and not verified hosted parity. - Creation retries observe future events without replay; retry with stream=false - to retrieve the Session. Claude SDK on none and Core-managed Docker openai_hosted - supports qualified object-root json_schema output with medium verbosity, single-Agent - execution and ordinary functions. Hosted execution reuses native workspace - tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, - Subagent and tool_search combinations remain unqualified, including inherited - template contents. Other non-text initial input remains unsupported. Basic - Codex and Claude SDK openai_hosted creation requires an explicitly configured - managed provider. The Claude workspace profile supports non-deferred function - tools with text or successful inline PNG/JPEG results alongside native workspace - tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; - initial provisioning has no caller connection action. Network defaults to - enabled; disabled and restricted exact ASCII hostnames are supported. Restricted - policy requires 1–100 allowed domains. Unsupported hostname forms and startup - installations are rejected. Confidential env, system/npm/Python packages and - ordered setup commands use the shared initialization lifecycle; requested - network applies after setup. Initial inline and tenant-owned file_id files - freeze encrypted bytes before provisioning, then install through the common - Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup - overrides are rejected pending semantic verification. Tenant-owned environment_template_id - references inherit omitted network and allow only narrowing overrides. Referenced - network:null is explicitly unsupported pending semantic verification. Core - freezes effective configuration; template updates/deletion do not alter Session - snapshots or same-intent creation retries. Inline or tenant-owned skill_reference - Skills share initialization. Templates preserve default/latest/explicit selectors; + initial timeout. Initial input is required for none and for streamed creation + outside self_hosted. Omitted/null input remains valid for non-streaming hosted + and self_hosted creation. With stream=true, returns live Session events starting + at creation; disconnect does not cancel execution. New Sessions retain their + authenticated creator; all creation retries require the same typed subject, + including across key rotation. Saved-Agent retries and inline requests using + Vault attachments or credential references retain caller intent independently + of later resource changes; unrelated inline retries preserve resolved/default + equivalences. Unknown historical creators reject retries; known creators without + recorded intent retain resolved-snapshot retry rules. These conflict policies + are local and not verified hosted parity. Creation retries observe future + events without replay; retry with stream=false to retrieve the Session. Claude + SDK on none and Core-managed Docker openai_hosted supports qualified object-root + json_schema output with medium verbosity, single-Agent execution and ordinary + functions. Hosted execution reuses native workspace tools and Files/Artifacts; + Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search + combinations remain unqualified, including inherited template contents. Other + non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted + creation requires an explicitly configured managed provider. The Claude workspace + profile supports non-deferred function tools with text or successful inline + PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. + Idle Sessions provision automatically; initial provisioning has no caller + connection action. Network defaults to enabled; disabled and restricted exact + ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. + Unsupported hostname forms and startup installations are rejected. Confidential + env, system/npm/Python packages and ordered setup commands use the shared + initialization lifecycle; requested network applies after setup. Initial inline + and tenant-owned file_id files freeze encrypted bytes before provisioning, + then install through the common Core lifecycle before native execution or + live Files access. Referenced files/env/packages/setup overrides are rejected + pending semantic verification. Tenant-owned environment_template_id references + inherit omitted network and allow only narrowing overrides. Referenced network:null + is explicitly unsupported pending semantic verification. Core freezes effective + configuration; template updates/deletion do not alter Session snapshots or + same-intent creation retries. Inline or tenant-owned skill_reference Skills + share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default @@ -3043,11 +3045,11 @@ paths: post: consumes: - application/json - description: Omit metadata to leave it unchanged, send null or {} to clear it, - or supply an object to replace all pairs. Up to 16 string pairs, with keys - at most 64 characters and values at most 512 characters. Execution configuration - and activity are unchanged. Returns the same safe Environment and pending-input - activity projection as Session retrieval. + description: The metadata field is required in an update body. Send null or + {} to clear it, or supply an object to replace all pairs. Up to 16 string + pairs, with keys at most 64 characters and values at most 512 characters. + Execution configuration and activity are unchanged. Returns the same safe + Environment and pending-input activity projection as Session retrieval. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md new file mode 100644 index 000000000..32ac75211 --- /dev/null +++ b/contracts/agents-api/operation-evidence.md @@ -0,0 +1,180 @@ +# Pinned operation evidence inventory — 2026-09-23 + +Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. + +Baseline: `contracts/agents-api/upstream.json`, SDK **3.13.0**, upstream commit **d7c41efee1b0802b79f3f88a678ef2052b06e9ce**, `OpenAI-Beta: agents=v1`. AGENTS.md and relevant CONTRIBUTING.md compatibility, ownership and evidence rules govern this inventory. + +## Scope and evidence interpretation + +There are **58 distinct HTTP operations: 42 beta/agents + 5 general Files + 11 Skills/version/content**. Every one has a registered Core route; every family retains partial or unverified semantics. This is an exhaustive operation inventory, not an exhaustive compatibility claim. Methods/paths were enumerated from the complete installed fixed SDK, not from Core OpenAPI; the appendix gives exact source lines. The installed beta subtree matches the cached pinned commit byte-for-byte. The unrelated experiments virtualenv contains SDK 3.14.0 and was excluded. + +- **Implemented** means current source supports the stated bounded behavior. A route or SDK parse does not establish matching server behavior. +- **Official wire** means retained raw official-service observations, confined to their cases. `None located` means no positive wire observation was found in the specified evidence set, not that no such evidence exists anywhere. +- **Core validation** distinguishes **DB** (actual HTTP/PostgreSQL resource checks, no model execution), **Live** (service → daemon → native harness → real model), and **Recorded** (historical acceptance documented in the repository; underlying remote artifacts were not independently replayed/read in this inventory). +- **Gap** distinguishes known local restrictions/differences from unknown upstream semantics. A native limitation is not a redefinition of the fixed protocol. Historical runs are not fresh validation of b571591. + +## Evidence register + +Repository paths below are relative to the inspected worktree; private evidence paths are explicit. These locators retain scope and original revisions rather than claiming all older tests were rerun. + +| ID | Exact source and evidentiary boundary | +| --- | --- | +| R | `~/.parsar/remediation/20260922/official-semantics-alignment/resources/raw-evidence.json` and sibling `REPORT.md`, `request-index.md`: 40 official raw requests on six owned Agent/Vault/Credential/Template resources. Row labels below identify records; repeated cleanup labels additionally require the resource path. No model/hosted-environment execution. Historical source-derived mismatches in that report must be reconciled with the merged fixes, not copied as current bugs. | +| S | `~/.parsar/remediation/20260922/official-semantics-alignment/sessions/REPORT.md` plus named sibling JSON files: official `none` text, metadata, reads, pages, errors and cleanup. Final follow-up totals are four owned Sessions/five tiny gpt-6-astra Turns, including duplicate creation on retry; the earlier report's two-Session/three-Turn scope was expanded explicitly. | +| H | `contracts/agents-api/history-events-usage.md:85`; raw directory `~/.parsar/remediation/20260922/history-events-usage/official/`, including `create-http.json`, `create-sse-frames.json`, `reconnect-events.json`, `turns-asc-pagination.json`, `items-asc-pagination.json`, `analysis.json`. Official one-owned-Session/two-Turn text observation; no tools/Subagents/hosted execution, no full timing or accounting proof. | +| E | `~/.parsar/remediation/20260922/official-semantics-alignment/error-surface-probe.json`: missing non-beta File/Skill observations only. Not successful-resource or full error-surface coverage. | +| C | `~/.parsar/remediation/20260922/official-semantics-alignment/live/acceptance-summary.json`, source `7c80d604ba47c578084ebc9fa99cff332bd5d5f2`. Seven resource/safety groups (DB), plus three real Turns per harness (Live): Codex/Claude Kimi K3; MiniMax M2.7. Successful runs: `live/resources/run-1790091139881592673`, `live/codex/run-1790091781322707360`, `live/claude_sdk/run-1790091781322554888`, `live/mcode/run-1790091781324544033`; each has `result.json` and `raw-evidence.json`, model runs also history/SSE evidence. Four network-interrupted attempts remain failures. No new native capability/OAuth refresh/E2B qualification. | +| A | `contracts/agents-api/official-semantics-alignment.md`: merged status/envelope/no-op/error/rejection changes and explicit remaining differences. `contracts/agents-api/README.md:63` supplies the current resource ledger; it is a coverage summary, not raw evidence. | +| T | `contracts/agents-api/execution-tools.md`: per-operation input, function, required-action, structured-output, discovery and policy matrix; evidence register M1/M2/F1/F2/F3/S1/S2/D1/P1/E1/E2 gives exact private run paths. These are profile-specific real acceptances. The older blanket OAuth rejection in this document is superseded by O. | +| D | `contracts/agents-api/README.md:115`, `contracts/agents-api/user-managed-runtime-v1.md`: recorded three-harness Docker MVP and separate user-managed Docker/E2B qualification. Historical `~/.parsar/remediation/20260920/three-harness-mvp/REPORT.md`, `acceptance-results.json` on zju_a100_2; prior Core-managed E2B qualification is retired-route evidence, not current enrollment qualification. | +| B | `contracts/agents-api/subagents.md:109`: recorded six-GET real Docker matrix, two children, identity/pagination/isolation, cancellation and cold same-child continuation. Remote `~/.parsar/remediation/20260922/subagent-contract/{public-codex-kimi1,public-claude_sdk-2,public-mcode-1}` on zju_a100_2. Codex Kimi K3, Claude Kimi K3, MiniMax M2.7. `resources_passed`/`requested_phase_passed` qualify common reads; aggregate `passed` additionally requires optional Codex close/reopen. | +| F | `contracts/agents-api/source-files.md:11,65`: implemented general Files workflow and recorded PostgreSQL/SDK/raw-list checks; `contracts/agents-api/environment-files.md:1,30,71`: bounded local workspace list/copy and real deployment references. D and K add real consumption/copy/retention workflows. | +| K | `contracts/agents-api/environment-templates.md:94,764`: recorded fixed-SDK/raw/PostgreSQL Skill resource checks plus all-three-harness Docker reference workflows (Codex/Claude Kimi K3, MiniMax M2.7). Remote `~/.parsar/remediation/20260921/template-skill-references/`; local index `~/.parsar/remediation/20260921/template-capabilities-design/`. Covers upload, frozen template/Session resolution, native supporting files, source/template deletion, retry and cold continuation. Individual resource mutation cases not explicitly claimed by the summary remain DB-only or unspecified. | +| I | `contracts/agents-api/environment-templates.md`: separate recorded initial-file (:589), env/setup/npm/Python (:621), inline-Skill (:657), system-package (:692), capability-directory, Plugin MCP (:241), composition (:327) and restricted-network (:523) qualification. Exact historical run roots are in each section. No combinatorial/full hosted parity inference. | +| O | `services/agents-api/oauth-credentials.md:144`: recorded genuine Keycloak 26.7.4 PKCE grants, real TLS MCP and Kimi execution. Codex Basic client-auth initial/refresh/restart/replacement/revocation/delete; Claude POST initial/refresh. Trusted `none` profiles only; not MiniMax, hosted, arbitrary-provider or complete error equivalence. | + +## Per-operation evidence matrix + +Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means partial implementation. General unresolved status/error/null/default/header/pagination/race semantics apply even where a row lists a narrower gap. + +| # | SDK operation | Implemented behavior | Official wire observation | Core validation | Known difference / unverified semantics | +| --- | --- | --- | --- | --- | --- | +| 1 | beta.agents.create | P: saved configuration, 201 | R `agent-create-supported`; initial unsupported model case 400 | C DB create; T saved-Agent execution references | Model-derived reasoning defaults and unsupported configurations; complete default/null/errors unknown | +| 2 | beta.agents.retrieve | P: tenant-owned saved read | R `agent-read`, `agent-read-deleted` | C DB own/foreign/deleted read | Full field defaults and inline-vs-saved lifetime | +| 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests | Model-dependent default recomputation; uncommon nested/null/error variants | +| 4 | beta.agents.list | P: scoped cursor list | R `agent-list-empty-scoped`, `agent-list-limit101` | Recorded controlled SDK/DB coverage; no positive list in C resource replay | Official 101 accepted in sampled empty page; Core generic max 100. No inferred official cap | +| 5 | beta.agents.delete | P: resource deletion | R `cleanup-agent`, subsequent 404 | C DB delete/post-delete | Referenced/in-flight/repeated-delete exact parity | +| 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream | C Live three profiles; D/T/K/I recorded additional qualified workflows | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Many input/tool/environment combinations restricted | +| 7 | beta.agents.sessions.retrieve | P: persisted state, required actions, usage | S `retrieve-1.json`, `session-after-1.json`; H recovered state | C Live history; T pending actions; H Core acceptance recorded | Complete statuses/actions/lifecycle timing; Claude/MiniMax public usage remains null | +| 8 | beta.agents.sessions.update | P: metadata-only replacement/clear | S `metadata-replace/null/empty/omit/invalid-value.json`; `update-agent.json` uses newer unpinned field | Recorded controlled metadata coverage; no dedicated real-model update qualification claimed by C | Session admission batch changes empty update to observed official 400; Session agent update belongs to baseline upgrade, not fixed-pin operation gap | +| 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples | C Live order/cursors/empty/tenant checks | Official >100 sample accepted; Core max 100. Empty order/unknown query differences; eventual visibility sample is not a required delay | +| 10 | beta.agents.sessions.delete | P: public deletion, owned managed cleanup, user compute retained | S cleanup files 200/deleted; retry-session active cleanup initially 409 | D recorded real cleanup; C cleanup separately recorded | Physical purge/retention and all active/unknown-effect races; caller compute ownership preserved | +| 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission | S `second-turn-create.json`, `events-empty/null.json`; H second-input | C Live real continuation/no-op; T qualified message/result/cancel workflows | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools | +| 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections | H create/reconnect frames; no historical frames in sampled idle interval | C Live; H recorded three-harness disconnect/recovery; T pending actions | Full SSE/Item variants/order; child deltas settle late; no replay guarantee or observer-disconnect proof for every state | +| 13 | beta.agents.sessions.turns.retrieve | P: persisted root/child Turn identity | H/S contain Turn list payloads; no isolated positive retrieve raw request identified in this set | Recorded H/B scoped Turn recovery; C history uses list | Distinguish list-shape evidence from retrieve wire qualification; full lifecycle/usage | +| 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root+child history | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions | C Live paging; H/B real child/root identity recorded | All interleavings, same-timestamp paging, interim/failed usage | +| 15 | beta.agents.sessions.items.list | P: scoped root Items, full envelope | S `items-final/empty-page/limit-high.json`; H both directions | C Live; H/T recorded content/coordination/result variants | Full Item union; official 101 accepted despite pinned 1–100 doc. Newer turn_id filter excluded from pin | +| 16 | beta.agents.sessions.artifacts.retrieve | P: immutable captured metadata | None located | D recorded live Docker/user-managed workspace output | Exact hosted metadata/default/error and capture-edge parity | +| 17 | beta.agents.sessions.artifacts.list | P: scoped stored list | None located | D recorded live output enumeration | Paging during capture/delete; unchanged-file republishing | +| 18 | beta.agents.sessions.artifacts.delete | P: stored deletion | None located | D recorded workflow summary; per-case remote evidence not re-read | Repeated/in-flight deletion and physical retention parity | +| 19 | beta.agents.sessions.artifacts.content | P: immutable download after Runtime loss | None located | D recorded live retained download | Range/content headers, cancellation-edge capture and partial-transfer parity | +| 20 | beta.agents.sessions.subagents.retrieve | P: owned durable child identity/lifecycle | None located | B recorded Live six-read matrix | Full lifecycle/multi-agent parity; native close differences | +| 21 | beta.agents.sessions.subagents.list | P: direct/nested/closed child records | None located | B recorded Live scopes/pages/continuation | Publication timing/parent propagation and unsupported native nesting | +| 22 | beta.agents.sessions.subagents.items.list | P: child-owned history only | None located | B recorded Live child separation/recovery | Full child Item union; continuous child progress not qualified | +| 23 | beta.agents.sessions.subagents.turns.retrieve | P: scoped shared child Turn ID | None located | B recorded Live six-read matrix | Full status/usage/timestamp official semantics | +| 24 | beta.agents.sessions.subagents.turns.list | P: persisted child history | None located | B recorded Live paging/cold continuation | Full concurrent/cancel ordering and accounting | +| 25 | beta.agents.sessions.subagents.turns.items.list | P: exact child-and-Turn Items | None located | B recorded Live six-read matrix | Complete union and live ordering, overlapping mutation/cursors | +| 26 | beta.agents.environments.retrieve | P: durable status, safe configured installation metadata | None located | D/I/K recorded native readiness and metadata | All lifecycle timing and installation inventory; configured metadata is not arbitrary workspace discovery | +| 27 | beta.agents.environments.files.create | P: inline/source-file copy to qualified workspace | None located | F/D/K recorded real copy, hashes/consumption/retention | 50 MiB local bound, parent/path/overwrite/error/unknown-write semantics | +| 28 | beta.agents.environments.files.list | P: direct regular-file directory, opaque cursor | None located | F/D/K recorded live workspace listing | 1,024-entry prefilter bound; no recursion/symlinks; exact defaults/path/errors/mutation invalidation unknown | +| 29 | beta.agents.environments.templates.create | P: reusable network/files/env/setup/packages/Skills/Plugins/capability config, 201 | R `template-create` | C DB; I/K recorded real frozen-reference initialization | Restricted forms and unqualified combinations; complete hosted initialization semantics | +| 30 | beta.agents.environments.templates.retrieve | P: safe resource read | R `template-read`, deleted owned read | C DB; I/K recorded reference workflow | Full field/default/redaction parity; no live-secret projection inference | +| 31 | beta.agents.environments.templates.update | P: field replacement/null clearing, empty timestamp touch | R `template-patch`, `template-null`, `template-noop` | C DB no-op; I/K recorded frozen Session behavior | Referenced Session files/env/setup/packages overrides reject; null network/list/Skill-version remains unresolved | +| 32 | beta.agents.environments.templates.list | P: scoped cursor list | R `template-list-empty-scoped` | Recorded resource DB checks; no positive C list replay | Full nonempty/multipage/mutation/default/error parity | +| 33 | beta.agents.environments.templates.delete | P: delete resource, preserve committed Session snapshot | R `cleanup` at Template path, post-delete read | C DB; K recorded live deletion then continuation | Concurrent references/delete and exact errors | +| 34 | beta.agents.vaults.create | P: tenant resource, 201 | R `vault-create`, `vault-empty-token-fixture` | C DB; O recorded MCP attachment workflow | Archive lifecycle, full defaults and selection parity | +| 35 | beta.agents.vaults.retrieve | P: safe metadata/status | R `vault-read`, post-cleanup read | C DB deleted/error checks; O recorded lifecycle | Full archived-state and visibility semantics | +| 36 | beta.agents.vaults.list | P: stored status filter, separate clamping pagination | R `vault-list-empty-scoped` | Recorded resource DB coverage; no positive C list replay | Real archive transitions, nonempty pages/filter/limits/error parity | +| 37 | beta.agents.vaults.delete | P: atomic credential cascade, frozen attachment boundaries | R `cleanup` at Vault path | C DB deletion; O recorded grant lifecycle | Archive vs delete, already-delivered tokens and active effects | +| 38 | beta.agents.vaults.credentials.create | P: encrypted static/OAuth, nonempty tokens, 201 | R static/OAuth create + empty token/access-token rejection labels | C DB exact-row preservation/restart; O recorded genuine grants/real MCP | Full provider grants/default/error/selection behavior; qualified none profiles only | +| 39 | beta.agents.vaults.credentials.retrieve | P: safe metadata, secret-free read | R `credential-read`, post-delete read | C DB own/foreign/restart; O recorded lifecycle | Full response metadata and archived-state parity | +| 40 | beta.agents.vaults.credentials.update | P: static replacement/OAuth grant patch, reject empty effective update | R `credential-rotate`, `credential-empty-token`, `credential-oauth-empty-patch` | C DB rejected-write preservation; O recorded real Codex replacement/refresh | Official empty-access-token update not separately probed; concurrent refresh/replacement/provider errors and in-flight withdrawal | +| 41 | beta.agents.vaults.credentials.list | P: safe scoped metadata/status list | R `credential-list` | C DB mixed list/rejected-write/restart; O recorded resource checks | Complete status-filter/page limits/archive behavior | +| 42 | beta.agents.vaults.credentials.delete | P: encrypted resource deletion and dispatch denial | R `cleanup` at Credential paths | C DB; O recorded live Codex refusal after deletion | Cannot recall already-delivered token; exact hosted withdrawal/error timing | +| 43 | files.create | P: immutable multipart purpose=user_data | None located | F recorded DB upload; D/K recorded native source consumption | Other purposes/expires_after unsupported, 512 MiB local size vs pinned 512 MB wording; full multipart/errors | +| 44 | files.retrieve | P: project-owned metadata | E missing-ID 404 only | F recorded DB workflow; C missing-ID DB replay | Successful official shape/default/status purpose union and error param unknown | +| 45 | files.list | P: purpose filter, default/max 10,000, cursor list | None located | F recorded actual PostgreSQL SDK/raw pages/autocontinuation/restart/isolation | Exact official ordering/cursor/error/concurrent-mutation semantics | +| 46 | files.delete | P: transactional metadata/body unlink | None located | F recorded DB deletion; D/K recorded retained workspace copies | Admitted immutable read may complete after deletion; hosted concurrency/retention unknown | +| 47 | files.content | P: immutable binary stream | None located | F recorded DB; D/K recorded source workflow | Range/header/partial-transfer and exact errors; retrieve_content is same HTTP operation | +| 48 | skills.create | P: encrypted bounded directory/ZIP bundle | None located | K recorded DB resources and Live three-harness upload/reference | Fixed SDK single-tuple multipart issue; 500 files/5 MiB compressed/20 MiB expanded local limits | +| 49 | skills.retrieve | P: safe top-level metadata | E missing-ID 404 only | K recorded DB resource checks; C missing-ID DB replay | Metadata evolution across versions is local policy, successful official semantics unknown | +| 50 | skills.update | P: change default version only | None located | K recorded DB resources; frozen Session behavior in Live reference workflow | Default/latest/explicit/null semantics and top-level metadata evolution unknown | +| 51 | skills.list | P: scoped resource list | None located | K recorded DB resources; no model needed | Current documentation minimum zero is only a lead; fixed/common 1–100 implementation and exact official behavior require evidence | +| 52 | skills.delete | P: remove owned source, retain committed Session content | None located | K recorded DB and Live source deletion/continuation | Exact hosted deletion/idempotence/default/latest semantics | +| 53 | skills.content.retrieve | P: unversioned content selects default | None located | K recorded DB resource checks | Default-vs-latest unversioned selection unverified; headers/errors | +| 54 | skills.versions.create | P: immutable increasing version; optional default change | None located | K recorded DB resource checks | Numbering/default/top-level metadata/error/null semantics; same upload limits | +| 55 | skills.versions.retrieve | P: owned immutable version metadata | None located | K recorded DB resource checks and Live concrete Session freeze | Selector/metadata/error parity; reference version:null rejects locally | +| 56 | skills.versions.list | P: scoped version cursor list | None located | K recorded DB resource checks | Exact ordering/cursors/zero/default/max limits and concurrent version mutation | +| 57 | skills.versions.delete | P: nondefault deletion; default deletion rejects | None located | K recorded DB resources; exact per-variant live case not claimed | Last/default/latest deletion behavior and non-reused numbers are unverified local choices | +| 58 | skills.versions.content.retrieve | P: decrypt/read owned concrete bundle | None located | K recorded DB checks; Live native frozen supporting files | Content headers/errors and source deletion/read races; consumption does not prove download wire parity | + +## Remaining gaps without task ordering + +1. **Public generic semantics:** sampled create/event/envelope/error/no-op corrections are merged. Resource-by-resource omissions/null/default/error params, malformed queries, list caps, unknown/empty query handling, concurrent mutation and deletion require separate evidence. Metadata U+0000 remains an implementation-validation question from the prior audit, not a newly reproduced result here. +2. **Session differences:** The Session admission batch removes idle `none` creation and empty metadata update. Local durable creation idempotency remains an explicit difference. Whitespace-only input succeeds officially but is rejected by the existing Core message validator; this newly observed difference is queued separately. Session agent updates, newer Environment shapes and root Item turn_id are baseline-upgrade questions. +3. **Template/Skill composition:** referenced files/env/setup/packages override rejection is a known implementation gap; exact merge/replacement/null semantics need evidence. Null reference versions/null override lists, unversioned Skill content, top-level version metadata and last/default/latest deletion remain unresolved. +4. **Execution coverage:** use T's qualified matrix, not a blanket missing-image/structured-output claim. MiniMax functions/service MCP, optional tool combinations, unsupported images/placements and broader native lifecycle are explicit restrictions. PTC omission retains approved native behavior; Claude/MiniMax public Usage remains null; child settlement cadence/native close limits remain visible. No second executor/model loop or guessed counters are justified. +5. **Workspace and resources:** live Files bounds, symlink/path/cursor choices, artifact overwrite/republishing/headers/cancellation edges, full Environment metadata/lifecycle and Vault archive/in-flight-token semantics remain partial or unknown. Retired Core-managed E2B acceptance cannot qualify current user enrollment. + +## Enumeration and wording mismatches + +- The README's **42** is correct only for beta/agents. A campaign that includes referenced Files and Skills needs the explicit **58** inventory. Its combined Skills/Versions row abbreviates six operation names, while the fixed SDK actually exposes eleven distinct HTTP operations across resource, version and content classes. +- SDK `files.retrieve_content` is deprecated but makes the same `GET /files/{file_id}/content` request as `files.content`; count it as an alias, not another HTTP operation. Overloads, async mirrors, raw/streaming response wrappers and polling helpers add no distinct paths/methods. Likewise Session create's stream overload shares POST create; events.stream is its own GET. +- Vault paths are `/v1/vaults`, not `/v1/agents/vaults`. Files/Skills are non-beta project-authenticated routes. No public Environment create/delete, Plugin CRUD or extra Subagent mutation operation exists in this fixed resource inventory. +- Existing stale prose must not drive new backlog: `execution-tools.md` blanket OAuth rejection is superseded by O; README broad non-text-input and safe-empty Environment metadata summaries need qualification against T/I/K; environment-templates.md older no-op timestamp uncertainty is superseded by A. Older API comments also mention retired registry transport or unsupported features already delivered. Current code plus latest bounded evidence wins over historical narratives. +- **58 registered routes ≠ 58 compatible operations.** The positive official sample is concentrated in Agents/Sessions/history/Template/Vault/Credentials. No positive official artifact, Environment Files, Subagent or Skill resource workflow is established by the inspected September 22 comparison set. + +## Fixed SDK and current route appendix + +The local fixed-SDK source links below identify the method implementation (overloads excluded). All paths have the base `/v1` added. Source version is verified in [_version.py](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/_version.py#L2). Core registration sources: [handler.go](../../services/agents-api/internal/api/handler.go), [subagents.go](../../services/agents-api/internal/api/subagents.go), [skills.go](../../services/agents-api/internal/api/skills.go). + +| Fixed resource source / method | HTTP operation | +| --- | --- | +| [resources/beta/agents/agents.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/agents.py#L85) | `POST /v1/agents` | +| [resources/beta/agents/agents.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/agents.py#L171) | `GET /v1/agents/{agent_id}` | +| [resources/beta/agents/agents.py:update](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/agents.py#L211) | `POST /v1/agents/{agent_id}` | +| [resources/beta/agents/agents.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/agents.py#L301) | `GET /v1/agents` | +| [resources/beta/agents/agents.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/agents.py#L359) | `DELETE /v1/agents/{agent_id}` | +| [resources/beta/agents/environments/environments.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/environments.py#L63) | `GET /v1/agents/environments/{environment_id}` | +| [resources/beta/agents/environments/files.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/files.py#L119) | `POST /v1/agents/environments/{environment_id}/files` | +| [resources/beta/agents/environments/files.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/files.py#L158) | `GET /v1/agents/environments/{environment_id}/files` | +| [resources/beta/agents/environments/templates.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py#L49) | `POST /v1/agents/environments/templates` | +| [resources/beta/agents/environments/templates.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py#L129) | `GET /v1/agents/environments/templates/{environment_template_id}` | +| [resources/beta/agents/environments/templates.py:update](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py#L174) | `POST /v1/agents/environments/templates/{environment_template_id}` | +| [resources/beta/agents/environments/templates.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py#L260) | `GET /v1/agents/environments/templates` | +| [resources/beta/agents/environments/templates.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py#L318) | `DELETE /v1/agents/environments/templates/{environment_template_id}` | +| [resources/beta/agents/sessions/artifacts.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/artifacts.py#L52) | `GET /v1/agents/sessions/{session_id}/artifacts/{artifact_id}` | +| [resources/beta/agents/sessions/artifacts.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/artifacts.py#L97) | `GET /v1/agents/sessions/{session_id}/artifacts` | +| [resources/beta/agents/sessions/artifacts.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/artifacts.py#L162) | `DELETE /v1/agents/sessions/{session_id}/artifacts/{artifact_id}` | +| [resources/beta/agents/sessions/artifacts.py:content](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/artifacts.py#L207) | `GET /v1/agents/sessions/{session_id}/artifacts/{artifact_id}/content` | +| [resources/beta/agents/sessions/events.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/events.py#L44) | `POST /v1/agents/sessions/{session_id}/events` | +| [resources/beta/agents/sessions/events.py:stream](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/events.py#L91) | `GET /v1/agents/sessions/{session_id}/events` | +| [resources/beta/agents/sessions/items.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/items.py#L44) | `GET /v1/agents/sessions/{session_id}/items` | +| [resources/beta/agents/sessions/sessions.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/sessions.py#L290) | `POST /v1/agents/sessions` | +| [resources/beta/agents/sessions/sessions.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/sessions.py#L336) | `GET /v1/agents/sessions/{session_id}` | +| [resources/beta/agents/sessions/sessions.py:update](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/sessions.py#L376) | `POST /v1/agents/sessions/{session_id}` | +| [resources/beta/agents/sessions/sessions.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/sessions.py#L422) | `GET /v1/agents/sessions` | +| [resources/beta/agents/sessions/sessions.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/sessions.py#L486) | `DELETE /v1/agents/sessions/{session_id}` | +| [resources/beta/agents/sessions/subagents/items.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/subagents/items.py#L44) | `GET /v1/agents/sessions/{session_id}/subagents/{subagent_id}/items` | +| [resources/beta/agents/sessions/subagents/subagents.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/subagents/subagents.py#L67) | `GET /v1/agents/sessions/{session_id}/subagents/{subagent_id}` | +| [resources/beta/agents/sessions/subagents/subagents.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/subagents/subagents.py#L112) | `GET /v1/agents/sessions/{session_id}/subagents` | +| [resources/beta/agents/sessions/subagents/turns/items.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/subagents/turns/items.py#L44) | `GET /v1/agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}/items` | +| [resources/beta/agents/sessions/subagents/turns/turns.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/subagents/turns/turns.py#L55) | `GET /v1/agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}` | +| [resources/beta/agents/sessions/subagents/turns/turns.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/subagents/turns/turns.py#L106) | `GET /v1/agents/sessions/{session_id}/subagents/{subagent_id}/turns` | +| [resources/beta/agents/sessions/turns.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/turns.py#L43) | `GET /v1/agents/sessions/{session_id}/turns/{turn_id}` | +| [resources/beta/agents/sessions/turns.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/sessions/turns.py#L87) | `GET /v1/agents/sessions/{session_id}/turns` | +| [resources/beta/agents/vaults/credentials.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/credentials.py#L52) | `POST /v1/vaults/{vault_id}/credentials` | +| [resources/beta/agents/vaults/credentials.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/credentials.py#L107) | `GET /v1/vaults/{vault_id}/credentials/{credential_id}` | +| [resources/beta/agents/vaults/credentials.py:update](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/credentials.py#L152) | `POST /v1/vaults/{vault_id}/credentials/{credential_id}` | +| [resources/beta/agents/vaults/credentials.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/credentials.py#L201) | `GET /v1/vaults/{vault_id}/credentials` | +| [resources/beta/agents/vaults/credentials.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/credentials.py#L269) | `DELETE /v1/vaults/{vault_id}/credentials/{credential_id}` | +| [resources/beta/agents/vaults/vaults.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/vaults.py#L58) | `POST /v1/vaults` | +| [resources/beta/agents/vaults/vaults.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/vaults.py#L110) | `GET /v1/vaults/{vault_id}` | +| [resources/beta/agents/vaults/vaults.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/vaults.py#L150) | `GET /v1/vaults` | +| [resources/beta/agents/vaults/vaults.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/vaults/vaults.py#L215) | `DELETE /v1/vaults/{vault_id}` | +| [resources/skills/content.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/content.py#L43) | `GET /v1/skills/{skill_id}/content` | +| [resources/skills/skills.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/skills.py#L80) | `POST /v1/skills` | +| [resources/skills/skills.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/skills.py#L126) | `GET /v1/skills/{skill_id}` | +| [resources/skills/skills.py:update](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/skills.py#L163) | `POST /v1/skills/{skill_id}` | +| [resources/skills/skills.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/skills.py#L204) | `GET /v1/skills` | +| [resources/skills/skills.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/skills.py#L257) | `DELETE /v1/skills/{skill_id}` | +| [resources/skills/versions/content.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/versions/content.py#L43) | `GET /v1/skills/{skill_id}/versions/{version}/content` | +| [resources/skills/versions/versions.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/versions/versions.py#L68) | `POST /v1/skills/{skill_id}/versions` | +| [resources/skills/versions/versions.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/versions/versions.py#L126) | `GET /v1/skills/{skill_id}/versions/{version}` | +| [resources/skills/versions/versions.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/versions/versions.py#L168) | `GET /v1/skills/{skill_id}/versions` | +| [resources/skills/versions/versions.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/skills/versions/versions.py#L223) | `DELETE /v1/skills/{skill_id}/versions/{version}` | +| [resources/files.py:create](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/files.py#L63) | `POST /v1/files` | +| [resources/files.py:retrieve](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/files.py#L157) | `GET /v1/files/{file_id}` | +| [resources/files.py:list](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/files.py#L194) | `GET /v1/files` | +| [resources/files.py:delete](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/files.py#L256) | `DELETE /v1/files/{file_id}` | +| [resources/files.py:content](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/files.py#L293) | `GET /v1/files/{file_id}/content` | diff --git a/contracts/agents-api/v1/sessions.go b/contracts/agents-api/v1/sessions.go index 8d5f21e24..837beeb42 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -11,13 +11,14 @@ type CreateSessionRequest struct { AgentID *string `json:"agent_id,omitempty"` Environment *Environment `json:"environment" binding:"required"` // Input accepts a string or an ordered array of user InputMessage objects. - // Omission and null create an idle Session; non-text content is not supported yet. + // Required for none and streamed creation outside self_hosted; otherwise optional. Input any `json:"input,omitempty" extensions:"x-nullable"` Metadata map[string]string `json:"metadata,omitempty" extensions:"x-nullable"` Stream bool `json:"stream,omitempty" default:"false"` VaultIDs []string `json:"vault_ids,omitempty"` } +// UpdateSessionRequest requires metadata; null and an empty object clear it. type UpdateSessionRequest struct { Metadata map[string]string `json:"metadata,omitempty" extensions:"x-nullable"` } diff --git a/services/agents-api/README.md b/services/agents-api/README.md index dae8ddfa8..919ae4df5 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -226,7 +226,8 @@ Ordinary JSON requests have a 1 MiB body limit; file transfers use the separate bounds in the Files contracts. Session lists support `after`, `limit` (1..100), `order` (`asc`/`desc`) and optional immutable root `agent_id`. The local defaults are 20 and descending order; exact hosted limits/error semantics remain unverified. -Metadata updates preserve omission, clear on null/empty and replace supplied pairs. +Session updates require the metadata field; null/empty clears it and an object +replaces supplied pairs. An empty update body rejects before resource lookup. Delete with `client.beta.agents.sessions.delete(session.id)`. Confirmation means public removal: Session/history reads and new input become unavailable. Active diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 09dca54e3..e490cee5a 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -124,7 +124,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms remain unqualified. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms remain unqualified. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream @@ -170,6 +170,14 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } + if input.Environment.Type == "none" && len(initialInputs) == 0 { + writeError(w, http.StatusBadRequest, "invalid_request_error", "conversation-only sessions currently require initial input") + return + } + if input.Stream && input.Environment.Type != "self_hosted" && len(initialInputs) == 0 { + writeError(w, http.StatusBadRequest, "invalid_request_error", "streaming session creation requires initial input") + return + } creationRequest, err := sessionCreationRequest(input, initialInputs) if err != nil { writeStoreError(w, r, err) diff --git a/services/agents-api/internal/api/session_admission_test.go b/services/agents-api/internal/api/session_admission_test.go new file mode 100644 index 000000000..78ca38fcd --- /dev/null +++ b/services/agents-api/internal/api/session_admission_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func TestSessionAdmissionRejectsBeforeResourceOrExecutionAccess(t *testing.T) { + for _, environment := range []string{"none", "openai_hosted"} { + for _, input := range []string{"", `,"input":null`} { + for _, stream := range []bool{false, true} { + if environment == "openai_hosted" && !stream { + continue + } + t.Run(fmt.Sprintf("%s/%s/stream=%t", environment, input, stream), func(t *testing.T) { + // Any resource access, including creation retry lookup, would panic. + handler, _, _ := testHandler(t, func(h *Handler) { + h.store = &struct{ ResourceStore }{} + h.inputs = &inputRecorder{} + }) + body := fmt.Sprintf(`{"agent":{"model":"example"},"environment":{"type":%q},"stream":%t%s}`, environment, stream, input) + for _, token := range []string{"test-api-key", "invalid"} { + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Idempotency-Key", "retained-creation-key") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if token == "invalid" { + if response.Code != http.StatusUnauthorized { + t.Fatal(response.Code, response.Body) + } + continue + } + var failure v1.ErrorResponse + if response.Code != http.StatusBadRequest || json.Unmarshal(response.Body.Bytes(), &failure) != nil || failure.Error.Code == nil || *failure.Error.Code != "invalid_request_error" || failure.Error.Type != "invalid_request_error" || failure.Error.Param != nil { + t.Fatal(response.Code, response.Body) + } + } + }) + } + } + } +} + +func TestSessionEmptyUpdateRejectsBeforeResourceAccess(t *testing.T) { + handler, _, _ := testHandler(t, func(h *Handler) { h.store = &struct{ ResourceStore }{} }) + for _, token := range []string{"test-api-key", "invalid"} { + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions/unknown", strings.NewReader(`{}`)) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if token == "invalid" { + if response.Code != http.StatusUnauthorized { + t.Fatal(response.Code, response.Body) + } + continue + } + var failure v1.ErrorResponse + if response.Code != http.StatusBadRequest || json.Unmarshal(response.Body.Bytes(), &failure) != nil || failure.Error.Code == nil || *failure.Error.Code != "invalid_request_error" || failure.Error.Message != "At least one update field is required" { + t.Fatal(response.Code, response.Body) + } + } +} diff --git a/services/agents-api/internal/api/session_metadata.go b/services/agents-api/internal/api/session_metadata.go index f69588773..b871bc166 100644 --- a/services/agents-api/internal/api/session_metadata.go +++ b/services/agents-api/internal/api/session_metadata.go @@ -11,7 +11,7 @@ import ( ) // @Summary Update execution Session metadata -// @Description Omit metadata to leave it unchanged, send null or {} to clear it, or supply an object to replace all pairs. Up to 16 string pairs, with keys at most 64 characters and values at most 512 characters. Execution configuration and activity are unchanged. Returns the same safe Environment and pending-input activity projection as Session retrieval. +// @Description The metadata field is required in an update body. Send null or {} to clear it, or supply an object to replace all pairs. Up to 16 string pairs, with keys at most 64 characters and values at most 512 characters. Execution configuration and activity are unchanged. Returns the same safe Environment and pending-input activity projection as Session retrieval. // @Tags Sessions // @Accept json // @Produce json @@ -41,7 +41,8 @@ func (h *Handler) updateSession(w http.ResponseWriter, r *http.Request) { var session store.Session var err error if len(request.Metadata) == 0 { - session, err = h.store.GetSession(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) + writeError(w, http.StatusBadRequest, "invalid_request_error", "At least one update field is required") + return } else { var values map[string]*string if err := json.Unmarshal(request.Metadata, &values); err != nil { From 141bd2a40dc929d89e5bcf8b7a7f0911dea304a8 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:19:41 +0800 Subject: [PATCH 03/11] Update Session admission examples and scope notes --- CONTRIBUTING.md | 3 ++- contracts/agents-api/README.md | 5 +++-- services/agents-api/README.md | 22 +++++++++------------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5d1ab31d..8b4bf4ff4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1962,7 +1962,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti or user-message array through the same parser and admission path. Commit the Session, initial input, first Turn and Item/event projections in one transaction. A creation retry returns the existing Session without re-admitting initial work, - including after terminal or later Turns. Omitted/null input retains idle creation. + including after terminal or later Turns. Omitted/null input is permitted only + for non-streaming hosted creation and self-hosted creation. Creation streaming uses the shared live path above; non-text messages remain a gap. - Enabling `AGENTS_API_DAEMON_WS_URL` also starts a bounded execution worker. Select only connected, capable devices owned by the authenticated tenant; bind once and diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 040b5a896..7ffafabd8 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -755,8 +755,9 @@ configured engine must support admission before any initial work is persisted. Fixed SDK/raw HTTP and PostgreSQL tests cover the accepted forms, saved and inline configuration, ordering, tenant isolation, retries, rollback and persistence. Image support is bounded as documented above. Empty arrays and blank text -currently fail the shared message validator; exact upstream handling of these -cases, local size limits and error details remains unverified. Swagger 2 cannot +fail the shared message validator. Official probes also rejected empty arrays +and empty strings, but accepted whitespace-only strings; the latter is a queued +difference. Full local size-limit and error-detail parity remains unverified. Swagger 2 cannot express the string/array union, so input is unconstrained with a type description. ### Session creation streaming diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 919ae4df5..c72dd6ee6 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -25,12 +25,12 @@ Credentials. Configure their independent encryption key and authenticated Sessio use through the [credential guide](credentials.md); see [OAuth credentials](oauth-credentials.md) for application authorization, dispatch-time refresh and revocation boundaries. -The pinned Python client can save configuration independently of execution: +The pinned Python client saves an Agent independently, then starts a Session with initial input: ```python agent = client.beta.agents.create(model="your-model", name="Example") session = client.beta.agents.sessions.create( - agent_id=agent.id, environment={"type": "none"}, + agent_id=agent.id, environment={"type": "none"}, input="Hello", ) ``` @@ -204,8 +204,9 @@ resources); general Files routes do not. Supported operations include: - Saved Agent create/retrieve/update/list/delete. - Session create/retrieve/list/delete and metadata-only update. Creation supports inline - configuration or a saved `agent_id`, field replacements, optional initial text - and ordinary or streaming responses. + configuration or a saved `agent_id`, field replacements, initial text + and ordinary or streaming responses. Initial input is required for `none` and + streamed creation outside `self_hosted`. - Session event submission and live streaming, Turn retrieve/list and Items list. - Environment retrieve for three-harness colocated self-hosted and Docker profiles, bounded live file listing, and inline/source copies into qualified @@ -394,13 +395,8 @@ client = OpenAI(base_url="http://127.0.0.1:8091/v1", api_key="") session = client.beta.agents.sessions.create( agent={"model": ""}, environment={"type": "none"}, -) -client.beta.agents.sessions.events.create( - session.id, - events=[{"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": "Hello"}]} - ]}], - idempotency_key="first-message", + input="Hello", + extra_headers={"Idempotency-Key": "first-message"}, ) ``` @@ -409,8 +405,8 @@ and daemon credentials authenticate this service, not a model provider. Never pu provider secrets in Session metadata. This path does not enable Parsar Skill/SP callbacks or bypass the pending product authorization work. -Session creation also accepts `input="Hello"` to admit initial text atomically and -`stream=True` for created/live events. Open a GET event stream before submitting +Session creation admits initial text atomically; add `stream=True` for +created/live events. Open a GET event stream before submitting later work, or use the official `sessions.stream` helper for one Turn. Function handlers return results through the same public events endpoint. Recover missed output with Session/Turn/Items queries; reconnecting SSE does not replay history. From e69a5918f25443a175f2d9f984da65abae0a8561 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:20:32 +0800 Subject: [PATCH 04/11] Clarify Core Web initial creation and recovery behavior --- docs/web/README.md | 2 +- docs/web/core-connection.md | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/web/README.md b/docs/web/README.md index 3703cddeb..d57df3387 100644 --- a/docs/web/README.md +++ b/docs/web/README.md @@ -111,7 +111,7 @@ Only the local Web server reads the caller-key file. Do not place a plaintext ke 1. Open **Agents** and choose **Create agent** or a starter template. 2. Set a name, model, and instructions; add supported tools only when needed. -3. Select **Start Session**, choose an Environment, and optionally send the first message. +3. Select **Start Session**, choose an Environment, and enter the first message for conversation-only execution. Hosted Sessions may be created without input. 4. Continue in **Sessions** while live events and durable history update. 5. Use **Trace**, **Vaults**, **Environment**, or **System** when the workflow needs them. diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index 108de66cf..8dd3c7567 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -65,7 +65,7 @@ SDK loop or call the Responses API as its Core transport. | Profile | Configuration | Result | | --- | --- | --- | | Daemon-backed `environment:none` chat | PostgreSQL, caller principal, Core with `AGENTS_API_DAEMON_WS_URL`, same-tenant device, connected daemon, native engine/provider setup | Agent CRUD, Sessions, Turns, Items, SSE, and supported execution | -| HTTP-only | PostgreSQL and caller principal; omit `AGENTS_API_DAEMON_WS_URL` | Agent CRUD and idle Session/history operations; input returns `503 execution_unavailable` | +| HTTP-only | PostgreSQL and caller principal; omit `AGENTS_API_DAEMON_WS_URL` | Agent CRUD and existing Session/history reads; valid execution creation/input returns `503 execution_unavailable` | | Caller-managed `self_hosted` | Codex Core with native registry/executor origin plus an operator-issued executor principal key and caller-started Linux executor | Session-scoped Environment, caller Workspace, native execution after connection | | Core-managed `openai_hosted` | Linux amd64 Core host, qualified immutable Runtime image, local Docker provider, execution options, database/caller identity, and daemon gateway reachable from the Runtime | Core provisions, leases, resumes, and reclaims one basic managed Runtime per Session | @@ -451,7 +451,8 @@ env \ go run ./services/agents-api/cmd/server ``` -Agent CRUD and idle Session/history operations work. Chat input intentionally returns: +Agent CRUD and existing Session/history reads work. Conversation-only creation +requires initial input and enabled execution; execution requests intentionally return: ```json { @@ -676,9 +677,10 @@ MCP, other engines/providers, and public readiness discovery remain unavailable. Web blocks managed creation when the effective Agent contains MCP. Core provisions the Runtime automatically, so managed Sessions have no caller-run -launcher, executor key, or `environment_connection` action. Web always consumes the -creation SSE for this profile, including an idle create, before handing off to the -ordinary live stream. A connected Environment proves authenticated transport only; +launcher, executor key, or `environment_connection` action. Web uses JSON for managed creation without input. With initial input, it consumes +creation SSE before handing off to the ordinary live stream. If creation fails +before revealing its Session ID, an unchanged manual retry uses the same input +and key with JSON to recover the original creation; it does not resubmit a Turn. A connected Environment proves authenticated transport only; durable Turn and Item state remains the execution result. For either supported Environment type, Web automatically retrieves the exact current From 5f4ea3693f7c17bb57e5dc6f407cdaa8795e3053 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:20:25 +0800 Subject: [PATCH 05/11] Require initial input for no-environment Web Sessions --- apps/web/e2e/agents-lifecycle.spec.ts | 152 +++++++++++++----- apps/web/e2e/fixture-core.mjs | 81 ++++++---- apps/web/e2e/vault-credentials.spec.ts | 3 +- apps/web/src/App.tsx | 9 +- .../create/SessionInitialInputEditor.tsx | 7 +- .../create/SessionStartDialog.test.tsx | 11 +- .../sessions/create/SessionStartDialog.tsx | 26 +-- .../create/session-initial-input.test.ts | 24 +++ .../sessions/create/session-initial-input.ts | 26 +++ .../create/session-start-draft.test.ts | 15 +- .../sessions/create/session-start-draft.ts | 7 +- packages/agents-client/src/types.ts | 1 + 12 files changed, 259 insertions(+), 103 deletions(-) diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index d179f0e0f..b2ec55bea 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -75,6 +75,7 @@ async function createFixtureSession(request: APIRequestContext, label: string) { agent_id: "agent_b", environment: { type: "none" }, metadata: { filter_fixture: label }, + input: `Review the filter fixture ${label}.`, stream: false, vault_ids: [], }, @@ -137,7 +138,12 @@ async function startSessionWithSecondAgent(page: Page) { await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session" }); await expect(dialog).toBeVisible(); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the selected Agent configuration."); + const liveHandoff = page.waitForResponse((response) => ( + response.request().method() === "GET" && /\/agents\/sessions\/session_created_[^/]+\/events$/u.test(new URL(response.url()).pathname) + )); await dialog.getByRole("button", { name: "Create Session" }).click(); + await liveHandoff; } async function openAdvancedSessionSettings(dialog: Locator) { @@ -536,7 +542,7 @@ test("supports global Create keyboard navigation and consumes setup requests onc expect(creates[0]?.body).not.toHaveProperty("reasoning"); }); -test("continues from a default Agent definition into an admitted idle Session", async ({ page, request }) => { +test("continues from a default Agent definition into a Session with initial input", async ({ page, request }) => { await openAgents(page, request); await page.getByRole("button", { name: /^Create agent/ }).click(); await page.getByLabel("Name").fill("Session-safe Agent"); @@ -555,6 +561,7 @@ test("continues from a default Agent definition into an admitted idle Session", await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); await page.getByRole("button", { name: "Start Session" }).click(); const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review this Agent definition."); await expect(sessionDialog).toBeVisible(); await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/); await expect(sessionDialog.getByRole("button", { name: /Advanced settings/ })).toHaveAttribute("aria-expanded", "false"); @@ -575,7 +582,7 @@ test("continues from a default Agent definition into an admitted idle Session", expect(sessionCreates[0]?.body).toMatchObject({ agent_id: expect.stringMatching(/^agent_created_/), environment: { type: "none" }, - stream: false, + stream: true, }); }); @@ -644,6 +651,7 @@ test("starts only Agents that pass known Session admission", async ({ page, requ )).length; await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Check the selected Agent configuration."); await expect(sessionDialog).toBeVisible(); await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); await sessionDialog.getByRole("button", { name: "Create Session" }).click(); @@ -656,15 +664,16 @@ test("starts only Agents that pass known Session admission", async ({ page, requ expect(sessionCreates.at(-1)?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, - stream: false, + stream: true, }); }); -test("serializes complete saved-Agent overrides while keeping idle creation unstreamed", async ({ page, request }) => { +test("serializes complete saved-Agent overrides with initial input", async ({ page, request }) => { await openAgents(page, request); await page.getByRole("button", { name: "Sessions", exact: true }).click(); await page.getByRole("button", { name: "New Session" }).click(); let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the Session-specific settings."); await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_a"); await expect(dialog.getByRole("alert")).toContainText("multi-agent execution is not supported"); @@ -687,6 +696,7 @@ test("serializes complete saved-Agent overrides while keeping idle creation unst await page.getByRole("button", { name: "New Session" }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the Session-specific settings."); await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b"); await openAdvancedSessionSettings(dialog); await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); @@ -710,18 +720,20 @@ test("serializes complete saved-Agent overrides while keeping idle creation unst }, environment: { type: "none" }, metadata: {}, - stream: false, + input: "Review the Session-specific settings.", + stream: true, vault_ids: [], }); expect(creates[1]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, metadata: {}, - stream: false, + input: "Review the Session-specific settings.", + stream: true, vault_ids: [], }); expect(creates[1]?.body).not.toHaveProperty("agent"); - expect(creates[1]?.body).not.toHaveProperty("input"); + expect(creates[1]?.body?.input).toBe("Review the Session-specific settings."); }); test("derives manual Vault attachments for anonymous and explicit MCP Credentials", async ({ page, request }) => { @@ -772,6 +784,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); await openAdvancedSessionSettings(dialog); await expect(dialog).toContainText("Anonymous for this Session"); await dialog.getByRole("button", { name: "Create Session" }).click(); @@ -780,6 +793,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await page.getByRole("button", { name: "Agents", exact: true }).click(); await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); await openAdvancedSessionSettings(dialog); await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); await expect(dialog).toContainText("Implicit unique match · Credential Alpha · Vault Alpha"); @@ -789,6 +803,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await page.getByRole("button", { name: "Agents", exact: true }).click(); await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); await openAdvancedSessionSettings(dialog); await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); await dialog.getByRole("checkbox", { name: "Vault Beta" }).check(); @@ -799,6 +814,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await page.getByRole("button", { name: "Agents", exact: true }).click(); await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); await openAdvancedSessionSettings(dialog); const requiredVault = dialog.getByRole("checkbox", { name: /Vault Alpha · attached automatically/ }); await expect(requiredVault).toBeChecked(); @@ -821,6 +837,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential await page.getByRole("button", { name: "Agents", exact: true }).click(); await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); const deleted = await request.delete(`${fixtureBaseUrl}/v1/vaults/${vaultAlpha.id}/credentials/${credentialAlpha.id}`); expect(deleted.ok()).toBe(true); await dialog.getByRole("button", { name: "Create Session" }).click(); @@ -859,6 +876,7 @@ test("clears manual Vault attachments when overrides remove HTTP MCP tools", asy await page.getByRole("button", { name: "Agents", exact: true }).click(); await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click(); const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Explain the Session without MCP tools."); await openAdvancedSessionSettings(dialog); await dialog.getByRole("checkbox", { name: "Vault to clear" }).check(); await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); @@ -990,7 +1008,7 @@ test("saves a reusable Environment Template and references it from a managed Ses type: "openai_hosted", environment_template_id: selectedTemplateId, }); - expect(latest?.stream).toBe(true); + expect(latest?.stream).toBe(false); }); test("hides Template selection when the connected Core lacks the resource", async ({ page, request }) => { @@ -1008,7 +1026,7 @@ test("hides Template selection when the connected Core lacks the resource", asyn await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); }); -test("creates managed hosted default, enabled and disabled profiles through POST SSE", async ({ page, request }) => { +test("creates managed hosted profiles with JSON for empty input and SSE for initial input", async ({ page, request }) => { await openAgents(page, request); const profiles = [ { label: "default", option: "default", environment: { type: "openai_hosted" }, input: undefined }, @@ -1037,7 +1055,7 @@ test("creates managed hosted default, enabled and disabled profiles through POST expect(latest).toMatchObject({ agent_id: "agent_b", environment: profile.environment, - stream: true, + stream: profile.input !== undefined, vault_ids: [], }); expect(latest?.environment).toEqual(profile.environment); @@ -1151,7 +1169,7 @@ test("blocks hosted MCP before persistence while allowing a Function-only manage entry.method === "POST" && entry.path === "/v1/agents/sessions" )); expect(creates).toHaveLength(before + 1); - expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: true }); + expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: false }); }); test("keeps managed Environment resource and terminal event states fail-closed", async ({ page, request }) => { @@ -1513,6 +1531,42 @@ test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px await expect(editSetup.getByRole("button", { name: "Delete Agent" })).toBeFocused(); }); +test("requires a first message without an Environment and preserves the draft across environment changes", async ({ page, request }) => { + await openAgents(page, request); + const before = await fixtureState(request); + for (const input of [undefined, null]) { + for (const fields of [{ environment: { type: "none" } }, { environment: { type: "openai_hosted" }, stream: true }]) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { data: { + agent_id: "agent_b", ...fields, ...(input === undefined ? {} : { input }), + } }); + expect(response.status()).toBe(400); + } + } + expect((await fixtureState(request)).sessions).toEqual(before.sessions); + const postsBefore = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions").length; + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + const firstMessage = dialog.getByRole("textbox", { name: /^First message\b/u }); + const create = dialog.getByRole("button", { name: "Create Session" }); + await expect(firstMessage).toHaveAttribute("aria-required", "true"); + await expect(create).toBeDisabled(); + await firstMessage.fill(" \t\u0085 "); + await expect(create).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions")).toHaveLength(postsBefore); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + await expect(create).toBeEnabled(); + await expect(firstMessage).toHaveAttribute("aria-required", "false"); + const input = " Explain this Agent's capabilities.\n"; + await firstMessage.fill(input); + await dialog.getByRole("radio", { name: /No environment/ }).check(); + await expect(firstMessage).toHaveValue(input); + await create.click(); + await expect(dialog).toHaveCount(0); + const creates = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); + expect(creates).toHaveLength(postsBefore + 1); + expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "none" }, input, stream: true }); +}); + test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => { await openAgents(page, request); await startSessionWithSecondAgent(page); @@ -1522,7 +1576,7 @@ test("starts one Session with an idempotency key and without browser authorizati const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); expect(creates).toHaveLength(1); expect(creates[0]?.idempotencyKeyPresent).toBe(true); - expect(creates[0]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, stream: false }); + expect(creates[0]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, input: "Review the selected Agent configuration.", stream: true }); for (const entry of requests.filter((candidate) => candidate.path.startsWith("/v1/"))) { expect(entry.beta).toBe("agents=v1"); expect(entry.authorizationPresent).toBe(false); @@ -1713,37 +1767,51 @@ test("streams initial Session creation, captures early events, then hands off to ).toBeVisible(); }); -test("keeps one Session create attempt across response loss and an unchanged manual retry", async ({ page, request }) => { - await openAgents(page, request); - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - const create = dialog.getByRole("button", { name: "Create Session" }); - await controlFixture(request, { sessionCreateDelayMs: 1_500, sessionCreateResponseLoss: 1 }); - - await create.evaluate((button) => { - button.click(); - button.click(); - }); - await expect(dialog.getByRole("alert")).toContainText("Agent core request failed (502)."); - await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible(); +for (const failure of [ + { label: "response loss", control: { sessionCreateResponseLoss: 1 }, message: "Agent core request failed (502)." }, + { label: "creation-stream EOF before identity", control: { sessionCreateStreamMissingIdentity: 1 }, message: "Agent core returned an empty event stream." }, +]) { + test(`keeps one Session create attempt across ${failure.label} and an unchanged manual retry`, async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review this request once after response recovery."); + const create = dialog.getByRole("button", { name: "Create Session" }); + await controlFixture(request, { sessionCreateDelayMs: 1_500, ...failure.control }); - let creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(1); - const originalKey = creates[0]?.idempotencyKey; - expect(originalKey).toBeTruthy(); - expect((await fixtureState(request)).sessions).toHaveLength(2); + await create.evaluate((button) => { + button.click(); + button.click(); + }); + await expect(dialog.getByRole("alert")).toContainText(failure.message); + await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible(); - await create.click(); - await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); - creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(2); - expect(creates[1]?.idempotencyKey).toBe(originalKey); - expect((await fixtureState(request)).sessions).toHaveLength(2); -}); + let creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + const originalKey = creates[0]?.idempotencyKey; + expect(originalKey).toBeTruthy(); + expect((await fixtureState(request)).sessions).toHaveLength(2); + + await create.click(); + await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(2); + expect(creates[1]?.idempotencyKey).toBe(originalKey); + expect(creates.map((entry) => entry.body?.stream)).toEqual([true, false]); + expect(creates[1]?.body?.input).toBe(creates[0]?.body?.input); + const recovered = (await fixtureState(request)).sessions; + expect(recovered).toHaveLength(2); + const sessionId = recovered.find((session) => session.id !== "session_snapshot")?.id; + const turns = await request.get(`${fixtureBaseUrl}/v1/agents/sessions/${sessionId}/turns`); + const items = await request.get(`${fixtureBaseUrl}/v1/agents/sessions/${sessionId}/items`); + expect((await turns.json() as { data: unknown[] }).data).toHaveLength(1); + expect((await items.json() as { data: unknown[] }).data).toHaveLength(1); + }); +} test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => { await resetFixture(request); @@ -3032,7 +3100,7 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques const nextDiagnostics = page.locator("details.trace-turn-diagnostics"); await nextDiagnostics.locator("summary").click(); const nextTimeline = nextDiagnostics.getByRole("region", { name: "Turn timeline" }); - await expect(nextTimeline).toContainText("No Turns reported yet."); + await expect(nextTimeline).toContainText("Queued"); await page.waitForTimeout(3_000); await expect(nextTimeline).not.toContainText("turn_queued"); await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 218be1efa..97845a260 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -389,6 +389,7 @@ function initialState() { sessionCreateDelayMs: 0, sessionCreateStatus: 201, sessionCreateResponseLoss: 0, + sessionCreateStreamMissingIdentity: 0, sessionCreateStreamCloseDelayMs: 120, sessionListDelayMs: 0, sessionListStatus: 200, @@ -1012,6 +1013,13 @@ const server = http.createServer(async (request, response) => { } if (request.method === "POST" && url.pathname === "/v1/agents/sessions") { + const hasInitialInput = body.input !== undefined && body.input !== null; + const initialInputMessages = hasInitialInput ? sessionInitialInputMessages(body.input) : []; + const requiresInitialInput = body.environment?.type === "none" + || (body.stream === true && body.environment?.type !== "self_hosted"); + if ((requiresInitialInput && !hasInitialInput) || (hasInitialInput && !initialInputMessages)) { + return sendError(response, 400, "Fixture Session requires valid initial input for this Environment and response mode."); + } const idempotencyKey = request.headers["idempotency-key"]; const { stream: _streamResponseMode, ...creationIntent } = body; const fingerprint = JSON.stringify(creationIntent); @@ -1038,6 +1046,8 @@ const server = http.createServer(async (request, response) => { const control = consumeControl("sessionCreate", 201); const responseLoss = state.controls.sessionCreateResponseLoss; state.controls.sessionCreateResponseLoss = 0; + const missingIdentity = state.controls.sessionCreateStreamMissingIdentity; + state.controls.sessionCreateStreamMissingIdentity = 0; if (control.delayMs) await wait(control.delayMs); if (control.status !== 201) return sendError(response, control.status, "Fixture Session create failed."); const savedAgent = typeof body.agent_id === "string" @@ -1067,11 +1077,6 @@ const server = http.createServer(async (request, response) => { typeof value !== "string" || [...key].length > 64 || [...value].length > 512 )) || Object.keys(body.metadata).length > 16) ) return sendError(response, 400, "Fixture Session metadata is invalid."); - const hasInitialInput = body.input !== undefined && body.input !== null; - const initialInputMessages = hasInitialInput ? sessionInitialInputMessages(body.input) : []; - if (hasInitialInput && !initialInputMessages) { - return sendError(response, 400, "Fixture initial Session input is invalid."); - } state.sequence += 1; const created = { id: `session_created_${state.sequence}`, @@ -1087,6 +1092,38 @@ const server = http.createServer(async (request, response) => { created_at: baseline + state.sequence, last_active_at: baseline + state.sequence, }; + const createdSnapshot = structuredClone(created); + let initialTurn = null; + let initialItems = []; + if (hasInitialInput && initialInputMessages) { + state.sequence += 1; + const turn = { + id: `turn_created_${state.sequence}`, + agent_id: created.agent.id, + session_id: created.id, + object: "agent.session.turn", + status: "queued", + created_at: baseline + state.sequence, + started_at: null, + completed_at: null, + error: null, + usage: null, + }; + const items = initialInputMessages.map((message, index) => ({ + id: `item_created_${state.sequence}_${index + 1}`, + turn_id: turn.id, + type: "message", + status: "completed", + role: "user", + content: message.content, + })); + state.turns.push(turn); + state.createdSessionItems.set(created.id, items); + initialTurn = turn; + initialItems = items; + created.status = "in_progress"; + created.last_active_at = baseline + state.sequence; + } state.sessions.unshift(created); if (typeof idempotencyKey === "string") { state.sessionCreateReceipts.set(idempotencyKey, { fingerprint, session: created }); @@ -1096,43 +1133,25 @@ const server = http.createServer(async (request, response) => { return; } if (body.stream === true) { - const createdSnapshot = structuredClone(created); response.writeHead(201, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", }); response.write(": connected\n\n"); + if (missingIdentity) { + response.end(); + return; + } state.sequence += 1; response.write(`event: agent.session.created\nid: create_${state.sequence}\ndata: ${JSON.stringify({ type: "agent.session.created", event_id: `create_${state.sequence}`, session: createdSnapshot, })}\n\n`); - if (hasInitialInput && initialInputMessages) { - state.sequence += 1; - const turn = { - id: `turn_created_${state.sequence}`, - agent_id: created.agent.id, - session_id: created.id, - object: "agent.session.turn", - status: "queued", - created_at: baseline + state.sequence, - started_at: null, - completed_at: null, - error: null, - usage: null, - }; - const items = initialInputMessages.map((message, index) => ({ - id: `item_created_${state.sequence}_${index + 1}`, - turn_id: turn.id, - type: "message", - status: "completed", - role: "user", - content: message.content, - })); - state.turns.push(turn); - state.createdSessionItems.set(created.id, items); + if (initialTurn) { + const turn = initialTurn; + const items = initialItems; response.write(`event: agent.session.turn.created\nid: turn_${state.sequence}\ndata: ${JSON.stringify({ type: "agent.session.turn.created", event_id: `turn_${state.sequence}`, @@ -1140,8 +1159,6 @@ const server = http.createServer(async (request, response) => { turn_id: turn.id, turn, })}\n\n`); - created.status = "in_progress"; - created.last_active_at = baseline + state.sequence; response.write(`event: agent.session.in_progress\nid: progress_${state.sequence}\ndata: ${JSON.stringify({ type: "agent.session.in_progress", event_id: `progress_${state.sequence}`, diff --git a/apps/web/e2e/vault-credentials.spec.ts b/apps/web/e2e/vault-credentials.spec.ts index 25d9a354d..6892686e2 100644 --- a/apps/web/e2e/vault-credentials.spec.ts +++ b/apps/web/e2e/vault-credentials.spec.ts @@ -213,8 +213,9 @@ test("creates, replaces, uses, and deletes a write-only Vault Credential", async await openAdvancedSessionSettings(sessionDialog); await expect(sessionDialog.getByRole("heading", { name: "Tools & Vaults" })).toBeVisible(); await expect(sessionDialog).toContainText("Private docs MCP · Runtime credentials"); + await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Find the documentation available through this Credential."); await sessionDialog.getByRole("button", { name: "Create Session" }).click(); - await expect(page.locator(".toast-region:not(.toast-region-assertive)")).toContainText("Idle Session created"); + await expect(page.locator(".toast-region:not(.toast-region-assertive)")).toContainText("Session created with initial input"); requests = await fixtureRequests(request); const vaultCreate = requests.find((entry) => entry.method === "POST" && entry.path === "/v1/vaults"); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e4a7d6f85..41a29971a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -39,6 +39,7 @@ import { type StreamState, } from "./features/sessions/SessionsView"; import type { SessionStartInput } from "./features/sessions/create/SessionStartDialog"; +import { sessionInitialInputError } from "./features/sessions/create/session-initial-input"; import { sessionCreateRequestPayload } from "./features/sessions/create/session-create-attempt"; import { normalizeSessionEnvironmentInput } from "./features/sessions/create/session-environment"; import { validateSessionAgentSubmission } from "./features/sessions/create/session-start-draft"; @@ -1487,6 +1488,12 @@ export function App() { notify(error.message, "error"); throw error; } + const inputError = sessionInitialInputError(input.input, environmentInput.type); + if (inputError) { + const error = new Error(`Session was not created. ${inputError}`); + notify(error.message, "error"); + throw error; + } const request = sessionCreateRequestPayload({ ...(input.agentMode === "saved" ? { agentId: input.agentId } : {}), ...(submittedAgent.requestAgent ? { agent: submittedAgent.requestAgent } : {}), @@ -1526,7 +1533,7 @@ export function App() { throw new Error("The Session creation outcome could not be confirmed."); } openSession(session); - notify("Idle Session created. Opening live events…", "success"); + notify(input.input === undefined ? "Idle Session created. Opening live events…" : "Session opened. Connecting live events…", "success"); return; } diff --git a/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx b/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx index b6c5b72f2..d99c05210 100644 --- a/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx +++ b/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx @@ -13,6 +13,7 @@ import "./SessionInitialInputEditor.css"; export interface SessionInitialInputEditorProps { draft: SessionInitialInputDraft; disabled?: boolean; + required?: boolean; showTextField?: boolean; onChange: (draft: SessionInitialInputDraft) => void; } @@ -20,6 +21,7 @@ export interface SessionInitialInputEditorProps { export function SessionInitialInputEditor({ draft, disabled = false, + required = false, showTextField = true, onChange, }: SessionInitialInputEditorProps) { @@ -96,10 +98,11 @@ export function SessionInitialInputEditor({ value={draft.text} onChange={(event) => dispatch({ type: "set-text", value: event.target.value })} rows={5} - placeholder="Optional first message…" + placeholder={required ? "Write the first message…" : "Optional first message…"} + aria-required={required} disabled={disabled} /> - Optional. Nonblank input is preserved exactly and starts the initial Turn during Session creation. + {required ? "Required without an Environment. " : "Optional. "}Nonblank input is preserved exactly and starts the initial Turn during Session creation. ) : draft.mode === "text" ? (

diff --git a/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx b/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx index 4f0619834..5298e0aaa 100644 --- a/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx +++ b/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx @@ -7,7 +7,6 @@ import type { VaultCatalog } from "../../vaults/vault-catalog"; import { genericSessionStartError, safeSessionStartError, - sessionCreationUsesStream, SessionStartDialog, } from "./SessionStartDialog"; @@ -100,11 +99,11 @@ describe("SessionStartDialog", () => { expect(html).not.toContain("template_id"); }); - it("uses POST SSE for managed idle and initial creation without changing none/self-hosted idle", () => { - expect(sessionCreationUsesStream(false, { type: "openai_hosted" })).toBe(true); - expect(sessionCreationUsesStream(true, { type: "openai_hosted", network: { access: "disabled" } })).toBe(true); - expect(sessionCreationUsesStream(false, { type: "none" })).toBe(false); - expect(sessionCreationUsesStream(true, { type: "none" })).toBe(true); + it("requires a first message for the default no-Environment selection", () => { + const html = render(false, compatible.id); + expect(html).toContain("Required without an Environment."); + expect(html).toContain('aria-required="true"'); + expect(html).toMatch(/]+disabled=""[^>]*>Create Session<\/button>/u); }); it("honors a compatible preselected Agent and exposes whole-field overrides", () => { diff --git a/apps/web/src/features/sessions/create/SessionStartDialog.tsx b/apps/web/src/features/sessions/create/SessionStartDialog.tsx index 4e1b33593..7e9850ffa 100644 --- a/apps/web/src/features/sessions/create/SessionStartDialog.tsx +++ b/apps/web/src/features/sessions/create/SessionStartDialog.tsx @@ -31,6 +31,7 @@ import { import { SessionInitialInputEditor } from "./SessionInitialInputEditor"; import { projectSessionInitialInput, + sessionInitialInputError, sessionInitialInputDraftReducer, } from "./session-initial-input"; import { @@ -81,13 +82,6 @@ export interface SessionStartDialogProps { export const genericSessionStartError = "Agent Core could not create the Session. Review the Core connection and try again."; -export function sessionCreationUsesStream( - requestedStream: boolean, - environment: AgentEnvironmentInput, -): boolean { - return requestedStream || environment.type === "openai_hosted"; -} - export function safeSessionStartError(error: unknown): string { return error instanceof AgentCoreError && error.message.trim() ? error.message @@ -277,6 +271,7 @@ export function SessionStartDialog({ || applicableManualVaultIds.length > 0 )); const initialInput = projectSessionInitialInput(details.initialInput); + const initialInputError = initialInput.ok ? sessionInitialInputError(initialInput.input, environmentType) : initialInput.error; const workspaceError = environmentType === "self_hosted" ? environment.error : null; const formDisabled = disabled || submitting; const canSubmit = open @@ -287,7 +282,7 @@ export function SessionStartDialog({ && !vaultPlan?.blocker && !environmentAdmissionBlocker && !templateNetworkBlocker - && initialInput.ok + && !initialInputError && Boolean(environment.input); const advancedNeedsAttention = Boolean( agentValidation.overrideError @@ -336,6 +331,7 @@ export function SessionStartDialog({ agentMode, selectedAgent, vaultCatalog, + environment.input.type, ); setMetadataError(validation.metadataError ?? null); setAgentError(validation.agentError ?? null); @@ -363,7 +359,7 @@ export function SessionStartDialog({ const common = { environment: environment.input, metadata: validation.request.metadata, - stream: sessionCreationUsesStream(validation.request.stream, environment.input), + stream: validation.request.stream, vaultIds: submittedVaultPlan.vaultIds, manualVaultIds: sorted(submittedManualVaultIds), ...(validation.request.input === undefined ? {} : { input: validation.request.input }), @@ -381,12 +377,14 @@ export function SessionStartDialog({ ...(validation.request.agent === undefined ? {} : { agent: validation.request.agent }), }; const attempt = beginSessionCreateAttempt(draft, attemptRef.current); + const retry = attemptRef.current?.fingerprint === attempt.fingerprint; attemptRef.current = attempt; submittingRef.current = true; setSubmitting(true); setRequestError(null); try { - await onSubmit({ ...draft, idempotencyKey: attempt.idempotencyKey } as SessionStartInput); + // A creation SSE retry has no created event. JSON recovers the same Session ID. + await onSubmit({ ...draft, stream: retry ? false : draft.stream, idempotencyKey: attempt.idempotencyKey } as SessionStartInput); onClose(); } catch (error) { setRequestError(safeSessionStartError(error)); @@ -521,7 +519,9 @@ export function SessionStartDialog({ value={details.initialInput.text} onChange={(event) => updateInitialText(event.target.value)} rows={4} - placeholder="Optional first message…" + placeholder={environmentType === "none" ? "Write the first message…" : "Optional first message…"} + aria-required={environmentType === "none"} + aria-describedby={`${formId}-first-message-help`} disabled={formDisabled} /> ) : ( @@ -530,7 +530,7 @@ export function SessionStartDialog({ )} - Optional. A nonblank message starts the first Turn during Session creation. + {environmentType === "none" ? "Required without an Environment. " : "Optional. "}A nonblank message starts the first Turn during Session creation.

@@ -730,7 +730,7 @@ export function SessionStartDialog({ ) : null} - updateDetails("initialInput", draft)} /> + updateDetails("initialInput", draft)} /> ) : null} diff --git a/apps/web/src/features/sessions/create/session-initial-input.test.ts b/apps/web/src/features/sessions/create/session-initial-input.test.ts index 1b4c92eb1..da1645dad 100644 --- a/apps/web/src/features/sessions/create/session-initial-input.test.ts +++ b/apps/web/src/features/sessions/create/session-initial-input.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createSessionInitialInputDraft, projectSessionInitialInput, + sessionInitialInputError, sessionInitialInputDraftReducer, type SessionInitialInputDraft, } from "./session-initial-input"; @@ -119,6 +120,29 @@ describe("Session initial input projection", () => { }); }); +describe("Session initial input admission", () => { + it.each([undefined, null, "", " \t\n\u0085", []])("rejects missing or empty none input: %j", (input) => { + expect(sessionInitialInputError(input, "none")).not.toBeNull(); + }); + + it.each(["self_hosted", "openai_hosted"])("permits missing or null input for %s", (environmentType) => { + expect(sessionInitialInputError(undefined, environmentType)).toBeNull(); + expect(sessionInitialInputError(null, environmentType)).toBeNull(); + }); + + it("validates the submitted Web profile without changing text or ordering", () => { + const projected = projectSessionInitialInput(messageDraft()); + expect(projected.ok && sessionInitialInputError(projected.input, "none")).toBeNull(); + expect(sessionInitialInputError(" Explain this code.\n", "none")).toBeNull(); + for (const input of [0, {}, [{ role: "assistant", content: [{ type: "input_text", text: "text" }] }], + [{ role: "user", content: [] }], [{ type: null, role: "user", content: [{ type: "input_text", text: "text" }] }], + [{ role: "user", content: [{ type: "input_text", text: " \u0085" }] }], + [{ role: "user", content: [{ type: "input_image", image_url: "https://example.test/image.png" }] }]]) { + expect(sessionInitialInputError(input, "none")).not.toBeNull(); + } + }); +}); + describe("Session initial input draft reducer", () => { it("seeds the first message from exact Text content and retains both drafts across switches", () => { const initial = createSessionInitialInputDraft(" exact seed\n"); diff --git a/apps/web/src/features/sessions/create/session-initial-input.ts b/apps/web/src/features/sessions/create/session-initial-input.ts index 137a7d76a..f2fdd5750 100644 --- a/apps/web/src/features/sessions/create/session-initial-input.ts +++ b/apps/web/src/features/sessions/create/session-initial-input.ts @@ -163,6 +163,32 @@ export function sessionInitialInputDraftReducer( }; } +/** Checks the finite text-only Web profile before Session creation. */ +export function sessionInitialInputError(input: unknown, environmentType: string): string | null { + if (input == null) { + return environmentType === "none" ? "A first message is required without an Environment." : null; + } + if (typeof input === "string") return isBlank(input) ? "Enter a nonblank first message." : null; + if (!Array.isArray(input) || input.length === 0) return "Add at least one user message."; + for (const [index, message] of input.entries()) { + if ( + !message || typeof message !== "object" || Array.isArray(message) + || Object.keys(message).some((key) => !["type", "role", "content"].includes(key)) + || (Object.hasOwn(message, "type") && message.type !== "message") + || message.role !== "user" || !Array.isArray(message.content) || message.content.length === 0 + || message.content.some((part: unknown) => ( + !part || typeof part !== "object" || Array.isArray(part) + || Object.keys(part).some((key) => !["type", "text"].includes(key)) + || !("type" in part) || part.type !== "input_text" || !("text" in part) || typeof part.text !== "string" + )) + ) return `User message ${index + 1} must contain supported text parts.`; + if (isBlank(message.content.map((part: { text: string }) => part.text).join(""))) { + return `User message ${index + 1} needs nonblank text across its parts.`; + } + } + return null; +} + /** * Strictly projects the active draft without trimming, joining, regrouping, or * otherwise rewriting meaningful user text. diff --git a/apps/web/src/features/sessions/create/session-start-draft.test.ts b/apps/web/src/features/sessions/create/session-start-draft.test.ts index a71bdaf13..0a09190ac 100644 --- a/apps/web/src/features/sessions/create/session-start-draft.test.ts +++ b/apps/web/src/features/sessions/create/session-start-draft.test.ts @@ -40,7 +40,7 @@ describe("Session start details", () => { metadata: JSON.stringify({ team: "web" }), }; - expect(validateSessionStartDetails(values, "saved", source)).toEqual({ + expect(validateSessionStartDetails(values, "saved", source, null, "openai_hosted")).toEqual({ effectiveAgent: source, request: { metadata: { team: "web", title: "Release review" }, @@ -49,6 +49,12 @@ describe("Session start details", () => { }); }); + it.each(["openai_hosted", "self_hosted"])("keeps empty %s creation unstreamed", (environmentType) => { + const source = agent(); + expect(validateSessionStartDetails(sessionStartDetailsFromAgent(source), "saved", source, null, environmentType).request) + .toEqual({ metadata: {}, stream: false }); + }); + it("enforces the Session metadata limit including title", () => { const source = agent(); const metadata = Object.fromEntries( @@ -64,7 +70,7 @@ describe("Session start details", () => { expect(result.metadataError).toContain("at most 16 pairs"); }); - it("omits blank text, preserves exact text, and forces creation streaming for input", () => { + it("requires nonblank none input, preserves exact text, and streams initial input", () => { const source = agent(); const blank = validateSessionStartDetails({ ...sessionStartDetailsFromAgent(source), @@ -77,7 +83,8 @@ describe("Session start details", () => { }, "saved", source); expect(optionalInitialSessionInput("\u0085")).toBeUndefined(); - expect(blank.request).toEqual({ metadata: {}, stream: false }); + expect(blank.request).toBeUndefined(); + expect(blank.inputError).toContain("first message is required"); expect(nonblank.request).toEqual({ metadata: {}, input: exact, stream: true }); }); @@ -123,7 +130,7 @@ describe("Session start details", () => { model: "provider/inline", instructions: "Work carefully.", }); - expect(validateSessionStartDetails(values, "inline").request).toEqual({ + expect(validateSessionStartDetails(values, "inline", undefined, null, "self_hosted").request).toEqual({ agent: { model: "provider/inline", instructions: "Work carefully." }, metadata: {}, stream: false, diff --git a/apps/web/src/features/sessions/create/session-start-draft.ts b/apps/web/src/features/sessions/create/session-start-draft.ts index 3025e7ff0..cd42254dd 100644 --- a/apps/web/src/features/sessions/create/session-start-draft.ts +++ b/apps/web/src/features/sessions/create/session-start-draft.ts @@ -20,6 +20,7 @@ import { validateSessionMetadata } from "../actions/session-actions"; import { createSessionInitialInputDraft, projectSessionInitialInput, + sessionInitialInputError, type SessionInitialInputDraft, } from "./session-initial-input"; @@ -208,19 +209,21 @@ export function validateSessionStartDetails( mode: SessionAgentMode, sourceAgent?: SavedAgent, catalog: VaultCatalog | null = null, + environmentType = "none", ): SessionStartDetailsValidation { const metadataResult = validateSessionMetadata({ title: values.title, metadata: values.metadata, }); const input = projectSessionInitialInput(values.initialInput); + const inputError = input.ok ? sessionInitialInputError(input.input, environmentType) : input.error; const agent = mode === "inline" ? validateInlineSessionAgent(values, catalog) : validateSessionAgentOverrides(values, sourceAgent, catalog); - if (!metadataResult.metadata || !input.ok || agent.overrideError || !agent.effectiveAgent) { + if (!metadataResult.metadata || !input.ok || inputError || agent.overrideError || !agent.effectiveAgent) { return { ...(metadataResult.metadataError ? { metadataError: metadataResult.metadataError } : {}), - ...(!input.ok ? { inputError: input.error } : {}), + ...(inputError ? { inputError } : {}), ...(agent.overrideError ? { agentError: agent.overrideError } : {}), }; } diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index ca2ab73f7..84df13716 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -473,6 +473,7 @@ export interface CreateSessionInput { agent_id?: string; agent?: InlineAgentInput; environment: AgentEnvironmentInput; + /** A nonempty initial input is required for environment:none. */ input?: string | InputMessage[] | null; metadata?: Record | null; /** This JSON-returning method does not support the endpoint's streaming create variant. */ From afb0987d74d41c2b68b7c2b27adfb663b96110d0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:23:06 +0800 Subject: [PATCH 06/11] Keep explicit metadata and Go client admission fixtures consistent --- contracts/agents-api/openapi.yaml | 2 ++ contracts/agents-api/v1/sessions.go | 2 +- packages/agents-client/v1/service_test.go | 7 ++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index d29dbb991..40ccff928 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1931,6 +1931,8 @@ definitions: type: string type: object x-nullable: true + required: + - metadata type: object v1.Vault: properties: diff --git a/contracts/agents-api/v1/sessions.go b/contracts/agents-api/v1/sessions.go index 837beeb42..8f9631da9 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -20,7 +20,7 @@ type CreateSessionRequest struct { // UpdateSessionRequest requires metadata; null and an empty object clear it. type UpdateSessionRequest struct { - Metadata map[string]string `json:"metadata,omitempty" extensions:"x-nullable"` + Metadata map[string]string `json:"metadata" extensions:"x-nullable" binding:"required"` } // InlineAgent supplies a complete inline configuration or per-Session overrides. diff --git a/packages/agents-client/v1/service_test.go b/packages/agents-client/v1/service_test.go index cf817f716..13ddd6c0c 100644 --- a/packages/agents-client/v1/service_test.go +++ b/packages/agents-client/v1/service_test.go @@ -37,6 +37,7 @@ func TestService(t *testing.T) { input := openai.BetaAgentSessionNewParams{ Agent: openai.BetaAgentSessionNewParamsAgent{Model: openai.String("go-client-test-model"), Instructions: openai.String("Keep this configuration.")}, Environment: openai.EnvironmentParamUnion{OfParamNone: &openai.EnvironmentParamNone{}}, + Input: openai.BetaAgentSessionNewParamsInputUnion{OfString: openai.String("Verify the Go client's queued creation and retry identity.")}, Metadata: map[string]string{"workspace": "not-an-identity"}, } retry := option.WithHeader("Idempotency-Key", "go-client-first") @@ -44,7 +45,7 @@ func TestService(t *testing.T) { if err != nil { t.Fatal(err) } - if first.ID == "" || first.Object != "agent.session" || first.Status != "idle" || first.Agent.Model != "go-client-test-model" || first.Agent.Instructions != "Keep this configuration." || first.Environment.Type != "none" { + if first.ID == "" || first.Object != "agent.session" || first.Status != "in_progress" || first.Agent.Model != "go-client-test-model" || first.Agent.Instructions != "Keep this configuration." || first.Environment.Type != "none" { t.Fatal("incorrect resolved Session") } read, err := a.Get(ctx, first.ID) @@ -55,6 +56,10 @@ func TestService(t *testing.T) { if err != nil || replay.ID != first.ID { t.Fatalf("retry: %v", err) } + turns, err := a.Turns.List(ctx, first.ID, openai.BetaAgentSessionTurnListParams{}) + if err != nil || len(turns.Data) != 1 || turns.Data[0].Status != "queued" { + t.Fatalf("creation retry must retain one queued Turn: %v", err) + } expectStatus := func(err error, status int) { t.Helper() var apiErr *openai.Error From 16683cf856aee09945fdddb7b619427b9808f4f1 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:25:04 +0800 Subject: [PATCH 07/11] Keep stream helper coverage after initial function admission --- .../agents-api/internal/store/function_stream_native_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/agents-api/internal/store/function_stream_native_test.go b/services/agents-api/internal/store/function_stream_native_test.go index a1b82fb35..bdf698bf8 100644 --- a/services/agents-api/internal/store/function_stream_native_test.go +++ b/services/agents-api/internal/store/function_stream_native_test.go @@ -18,6 +18,7 @@ func TestNativePublicFunctionStreamHelper(t *testing.T) { } h, ctx, home := nativeDispatchHarness(t) model, requests := nativeFunctionResultsModel(t, home, []any{ + []any{map[string]any{"type": "input_text", "text": "setup complete"}}, `{"ticket":"42","status":"open"}`, "Tool handler failed.", }) @@ -50,7 +51,7 @@ func TestNativePublicFunctionStreamHelper(t *testing.T) { if err != nil || bound.NativeSessionID == "" || bound.Device.ID != h.device.ID { t.Fatal(bound, err) } - if requests.Load() != 4 { + if requests.Load() != 6 { t.Fatal("unexpected replay or missing native continuation", requests.Load()) } t.Logf("Official SDK stream tool handlers, error omission, public history and native application passed; evidence %s", home) From cd4857934b1ee314a1edfca0c3c85b8c3d973d71 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:30:19 +0800 Subject: [PATCH 08/11] Record three-harness real Session admission acceptance --- .../official-semantics-alignment.md | 26 ++++++++++++++++++- contracts/agents-api/operation-evidence.md | 5 ++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 40834b292..63ffc3b28 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -99,4 +99,28 @@ Sessions (self-hosted without input and none with whitespace). Both were deleted successfully; no hosted environment was created. Metadata observations are reused from September 22. Evidence: `~/.parsar/remediation/20260923/session-admission-alignment/official/`. See [operation evidence](operation-evidence.md) for the wider 58-operation audit. -Implementation validation is recorded separately when this batch completes. +Real Core/daemon/native-model acceptance ran on production source `7ccc636`: +Codex and Claude used Kimi K3; MiniMax Code used MiniMax M2.7. Each completed one +JSON string-input Turn and one SSE ordered-message Turn, six total with no failed +attempt or rerun. Same-key JSON recovery retained each Session and exactly one +Turn. Twenty-one initial invalid creates plus three missing-input retries rejected +without adding rows to the eight checked execution tables. Empty updates preserved +metadata; null/empty clearing and foreign-tenant reads/valid updates were checked. +Hosted JSON no-input admission was retained for all three configured profiles; +self-hosted idle admission was checked on Codex only. Neither check claims a new +hosted or self-hosted execution capability. + +Evidence is retained under +`~/.parsar/remediation/20260923/session-admission-alignment/live/`, including raw +HTTP, fixed-SDK responses, SSE, history, native outputs, exact source/image hashes +and cleanup. Six assistant results matched the requested markers. Claude emitted +two assistant Items for its two-message input within one Turn; native Item counts +were preserved. All 39 evidence hashes and known-secret scans passed. Owned +Runtime/Core processes, three databases, three derived images and the dedicated +network forward were removed, and temporary devices were revoked. + +The integrated Web gate passed 287 client and 583 Web unit tests, type checks, +builds and all 76 fixture browser cases. These controlled UI checks include +empty-input prevention and same-key JSON recovery after a lost creation response; +they are separate from the real-model evidence. Final server gate and blind-review +results are recorded at batch closure. diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 32ac75211..321c6b261 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -24,6 +24,7 @@ Repository paths below are relative to the inspected worktree; private evidence | H | `contracts/agents-api/history-events-usage.md:85`; raw directory `~/.parsar/remediation/20260922/history-events-usage/official/`, including `create-http.json`, `create-sse-frames.json`, `reconnect-events.json`, `turns-asc-pagination.json`, `items-asc-pagination.json`, `analysis.json`. Official one-owned-Session/two-Turn text observation; no tools/Subagents/hosted execution, no full timing or accounting proof. | | E | `~/.parsar/remediation/20260922/official-semantics-alignment/error-surface-probe.json`: missing non-beta File/Skill observations only. Not successful-resource or full error-surface coverage. | | C | `~/.parsar/remediation/20260922/official-semantics-alignment/live/acceptance-summary.json`, source `7c80d604ba47c578084ebc9fa99cff332bd5d5f2`. Seven resource/safety groups (DB), plus three real Turns per harness (Live): Codex/Claude Kimi K3; MiniMax M2.7. Successful runs: `live/resources/run-1790091139881592673`, `live/codex/run-1790091781322707360`, `live/claude_sdk/run-1790091781322554888`, `live/mcode/run-1790091781324544033`; each has `result.json` and `raw-evidence.json`, model runs also history/SSE evidence. Four network-interrupted attempts remain failures. No new native capability/OAuth refresh/E2B qualification. | +| N | `contracts/agents-api/official-semantics-alignment.md`, September 23 admission section; private `~/.parsar/remediation/20260923/session-admission-alignment/{official,live}/`: conditional create/update official probes plus exact-source `7ccc636` Core/daemon real none execution (six Turns), write rejection, local retry, metadata and isolation. Qualified idle hosted admission and Codex self-hosted admission are not new execution profiles. | | A | `contracts/agents-api/official-semantics-alignment.md`: merged status/envelope/no-op/error/rejection changes and explicit remaining differences. `contracts/agents-api/README.md:63` supplies the current resource ledger; it is a coverage summary, not raw evidence. | | T | `contracts/agents-api/execution-tools.md`: per-operation input, function, required-action, structured-output, discovery and policy matrix; evidence register M1/M2/F1/F2/F3/S1/S2/D1/P1/E1/E2 gives exact private run paths. These are profile-specific real acceptances. The older blanket OAuth rejection in this document is superseded by O. | | D | `contracts/agents-api/README.md:115`, `contracts/agents-api/user-managed-runtime-v1.md`: recorded three-harness Docker MVP and separate user-managed Docker/E2B qualification. Historical `~/.parsar/remediation/20260920/three-harness-mvp/REPORT.md`, `acceptance-results.json` on zju_a100_2; prior Core-managed E2B qualification is retired-route evidence, not current enrollment qualification. | @@ -44,9 +45,9 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests | Model-dependent default recomputation; uncommon nested/null/error variants | | 4 | beta.agents.list | P: scoped cursor list | R `agent-list-empty-scoped`, `agent-list-limit101` | Recorded controlled SDK/DB coverage; no positive list in C resource replay | Official 101 accepted in sampled empty page; Core generic max 100. No inferred official cap | | 5 | beta.agents.delete | P: resource deletion | R `cleanup-agent`, subsequent 404 | C DB delete/post-delete | Referenced/in-flight/repeated-delete exact parity | -| 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream | C Live three profiles; D/T/K/I recorded additional qualified workflows | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Many input/tool/environment combinations restricted | +| 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream | N Live none admission and retry; C Live three hosted profiles; D/T/K/I recorded additional workflows | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Many input/tool/environment combinations restricted | | 7 | beta.agents.sessions.retrieve | P: persisted state, required actions, usage | S `retrieve-1.json`, `session-after-1.json`; H recovered state | C Live history; T pending actions; H Core acceptance recorded | Complete statuses/actions/lifecycle timing; Claude/MiniMax public usage remains null | -| 8 | beta.agents.sessions.update | P: metadata-only replacement/clear | S `metadata-replace/null/empty/omit/invalid-value.json`; `update-agent.json` uses newer unpinned field | Recorded controlled metadata coverage; no dedicated real-model update qualification claimed by C | Session admission batch changes empty update to observed official 400; Session agent update belongs to baseline upgrade, not fixed-pin operation gap | +| 8 | beta.agents.sessions.update | P: metadata-only replacement/clear | S `metadata-replace/null/empty/omit/invalid-value.json`; `update-agent.json` uses newer unpinned field | N Live completed Session metadata rejection/clear/isolation; recorded active controlled metadata coverage | Session admission batch changes empty update to observed official 400; Session agent update belongs to baseline upgrade, not fixed-pin operation gap | | 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples | C Live order/cursors/empty/tenant checks | Official >100 sample accepted; Core max 100. Empty order/unknown query differences; eventual visibility sample is not a required delay | | 10 | beta.agents.sessions.delete | P: public deletion, owned managed cleanup, user compute retained | S cleanup files 200/deleted; retry-session active cleanup initially 409 | D recorded real cleanup; C cleanup separately recorded | Physical purge/retention and all active/unknown-effect races; caller compute ownership preserved | | 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission | S `second-turn-create.json`, `events-empty/null.json`; H second-input | C Live real continuation/no-op; T qualified message/result/cancel workflows | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools | From 5ecb1b357e8a5fa5ed8d7d169db18f47578bd1aa Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:33:05 +0800 Subject: [PATCH 09/11] Migrate official SDK fixtures to conditional Session input admission --- .../agents-api/tests/official_agent_delete.py | 7 +- .../tests/official_agent_references.py | 16 ++--- .../agents-api/tests/official_agent_update.py | 2 +- services/agents-api/tests/official_client.py | 49 +++++++++++--- .../tests/official_credential_rotation.py | 6 +- .../agents-api/tests/official_execution.py | 50 ++++++++------ .../tests/official_function_images.py | 14 ++-- .../tests/official_function_stream.py | 29 ++++++-- .../agents-api/tests/official_functions.py | 12 ++-- services/agents-api/tests/official_items.py | 11 +-- .../agents-api/tests/official_mcode_native.py | 15 +++-- services/agents-api/tests/official_mcp.py | 6 +- .../tests/official_mcp_credentials.py | 16 ++--- .../tests/official_session_agent_filter.py | 6 +- .../tests/official_session_creation_stream.py | 12 ++-- .../tests/official_session_creators.py | 9 +-- .../tests/official_session_delete.py | 4 +- .../tests/official_session_initial_input.py | 17 +++-- .../tests/official_session_metadata.py | 14 ++-- .../tests/official_session_requests.py | 2 +- .../tests/official_structured_output.py | 29 +++++--- .../agents-api/tests/official_tool_policy.py | 67 +++++++++++-------- .../agents-api/tests/official_tool_search.py | 35 ++++++---- .../agents-api/tests/official_vault_delete.py | 2 +- 24 files changed, 269 insertions(+), 161 deletions(-) diff --git a/services/agents-api/tests/official_agent_delete.py b/services/agents-api/tests/official_agent_delete.py index 98300a681..4a566acc9 100644 --- a/services/agents-api/tests/official_agent_delete.py +++ b/services/agents-api/tests/official_agent_delete.py @@ -26,7 +26,7 @@ def main(): original = agents.create(model="saved-model", instructions="Saved instructions.", metadata={"source": "only"}) peer = agents.create(model="peer-model", name="Unaffected peer") foreign_agent = other.beta.agents.create(model="foreign-model") - spec = {"agent_id": original.id, "environment": {"type": "none"}, "metadata": {"original": "session"}} + spec = {"input": "Verify agent delete fixture admission.", "agent_id": original.id, "environment": {"type": "none"}, "metadata": {"original": "session"}} retry = {"Idempotency-Key": "saved-before-delete"} accepted = sessions.create(**spec, extra_headers=retry) current = sessions.update(accepted.id, metadata={"current": "session"}) @@ -58,8 +58,9 @@ def main(): assert sessions.create(**spec, extra_headers=retry) == current absent(lambda: sessions.create(**spec)) assert {s.id for s in sessions.list()} == {current.id} - assert list(sessions.items.list(current.id)) == [] - assert list(sessions.turns.list(current.id)) == [] + assert [item.content[0].text for item in sessions.items.list(current.id)] == [spec["input"]] + turns = list(sessions.turns.list(current.id)) + assert len(turns) == 1 and turns[0].status == "queued" recovered = OpenAI(api_key=token, base_url=restarted + "/v1", http_client=http, max_retries=0, _strict_response_validation=True) absent(lambda: recovered.beta.agents.retrieve(original.id)) diff --git a/services/agents-api/tests/official_agent_references.py b/services/agents-api/tests/official_agent_references.py index ed42f5b21..cdddefff0 100644 --- a/services/agents-api/tests/official_agent_references.py +++ b/services/agents-api/tests/official_agent_references.py @@ -13,7 +13,7 @@ def verify_agent_references(client, other, expect_error): resource = agents.create(model=" requested-model ", name="Reusable configuration", instructions="Saved instructions.", metadata={"business": "not-session-metadata"}, text={"verbosity": "high"}, tools=[tool]) - spec = {"agent_id": resource.id, "environment": {"type": "none"}} + spec = {"input": "Verify agent references fixture admission.", "agent_id": resource.id, "environment": {"type": "none"}} headers = {"Idempotency-Key": "saved-agent-reference"} first = sessions.create(**spec, extra_headers=headers) expected = resource.to_dict(mode="json") @@ -51,10 +51,10 @@ def verify_agent_references(client, other, expect_error): expect_error(ConflictError, lambda: sessions.create(**spec, agent={"instructions": "Changed"}, extra_headers=headers)) same_config = agents.create(model=resource.model, name=resource.name, instructions=resource.instructions, text={"verbosity": "high"}, tools=[tool]) - expect_error(ConflictError, lambda: sessions.create(agent_id=same_config.id, environment={"type": "none"}, extra_headers=headers)) + expect_error(ConflictError, lambda: sessions.create(agent_id=same_config.id, input="Verify agent references fixture admission.", environment={"type": "none"}, extra_headers=headers)) expect_error(NotFoundError, lambda: other.beta.agents.sessions.create(**spec)) for missing in (str(uuid.uuid4()), "not-an-agent", str(uuid.UUID(int=0))): - expect_error(NotFoundError, lambda: sessions.create(agent_id=missing, environment={"type": "none"})) + expect_error(NotFoundError, lambda: sessions.create(agent_id=missing, input="Verify agent references fixture admission.", environment={"type": "none"})) # Configuration storage is broader than execution. Never silently drop an # unsupported saved option, but admit a supported whole-field replacement. @@ -67,10 +67,10 @@ def verify_agent_references(client, other, expect_error): ("tools", [{"type": "tool_search"}], []), ): unsupported = agents.create(model="model", **{field: value}) - reference = {"agent_id": unsupported.id, "environment": {"type": "none"}} + reference = {"input": "Verify agent references fixture admission.", "agent_id": unsupported.id, "environment": {"type": "none"}} expect_error(BadRequestError, lambda: sessions.create(**reference)) recovered.append(sessions.create(**reference, agent={field: replacement})) - expect_error(BadRequestError, lambda: sessions.create(agent={"model": "model", field: value}, environment={"type": "none"})) + expect_error(BadRequestError, lambda: sessions.create(agent={"model": "model", field: value}, input="Verify agent references fixture admission.", environment={"type": "none"})) assert agents.retrieve(unsupported.id) == unsupported # These controls are admitted by the current Codex profile. Both reference @@ -80,14 +80,14 @@ def verify_agent_references(client, other, expect_error): ("tools", [{"type": "programmatic_tool_calling", "enabled": False}], []), ): supported = agents.create(model="model", **{field: value}) - reference = {"agent_id": supported.id, "environment": {"type": "none"}} + reference = {"input": "Verify agent references fixture admission.", "agent_id": supported.id, "environment": {"type": "none"}} inherited = sessions.create(**reference) - inline = sessions.create(agent={"model": "model", field: value}, environment={"type": "none"}) + inline = sessions.create(agent={"model": "model", field: value}, input="Verify agent references fixture admission.", environment={"type": "none"}) expected_field = supported.to_dict(mode="json")[field] assert inherited.agent.to_dict(mode="json")[field] == expected_field assert inline.agent.to_dict(mode="json")[field] == expected_field replaced = sessions.create(**reference, agent={field: replacement}) - inline_replacement = sessions.create(agent={"model": "model", field: replacement}, environment={"type": "none"}) + inline_replacement = sessions.create(agent={"model": "model", field: replacement}, input="Verify agent references fixture admission.", environment={"type": "none"}) assert replaced.agent.to_dict(mode="json")[field] == inline_replacement.agent.to_dict(mode="json")[field] assert replaced.agent.to_dict(mode="json")[field] != expected_field assert agents.retrieve(supported.id) == supported diff --git a/services/agents-api/tests/official_agent_update.py b/services/agents-api/tests/official_agent_update.py index 69e0c1613..fa4b109b0 100644 --- a/services/agents-api/tests/official_agent_update.py +++ b/services/agents-api/tests/official_agent_update.py @@ -20,7 +20,7 @@ def main(): original = agents.create(model="original-model", name="Original", instructions="Keep original.", metadata={"old": "value"}, tools=[tool]) endpoint = base + "/v1/agents/" + original.id - spec = {"agent_id": original.id, "environment": {"type": "none"}} + spec = {"input": "Verify agent update fixture admission.", "agent_id": original.id, "environment": {"type": "none"}} retry = {"Idempotency-Key": "before-agent-update"} old = sessions.create(**spec, extra_headers=retry) updated = agents.update(original.id, instructions="Use updated instructions.", diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index 3448246af..e566602a2 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/agents-api/tests/official_client.py @@ -61,8 +61,8 @@ def validate_response(response): path = "/agents/sessions/{session_id}" if suffix and suffix[0] == "turns": path += "/turns" + ("/{turn_id}" if len(suffix) > 1 else "") - elif suffix and suffix[0] == "items": - path += "/items" + elif suffix and suffix[0] in ("items", "events"): + path += "/" + suffix[0] elif path.startswith("/vaults/"): suffix = path.split("/")[3:] path = "/vaults/{vault_id}" @@ -72,6 +72,9 @@ def validate_response(response): path = "/agents/{agent_id}" elif path.startswith("/files/"): path = "/files/{file_id}/content" if path.endswith("/content") else "/files/{file_id}" + if path.endswith("/events") and response.status_code == 202: + assert response.content == b"" + return schema = contract["paths"][path][response.request.method.lower()]["responses"][str(response.status_code)]["schema"] Draft4Validator({"definitions": contract["definitions"], **schema}).validate(response.json()) pin = json.loads((root / "contracts/agents-api/upstream.json").read_text()) @@ -108,6 +111,9 @@ def validate_response(response): credential_key.write_text(base64.b64encode(secrets.token_bytes(32)).decode() + "\n") env = dict(os.environ, AGENTS_API_DATABASE_URL=dsn, AGENTS_API_KEYS_FILE=str(keys), AGENTS_API_ADDR=f"127.0.0.1:{port}", AGENTS_API_ENGINE="codex") env["AGENTS_API_CREDENTIAL_KEY_FILE"] = str(credential_key) + # Enable the real Worker/gateway admission path without connecting a daemon. + # Synthetic fixture inputs remain queued; this is not live model acceptance. + env["AGENTS_API_DAEMON_WS_URL"] = f"ws://127.0.0.1:{port}/api/v1/agent-daemon/ws" with (Path(directory) / "server.log").open("w+") as log: def start(): child = subprocess.Popen([binary], env=env, stdout=log, stderr=log) @@ -158,13 +164,15 @@ def expect_error(error, operation): saved_agents = verify_agents(a, b, invalid, expect_error) listed_agents = verify_agent_list(a, b, invalid, saved_agents, expect_error) sessions = a.beta.agents.sessions - spec = {"agent": {"model": "requested-test-model", "instructions": "Keep the configuration."}, "environment": {"type": "none"}} + spec = {"input": "Verify client fixture admission.", "agent": {"model": "requested-test-model", "instructions": "Keep the configuration."}, "environment": {"type": "none"}} headers = {"Idempotency-Key": "same-key"} first = sessions.create(**spec, metadata={"workspace": "untrusted-reference"}, extra_headers=headers) - assert first.object == "agent.session" and first.status == "idle" + assert first.object == "agent.session" and first.status == "in_progress" assert first.agent.model == spec["agent"]["model"] and first.agent.instructions == spec["agent"]["instructions"] assert first.environment.type == "none" and first.required_actions == [] and first.vault_ids == [] assert first.agent.tools == [] and first.agent.multi_agent.enabled is False + default_raw = sessions.with_raw_response.retrieve(first.id) + assert default_raw.http_response.json()["agent"]["tools"] == [] assert first.created_at == first.last_active_at and isinstance(first.created_at, int) replay = sessions.create(**spec, metadata={"workspace": "untrusted-reference"}, extra_headers=headers) assert replay == first @@ -187,9 +195,11 @@ def expect_error(error, operation): expect_error(NotFoundError, lambda: b.beta.agents.sessions.list(after=first.id)) expect_error(AuthenticationError, lambda: invalid.beta.agents.sessions.retrieve(first.id)) expect_error(BadRequestError, lambda: sessions.retrieve(first.id, extra_headers={"OpenAI-Beta": ""})) - expect_error(BadRequestError, lambda: sessions.create(**spec, input=[{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.com/image.png"}]}])) - unavailable = expect_error(InternalServerError, lambda: sessions.create(agent=spec["agent"], environment={"type": "self_hosted", "workspace_directory": "/workspace"})) - assert unavailable.status_code == 503 and unavailable.body["code"] == "execution_unavailable" + expect_error(BadRequestError, lambda: sessions.create(**{**spec, "input": [{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.com/image.png"}]}]})) + self_hosted = sessions.create(agent=spec["agent"], environment={"type": "self_hosted", "workspace_directory": "/workspace"}) + assert self_hosted.environment.type == "self_hosted" + assert list(sessions.turns.list(self_hosted.id)) == [] + assert sessions.delete(self_hosted.id).deleted expect_error(BadRequestError, lambda: sessions.create(**spec, extra_body={"tenant_id": bindings[1]["tenant_id"]})) assert list(sessions.list(agent_id="unknown-agent")) == [] assert list(sessions.list(agent_id=first.agent.id)) == [first] @@ -202,6 +212,9 @@ def expect_error(error, operation): request_sessions = verify_session_create_requests(a, spec) request_sessions.append(verify_session_metadata(a, b, invalid, spec, expect_error)) turn_session = sessions.create(**spec) + sessions.events.create(turn_session.id, events=[{"type": "agent.session.input.cancel"}]) + initial_turn = list(sessions.turns.list(turn_session.id))[0] + assert initial_turn.status == "cancelled" fixture = Path(directory) / "turns.json" fixture.write_text(json.dumps({"tenant": bindings[0]["tenant_id"], "session": turn_session.id})) subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, @@ -209,6 +222,8 @@ def expect_error(error, operation): turn_ids = json.loads(fixture.read_text())["turns"] turns = sessions.turns recovered = list(turns.list(turn_session.id, limit=1, order="asc")) + assert recovered[0] == initial_turn + recovered = recovered[1:] assert [turn.id for turn in recovered] == turn_ids assert [turn.status for turn in recovered] == ["completed", "failed", "cancelled", "in_progress"] assert all(turn.agent_id == turn_session.agent.id and turn.session_id == turn_session.id for turn in recovered) @@ -217,9 +232,9 @@ def expect_error(error, operation): assert recovered[1].error.code == "internal_error" and all(turn.error is None for turn in [recovered[0], *recovered[2:]]) assert all(turn.usage is None for turn in recovered) assert "SECRET" not in repr(recovered) and "PRIVATE" not in repr(recovered) - assert [turn.id for turn in turns.list(turn_session.id, limit=2)] == list(reversed(turn_ids)) + assert [turn.id for turn in turns.list(turn_session.id, limit=2)] == list(reversed([initial_turn.id, *turn_ids])) assert list(turns.list(turn_session.id, after=turn_ids[-1], order="asc")) == [] - assert list(turns.list(first.id)) == [] + assert len(list(turns.list(first.id))) == 1 assert turns.retrieve(turn_ids[0], session_id=turn_session.id) == recovered[0] expect_error(NotFoundError, lambda: b.beta.agents.sessions.turns.list(turn_session.id)) expect_error(NotFoundError, lambda: b.beta.agents.sessions.turns.retrieve(turn_ids[0], session_id=turn_session.id)) @@ -258,11 +273,25 @@ def expect_error(error, operation): verify_vault_deletion_recovery(a, b, vault_deletion, expect_error) assert [a.beta.agents.retrieve(item.id) for item in saved_agents] == saved_agents assert [item.id for item in a.beta.agents.list(limit=2, order="asc") if item.id in listed_agents] == listed_agents + # The active SQL fixture has no native process. The real Worker + # marks its interrupted Turn failed during restart recovery. + prior_history_session = next(item for item in request_sessions if item.id == turn_session.id) + final_history_session = sessions.retrieve(turn_session.id) + assert final_history_session.status == "failed" + assert final_history_session.error == "The execution could not complete." + lifecycle = {"status", "error", "last_active_at"} + assert {k: v for k, v in final_history_session.to_dict().items() if k not in lifecycle} == {k: v for k, v in prior_history_session.to_dict().items() if k not in lifecycle} + request_sessions = [final_history_session if item.id == turn_session.id else item for item in request_sessions] + interrupted = turns.retrieve(turn_ids[-1], session_id=turn_session.id) + assert interrupted.status == "failed" and interrupted.error.code == "internal_error" + assert interrupted.id == recovered[-1].id and interrupted.completed_at is not None + recovered[-1] = interrupted assert [sessions.retrieve(item.id) for item in request_sessions] == request_sessions reference_spec, reference_headers, reference_result = reference_retry assert sessions.create(**reference_spec, extra_headers=reference_headers) == reference_result + saved_items = [item.model_copy(update={"status": "incomplete"}) if item.turn_id == interrupted.id and item.status == "in_progress" else item for item in saved_items] assert list(sessions.items.list(turn_session.id, order="asc")) == saved_items - assert list(turns.list(turn_session.id, order="asc")) == recovered + assert list(turns.list(turn_session.id, order="asc")) == [initial_turn, *recovered] assert sessions.retrieve(first.id) == first assert sessions.create(**spec, metadata={"workspace": "untrusted-reference"}, extra_headers=headers) == first verify_creator_recovery(client, same_principal, peer_principal, same_subject_id, diff --git a/services/agents-api/tests/official_credential_rotation.py b/services/agents-api/tests/official_credential_rotation.py index 7789f23c6..b44705ae4 100644 --- a/services/agents-api/tests/official_credential_rotation.py +++ b/services/agents-api/tests/official_credential_rotation.py @@ -20,7 +20,7 @@ def verify_credential_rotation(client, other, invalid, peer, saved_vaults, saved replacement = {"auth": {"type": "static_bearer", "token": canary + "replacement"}} sessions = [] for selected in (None, original.id): - request = {"agent": {"model": "requested-model", "tools": [{ + request = {"input": "Verify credential rotation fixture admission.", "agent": {"model": "requested-model", "tools": [{ "type": "mcp", "server_label": "rotating", "credential_id": selected, "connection_origin": "service", "allowed_tools": [], "transport": {"type": "http", "server_url": destination}}]}, @@ -120,5 +120,5 @@ def verify_rotation_recovery(client, peer, state): for request, key, original in sessions: assert client.beta.agents.sessions.retrieve(original.id) == original assert client.beta.agents.sessions.create(**request, extra_headers=key) == original - assert list(client.beta.agents.sessions.turns.list(original.id)) == [] - assert list(client.beta.agents.sessions.items.list(original.id)) == [] + assert len(list(client.beta.agents.sessions.turns.list(original.id))) == 1 + assert [item.content[0].text for item in client.beta.agents.sessions.items.list(original.id)] == [request["input"]] diff --git a/services/agents-api/tests/official_execution.py b/services/agents-api/tests/official_execution.py index 037b87952..6f670f367 100644 --- a/services/agents-api/tests/official_execution.py +++ b/services/agents-api/tests/official_execution.py @@ -35,9 +35,9 @@ def wait_turn(session, status, count=1): time.sleep(0.05) raise AssertionError([(turn.id, turn.status, turn.error) for turn in turns]) - def create(): + def create(initial, **options): return sessions.create(agent={"model": "gpt-5.5", "instructions": "Keep the conversation."}, - environment={"type": "none"}) + environment={"type": "none"}, input=initial, **options) def until_idle(stream): events = [] @@ -53,12 +53,12 @@ def until_idle(stream): raise AssertionError("stream ended without an idle Session") try: - session = create() - assert session.agent.tools == [] event = message("Search the web for this answer.", "Second message in the same event.") - with sessions.events.stream(session.id, timeout=20) as stream: - assert sessions.events.create(session.id, events=[event], idempotency_key="first") is None - sessions.events.create(session.id, events=[event], idempotency_key="first") + creation_key = {"Idempotency-Key": "first"} + with create(event["input"], stream=True, extra_headers=creation_key) as stream: + session = next(stream).session + assert session.agent.tools == [] + assert create(event["input"], extra_headers=creation_key).id == session.id first_events = until_idle(stream) first = wait_turn(session.id, "completed") assert sessions.retrieve(session.id).status == "idle" @@ -84,7 +84,7 @@ def until_idle(stream): deltas = [value.delta for value in text_events if value.type.endswith(".delta")] assert len(deltas) >= 2 and "".join(deltas) == answers[0].content[0].text, deltas try: - sessions.events.create(session.id, events=[message("changed")], idempotency_key="first") + create(message("changed")["input"], extra_headers=creation_key) raise AssertionError("changed retry accepted") except ConflictError: pass @@ -94,7 +94,9 @@ def until_idle(stream): except NotFoundError: pass with sessions.events.stream(session.id, timeout=20, extra_headers={"Last-Event-ID": first_events[-1].event_id}) as stream: - sessions.events.create(session.id, events=[message("Continue the same native conversation.")], idempotency_key="second") + continuation = message("Continue the same native conversation.") + for _ in range(2): + assert sessions.events.create(session.id, events=[continuation], idempotency_key="second") is None second_events = until_idle(stream) second = wait_turn(session.id, "completed", 2) assert all(getattr(value, "turn_id", None) != first.id for value in second_events) @@ -102,7 +104,13 @@ def until_idle(stream): expected_total = {"input_tokens": 20, "input_tokens_details": {"cached_tokens": 8}, "output_tokens": 6, "output_tokens_details": {"reasoning_tokens": 4}, "total_tokens": 26} assert sessions.retrieve(session.id).usage.model_dump() == expected_total - sessions.events.create(session.id, events=[event], idempotency_key="first") + assert create(event["input"], extra_headers=creation_key).id == session.id + sessions.events.create(session.id, events=[continuation], idempotency_key="second") + try: + sessions.events.create(session.id, events=[message("changed continuation")], idempotency_key="second") + raise AssertionError("changed event retry accepted") + except ConflictError: + pass assert len(sessions.turns.list(session.id).data) == 2 client.close() client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, @@ -112,8 +120,7 @@ def until_idle(stream): assert len(sessions.items.list(session.id, limit=100).data) == 5 assert sessions.retrieve(session.id).usage.model_dump() == expected_total assert sessions.turns.retrieve(first.id, session_id=session.id).usage.model_dump() == expected_usage - cancelled = create() - sessions.events.create(cancelled.id, events=[message("PUBLIC-CANCEL")]) + cancelled = create("PUBLIC-CANCEL") wait_turn(cancelled.id, "in_progress") time.sleep(0.5) retained = sessions.items.list(cancelled.id, limit=100).data @@ -128,34 +135,35 @@ def until_idle(stream): for verbosity in ("low", "medium", "high"): agent = {"model": "gpt-5.5", "text": {"verbosity": verbosity, "format": {"type": "text"}}} key = "text-" + verbosity - configured = sessions.create(agent=agent, environment={"type": "none"}, extra_headers={"Idempotency-Key": key}) + configured = sessions.create(agent=agent, environment={"type": "none"}, input="TEXT-VERBOSITY:" + verbosity, extra_headers={"Idempotency-Key": key}) agent["text"]["format"] = None - assert sessions.create(agent=agent, environment={"type": "none"}, extra_headers={"Idempotency-Key": key}).id == configured.id + assert sessions.create(agent=agent, environment={"type": "none"}, input="TEXT-VERBOSITY:" + verbosity, extra_headers={"Idempotency-Key": key}).id == configured.id assert configured.agent.text.model_dump() == {"format": {"type": "text"}, "verbosity": verbosity} for count in (1, 2): - sessions.events.create(configured.id, events=[message("TEXT-VERBOSITY:" + verbosity)]) + if count > 1: + sessions.events.create(configured.id, events=[message("TEXT-VERBOSITY:" + verbosity)]) wait_turn(configured.id, "completed", count) assert sessions.retrieve(configured.id).agent.text == configured.agent.text agent["text"]["verbosity"] = "high" if verbosity != "high" else "low" try: - sessions.create(agent=agent, environment={"type": "none"}, extra_headers={"Idempotency-Key": key}) + sessions.create(agent=agent, environment={"type": "none"}, input="TEXT-VERBOSITY:" + verbosity, extra_headers={"Idempotency-Key": key}) raise AssertionError("changed text configuration reused a retry key") except ConflictError: pass default_agent = {"model": "custom-provider-model"} default = sessions.create(agent=default_agent, environment={"type": "none"}, - extra_headers={"Idempotency-Key": "native-default"}) + input="DEFAULT-VERBOSITY", extra_headers={"Idempotency-Key": "native-default"}) for text in (None, {"verbosity": None}, {"verbosity": "medium"}): configured = sessions.create(agent=dict(default_agent, text=text), environment={"type": "none"}, - extra_headers={"Idempotency-Key": "native-default"}) + input="DEFAULT-VERBOSITY", extra_headers={"Idempotency-Key": "native-default"}) assert configured.id == default.id and configured.agent.text.verbosity == "medium" for count in (1, 2): - sessions.events.create(default.id, events=[message("DEFAULT-VERBOSITY")]) + if count > 1: + sessions.events.create(default.id, events=[message("DEFAULT-VERBOSITY")]) wait_turn(default.id, "completed", count) assert sessions.retrieve(default.id).agent.text.verbosity == "medium" unsupported = sessions.create(agent={"model": "custom-provider-model", "text": {"verbosity": "high"}}, - environment={"type": "none"}) - sessions.events.create(unsupported.id, events=[message("UNSUPPORTED-VERBOSITY")]) + environment={"type": "none"}, input="UNSUPPORTED-VERBOSITY") failed = wait_turn(unsupported.id, "failed") assert failed.error is not None and failed.error.code == "internal_error", failed.error assert sessions.retrieve(unsupported.id).status == "failed" diff --git a/services/agents-api/tests/official_function_images.py b/services/agents-api/tests/official_function_images.py index 116ecbc6a..a6fda8b49 100644 --- a/services/agents-api/tests/official_function_images.py +++ b/services/agents-api/tests/official_function_images.py @@ -52,13 +52,14 @@ def answer(sid, expected): assert all(p >= 0 for p in positions) and positions == sorted(positions), result -def run(sid, output=None, expected=None, cancel=False, failed_text=False, validate=False, recall=False): +def run(sid, output=None, expected=None, cancel=False, failed_text=False, validate=False, recall=False, creation=None): events, handled = [], False prompt = "Call get_visual exactly once. Read the image returned by that tool and reply with its four band colors from left to right. Do not call it again." if recall: prompt = "Without calling tools, recall the most recent image from get_visual and repeat its four band colors from left to right." - with sessions.events.stream(sid, timeout=180) as stream: - sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [text(prompt)]}]}]) + with (creation or sessions.events.stream(sid, timeout=180)) as stream: + if creation is None: + sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [text(prompt)]}]}]) for event in stream: events.append(event.to_dict()) if event.type == "agent.session.requires_action": @@ -120,13 +121,14 @@ def run(sid, output=None, expected=None, cancel=False, failed_text=False, valida colors = ["red", "green", "blue", "yellow"] secrets.SystemRandom().shuffle(colors) proof["colors"] = colors - session = sessions.create(agent={"model": model, "tools": [{"type": "function", "name": "get_visual", - "description": "Return a visual to inspect.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}}]}, environment={"type": "none"}) + creation = sessions.create(agent={"model": model, "tools": [{"type": "function", "name": "get_visual", + "description": "Return a visual to inspect.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}}]}, environment={"type": "none"}, input="Call get_visual exactly once. Read the image returned by that tool and reply with its four band colors from left to right. Do not call it again.", stream=True) + session = next(creation).session proof["session"] = sid = session.id save() for scale in [1, 15]: output = [text("Read this visual."), {"type": "input_image", "image_url": picture(colors, scale)}, text("Return its four band colors in order.")] - run(sid, output, colors, validate=scale == 1) + run(sid, output, colors, validate=scale == 1, creation=creation if scale == 1 else None) jpeg = base64.b64encode((Path(__file__).parent / "testdata/function-bands.jpg").read_bytes()).decode() proof["colors"] = ["yellow", "blue", "red", "green"] run(sid, [{"type": "input_image", "image_url": "data:image/jpeg;base64," + jpeg}], proof["colors"]) diff --git a/services/agents-api/tests/official_function_stream.py b/services/agents-api/tests/official_function_stream.py index d5a24b391..d8a32eed3 100644 --- a/services/agents-api/tests/official_function_stream.py +++ b/services/agents-api/tests/official_function_stream.py @@ -22,7 +22,28 @@ with OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=30)) as client: sessions = client.beta.agents.sessions - session = sessions.create(agent=agent, environment={"type": "none"}) + # Complete one controlled initialization Turn before exercising the idle-only + # pinned stream helper. This synthetic fixture does not claim live acceptance. + with sessions.create(agent=agent, environment={"type": "none"}, + input="Look up ticket 42 for stream helper setup.", stream=True) as creation: + session = next(creation).session + setup_turn = None + for event in creation: + if event.type == "agent.session.requires_action": + action = event.session.required_actions[0] + setup_turn = action.turn_id + sessions.events.create(session.id, events=[{ + "type": "agent.session.input.tool_result", "turn_id": action.turn_id, + "call_id": action.call_id, "success": True, + "output": [{"type": "input_text", "text": "setup complete"}], + }]) + assert event.type not in {"agent.session.failed", "agent.session.turn.failed"} + if event.type == "agent.session.idle": + break + else: + raise AssertionError("setup creation stream ended without idle") + assert setup_turn is not None + assert sessions.turns.retrieve(setup_turn, session_id=session.id).status == "completed" turns, calls, handler_calls, observed = [], [], [], [] for index in range(2): def lookup_ticket(arguments): @@ -67,12 +88,12 @@ def lookup_ticket(arguments): assert current.status == "idle" and current.required_actions == [] items = sessions.items.list(session.id, limit=100, order="asc").data results = {item.call_id: item.to_dict() for item in items if item.type == "function_call_output"} - assert len(results) == 2 and len(sessions.turns.list(session.id).data) == 2 + assert len(results) == 3 and len(sessions.turns.list(session.id).data) == 3 for index, call_id in enumerate(calls): assert {key: results[call_id][key] for key in ("output", "error") if key in results[call_id]} == expected[index] answers = [item for item in items if item.type == "message" and item.role == "assistant"] - assert len(answers) == 2 and all(item.content[0].text == "FUNCTION-EXECUTION-OK" for item in answers) + assert len(answers) == 3 and all(item.content[0].text == "FUNCTION-EXECUTION-OK" for item in answers) proof = {"session": session.id, "turns": turns, "calls": calls, "handler_calls": handler_calls, - "events": observed, "results": results} + "events": observed, "results": results, "setup_turn": setup_turn} assert "private-handler-exception-must-not-be-exposed" not in json.dumps(proof) Path(evidence).write_text(json.dumps(proof)) diff --git a/services/agents-api/tests/official_functions.py b/services/agents-api/tests/official_functions.py index 863264a2d..beb9ecf77 100644 --- a/services/agents-api/tests/official_functions.py +++ b/services/agents-api/tests/official_functions.py @@ -19,23 +19,25 @@ with OpenAI(base_url=base+"/v1", api_key=token, max_retries=0, _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=30)) as client: sessions = client.beta.agents.sessions - session = sessions.create(agent=agent, environment={"type":"none"}, extra_headers={"Idempotency-Key":"functions"}) + creation = sessions.create(agent=agent, environment={"type":"none"}, input="Look up ticket 42", stream=True, extra_headers={"Idempotency-Key":"functions"}) + session = next(creation).session expected = dict(tool, defer_loading=False) assert session.agent.tools[0].to_dict() == expected, session.agent.tools tool["defer_loading"] = False - assert sessions.create(agent=agent, environment={"type":"none"}, extra_headers={"Idempotency-Key":"functions"}).id == session.id + assert sessions.create(agent=agent, environment={"type":"none"}, input="Look up ticket 42", extra_headers={"Idempotency-Key":"functions"}).id == session.id tool["description"] = "changed" try: - sessions.create(agent=agent, environment={"type":"none"}, extra_headers={"Idempotency-Key":"functions"}) + sessions.create(agent=agent, environment={"type":"none"}, input="Look up ticket 42", extra_headers={"Idempotency-Key":"functions"}) raise AssertionError("changed tools reused a creation identity") except ConflictError: pass turns, calls = [], [] for index in range(3): handled = False - with sessions.events.stream(session.id, timeout=30) as stream: + with (creation if index == 0 else sessions.events.stream(session.id, timeout=30)) as stream: message = {"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"Look up ticket 42"}]}]} - sessions.events.create(session.id, events=[message], idempotency_key="message-"+str(index)) + if index > 0: + sessions.events.create(session.id, events=[message], idempotency_key="message-"+str(index)) for event in stream: if event.type in ("agent.session.turn.item.added", "agent.session.turn.item.done") and event.item.type == "function_call_output": assert event.type == "agent.session.turn.item.added" and event.output_index is None diff --git a/services/agents-api/tests/official_items.py b/services/agents-api/tests/official_items.py index 5b00358ff..f8117c36f 100644 --- a/services/agents-api/tests/official_items.py +++ b/services/agents-api/tests/official_items.py @@ -2,15 +2,18 @@ from openai import AuthenticationError, BadRequestError, NotFoundError -def verify_items(a, b, invalid, session, empty_session, turns, expect_error): +def verify_items(a, b, invalid, session, peer_session, turns, expect_error): items = a.beta.agents.sessions.items recovered = list(items.list(session, limit=3, order="asc")) - assert len(recovered) == 32 and len({item.id for item in recovered}) == 32 + assert len(recovered) == 33 and len({item.id for item in recovered}) == 33 + assert recovered[0].type == "message" and recovered[0].role == "user" + assert recovered[0].turn_id not in turns assert list(items.list(session, limit=5)) == list(reversed(recovered)) assert list(items.list(session, after=recovered[-1].id, order="asc")) == [] - assert list(items.list(empty_session)) == [] + peer_items = list(items.list(peer_session)) + assert len(peer_items) == 1 and peer_items[0].role == "user" expect_error(NotFoundError, lambda: b.beta.agents.sessions.items.list(session)) - expect_error(NotFoundError, lambda: items.list(empty_session, after=recovered[0].id)) + expect_error(NotFoundError, lambda: items.list(peer_session, after=recovered[0].id)) expect_error(AuthenticationError, lambda: invalid.beta.agents.sessions.items.list(session)) expect_error(BadRequestError, lambda: items.list(session, limit=101)) assert "PRIVATE" not in repr(recovered) and "SECRET" not in repr(recovered) diff --git a/services/agents-api/tests/official_mcode_native.py b/services/agents-api/tests/official_mcode_native.py index 5ccc960e5..4927fcebb 100644 --- a/services/agents-api/tests/official_mcode_native.py +++ b/services/agents-api/tests/official_mcode_native.py @@ -30,10 +30,11 @@ def message(text): def submit(sid, text, key): sessions.events.create(sid, events=[message(text)], idempotency_key=key) - def execute(sid, text, steer=False, cancel=False): + def execute(sid, text, steer=False, cancel=False, creation=None): types, submitted = [], False - with sessions.events.stream(sid, timeout=300) as stream: - submit(sid, text, str(uuid.uuid4())) + with (creation or sessions.events.stream(sid, timeout=300)) as stream: + if creation is None: + submit(sid, text, str(uuid.uuid4())) for event in stream: types.append(event.type) assert event.type != "agent.session.failed", event @@ -60,17 +61,19 @@ def answer(sid): try: if stage == "initial": marker = "MCODE-MEMORY-" + uuid.uuid4().hex[:12] - session = sessions.create(agent={"model": model, "instructions": "Follow user instructions. Remember supplied markers. Do not use tools."}, environment={"type": "none"}) + prompt = "Remember " + marker + ". Write 120 numbered lines explaining addition, one sentence per line. Start immediately." + creation = sessions.create(agent={"model": model, "instructions": "Follow user instructions. Remember supplied markers. Do not use tools."}, environment={"type": "none"}, input=prompt, stream=True) + session = next(creation).session record = {"session": session.id, "marker": marker, "model": model, "checks": []} Path(output).write_text(json.dumps(record, indent=2)) for agent_patch, environment in [({"tools": [{"type": "function", "name": "f", "parameters": {"type": "object"}}]}, {"type": "none"}), ({"text": {"verbosity": "high"}}, {"type": "none"})]: - r = http.post(base + "/v1/agents/sessions", headers=headers, json={"agent": {"model": model, **agent_patch}, "environment": environment}) + r = http.post(base + "/v1/agents/sessions", headers=headers, json={"agent": {"model": model, **agent_patch}, "environment": environment, "input": "Verify native capability rejection."}) assert r.status_code == 400, r.status_code # This text-only fixture deliberately has no hosted provisioner. r = http.post(base + "/v1/agents/sessions", headers=headers, json={ "agent": {"model": model}, "environment": {"type": "openai_hosted"}}) assert r.status_code == 503 and r.json()["error"]["code"] == "execution_unavailable" - record["initial_events"] = execute(session.id, "Remember " + marker + ". Write 120 numbered lines explaining addition, one sentence per line. Start immediately.", steer=True) + record["initial_events"] = execute(session.id, prompt, steer=True, creation=creation) turns = sessions.turns.list(session.id, order="asc", limit=100).data assert len(turns) == 1 and turns[0].status == "completed", [(t.id, t.status) for t in turns] record["first_turn"] = turns[0].id diff --git a/services/agents-api/tests/official_mcp.py b/services/agents-api/tests/official_mcp.py index 3516043f5..919f9753b 100644 --- a/services/agents-api/tests/official_mcp.py +++ b/services/agents-api/tests/official_mcp.py @@ -24,14 +24,14 @@ def verify_mcp_configuration(client, other, expect_error): "credential_id": None, "request_metadata": {}, "required": readiness.get("required", False), "transport": {**transport, "headers": {}}} assert body["tools"] == [canonical] - spec = {"agent_id": resource.id, "environment": {"type": "none"}} + spec = {"input": "Verify mcp fixture admission.", "agent_id": resource.id, "environment": {"type": "none"}} headers = {"Idempotency-Key": "mcp-snapshot-" + resource.id} response = sessions.with_raw_response.create(**spec, extra_headers=headers) session, body = response.parse(), response.http_response.json() assert body["agent"]["tools"] == [{**canonical, "transport": transport}] expect_error(NotFoundError, lambda: other.beta.agents.sessions.create(**spec)) assert sessions.create(agent={"model": "requested-model", "tools": [declared]}, - environment={"type": "none"}).agent.tools == session.agent.tools + input="Verify mcp fixture admission.", environment={"type": "none"}).agent.tools == session.agent.tools override = sessions.create(**spec, agent={"tools": []}) assert override.agent.tools == [] changed = agents.update(resource.id, tools=[]) @@ -57,7 +57,7 @@ def verify_mcp_configuration(client, other, expect_error): for operation in ( lambda: agents.create(model="requested-model", tools=[declaration]), lambda: sessions.create(agent={"model": "requested-model", "tools": [declaration]}, - environment={"type": "none"}), + input="Verify mcp fixture admission.", environment={"type": "none"}), ): error = expect_error(BadRequestError, operation) assert "synthetic-private" not in str(error.body) diff --git a/services/agents-api/tests/official_mcp_credentials.py b/services/agents-api/tests/official_mcp_credentials.py index 5b35d271d..9d9172978 100644 --- a/services/agents-api/tests/official_mcp_credentials.py +++ b/services/agents-api/tests/official_mcp_credentials.py @@ -34,7 +34,7 @@ def credential(api, vault, destination, name): "transport": {"type": "http", "server_url": url}, "allowed_tools": ["remember"]} anonymous = {**tool, "server_label": "anonymous", "transport": {"type": "http", "server_url": anonymous_url}} - inline = {"agent": {"model": "requested-model", "tools": [tool, anonymous]}, + inline = {"input": "Verify mcp credentials fixture admission.", "agent": {"model": "requested-model", "tools": [tool, anonymous]}, "environment": {"type": "none"}, "vault_ids": ids} saved_sessions, saved_agents, retries = [], [], [] @@ -44,9 +44,9 @@ def verify_session(value, expected_ids, expected_credential): assert body["agent"]["tools"][0]["credential_id"] == expected_credential assert "headers" not in body["agent"]["tools"][0]["transport"] assert canary not in json.dumps(body) and "mcp_credentials" not in body - assert value.status == "idle" - assert list(sessions.turns.list(value.id)) == [] - assert list(sessions.items.list(value.id)) == [] + assert value.status == "in_progress" + assert len(list(sessions.turns.list(value.id))) == 1 + assert [item.content[0].text for item in sessions.items.list(value.id)] == [inline["input"]] assert sessions.retrieve(value.id) == value assert peer.beta.agents.sessions.retrieve(value.id) == value expect_error(NotFoundError, lambda: other.beta.agents.sessions.retrieve(value.id)) @@ -65,7 +65,7 @@ def verify_session(value, expected_ids, expected_credential): retries.append((request, key, value)) saved = client.beta.agents.create(model="requested-model", tools=[tool, anonymous]) - saved_spec = {"agent_id": saved.id, "environment": {"type": "none"}, "vault_ids": ids} + saved_spec = {"input": "Verify mcp credentials fixture admission.", "agent_id": saved.id, "environment": {"type": "none"}, "vault_ids": ids} saved_key = {"Idempotency-Key": "mcp-vault-saved-" + saved.id} value = sessions.create(**saved_spec, extra_headers=saved_key) saved_session = value @@ -87,7 +87,7 @@ def verify_session(value, expected_ids, expected_credential): assert event["type"] == "agent.session.created" assert canary not in json.dumps(event) and "mcp_credentials" not in event["session"] streamed = sessions.retrieve(event["session"]["id"]) - assert streamed.to_dict() == event["session"] + assert streamed.id == event["session"]["id"] and streamed.agent.to_dict() == event["session"]["agent"] verify_session(streamed, ids, None) retries.append((inline, stream_key, streamed)) @@ -111,7 +111,7 @@ def verify_session(value, expected_ids, expected_credential): verify_session(value, ids, second.id) for request, key, original in retries: assert sessions.create(**request, extra_headers=key) == original - assert list(sessions.turns.list(original.id)) == [] + assert len(list(sessions.turns.list(original.id))) == 1 expect_error(BadRequestError, lambda: sessions.create(**inline)) changed = client.beta.agents.update(saved.id, tools=[]) @@ -124,7 +124,7 @@ def verify_session(value, expected_ids, expected_credential): referenced = client.beta.agents.create(model="requested-model", tools=[{**tool, "credential_id": outside.id}]) saved_agents.append(referenced) expect_error(NotFoundError, lambda: sessions.create(agent_id=referenced.id, - environment={"type": "none"}, vault_ids=ids)) + input="Verify mcp credentials fixture admission.", environment={"type": "none"}, vault_ids=ids)) before = {item.id for item in sessions.list()} foreign_before = {item.id for item in other.beta.agents.sessions.list()} diff --git a/services/agents-api/tests/official_session_agent_filter.py b/services/agents-api/tests/official_session_agent_filter.py index 0bb3f654c..44db9eea4 100644 --- a/services/agents-api/tests/official_session_agent_filter.py +++ b/services/agents-api/tests/official_session_agent_filter.py @@ -26,14 +26,14 @@ def main(): selected, all_ids = [], [] for index in range(7): agent = root if index % 2 == 0 else peer - session = sessions.create(agent_id=agent.id, environment={"type": "none"}) + session = sessions.create(agent_id=agent.id, input="Verify session agent filter fixture admission.", environment={"type": "none"}) all_ids.append(session.id) if agent.id == root.id: selected.append(session) - inline = sessions.create(agent={"model": "inline-model"}, environment={"type": "none"}) + inline = sessions.create(agent={"model": "inline-model"}, input="Verify session agent filter fixture admission.", environment={"type": "none"}) all_ids.append(inline.id) foreign_agent = other.beta.agents.create(model="foreign-model") - foreign_session = other.beta.agents.sessions.create(agent_id=foreign_agent.id, environment={"type": "none"}) + foreign_session = other.beta.agents.sessions.create(agent_id=foreign_agent.id, input="Verify session agent filter fixture admission.", environment={"type": "none"}) assert [s.id for s in sessions.list(agent_id=root.id, limit=2, order="asc")] == [s.id for s in selected] assert [s.id for s in sessions.list(agent_id=root.id, limit=2)] == [s.id for s in reversed(selected)] assert [s.id for s in sessions.list(agent_id=inline.agent.id)] == [inline.id] diff --git a/services/agents-api/tests/official_session_creation_stream.py b/services/agents-api/tests/official_session_creation_stream.py index 74169e835..7fe52bae5 100644 --- a/services/agents-api/tests/official_session_creation_stream.py +++ b/services/agents-api/tests/official_session_creation_stream.py @@ -14,10 +14,10 @@ def verify_creation_streams(client, raw, base, headers, foreign, unsupported): sessions = client.beta.agents.sessions spec = {"agent": {"model": "test-model"}, "environment": {"type": "none"}} saved = client.beta.agents.create(model="test-model", instructions="Saved stream configuration.") - forms = [None, "First", [{"role": "user", "content": [{"type": "input_text", "text": "First"}]}, + forms = ["First", [{"role": "user", "content": [{"type": "input_text", "text": "First"}]}, {"role": "user", "content": [{"type": "input_text", "text": "Second"}]}]] for index, initial in enumerate(forms): - config = spec if index != 2 else {"agent_id": saved.id, "environment": {"type": "none"}} + config = spec if index != 1 else {"agent_id": saved.id, "environment": {"type": "none"}} request = {**config, "input": initial} key = {"Idempotency-Key": str(uuid.uuid4())} with sessions.create(**request, stream=True, extra_headers=key) as stream: @@ -26,11 +26,9 @@ def verify_creation_streams(client, raw, base, headers, foreign, unsupported): assert set(first.to_dict()) == {"type", "event_id", "session"} session = first.session assert session.status == "idle" and session.last_active_at == session.created_at - if index == 2: + if index == 1: assert session.agent.id == saved.id and session.agent.instructions == saved.instructions - if initial is None: - sessions.events.create(session.id, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": "First"}]}]}]) - count = 2 if index == 2 else 1 + count = 2 if index == 1 else 1 events = [next(stream) for _ in range(2 + count)] assert [event.type for event in events] == ["agent.session.turn.created", "agent.session.in_progress"] + ["agent.session.turn.item.added"] * count assert len({event.event_id for event in [first, *events]}) == 3 + count @@ -88,4 +86,4 @@ def verify_creation_streams(client, raw, base, headers, foreign, unsupported): response = raw.post(unsupported + "/v1/agents/sessions", headers=headers, json={**request, "stream": True}) assert response.status_code == 400 and response.headers["content-type"].startswith("application/json") assert {session.id for session in sessions.list()} == before - print("Creation streams: fixed SDK/raw HTTP, initial and idle creation, snapshots/order, saved Agents, safe retries, later Turns, disconnect recovery and pre-stream errors passed.") + print("Creation streams: fixed SDK/raw HTTP, initial admission and later idle continuation, snapshots/order, saved Agents, safe retries, later Turns, disconnect recovery and pre-stream errors passed.") diff --git a/services/agents-api/tests/official_session_creators.py b/services/agents-api/tests/official_session_creators.py index 88c21e6cc..47c1dc65f 100644 --- a/services/agents-api/tests/official_session_creators.py +++ b/services/agents-api/tests/official_session_creators.py @@ -50,7 +50,7 @@ def check_retries(request, key, current): request = spec | {"metadata": metadata} key = {"Idempotency-Key": str(uuid.uuid4())} first = sessions.create(**request, extra_headers=key | forged) - assert first.status == "idle" and first.metadata == metadata + assert first.status == "in_progress" and first.metadata == metadata check_retries(request, key, first) foreign = other.beta.agents.sessions.create(**request, extra_headers=key) assert foreign.id != first.id @@ -61,8 +61,8 @@ def check_retries(request, key, current): for caller in (collaborator, typed_peer): assert caller.beta.agents.sessions.retrieve(first.id) == first assert first.id in {item.id for item in caller.beta.agents.sessions.list()} - assert list(caller.beta.agents.sessions.turns.list(first.id)) == [] - assert list(caller.beta.agents.sessions.items.list(first.id)) == [] + assert len(list(caller.beta.agents.sessions.turns.list(first.id))) == 1 + assert [item.content[0].text for item in caller.beta.agents.sessions.items.list(first.id)] == [request["input"]] current = collaborator.beta.agents.sessions.update(first.id, metadata={"creator_id": "test-peer"}) assert without_metadata(current) == without_metadata(first) assert_no_creator_fields(current.to_dict()) @@ -84,7 +84,7 @@ def check_retries(request, key, current): # Saved references recover before source resolution, even after a peer # changes or deletes the source Agent. source = owner.beta.agents.create(model="creator-fixture-model", instructions="Frozen source.") - request = {"agent_id": source.id, "environment": {"type": "none"}, "metadata": metadata} + request = {"input": "Verify session creators fixture admission.", "agent_id": source.id, "environment": {"type": "none"}, "metadata": metadata} key = {"Idempotency-Key": str(uuid.uuid4())} saved = sessions.create(**request, extra_headers=key | forged) check_retries(request, key, saved) @@ -110,6 +110,7 @@ def check_retries(request, key, current): assert streamed.id == created["session"]["id"] for caller in (owner, collaborator): expect_error(ConflictError, lambda: caller.beta.agents.sessions.create(**spec, extra_headers=key)) + collaborator.beta.agents.sessions.events.create(streamed.id, events=[{"type": "agent.session.input.cancel"}]) deleted = collaborator.beta.agents.sessions.delete(streamed.id) assert deleted.deleted is True and deleted.id == streamed.id expect_error(NotFoundError, lambda: typed_peer.beta.agents.sessions.retrieve(streamed.id)) diff --git a/services/agents-api/tests/official_session_delete.py b/services/agents-api/tests/official_session_delete.py index 650b863d5..1f008f869 100644 --- a/services/agents-api/tests/official_session_delete.py +++ b/services/agents-api/tests/official_session_delete.py @@ -27,8 +27,8 @@ def client(url, key): other = client(base, foreign).beta.agents.sessions recovered = client(restarted, token).beta.agents.sessions agent = api.beta.agents.create(model="test-model") - peer = sessions.create(agent_id=agent.id, environment={"type": "none"}) - foreign_session = other.create(agent={"model": "test-model"}, environment={"type": "none"}) + peer = sessions.create(agent_id=agent.id, environment={"type": "none"}, input="Verify deletion leaves peer history unchanged.") + foreign_session = other.create(agent={"model": "test-model"}, environment={"type": "none"}, input="Verify deletion leaves peer history unchanged.") headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} for saved in (False, True): for streaming in (False, True): diff --git a/services/agents-api/tests/official_session_initial_input.py b/services/agents-api/tests/official_session_initial_input.py index d2c711610..0452a610e 100644 --- a/services/agents-api/tests/official_session_initial_input.py +++ b/services/agents-api/tests/official_session_initial_input.py @@ -18,17 +18,24 @@ def main(): _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) as client, httpx2.Client(trust_env=False) as raw: sessions = client.beta.agents.sessions saved = client.beta.agents.create(model="test-model", instructions="Saved instructions.") - idle_key = {"Idempotency-Key": str(uuid.uuid4())} - idle = sessions.create(**spec, extra_headers=idle_key) - assert sessions.create(**spec, input=None, extra_headers=idle_key) == idle - assert list(sessions.turns.list(idle.id)) == [] + before = {session.id for session in sessions.list()} + for fields in ({}, {"input": None}): + rejected = raw.post(base + "/v1/agents/sessions", headers=headers, json={**spec, **fields}) + assert rejected.status_code == 400 and rejected.json()["error"]["code"] == "invalid_request_error" + assert {session.id for session in sessions.list()} == before + idle = sessions.create(**spec, input="Verify empty event no-op preserves admitted history.") + sessions.events.create(idle.id, events=[{"type": "agent.session.input.cancel"}]) + idle = sessions.retrieve(idle.id) + prior_turns = list(sessions.turns.list(idle.id)) + prior_items = list(sessions.items.list(idle.id)) + assert len(prior_turns) == len(prior_items) == 1 and prior_turns[0].status == "cancelled" empty_headers = {**headers, "Idempotency-Key": str(uuid.uuid4())} empty_endpoint = base + "/v1/agents/sessions/" + idle.id + "/events" for _ in range(2): response = raw.post(empty_endpoint, headers=empty_headers, json={"events": []}) assert response.status_code == 202 and response.content == b"" assert sessions.retrieve(idle.id) == idle - assert list(sessions.turns.list(idle.id)) == [] and list(sessions.items.list(idle.id)) == [] + assert list(sessions.turns.list(idle.id)) == prior_turns and list(sessions.items.list(idle.id)) == prior_items foreign_empty = raw.post(empty_endpoint, headers={**empty_headers, "Authorization": "Bearer " + foreign}, json={"events": []}) assert foreign_empty.status_code == 404 # Empty requests do not consume a nonempty batch's retry identity. diff --git a/services/agents-api/tests/official_session_metadata.py b/services/agents-api/tests/official_session_metadata.py index 3cdc7bac1..d1133901e 100644 --- a/services/agents-api/tests/official_session_metadata.py +++ b/services/agents-api/tests/official_session_metadata.py @@ -27,7 +27,8 @@ def assert_metadata(session, expected): assert next(item for item in sessions.list(limit=1) if item.id == first.id) == session return session - assert_metadata(sessions.update(first.id), original) + expect_error(BadRequestError, lambda: sessions.update(first.id)) + assert_metadata(sessions.retrieve(first.id), original) with httpx2.Client(trust_env=False, timeout=10) as raw: for metadata in [{"keep": "new", "empty": ""}, None, {}, {"🧪" * 64: "界" * 512, **{str(i): "🧪" * 512 for i in range(15)}}]: @@ -40,8 +41,9 @@ def assert_metadata(session, expected): assert response.json()["metadata"] == expected current = assert_metadata(sessions.retrieve(first.id), expected) assert response.json() == current.to_dict() - assert sessions.update(first.id) == current - assert raw.post(url, headers=headers, json={}).json() == current.to_dict() + expect_error(BadRequestError, lambda: sessions.update(first.id)) + empty = raw.post(url, headers=headers, json={}) + assert empty.status_code == 400 and empty.json()["error"]["code"] == "invalid_request_error" # Updating metadata must not rewrite or reapply the original creation request. assert sessions.create(**spec, metadata=original, extra_headers=retry) == current expect_error(ConflictError, lambda: sessions.create(**spec, metadata=metadata, extra_headers=retry)) @@ -71,10 +73,12 @@ def assert_metadata(session, expected): assert raw.post(path, headers=headers, json={}).status_code == 400 for target in [other, invalid]: expected_error = AuthenticationError if target is invalid else NotFoundError - for fields in [{}, {"metadata": None}, {"metadata": {"tenant_id": "untrusted"}}]: + for fields in [{"metadata": None}, {"metadata": {"tenant_id": "untrusted"}}]: expect_error(expected_error, lambda: target.beta.agents.sessions.update(first.id, **fields)) expect_error(NotFoundError, lambda: sessions.update(str(uuid.uuid4()), metadata={})) - expect_error(NotFoundError, lambda: sessions.update(str(uuid.uuid4()))) + expect_error(BadRequestError, lambda: sessions.update(str(uuid.uuid4()))) + expect_error(BadRequestError, lambda: other.beta.agents.sessions.update(first.id)) + expect_error(AuthenticationError, lambda: invalid.beta.agents.sessions.update(first.id)) expect_error(BadRequestError, lambda: sessions.update("invalid-id", metadata={})) expect_error(BadRequestError, lambda: sessions.update(first.id, extra_headers={"OpenAI-Beta": ""})) assert sessions.retrieve(first.id) == current diff --git a/services/agents-api/tests/official_session_requests.py b/services/agents-api/tests/official_session_requests.py index 49dba8721..2582b0a7b 100644 --- a/services/agents-api/tests/official_session_requests.py +++ b/services/agents-api/tests/official_session_requests.py @@ -36,7 +36,7 @@ def verify_session_create_requests(client, spec): key = {"Idempotency-Key": str(uuid.uuid4())} first = sessions.create(**spec, extra_headers=key) assert first.metadata == {} - for fields in [{}, {"input": None}, {"stream": False}, {"metadata": None}, {"metadata": {}}, + for fields in [{}, {"stream": False}, {"metadata": None}, {"metadata": {}}, {"stream": False, "metadata": None}]: assert sessions.create(**spec, extra_body=fields, extra_headers=key) == first response = raw.post(str(client.base_url).rstrip("/") + "/agents/sessions", diff --git a/services/agents-api/tests/official_structured_output.py b/services/agents-api/tests/official_structured_output.py index fe8fb90a5..c06498631 100644 --- a/services/agents-api/tests/official_structured_output.py +++ b/services/agents-api/tests/official_structured_output.py @@ -24,10 +24,11 @@ def message(text): return {"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": text}]}]} -def run(session, prompt, respond=False, cancel=False): +def run(session, prompt, respond=False, cancel=False, creation=None): events, handled = [], False - with sessions.events.stream(session, timeout=150) as stream: - sessions.events.create(session, events=[message(prompt)], idempotency_key=str(uuid.uuid4())) + with (creation or sessions.events.stream(session, timeout=150)) as stream: + if creation is None: + sessions.events.create(session, events=[message(prompt)], idempotency_key=str(uuid.uuid4())) for event in stream: events.append(event.to_dict()) if event.type == "agent.session.requires_action": @@ -88,10 +89,12 @@ def final(session, events=None): "tools": [{"type": "function", "name": "remember", "description": "Return a private memory value.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}}]} saved = client.beta.agents.create(**config) - session = sessions.create(agent_id=saved.id, environment={"type": "none"}) + prompt = "Call remember exactly once and return its exact memory value as the requested JSON. Do not invent it." + creation = sessions.create(agent_id=saved.id, environment={"type": "none"}, input=prompt, stream=True) + session = next(creation).session proof.update(session=session.id, agent=saved.id, memory=str(uuid.uuid4()), format=config["text"]["format"]) assert session.agent.text.format.to_dict() == proof["format"] - events, turn = run(session.id, "Call remember exactly once and return its exact memory value as the requested JSON. Do not invent it.", respond=True) + events, turn = run(session.id, prompt, respond=True, creation=creation) proof.update(first_events=events, first_output=final(session.id, events)) assert len([i for i in sessions.items.list(session.id, limit=100).data if i.type == "function_call"]) == 1 try: @@ -100,7 +103,7 @@ def final(session, events=None): except NotFoundError: pass try: - other.beta.agents.sessions.create(agent_id=saved.id, environment={"type": "none"}) + other.beta.agents.sessions.create(agent_id=saved.id, environment={"type": "none"}, input="Verify foreign Agent rejection.") raise AssertionError("cross-tenant Agent reference accepted") except NotFoundError: pass @@ -108,7 +111,7 @@ def final(session, events=None): huge = client.beta.agents.create(model=model, text={"format": {"type": "json_schema", "schema": {"type": "object", "const": 9007199254740993}}}) assert huge.text.format.to_dict()["schema"]["const"] == 9007199254740993 try: - sessions.create(agent_id=huge.id, environment={"type": "none"}) + sessions.create(agent_id=huge.id, environment={"type": "none"}, input="Verify unsupported schema rejection.") raise AssertionError("lossy runtime schema accepted") except BadRequestError: pass @@ -122,12 +125,16 @@ def final(session, events=None): assert proof["resumed_output"] != proof["first_output"] assert len(sessions.turns.list(session.id).data) == 2 assert len([i for i in sessions.items.list(session.id, limit=100).data if i.type == "function_call"]) == 1 - cancelled = sessions.create(agent_id=proof["agent"], environment={"type": "none"}) - events, _ = run(cancelled.id, "Call remember to obtain the memory, then return it as JSON.", cancel=True) + prompt = "Call remember to obtain the memory, then return it as JSON." + creation = sessions.create(agent_id=proof["agent"], environment={"type": "none"}, input=prompt, stream=True) + cancelled = next(creation).session + events, _ = run(cancelled.id, prompt, cancel=True, creation=creation) assert not any(i.type == "message" and i.role == "assistant" and i.phase == "final_answer" for i in sessions.items.list(cancelled.id, limit=100).data) proof["cancel_events"] = events - plain = sessions.create(agent_id=proof["agent"], agent={"text": {"format": {"type": "text"}}}, environment={"type": "none"}) - events, _ = run(plain.id, "Do not use tools. Say PLAIN_OK in ordinary text.") + prompt = "Do not use tools. Say PLAIN_OK in ordinary text." + creation = sessions.create(agent_id=proof["agent"], agent={"text": {"format": {"type": "text"}}}, environment={"type": "none"}, input=prompt, stream=True) + plain = next(creation).session + events, _ = run(plain.id, prompt, creation=creation) assert any(i.type == "message" and i.role == "assistant" and "PLAIN_OK" in i.content[0].text for i in sessions.items.list(plain.id, limit=100).data) proof["plain_events"] = events sessions.delete(cancelled.id) diff --git a/services/agents-api/tests/official_tool_policy.py b/services/agents-api/tests/official_tool_policy.py index 5859afb72..c172e92b9 100644 --- a/services/agents-api/tests/official_tool_policy.py +++ b/services/agents-api/tests/official_tool_policy.py @@ -4,6 +4,8 @@ import sys import uuid from pathlib import Path +from contextlib import ExitStack, nullcontext +from types import SimpleNamespace import httpx2 from openai import BadRequestError, NotFoundError, OpenAI @@ -24,7 +26,7 @@ def main(): sessions = client.beta.agents.sessions headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} foreign_headers = {**headers, "Authorization": "Bearer " + foreign} - proof = {"sessions": [], "omitted": []} if stage == "initial" else json.loads(Path(evidence).read_text()) + proof = {"sessions": []} if stage == "initial" else json.loads(Path(evidence).read_text()) tools = [{"type": "web_search", "mode": "disabled"}, {"type": "programmatic_tool_calling", "enabled": False}] # These are pinned response defaults, including explicit nullable fields. @@ -45,13 +47,14 @@ def check_config(sid): assert [tool.to_dict() for tool in sessions.retrieve(sid).agent.tools] == expected assert request("GET", "/sessions/" + sid)["agent"]["tools"] == expected - def execute(entry, prompt): + def execute(entry, prompt, creation=None): sid = entry["id"] events = [] - with sessions.events.stream(sid, timeout=150) as stream: - sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": prompt}]}]}], - idempotency_key=str(uuid.uuid4())) + with (nullcontext(creation) if creation is not None else sessions.events.stream(sid, timeout=150)) as stream: + if creation is None: + sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": prompt}]}]}], + idempotency_key=str(uuid.uuid4())) for event in stream: events.append(event.type) assert event.type not in {"agent.session.failed", "agent.session.turn.failed", "agent.session.requires_action"}, event.type @@ -86,7 +89,7 @@ def reject_configuration(payload): if stage == "initial": sdk_agent = client.beta.agents.create(**config) assert [tool.to_dict() for tool in sdk_agent.tools] == expected - raw_agent = request("POST", "", json=config) + raw_agent = request("POST", "", status=201, json=config) assert raw_agent["tools"] == expected proof["agents"] = [sdk_agent.id, raw_agent["id"]] for aid in proof["agents"]: @@ -100,36 +103,44 @@ def reject_configuration(payload): ] for name, payload in payloads: payload["environment"] = {"type": "none"} - if name.startswith("sdk"): - sid = sessions.create(**payload).id - else: - sid = request("POST", "/sessions", json=payload)["id"] - entry = {"id": sid, "path": name, "marker": "TOOL-POLICY-" + uuid.uuid4().hex} - proof["sessions"].append(entry) - save() - check_config(sid) - execute(entry, "Remember this exact marker for later: " + entry["marker"] + ". Reply with that marker only.") + marker = "TOOL-POLICY-" + uuid.uuid4().hex + prompt = "Remember this exact marker for later: " + marker + ". Reply with that marker only." + payload.update(input=prompt, stream=True) + with ExitStack() as stack: + if name.startswith("sdk"): + creation = stack.enter_context(sessions.create(**payload)) + first = next(creation) + assert first.type == "agent.session.created" + sid = first.session.id + else: + response = stack.enter_context(raw.stream("POST", base + "/v1/agents/sessions", headers=headers, json=payload)) + assert response.status_code == 201 + frames = (json.loads(line[6:]) for line in response.iter_lines() if line.startswith("data: ")) + first = next(frames) + assert first["type"] == "agent.session.created" + sid = first["session"]["id"] + creation = (SimpleNamespace(type=frame["type"]) for frame in frames) + entry = {"id": sid, "path": name, "marker": marker} + proof["sessions"].append(entry) + save() + check_config(sid) + execute(entry, prompt, creation=creation) for enabled in [{"type": "web_search", "mode": "cached"}, {"type": "web_search", "mode": "live"}, {"type": "programmatic_tool_calling", "enabled": True}]: - reject_configuration({"agent": {"model": model, "tools": [enabled]}, "environment": {"type": "none"}}) - reject_configuration({"agent_id": sdk_agent.id, "agent": {"tools": [enabled]}, "environment": {"type": "none"}}) + reject_configuration({"agent": {"model": model, "tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) + reject_configuration({"agent_id": sdk_agent.id, "agent": {"tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) # Saving PTC intent is independent of Session execution qualification. enabled_agent = client.beta.agents.create(model=model, tools=[{"type": "programmatic_tool_calling", "enabled": True}]) - reject_configuration({"agent_id": enabled_agent.id, "environment": {"type": "none"}}) - payload = {"agent": {"model": model}, "environment": {"type": "none"}} - omitted = sessions.create(**payload) - assert omitted.agent.tools == [] - proof["omitted"].append(omitted.id) - omitted_raw = request("POST", "/sessions", json=payload) - assert omitted_raw["agent"]["tools"] == [] - proof["omitted"].append(omitted_raw["id"]) + reject_configuration({"agent_id": enabled_agent.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) + # Omitted-tool default projections are covered by the queued SDK/raw + # resource fixture; these live cases exercise explicit disabled tools. for aid in proof["agents"]: request("GET", "/" + aid, status=404, foreign_tenant=True) request("POST", "/sessions", status=404, foreign_tenant=True, - json={"agent_id": aid, "environment": {"type": "none"}}) + json={"agent_id": aid, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) try: - other.beta.agents.sessions.create(agent_id=aid, environment={"type": "none"}) + other.beta.agents.sessions.create(agent_id=aid, environment={"type": "none"}, input="Verify foreign Agent rejection.") raise AssertionError("Foreign tenant used a saved Agent") except NotFoundError: pass diff --git a/services/agents-api/tests/official_tool_search.py b/services/agents-api/tests/official_tool_search.py index 6e4ab7514..387a363ea 100644 --- a/services/agents-api/tests/official_tool_search.py +++ b/services/agents-api/tests/official_tool_search.py @@ -22,7 +22,7 @@ proof = {} if stage == "initial" else json.loads(Path(evidence).read_text()) -def run(session, name, cancel=False, images=False): +def run(session, name, cancel=False, images=False, creation_request=None): marker = "RESULT-" + str(uuid.uuid4()) events, handled = [], False prompt = "Call " + name + " exactly once, using the exact required ticket from its schema. Return only the fresh tool result. Discover its definition if needed." @@ -32,8 +32,18 @@ def run(session, name, cancel=False, images=False): if images: message["input"][0]["content"].append({"type":"input_image","image_url":picture(colors)}) message["input"][0]["content"].append({"type":"input_text","text":"Also remember these four band colors in left-to-right order."}) - with sessions.events.stream(session, timeout=150) as stream: - sessions.events.create(session, events=[message], idempotency_key=str(uuid.uuid4())) + if creation_request is not None: + creation = sessions.create(**creation_request, input=message["input"], stream=True) + session = next(creation).session.id + proof["cancel_session" if cancel else "session"] = session + if not cancel: + proof["initial_input"] = message["input"] + Path(evidence).write_text(json.dumps(proof, indent=2)) + else: + creation = None + with (creation or sessions.events.stream(session, timeout=150)) as stream: + if creation is None: + sessions.events.create(session, events=[message], idempotency_key=str(uuid.uuid4())) for event in stream: events.append(event.to_dict()) if event.type == "agent.session.requires_action": @@ -102,14 +112,16 @@ def run(session, name, cancel=False, images=False): for name, description, deferred in [("lookup_account","Return the account result.",True), ("clock","Return the current clock result.",False), ("unrelated_report","Read an unrelated report.",True)]] config = {"model":model,"tools":tools} saved = client.beta.agents.create(**config) - session = sessions.create(agent_id=saved.id, environment={"type":"none"}, extra_headers={"Idempotency-Key":"discovery-create"}) - assert sessions.create(agent_id=saved.id, environment={"type":"none"}, extra_headers={"Idempotency-Key":"discovery-create"}).id == session.id + request = {"agent_id": saved.id, "environment": {"type": "none"}, + "extra_headers": {"Idempotency-Key": "discovery-create"}} + proof.update(agent=saved.id, tools=tools) + run(None, "lookup_account", images=True, creation_request=request) + session = sessions.retrieve(proof["session"]) + assert sessions.create(**request, input=proof["initial_input"]).id == session.id assert [t.to_dict() for t in saved.tools] == tools assert [t.to_dict() for t in session.agent.tools] == tools[1:] - proof.update(session=session.id, agent=saved.id, tools=tools) - run(session.id, "lookup_account", images=True) run(session.id, "clock") - for action in [lambda:other.beta.agents.sessions.retrieve(session.id), lambda:other.beta.agents.sessions.create(agent_id=saved.id,environment={"type":"none"})]: + for action in [lambda:other.beta.agents.sessions.retrieve(session.id), lambda:other.beta.agents.sessions.create(agent_id=saved.id,environment={"type":"none"}, input="Verify tenant rejection.")]: try: action() raise AssertionError("foreign tenant accessed discovery configuration") @@ -117,7 +129,7 @@ def run(session, name, cancel=False, images=False): pass for invalid in [[tools[1]], [tools[0]], [tools[0],tools[0],tools[1]], [{"type":"tool_search","execution":"client"},tools[1]]]: try: - sessions.create(agent={"model":model,"tools":invalid}, environment={"type":"none"}) + sessions.create(agent={"model":model,"tools":invalid}, environment={"type":"none"}, input="Verify unqualified tool configuration rejection.") raise AssertionError("unqualified discovery configuration admitted") except BadRequestError: pass @@ -128,9 +140,8 @@ def run(session, name, cancel=False, images=False): assert len(sessions.turns.list(session.id).data) == 3 assert len([i for i in sessions.items.list(session.id,limit=100).data if i.type == "function_call"]) == 3 # Inline configuration exercises a fresh native Session and pending-call cancellation. - cancelled = sessions.create(agent={"model":model,"tools":proof["tools"]}, environment={"type":"none"}) - run(cancelled.id, "lookup_account", cancel=True) - proof["cancel_session"] = cancelled.id + run(None, "lookup_account", cancel=True, creation_request={ + "agent": {"model": model, "tools": proof["tools"]}, "environment": {"type": "none"}}) proof["passed"] = True finally: Path(evidence).write_text(json.dumps(proof, indent=2)) diff --git a/services/agents-api/tests/official_vault_delete.py b/services/agents-api/tests/official_vault_delete.py index 5266ffb51..b5846fe15 100644 --- a/services/agents-api/tests/official_vault_delete.py +++ b/services/agents-api/tests/official_vault_delete.py @@ -15,7 +15,7 @@ def verify_vault_deletion(client, other, invalid, peer, canary, expect_error): keyless = vaults.credentials.create(values[2].id, name="Keyless child", auth=auth) retained = vaults.credentials.create(values[3].id, name="Retained child", auth=auth) foreign_child = other.beta.agents.vaults.credentials.create(foreign.id, name="Foreign child", auth=auth) - spec = {"agent": {"model": "requested-model"}, "environment": {"type": "none"}, "vault_ids": [values[0].id, values[3].id]} + spec = {"input": "Verify vault delete fixture admission.", "agent": {"model": "requested-model"}, "environment": {"type": "none"}, "vault_ids": [values[0].id, values[3].id]} headers = {"Idempotency-Key": "vault-delete-" + str(uuid.uuid4())} session = client.beta.agents.sessions.create(**spec, extra_headers=headers) target = values[0] From 4981580d1f4cffe905fc8e66b97ab37093f79f3b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:39:37 +0800 Subject: [PATCH 10/11] Match tool policy evidence to execution-bearing Sessions --- .../agents-api/internal/store/tool_policy_native_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/agents-api/internal/store/tool_policy_native_test.go b/services/agents-api/internal/store/tool_policy_native_test.go index eef1559c8..74ad2eb5a 100644 --- a/services/agents-api/internal/store/tool_policy_native_test.go +++ b/services/agents-api/internal/store/tool_policy_native_test.go @@ -91,14 +91,13 @@ func TestNativeToolPolicyPublicExecution(t *testing.T) { ID string `json:"id"` FirstTurn string `json:"first_turn"` } `json:"sessions"` - Omitted []string `json:"omitted"` } raw, err = os.ReadFile(evidence) - if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Sessions) != 4 || len(proof.Omitted) != 2 { + if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Sessions) != 4 { t.Fatal("invalid public evidence", err) } page, err := h.s.ListSessions(ctx, h.tenant, "", 100, true, nil) - if err != nil || len(page.Sessions) != 1+len(proof.Sessions)+len(proof.Omitted) { + if err != nil || len(page.Sessions) != 1+len(proof.Sessions) { t.Fatal("rejected configuration persisted a Session", err) } foreignPage, err := h.s.ListSessions(ctx, foreignTenant, "", 100, true, nil) From c4c8e51edeebd7f1501a7c0040b08e1aed952d5e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 00:45:07 +0800 Subject: [PATCH 11/11] Record final Session admission checks and independent review --- .../agents-api/official-semantics-alignment.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 63ffc3b28..db2fe79ca 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -122,5 +122,17 @@ network forward were removed, and temporary devices were revoked. The integrated Web gate passed 287 client and 583 Web unit tests, type checks, builds and all 76 fixture browser cases. These controlled UI checks include empty-input prevention and same-key JSON recovery after a lost creation response; -they are separate from the real-model evidence. Final server gate and blind-review -results are recorded at batch closure. +they are separate from the real-model evidence. The server `make -o check-web check` +passed at `01f9356` with dedicated PostgreSQL, generated-query checks, Go service +and adapter tests/builds, and Rust tests/format/Clippy. The optional packaged +MiniMax native-tools probe was skipped; the separate real-model evidence above +qualifies this batch, not every native capability. Fixed Python SDK and Go-client +service acceptance also passed. The subsequent tool-policy evidence-count test +change compiled; its opt-in native run was not repeated. + +A fresh independent Astra high reviewer inspected all 66 changed files and found +no grounded in-scope blockers; API/contract tests, 39 focused Web tests and whitespace +checks passed independently. Rebase onto main `6a3131e` preserved every batch patch. +The combined tree at `4981580` passed API, execution, contract and dedicated-PostgreSQL +Environment scheduling/initial-input/creation-stream regressions. No new E2B, +OAuth provider or native capability combination was qualified.