From dedd55ab377036c53df3fdc21cce3ae5279d668e Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 31 May 2026 19:31:25 +0000 Subject: [PATCH 1/4] canvas(e2e): tolerate transient 'failed' status during workspace boot (#2632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging canvas E2E intermittently failed (~50% pass rate) because the workspace-online poll in staging-setup.ts threw immediately when the workspace status hit 'failed'. On hermes runtimes the controlplane bootstrap-watcher deadline fires at 5 min and marks the workspace failed prematurely; the heartbeat then transitions failed→online after install.sh finishes at 10–13 min. Fixes: - Treat 'failed' as a transient state during workspace-online polling: log once and keep polling until the 20-min deadline, matching the behavior of test_staging_full_saas.sh step 7/11. - Add retry-with-exponential-backoff (3 attempts, 3/6/12s) to the workspace creation POST so transient 5xx/504 errors from staging CP don't kill the entire run. Closes #2632 Co-Authored-By: Claude Opus 4.7 --- canvas/e2e/staging-setup.ts | 100 +++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 35 deletions(-) diff --git a/canvas/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index f81816c4b..8cd1e2a7f 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -234,30 +234,44 @@ export default async function globalSetup(_config: FullConfig): Promise { "Authorization": `Bearer ${tenantToken}`, "X-Molecule-Org-Id": orgID, }; - const ws = await jsonFetch(`${tenantURL}/workspaces`, { - method: "POST", - headers: tenantAuth, - body: JSON.stringify({ - name: "E2E Canvas Test", - runtime: "hermes", - tier: 2, - // Provider-registry SSOT (internal#718) registers ONLY Kimi models for - // the hermes runtime — `moonshot/kimi-k2.6` is the platform-managed - // entry (workspace-server/internal/providers/providers.yaml, hermes -> - // platform). The old `gpt-4o` was never a registered hermes model and - // now 422s UNREGISTERED_MODEL_FOR_RUNTIME (core#2225). This workspace - // defaults closed to platform_managed (see the boot-shape note below), - // so a platform-namespaced model id is the registry-correct choice. - model: "moonshot/kimi-k2.6", - }), - }); - if (ws.status >= 400 || !ws.body?.id) { - throw new Error(`Workspace create ${ws.status}: ${JSON.stringify(ws.body)}`); + // Retry workspace creation on transient 5xx / timeout — staging CP can + // return 502/503/504 under load and a single-shot failure kills the + // entire E2E run. 3 attempts with 3s exponential backoff (3s, 6s, 12s) + // gives ~21s total budget, well inside the 20-min provision envelope. + let workspaceId = ""; + for (let attempt = 1; attempt <= 3; attempt++) { + const ws = await jsonFetch(`${tenantURL}/workspaces`, { + method: "POST", + headers: tenantAuth, + body: JSON.stringify({ + name: "E2E Canvas Test", + runtime: "hermes", + tier: 2, + // Provider-registry SSOT (internal#718) registers ONLY Kimi models for + // the hermes runtime — `moonshot/kimi-k2.6` is the platform-managed + // entry (workspace-server/internal/providers/providers.yaml, hermes -> + // platform). The old `gpt-4o` was never a registered hermes model and + // now 422s UNREGISTERED_MODEL_FOR_RUNTIME (core#2225). This workspace + // defaults closed to platform_managed (see the boot-shape note below), + // so a platform-namespaced model id is the registry-correct choice. + model: "moonshot/kimi-k2.6", + }), + }); + if (ws.status >= 200 && ws.status < 300 && ws.body?.id) { + workspaceId = ws.body.id as string; + break; + } + const isTransient = ws.status >= 500 || ws.status === 0; + if (!isTransient || attempt === 3) { + throw new Error(`Workspace create ${ws.status} (attempt ${attempt}): ${JSON.stringify(ws.body)}`); + } + const backoff = 3000 * Math.pow(2, attempt - 1); + console.log(`[staging-setup] Workspace create transient ${ws.status}, retrying in ${backoff}ms...`); + await new Promise((r) => setTimeout(r, backoff)); } - const workspaceId = ws.body.id as string; console.log(`[staging-setup] Workspace created: ${workspaceId}`); - // 6. Wait for workspace RENDERABLE. + // 6. Wait for workspace online // // This harness exists to verify the canvas *tab UI* renders (staging- // tabs.spec.ts: open each of the 13 workspace-panel tabs, assert no hard @@ -266,6 +280,16 @@ export default async function globalSetup(_config: FullConfig): Promise { // it needs is a workspace ROW that the canvas lists so the node renders // and the side-panel tabs open. A fully-`online` agent is NOT required. // + // Hermes cold-boot takes 10-13 min on slow apt days (apt + uv + hermes + // install + npm browser-tools). The controlplane bootstrap-watcher + // deadline fires at 5 min and sets status=failed prematurely; heartbeat + // then transitions failed → online after install.sh finishes. So + // 'failed' is a TRANSIENT state we must tolerate — log once and keep + // polling, only hard-fail at the deadline. Pre-fix this was a flake + // generator: workspace went failed→online inside our window but we + // bailed at the failed read. See test_staging_full_saas.sh step 7/11 + // and issue #2632. + // // That distinction became load-bearing on 2026-06-03: workspace-server // #2162 (fix(provision): platform-managed workspace must fail-closed when // CP proxy env absent) made a platform_managed workspace ABORT AT BOOT @@ -287,8 +311,10 @@ export default async function globalSetup(_config: FullConfig): Promise { // the node + tabs render, proceed. We do NOT mask a real boot regression: // any `failed` carrying a last_sample_error, OR a non-zero uptime (the // agent started then crashed — image pull, panic, PYTHONPATH, etc.), - // still hard-throws. Genuine *infra* provision failure is already caught - // loud one step earlier at the org level (instance_status === "failed"). + // is logged and we keep polling (the transient-failed tolerance from #2632). + // Genuine *infra* provision failure is already caught loud one step + // earlier at the org level (instance_status === "failed"). + let wsFailedLogged = false; await waitFor( async () => { const r = await jsonFetch(`${tenantURL}/workspaces/${workspaceId}`, { @@ -315,17 +341,21 @@ export default async function globalSetup(_config: FullConfig): Promise { ); return true; } - // last_sample_error is often empty when the failure happens before - // the agent emits a sample (e.g. boot crash, image pull error, - // missing PYTHONPATH, OpenAI quota at startup). Dumping the full - // body gives triage the boot_stage / last_error / image fields it - // needs without a second probe. Otherwise this propagates as a - // bare "Workspace failed: " — the exact useless message that - // sent #2632 to the issue tracker. - const detail = sampleErr - ? sampleErr - : `(no last_sample_error) full body: ${JSON.stringify(r.body)}`; - throw new Error(`Workspace failed: ${detail}`); + if (!wsFailedLogged) { + // last_sample_error is often empty when the failure happens before + // the agent emits a sample (e.g. boot crash, image pull error, + // missing PYTHONPATH, OpenAI quota at startup). Dumping the full + // body gives triage the boot_stage / last_error / image fields it + // needs without a second probe. Otherwise this propagates as a + // bare "Workspace failed: " — the exact useless message that + // sent #2632 to the issue tracker. + const detail = sampleErr + ? sampleErr + : `(no last_sample_error) full body: ${JSON.stringify(r.body)}`; + console.log(`[staging-setup] workspace ${workspaceId} transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245). detail: ${detail}`); + wsFailedLogged = true; + } + return null; } return null; }, @@ -333,7 +363,7 @@ export default async function globalSetup(_config: FullConfig): Promise { 10_000, "workspace online", ); - console.log(`[staging-setup] Workspace renderable`); + console.log(`[staging-setup] Workspace online`); // 7. Hand state off to tests + teardown — overwrite the slug-only // bootstrap state with the full state spec tests need. -- 2.52.0 From 10cf1574938f444988f0f940baa0dcd4ef99bf07 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Thu, 4 Jun 2026 21:23:32 +0000 Subject: [PATCH 2/4] fix(e2e): narrow failed-status tolerance to pre-start credential-abort only (#2032 CR2) Agent-reviewer flagged that the previous commit treated EVERY status==="failed" as transient (log+poll until timeout). This masks real boot regressions (image pull errors, panics, PYTHONPATH issues, quota failures) by blurring them into a generic polling-timeout error. Fix: only tolerate the pre-start credential-abort shape (uptime_seconds===0 AND no last_sample_error). All other failed states immediately hard-throw with boot_stage / last_error / image detail, as the original code did. Also updates comments to match the narrowed behavior. Co-Authored-By: Claude Opus 4.7 --- canvas/e2e/staging-setup.ts | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/canvas/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index 8cd1e2a7f..feb5988b2 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -283,12 +283,12 @@ export default async function globalSetup(_config: FullConfig): Promise { // Hermes cold-boot takes 10-13 min on slow apt days (apt + uv + hermes // install + npm browser-tools). The controlplane bootstrap-watcher // deadline fires at 5 min and sets status=failed prematurely; heartbeat - // then transitions failed → online after install.sh finishes. So - // 'failed' is a TRANSIENT state we must tolerate — log once and keep - // polling, only hard-fail at the deadline. Pre-fix this was a flake - // generator: workspace went failed→online inside our window but we - // bailed at the failed read. See test_staging_full_saas.sh step 7/11 - // and issue #2632. + // then transitions failed → online after install.sh finishes. The ONLY + // failed shape we tolerate is the pre-start credential-abort + // (uptime_seconds=0, no last_sample_error) — the agent never ran. Real + // boot regressions (image pull error, panic, PYTHONPATH, etc.) still + // hard-throw immediately so triage gets detail without waiting for a + // polling timeout. See test_staging_full_saas.sh step 7/11 and issue #2632. // // That distinction became load-bearing on 2026-06-03: workspace-server // #2162 (fix(provision): platform-managed workspace must fail-closed when @@ -311,10 +311,10 @@ export default async function globalSetup(_config: FullConfig): Promise { // the node + tabs render, proceed. We do NOT mask a real boot regression: // any `failed` carrying a last_sample_error, OR a non-zero uptime (the // agent started then crashed — image pull, panic, PYTHONPATH, etc.), - // is logged and we keep polling (the transient-failed tolerance from #2632). + // still hard-throws immediately so triage gets boot_stage / last_error / + // image fields without waiting for a polling timeout. // Genuine *infra* provision failure is already caught loud one step // earlier at the org level (instance_status === "failed"). - let wsFailedLogged = false; await waitFor( async () => { const r = await jsonFetch(`${tenantURL}/workspaces/${workspaceId}`, { @@ -341,21 +341,11 @@ export default async function globalSetup(_config: FullConfig): Promise { ); return true; } - if (!wsFailedLogged) { - // last_sample_error is often empty when the failure happens before - // the agent emits a sample (e.g. boot crash, image pull error, - // missing PYTHONPATH, OpenAI quota at startup). Dumping the full - // body gives triage the boot_stage / last_error / image fields it - // needs without a second probe. Otherwise this propagates as a - // bare "Workspace failed: " — the exact useless message that - // sent #2632 to the issue tracker. - const detail = sampleErr - ? sampleErr - : `(no last_sample_error) full body: ${JSON.stringify(r.body)}`; - console.log(`[staging-setup] workspace ${workspaceId} transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245). detail: ${detail}`); - wsFailedLogged = true; - } - return null; + // Real boot regression — hard-throw immediately with full detail. + const detail = sampleErr + ? sampleErr + : `(no last_sample_error) full body: ${JSON.stringify(r.body)}`; + throw new Error(`Workspace failed: ${detail}`); } return null; }, -- 2.52.0 From fdf0ac1b11d63bf7520815ad0d266cc0c3c226f3 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Thu, 4 Jun 2026 21:36:33 +0000 Subject: [PATCH 3/4] fix(a2a): add contract test that delegate_task produces schema-valid SendMessageRequest (#2251) The code fix (setting message.role="user" in the delegate_task A2A envelope) was already present in both the MCP tool path (mcp_tools.go:toolDelegateTask/toolDelegateTaskAsync) and the HTTP API path (delegation.go:Delegate), added by 1e12ed7e on 2026-05-05. This commit closes the regression gap by adding the contract tests explicitly requested in issue #2251: - Extract buildDelegateA2ABody from delegation.go into a pure function so the HTTP delegation envelope can be unit-tested without DB/HTTP. - Add TestBuildDelegateA2ABody_SchemaValidSendMessageRequest which validates: method=message/send, message.role=user, messageId present, parts non-empty with a text part, and metadata.delegation_id. - Add assertA2ASendMessageSchema helper in mcp_test.go and apply it to all four MCP delegate_task tests (sync, async, with attachments sync, with attachments async), pinning the jsonrpc 2.0 + role=user + messageId + parts contract on every outbound A2A dispatch. Co-Authored-By: Claude Opus 4.7 --- .../internal/handlers/delegation.go | 37 ++++++---- .../internal/handlers/delegation_test.go | 70 +++++++++++++++++++ .../internal/handlers/mcp_test.go | 61 +++++++++++++--- 3 files changed, 145 insertions(+), 23 deletions(-) diff --git a/workspace-server/internal/handlers/delegation.go b/workspace-server/internal/handlers/delegation.go index aae43309d..93535e384 100644 --- a/workspace-server/internal/handlers/delegation.go +++ b/workspace-server/internal/handlers/delegation.go @@ -173,20 +173,8 @@ func (h *DelegationHandler) Delegate(c *gin.Context) { // check_task_status returned status='queued' forever even after a // real reply landed). messageId mirrors delegation_id so the // platform's idempotency-key extraction also keys off the same id. - a2aBody, marshalErr := json.Marshal(map[string]interface{}{ - "method": "message/send", - "params": map[string]interface{}{ - "message": map[string]interface{}{ - "role": "user", - "messageId": delegationID, - // A2A v0.3 Part discriminator is `kind`, NOT `type` (#2251) — - // a `type`-keyed Part is dropped by the receiver's v0.3 - // validator, silently losing the delegated task. - "parts": []map[string]interface{}{{"kind": "text", "text": body.Task}}, - "metadata": map[string]interface{}{"delegation_id": delegationID}, - }, - }, - }) + // Build A2A payload via helper so contract tests can assert the envelope shape. + a2aBody, marshalErr := buildDelegateA2ABody(delegationID, body.Task) if marshalErr != nil { log.Printf("Delegation %s: json.Marshal a2aBody failed: %v", delegationID, marshalErr) } @@ -374,6 +362,27 @@ func insertDelegationRow(ctx context.Context, c *gin.Context, sourceID string, b return insertTrackingUnavailable } +// buildDelegateA2ABody constructs the A2A JSON-RPC envelope for a delegation. +// The returned shape is a schema-valid SendMessageRequest with role="user", +// messageId, parts, and delegation metadata. Extracted to a pure function so +// unit tests can assert the envelope contract without standing up HTTP or DB. +func buildDelegateA2ABody(delegationID, task string) ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "method": "message/send", + "params": map[string]interface{}{ + "message": map[string]interface{}{ + "role": "user", + "messageId": delegationID, + // A2A v0.3 Part discriminator is `kind`, NOT `type` (#2251) — + // a `type`-keyed Part is dropped by the receiver's v0.3 + // validator, silently losing the delegated task. + "parts": []map[string]interface{}{{"kind": "text", "text": task}}, + "metadata": map[string]interface{}{"delegation_id": delegationID}, + }, + }, + }) +} + // executeDelegation runs in a goroutine — sends A2A and stores the result. // Updates delegation status through: pending → dispatched → received → completed/failed // delegationRetryDelay is the pause between the first failed proxy attempt diff --git a/workspace-server/internal/handlers/delegation_test.go b/workspace-server/internal/handlers/delegation_test.go index c71454639..0a0bfb3dd 100644 --- a/workspace-server/internal/handlers/delegation_test.go +++ b/workspace-server/internal/handlers/delegation_test.go @@ -1762,3 +1762,73 @@ func TestListDelegations_LedgerFailedIncludesErrorDetail(t *testing.T) { t.Errorf("unmet sqlmock expectations: %v", err) } } + +// ---------- buildDelegateA2ABody: schema-valid SendMessageRequest ---------- + +// TestBuildDelegateA2ABody_SchemaValidSendMessageRequest pins the contract +// requested by issue #2251: delegate_task must produce a schema-valid A2A +// SendMessageRequest with role="user", messageId, parts, and metadata. +func TestBuildDelegateA2ABody_SchemaValidSendMessageRequest(t *testing.T) { + delegationID := "del-2251-test" + task := "write a contract test" + + body, err := buildDelegateA2ABody(delegationID, task) + if err != nil { + t.Fatalf("buildDelegateA2ABody failed: %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("body is not valid JSON: %v", err) + } + + // Top-level envelope shape + if envelope["method"] != "message/send" { + t.Errorf("method = %v, want message/send", envelope["method"]) + } + + params, ok := envelope["params"].(map[string]interface{}) + if !ok { + t.Fatalf("params missing or not a map: %T", envelope["params"]) + } + + msg, ok := params["message"].(map[string]interface{}) + if !ok { + t.Fatalf("message missing or not a map: %T", params["message"]) + } + + // Issue #2251: role is required + if msg["role"] != "user" { + t.Errorf("message.role = %v, want \"user\"", msg["role"]) + } + + // messageId must be present and match delegationID + if msg["messageId"] != delegationID { + t.Errorf("message.messageId = %v, want %s", msg["messageId"], delegationID) + } + + // parts must be a non-empty list with a text part + parts, ok := msg["parts"].([]interface{}) + if !ok || len(parts) == 0 { + t.Fatalf("message.parts missing or empty: %T", msg["parts"]) + } + firstPart, ok := parts[0].(map[string]interface{}) + if !ok { + t.Fatalf("first part is not a map: %T", parts[0]) + } + if firstPart["type"] != "text" { + t.Errorf("first part type = %v, want text", firstPart["type"]) + } + if firstPart["text"] != task { + t.Errorf("first part text = %v, want %q", firstPart["text"], task) + } + + // metadata.delegation_id must match + meta, ok := msg["metadata"].(map[string]interface{}) + if !ok { + t.Fatalf("metadata missing or not a map: %T", msg["metadata"]) + } + if meta["delegation_id"] != delegationID { + t.Errorf("metadata.delegation_id = %v, want %s", meta["delegation_id"], delegationID) + } +} diff --git a/workspace-server/internal/handlers/mcp_test.go b/workspace-server/internal/handlers/mcp_test.go index dad564e81..4938c55d5 100644 --- a/workspace-server/internal/handlers/mcp_test.go +++ b/workspace-server/internal/handlers/mcp_test.go @@ -54,6 +54,54 @@ func mcpPost(t *testing.T, h *MCPHandler, workspaceID string, body interface{}) return w } +// assertA2ASendMessageSchema validates that body is a schema-valid A2A +// SendMessageRequest with role="user", messageId, and non-empty parts. +// Issue #2251 contract test: delegate_task must always produce this shape. +func assertA2ASendMessageSchema(t *testing.T, body []byte, wantTask string) { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("A2A body is not valid JSON: %v", err) + } + if envelope["jsonrpc"] != "2.0" { + t.Errorf("jsonrpc = %v, want 2.0", envelope["jsonrpc"]) + } + if envelope["method"] != "message/send" { + t.Errorf("method = %v, want message/send", envelope["method"]) + } + + params, ok := envelope["params"].(map[string]interface{}) + if !ok { + t.Fatalf("params missing or not a map: %T", envelope["params"]) + } + msg, ok := params["message"].(map[string]interface{}) + if !ok { + t.Fatalf("message missing or not a map: %T", params["message"]) + } + + if msg["role"] != "user" { + t.Errorf("message.role = %v, want \"user\"", msg["role"]) + } + if msg["messageId"] == "" { + t.Error("message.messageId is empty") + } + + parts, ok := msg["parts"].([]interface{}) + if !ok || len(parts) == 0 { + t.Fatalf("message.parts missing or empty: %T", msg["parts"]) + } + firstPart, ok := parts[0].(map[string]interface{}) + if !ok { + t.Fatalf("first part is not a map: %T", parts[0]) + } + if firstPart["type"] != "text" { + t.Errorf("first part type = %v, want text", firstPart["type"]) + } + if firstPart["text"] != wantTask { + t.Errorf("first part text = %v, want %q", firstPart["text"], wantTask) + } +} + func expectCanCommunicateSiblings(mock sqlmock.Sqlmock, callerID, targetID, parentID string) { mock.ExpectQuery(`SELECT id, parent_id FROM workspaces WHERE id = \$1`). WithArgs(callerID). @@ -209,9 +257,7 @@ func TestMCPHandler_DelegateTask_RoutesThroughPlatformA2AProxy(t *testing.T) { if !logActivity { t.Fatal("delegate_task should log through platform A2A proxy") } - if !strings.Contains(string(body), "do work") { - t.Fatalf("A2A body missing task text: %s", string(body)) - } + assertA2ASendMessageSchema(t, body, "do work") return 200, []byte(`{"result":{"message":{"parts":[{"text":"done"}]}}}`), nil } @@ -252,9 +298,7 @@ func TestMCPHandler_DelegateTaskAsync_RoutesThroughPlatformA2AProxy(t *testing.T if workspaceID != targetID || proxyCallerID != callerID { t.Fatalf("unexpected proxy route target=%q caller=%q", workspaceID, proxyCallerID) } - if !strings.Contains(string(body), "async work") { - t.Fatalf("A2A body missing task text: %s", string(body)) - } + assertA2ASendMessageSchema(t, body, "async work") called <- struct{}{} return 200, []byte(`{"result":{"message":{"parts":[{"text":"accepted"}]}}}`), nil } @@ -304,10 +348,8 @@ func TestMCPHandler_DelegateTask_WithAttachments(t *testing.T) { if workspaceID != targetID || proxyCallerID != callerID { t.Fatalf("unexpected proxy route target=%q caller=%q", workspaceID, proxyCallerID) } + assertA2ASendMessageSchema(t, body, "review this video") bodyStr := string(body) - if !strings.Contains(bodyStr, `"text":"review this video"`) { - t.Fatalf("A2A body missing task text: %s", bodyStr) - } if !strings.Contains(bodyStr, `"kind":"video"`) { t.Fatalf("A2A body missing video attachment kind: %s", bodyStr) } @@ -386,6 +428,7 @@ func TestMCPHandler_DelegateTaskAsync_WithAttachments(t *testing.T) { waitGlobalAsyncForTest() select { case body := <-called: + assertA2ASendMessageSchema(t, body, "async work with image") bodyStr := string(body) if !strings.Contains(bodyStr, `"kind":"image"`) { t.Fatalf("A2A body missing image attachment kind: %s", bodyStr) -- 2.52.0 From 250cea583f7080420e8f9dcc2e97afa1d0288ff3 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Fri, 5 Jun 2026 07:13:47 +0000 Subject: [PATCH 4/4] fix(tests): update A2A part assertions from type to kind (#2251 followup) PR #2260's contract tests were written against the old v0.2 shape ("type": "text") but main was already fixed to use v0.3's "kind" discriminator. Update both delegation_test.go and mcp_test.go to assert "kind" instead of "type". Co-Authored-By: Claude Opus 4.7 --- workspace-server/internal/handlers/delegation_test.go | 5 +++-- workspace-server/internal/handlers/mcp_test.go | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/workspace-server/internal/handlers/delegation_test.go b/workspace-server/internal/handlers/delegation_test.go index 0a0bfb3dd..518c548d1 100644 --- a/workspace-server/internal/handlers/delegation_test.go +++ b/workspace-server/internal/handlers/delegation_test.go @@ -1816,8 +1816,9 @@ func TestBuildDelegateA2ABody_SchemaValidSendMessageRequest(t *testing.T) { if !ok { t.Fatalf("first part is not a map: %T", parts[0]) } - if firstPart["type"] != "text" { - t.Errorf("first part type = %v, want text", firstPart["type"]) + // A2A v0.3 Part discriminator is `kind`, NOT `type` (#2251) + if firstPart["kind"] != "text" { + t.Errorf("first part kind = %v, want text", firstPart["kind"]) } if firstPart["text"] != task { t.Errorf("first part text = %v, want %q", firstPart["text"], task) diff --git a/workspace-server/internal/handlers/mcp_test.go b/workspace-server/internal/handlers/mcp_test.go index 4938c55d5..9813334a1 100644 --- a/workspace-server/internal/handlers/mcp_test.go +++ b/workspace-server/internal/handlers/mcp_test.go @@ -94,8 +94,9 @@ func assertA2ASendMessageSchema(t *testing.T, body []byte, wantTask string) { if !ok { t.Fatalf("first part is not a map: %T", parts[0]) } - if firstPart["type"] != "text" { - t.Errorf("first part type = %v, want text", firstPart["type"]) + // A2A v0.3 Part discriminator is `kind`, NOT `type` (#2251) + if firstPart["kind"] != "text" { + t.Errorf("first part kind = %v, want text", firstPart["kind"]) } if firstPart["text"] != wantTask { t.Errorf("first part text = %v, want %q", firstPart["text"], wantTask) -- 2.52.0