From 68ef16494e94b6e6f60b2d939f3c3ad5d8333230 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 05:34:10 +0000 Subject: [PATCH 1/3] test(handlers): add coverage for ProviderEndpointGone Adds a single unit test pinning the retired-provider endpoint contract: 410 Gone with code PROVIDER_ENDPOINT_RETIRED and the expected issue tag. No product code changes. Co-Authored-By: Claude --- .../handlers/provider_endpoint_gone_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 workspace-server/internal/handlers/provider_endpoint_gone_test.go diff --git a/workspace-server/internal/handlers/provider_endpoint_gone_test.go b/workspace-server/internal/handlers/provider_endpoint_gone_test.go new file mode 100644 index 000000000..ad9e2d226 --- /dev/null +++ b/workspace-server/internal/handlers/provider_endpoint_gone_test.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// ProviderEndpointGone is wired for both GET and PUT +// /workspaces/:id/provider. The handler has no side effects and no +// dependencies, so a single test pins the retirement contract. +func TestProviderEndpointGone(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/provider", nil) + + ProviderEndpointGone(c) + + if w.Code != http.StatusGone { + t.Fatalf("expected 410, got %d: %s", w.Code, w.Body.String()) + } + + var body struct { + Code string `json:"code"` + Error string `json:"error"` + Issue string `json:"issue"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("body parse: %v", err) + } + if body.Code != "PROVIDER_ENDPOINT_RETIRED" { + t.Errorf("code: expected PROVIDER_ENDPOINT_RETIRED, got %q", body.Code) + } + if body.Issue != "internal#718" { + t.Errorf("issue: expected internal#718, got %q", body.Issue) + } + if body.Error == "" { + t.Errorf("error message should be present") + } +} -- 2.52.0 From 1de8670fe7ef2c7d40dd8df9aa1c936b0587732d Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 06:32:28 +0000 Subject: [PATCH 2/3] test(handlers): wait for async cleanup in TestProxyA2A_CanvasCapAndQueue The cap-and-queue path dispatches the agent call on a detached goroutine that logs to activity_logs after the HTTP response returns. Without waitForHandlerAsyncBeforeDBCleanup the test could hit a data race and an unexpected sqlmock INSERT after expectations were met. Fixes the red Platform (Go) run that blocked PR #2826. Co-Authored-By: Claude --- workspace-server/internal/handlers/a2a_proxy_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/workspace-server/internal/handlers/a2a_proxy_test.go b/workspace-server/internal/handlers/a2a_proxy_test.go index a89312f89..3d7c5393b 100644 --- a/workspace-server/internal/handlers/a2a_proxy_test.go +++ b/workspace-server/internal/handlers/a2a_proxy_test.go @@ -2976,6 +2976,7 @@ func TestProxyA2A_CanvasCapAndQueue(t *testing.T) { allowLoopbackForTest(t) broadcaster := newTestBroadcaster() handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + waitForHandlerAsyncBeforeDBCleanup(t, handler) // Agent that holds the connection PAST the budget (bounded sleep — no // deadlock with agentServer.Close()). 600ms >> the 100ms budget, so the -- 2.52.0 From a24f407b0ec0d8a809d2a0f448393a3fa76648de Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 07:08:34 +0000 Subject: [PATCH 3/3] fix(handlers): #2826 two-part race fix for cap-and-queue + recordingBroadcaster (A) Track the cap-and-queue goroutine on h.asyncWG so waitForHandlerAsyncBeforeDBCleanup blocks on it, preventing nil-DB/use-after-close panics in tests. (B) Add sync.Mutex to recordingBroadcaster, lock the .calls append in BroadcastOnly, and expose snapshotCalls() for race-safe reads in TestProxyA2A_CanvasCapAndQueue. Co-Authored-By: Claude --- workspace-server/internal/handlers/a2a_proxy.go | 2 ++ .../internal/handlers/a2a_proxy_test.go | 12 ++++++------ .../internal/handlers/activity_test.go | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/workspace-server/internal/handlers/a2a_proxy.go b/workspace-server/internal/handlers/a2a_proxy.go index 7c32526b4..bbcc4f4a4 100644 --- a/workspace-server/internal/handlers/a2a_proxy.go +++ b/workspace-server/internal/handlers/a2a_proxy.go @@ -415,7 +415,9 @@ func (h *WorkspaceHandler) ProxyA2A(c *gin.Context) { detached := context.WithoutCancel(ctx) budget := canvasA2ASyncBudget() // local copy for the time.After below done := make(chan a2aResult, 1) + h.asyncWG.Add(1) go func() { + defer h.asyncWG.Done() s, b, pe := h.proxyA2ARequest(detached, workspaceID, body, callerID, true, isCanvasUser) done <- a2aResult{s, b, pe} }() diff --git a/workspace-server/internal/handlers/a2a_proxy_test.go b/workspace-server/internal/handlers/a2a_proxy_test.go index 3d7c5393b..da1a06cab 100644 --- a/workspace-server/internal/handlers/a2a_proxy_test.go +++ b/workspace-server/internal/handlers/a2a_proxy_test.go @@ -3258,7 +3258,7 @@ func TestProxyA2A_CanvasCapAndQueue_EndToEndContract(t *testing.T) { var sawA2AResponse bool var sawResponseBodyContent bool for time.Now().Before(deadline) { - for _, c := range rec.calls { + for _, c := range rec.snapshotCalls() { if c.eventType == "A2A_RESPONSE" && c.workspaceID == "ws-e2e" { // Assert the originating message_id is carried so the // canvas WS handler can attach the reply to the right @@ -3290,10 +3290,10 @@ func TestProxyA2A_CanvasCapAndQueue_EndToEndContract(t *testing.T) { time.Sleep(20 * time.Millisecond) } if !sawA2AResponse { - t.Fatalf("expected A2A_RESPONSE broadcast for ws-e2e with message_id=msg-e2e-001 within 2s; recorded: %+v", rec.calls) + t.Fatalf("expected A2A_RESPONSE broadcast for ws-e2e with message_id=msg-e2e-001 within 2s; recorded: %+v", rec.snapshotCalls()) } if !sawResponseBodyContent { - t.Fatalf("expected A2A_RESPONSE payload to carry the agent's actual reply content (`reply:\"hello\"`) so the canvas can render it; recorded: %+v", rec.calls) + t.Fatalf("expected A2A_RESPONSE payload to carry the agent's actual reply content (`reply:\"hello\"`) so the canvas can render it; recorded: %+v", rec.snapshotCalls()) } } @@ -3320,13 +3320,13 @@ func TestLogA2ASuccess_BroadcastsForCanvasUser(t *testing.T) { time.Sleep(80 * time.Millisecond) got := false - for _, c := range rec.calls { + for _, c := range rec.snapshotCalls() { if c.eventType == "A2A_RESPONSE" && c.workspaceID == "ws-cu" { got = true } } if !got { - t.Fatalf("expected A2A_RESPONSE broadcast for authenticated canvas user; recorded: %+v", rec.calls) + t.Fatalf("expected A2A_RESPONSE broadcast for authenticated canvas user; recorded: %+v", rec.snapshotCalls()) } } @@ -3346,7 +3346,7 @@ func TestLogA2ASuccess_NoBroadcastForWorkspaceCaller(t *testing.T) { handler.logA2ASuccess(context.Background(), "ws-peer", "ws-other", false, []byte(`{}`), []byte(`{"result":"x"}`), "message/send", 200, 12) time.Sleep(80 * time.Millisecond) - for _, c := range rec.calls { + for _, c := range rec.snapshotCalls() { if c.eventType == "A2A_RESPONSE" { t.Fatalf("unexpected A2A_RESPONSE broadcast for a workspace-to-workspace caller") } diff --git a/workspace-server/internal/handlers/activity_test.go b/workspace-server/internal/handlers/activity_test.go index d2b72cdd7..00875bc36 100644 --- a/workspace-server/internal/handlers/activity_test.go +++ b/workspace-server/internal/handlers/activity_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" @@ -849,6 +850,7 @@ func TestScanSessionSearchRows_RowsErrPropagates(t *testing.T) { // recordingBroadcaster records every BroadcastOnly invocation so a test // can assert what made it onto the wire. Implements events.EventEmitter. type recordingBroadcaster struct { + mu sync.Mutex calls []recordedBroadcast } @@ -867,6 +869,8 @@ func (c *recordingBroadcaster) BroadcastOnly(workspaceID string, eventType strin // what hub.Broadcast does before sending). json.RawMessage values in // the source payload survive the round-trip as their underlying JSON. raw, err := json.Marshal(payload) + c.mu.Lock() + defer c.mu.Unlock() if err != nil { c.calls = append(c.calls, recordedBroadcast{workspaceID, eventType, nil}) return @@ -879,6 +883,17 @@ func (c *recordingBroadcaster) BroadcastOnly(workspaceID string, eventType strin c.calls = append(c.calls, recordedBroadcast{workspaceID, eventType, out}) } +// snapshotCalls returns a copy of the recorded calls under the mutex so +// tests can assert concurrently with BroadcastOnly without triggering the +// -race detector. +func (c *recordingBroadcaster) snapshotCalls() []recordedBroadcast { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]recordedBroadcast, len(c.calls)) + copy(out, c.calls) + return out +} + // TestLogActivity_Broadcast_IncludesRequestAndResponseBodies pins the // fix for the canvas Agent Comms "Delegating to " boilerplate // regression: without request_body/response_body in the live broadcast, -- 2.52.0