on main help sign in
chore(server): apply asynchronous prompt contract to clients

Treat HTTP 202 as a successful prompt submission and preserve server-provided error messages for rejected prompts. Callers no longer see generic status errors when the server explains the failure. Co-Authored-By: Charm Crush <crush@charm.land>

commitd2e9e51fceddf5ed4c57c23bbb15f7a799e88892 authorChristian Rocha <christian@rocha.is> dateSun 31 May 2026, 23:14 (5 months ago) parentee94df0
2 files changed+87−1
internal/client/proto.go+17−1 internal/client/proto_test.go+70
internal/client/proto.go+17−1
@@ -423,12 +423,28 @@ func (c *Client) SendMessage(ctx context.Context, id string, sessionID, runID, p
423423 return fmt.Errorf("failed to send message to agent: %w", err)
424424 }
425425 defer rsp.Body.Close()
426− if rsp.StatusCode != http.StatusOK {
426+ if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusAccepted {
427+ if msg := decodeErrorMessage(rsp.Body); msg != "" {
428+ return fmt.Errorf("failed to send message to agent: status code %d: %s", rsp.StatusCode, msg)
429+ }
427430 return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
428431 }
429432 return nil
430433}
431434
435+// decodeErrorMessage attempts to decode the response body as a
436+// proto.Error and returns its message. It returns an empty string
437+// when the body is empty or cannot be decoded into a proto.Error
438+// with a non-empty message, letting callers fall back to a
439+// status-only error.
440+func decodeErrorMessage(body io.Reader) string {
441+ var e proto.Error
442+ if err := json.NewDecoder(body).Decode(&e); err != nil {
443+ return ""
444+ }
445+ return e.Message
446+}
447+
432448// GetAgentSessionInfo retrieves the agent session info for a workspace.
433449func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
434450 rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
internal/client/proto_test.go+70
@@ -88,6 +88,76 @@ func TestSubscribeEventsContextCancelClosesEvents(t *testing.T) {
8888 }
8989}
9090
91+func TestSendMessageAcceptsStatusAccepted(t *testing.T) {
92+ t.Parallel()
93+
94+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
95+ w.WriteHeader(http.StatusAccepted)
96+ }))
97+ defer srv.Close()
98+
99+ c := captureClient(t, srv)
100+ require.NoError(t, c.SendMessage(context.Background(), "ws1", "sess1", "", "hello"))
101+}
102+
103+func TestSendMessageAcceptsStatusOK(t *testing.T) {
104+ t.Parallel()
105+
106+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
107+ w.WriteHeader(http.StatusOK)
108+ }))
109+ defer srv.Close()
110+
111+ c := captureClient(t, srv)
112+ require.NoError(t, c.SendMessage(context.Background(), "ws1", "sess1", "", "hello"))
113+}
114+
115+func TestSendMessageDecodesErrorBody(t *testing.T) {
116+ t.Parallel()
117+
118+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
119+ w.WriteHeader(http.StatusBadRequest)
120+ _ = json.NewEncoder(w).Encode(proto.Error{Message: "session id is required"})
121+ }))
122+ defer srv.Close()
123+
124+ c := captureClient(t, srv)
125+ err := c.SendMessage(context.Background(), "ws1", "", "", "hello")
126+ require.Error(t, err)
127+ require.Contains(t, err.Error(), "status code 400")
128+ require.Contains(t, err.Error(), "session id is required")
129+}
130+
131+func TestSendMessageFallsBackOnMalformedErrorBody(t *testing.T) {
132+ t.Parallel()
133+
134+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
135+ w.WriteHeader(http.StatusInternalServerError)
136+ _, _ = w.Write([]byte("not json"))
137+ }))
138+ defer srv.Close()
139+
140+ c := captureClient(t, srv)
141+ err := c.SendMessage(context.Background(), "ws1", "sess1", "", "hello")
142+ require.Error(t, err)
143+ require.Contains(t, err.Error(), "status code 500")
144+ require.NotContains(t, err.Error(), "not json")
145+}
146+
147+func TestSendMessageFallsBackOnEmptyErrorBody(t *testing.T) {
148+ t.Parallel()
149+
150+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
151+ w.WriteHeader(http.StatusInternalServerError)
152+ }))
153+ defer srv.Close()
154+
155+ c := captureClient(t, srv)
156+ err := c.SendMessage(context.Background(), "ws1", "sess1", "", "hello")
157+ require.Error(t, err)
158+ require.Contains(t, err.Error(), "status code 500")
159+}
160+
91161func marshalSSEPayload(t *testing.T) []byte {
92162 t.Helper()
93163