chore(server): report background prompt failures via events

Christian Rocha and Charm Crush created

Add a prompt failure event that can be delivered after the HTTP request
has already returned. Remote clients and subscribers now receive the
same failure message through the event stream.

Co-Authored-By: Charm Crush <crush@charm.land>

Change summary

internal/agent/notify/notify.go        |  6 ++++++
internal/server/events.go              | 18 ++++++++++++------
internal/workspace/client_workspace.go | 16 ++++++++++------
3 files changed, 28 insertions(+), 12 deletions(-)

Detailed changes

internal/agent/notify/notify.go 🔗

@@ -12,6 +12,9 @@ const (
 	// TypeReAuthenticate indicates the agent encountered an
 	// authentication error and the user needs to re-authenticate.
 	TypeReAuthenticate Type = "re_authenticate"
+	// TypeAgentError indicates the agent's turn terminated with an
+	// error. The error text is carried in Notification.Message.
+	TypeAgentError Type = "error"
 )
 
 // Notification represents a domain event published by the agent.
@@ -20,6 +23,9 @@ type Notification struct {
 	SessionTitle string
 	Type         Type
 	ProviderID   string
+	// Message carries the error text for TypeAgentError. Other
+	// notification types ignore it.
+	Message string
 }
 
 // RunComplete is the authoritative end-of-run signal for a session.

internal/server/events.go 🔗

@@ -2,6 +2,7 @@ package server
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"log/slog"
 
@@ -85,13 +86,18 @@ func wrapEvent(ev any) *pubsub.Payload {
 			Payload: fileToProto(e.Payload),
 		})
 	case pubsub.Event[notify.Notification]:
+		payload := proto.AgentEvent{
+			SessionID:    e.Payload.SessionID,
+			SessionTitle: e.Payload.SessionTitle,
+			Type:         proto.AgentEventType(e.Payload.Type),
+		}
+		if e.Payload.Type == notify.TypeAgentError {
+			payload.Type = proto.AgentEventTypeError
+			payload.Error = errors.New(e.Payload.Message)
+		}
 		return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
-			Type: e.Type,
-			Payload: proto.AgentEvent{
-				SessionID:    e.Payload.SessionID,
-				SessionTitle: e.Payload.SessionTitle,
-				Type:         proto.AgentEventType(e.Payload.Type),
-			},
+			Type:    e.Type,
+			Payload: payload,
 		})
 	case pubsub.Event[notify.RunComplete]:
 		return envelope(pubsub.PayloadTypeRunComplete, pubsub.Event[proto.RunComplete]{

internal/workspace/client_workspace.go 🔗

@@ -703,13 +703,17 @@ func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
 			Payload: protoToFile(e.Payload),
 		}
 	case pubsub.Event[proto.AgentEvent]:
+		n := notify.Notification{
+			SessionID:    e.Payload.SessionID,
+			SessionTitle: e.Payload.SessionTitle,
+			Type:         notify.Type(e.Payload.Type),
+		}
+		if e.Payload.Error != nil {
+			n.Message = e.Payload.Error.Error()
+		}
 		return pubsub.Event[notify.Notification]{
-			Type: e.Type,
-			Payload: notify.Notification{
-				SessionID:    e.Payload.SessionID,
-				SessionTitle: e.Payload.SessionTitle,
-				Type:         notify.Type(e.Payload.Type),
-			},
+			Type:    e.Type,
+			Payload: n,
 		}
 	case pubsub.Event[proto.RunComplete]:
 		// Translate the wire-level proto.RunComplete back into the