Skip to content

Commit b1ab615

Browse files
committed
feat: implement error handling for agent events
1 parent bd75240 commit b1ab615

8 files changed

Lines changed: 126 additions & 9 deletions

File tree

chat/src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function RootLayout({
2929
disableTransitionOnChange
3030
>
3131
{children}
32-
<Toaster richColors />
32+
<Toaster richColors closeButton />
3333
</ThemeProvider>
3434
</body>
3535
</html>

chat/src/components/chat-provider.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ interface StatusChangeEvent {
3636
agent_type: string;
3737
}
3838

39+
interface ErrorEventData {
40+
message: string;
41+
level: string;
42+
time: string;
43+
}
44+
3945
interface APIErrorDetail {
4046
location: string;
4147
message: string;
@@ -215,6 +221,25 @@ export function ChatProvider({ children }: PropsWithChildren) {
215221
setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType);
216222
});
217223

224+
// Handle agent error events
225+
eventSource.addEventListener("agent_error", (event) => {
226+
const messageEvent = event as MessageEvent;
227+
try {
228+
const data: ErrorEventData = JSON.parse(messageEvent.data);
229+
230+
// Display error as toast notification that persists until manually dismissed
231+
if (data.level === "error") {
232+
toast.error(data.message, { duration: Infinity });
233+
} else if (data.level === "warning") {
234+
toast.warning(data.message, { duration: Infinity });
235+
} else {
236+
toast.info(data.message, { duration: Infinity });
237+
}
238+
} catch (e) {
239+
console.error("Failed to parse agent_error event data:", e);
240+
}
241+
});
242+
218243
// Handle connection open (server is online)
219244
eventSource.onopen = () => {
220245
// Connection is established, but we'll wait for status_change event

lib/httpapi/events.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
EventTypeMessageUpdate EventType = "message_update"
1919
EventTypeStatusChange EventType = "status_change"
2020
EventTypeScreenUpdate EventType = "screen_update"
21+
EventTypeError EventType = "agent_error"
2122
)
2223

2324
type AgentStatus string
@@ -52,6 +53,12 @@ type ScreenUpdateBody struct {
5253
Screen string `json:"screen"`
5354
}
5455

56+
type ErrorBody struct {
57+
Message string `json:"message" doc:"Error message"`
58+
Level string `json:"level" doc:"Error level: 'warning' or 'error'"`
59+
Time time.Time `json:"time" doc:"Timestamp when the error occurred"`
60+
}
61+
5562
type Event struct {
5663
Type EventType
5764
Payload any
@@ -66,6 +73,7 @@ type EventEmitter struct {
6673
chanIdx int
6774
subscriptionBufSize uint
6875
screen string
76+
errors []ErrorBody
6977
}
7078

7179
func convertStatus(status st.ConversationStatus) AgentStatus {
@@ -194,6 +202,22 @@ func (e *EventEmitter) EmitScreen(newScreen string) {
194202
e.screen = newScreen
195203
}
196204

205+
func (e *EventEmitter) EmitError(message string, level string) {
206+
e.mu.Lock()
207+
defer e.mu.Unlock()
208+
209+
errorBody := ErrorBody{
210+
Message: message,
211+
Level: level,
212+
Time: time.Now(),
213+
}
214+
215+
// Store the error so new subscribers can receive all errors
216+
e.errors = append(e.errors, errorBody)
217+
218+
e.notifyChannels(EventTypeError, errorBody)
219+
}
220+
197221
// Assumes the caller holds the lock.
198222
func (e *EventEmitter) currentStateAsEvents() []Event {
199223
events := make([]Event, 0, len(e.messages)+2)
@@ -211,6 +235,15 @@ func (e *EventEmitter) currentStateAsEvents() []Event {
211235
Type: EventTypeScreenUpdate,
212236
Payload: ScreenUpdateBody{Screen: strings.TrimRight(e.screen, mf.WhiteSpaceChars)},
213237
})
238+
239+
// Include all error events
240+
for _, err := range e.errors {
241+
events = append(events, Event{
242+
Type: EventTypeError,
243+
Payload: err,
244+
})
245+
}
246+
214247
return events
215248
}
216249

lib/httpapi/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ func (s *Server) registerRoutes() {
396396
// Mapping of event type name to Go struct for that event.
397397
"message_update": MessageUpdateBody{},
398398
"status_change": StatusChangeBody{},
399+
"agent_error": ErrorBody{},
399400
}, s.subscribeEvents)
400401

401402
sse.Register(s.api, huma.Operation{

lib/screentracker/conversation.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type Emitter interface {
8080
EmitMessages([]ConversationMessage)
8181
EmitStatus(ConversationStatus)
8282
EmitScreen(string)
83+
EmitError(message string, level string)
8384
}
8485

8586
type ConversationMessage struct {

lib/screentracker/pty_conversation.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ type PTYConversation struct {
125125
firstStableSnapshot string
126126
// userSentMessageAfterLoadState tracks if the user has sent their first message after we load the state
127127
userSentMessageAfterLoadState bool
128-
// loadStateSuccessful indicates whether conversation state was successfully restored from file.
129-
loadStateSuccessful bool
128+
// loadStateAttempted indicates whether we have attempted to load conversation state from file (regardless of success).
129+
loadStateAttempted bool
130130
// initialPromptReady is set to true when ReadyForInitialPrompt returns true.
131131
// Checked inline in the snapshot loop on each tick.
132132
initialPromptReady bool
@@ -141,6 +141,7 @@ type noopEmitter struct{}
141141
func (noopEmitter) EmitMessages([]ConversationMessage) {}
142142
func (noopEmitter) EmitStatus(ConversationStatus) {}
143143
func (noopEmitter) EmitScreen(string) {}
144+
func (noopEmitter) EmitError(_ string, _ string) {}
144145

145146
func NewPTY(ctx context.Context, cfg PTYConversationConfig, emitter Emitter) *PTYConversation {
146147
if cfg.Clock == nil {
@@ -168,7 +169,7 @@ func NewPTY(ctx context.Context, cfg PTYConversationConfig, emitter Emitter) *PT
168169
dirty: false,
169170
firstStableSnapshot: "",
170171
userSentMessageAfterLoadState: false,
171-
loadStateSuccessful: false,
172+
loadStateAttempted: false,
172173
}
173174
if c.cfg.ReadyForInitialPrompt == nil {
174175
c.cfg.ReadyForInitialPrompt = func(string) bool { return true }
@@ -192,11 +193,13 @@ func (c *PTYConversation) Start(ctx context.Context) {
192193
c.initialPromptReady = true
193194
}
194195

195-
if c.initialPromptReady && !c.loadStateSuccessful && c.cfg.StatePersistenceConfig.LoadState {
196+
var loadStateErr error
197+
if c.initialPromptReady && !c.loadStateAttempted && c.cfg.StatePersistenceConfig.LoadState {
196198
if err := c.loadStateLocked(); err != nil {
197199
c.cfg.Logger.Error("Failed to load state", "error", err)
200+
loadStateErr = err
198201
}
199-
c.loadStateSuccessful = true
202+
c.loadStateAttempted = true
200203
}
201204

202205
// Enqueue initial prompt once after agent is ready (and after state is potentially loaded)
@@ -219,6 +222,9 @@ func (c *PTYConversation) Start(ctx context.Context) {
219222
c.emitter.EmitStatus(status)
220223
c.emitter.EmitMessages(messages)
221224
c.emitter.EmitScreen(screen)
225+
if loadStateErr != nil {
226+
c.emitter.EmitError(fmt.Sprintf("Failed to restore previous session: %v", loadStateErr), "warning")
227+
}
222228
return nil
223229
}, "snapshot")
224230

@@ -282,7 +288,7 @@ func (c *PTYConversation) updateLastAgentMessageLocked(screen string, timestamp
282288
if c.cfg.FormatMessage != nil {
283289
agentMessage = c.cfg.FormatMessage(agentMessage, lastUserMessage.Message)
284290
}
285-
if c.loadStateSuccessful {
291+
if c.loadStateAttempted {
286292
agentMessage = c.adjustScreenAfterStateLoad(agentMessage)
287293
}
288294
if c.cfg.FormatToolCall != nil {
@@ -601,7 +607,7 @@ func (c *PTYConversation) loadStateLocked() error {
601607
stateFile := c.cfg.StatePersistenceConfig.StateFile
602608
loadState := c.cfg.StatePersistenceConfig.LoadState
603609

604-
if !loadState || c.loadStateSuccessful {
610+
if !loadState || c.loadStateAttempted {
605611
return nil
606612
}
607613

@@ -656,7 +662,7 @@ func (c *PTYConversation) loadStateLocked() error {
656662
c.firstStableSnapshot = c.cfg.FormatMessage(strings.TrimSpace(snapshots[len(snapshots)-1].screen), "")
657663
}
658664

659-
c.loadStateSuccessful = true
665+
c.loadStateAttempted = true
660666
c.dirty = false
661667

662668
c.cfg.Logger.Info("Successfully loaded state", "path", stateFile, "messages", len(c.messages))

lib/screentracker/pty_conversation_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type testEmitter struct{}
5656
func (testEmitter) EmitMessages([]st.ConversationMessage) {}
5757
func (testEmitter) EmitStatus(st.ConversationStatus) {}
5858
func (testEmitter) EmitScreen(string) {}
59+
func (testEmitter) EmitError(_ string, _ string) {}
5960

6061
// advanceFor is a shorthand for advanceUntil with a time-based condition.
6162
func advanceFor(ctx context.Context, t *testing.T, mClock *quartz.Mock, total time.Duration) {

openapi.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,30 @@
1919
"title": "ConversationRole",
2020
"type": "string"
2121
},
22+
"ErrorBody": {
23+
"additionalProperties": false,
24+
"properties": {
25+
"level": {
26+
"description": "Error level: 'warning' or 'error'",
27+
"type": "string"
28+
},
29+
"message": {
30+
"description": "Error message",
31+
"type": "string"
32+
},
33+
"time": {
34+
"description": "Timestamp when the error occurred",
35+
"format": "date-time",
36+
"type": "string"
37+
}
38+
},
39+
"required": [
40+
"level",
41+
"message",
42+
"time"
43+
],
44+
"type": "object"
45+
},
2246
"ErrorDetail": {
2347
"additionalProperties": false,
2448
"properties": {
@@ -326,6 +350,32 @@
326350
"description": "Each oneOf object in the array represents one possible Server Sent Events (SSE) message, serialized as UTF-8 text according to the SSE specification.",
327351
"items": {
328352
"oneOf": [
353+
{
354+
"properties": {
355+
"data": {
356+
"$ref": "#/components/schemas/ErrorBody"
357+
},
358+
"event": {
359+
"const": "agent_error",
360+
"description": "The event name.",
361+
"type": "string"
362+
},
363+
"id": {
364+
"description": "The event ID.",
365+
"type": "integer"
366+
},
367+
"retry": {
368+
"description": "The retry time in milliseconds.",
369+
"type": "integer"
370+
}
371+
},
372+
"required": [
373+
"data",
374+
"event"
375+
],
376+
"title": "Event agent_error",
377+
"type": "object"
378+
},
329379
{
330380
"properties": {
331381
"data": {

0 commit comments

Comments
 (0)