Skip to content

Commit 43ae051

Browse files
authored
feat: improve error handling in the TUI (#44)
1 parent 4939898 commit 43ae051

File tree

8 files changed

+72
-183
lines changed

8 files changed

+72
-183
lines changed

packages/blink/src/cli/run.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export default async function run(
6767
const manager = new ChatManager({
6868
chatId: opts?.chat,
6969
chatsDirectory: chatsDir,
70+
onError: (error) => {
71+
console.error("Error:", error);
72+
},
7073
});
7174
manager.setAgent(agent.client);
7275

@@ -95,9 +98,6 @@ export default async function run(
9598

9699
// Print final state
97100
const finalState = manager.getState();
98-
if (finalState.error) {
99-
console.error("Error:", finalState.error);
100-
}
101101
console.log("Final state:", finalState.messages.pop());
102102
} finally {
103103
manager.dispose();

packages/blink/src/local/chat-manager.test.ts

Lines changed: 30 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,6 @@ test("initializes with empty state for non-existent chat", async () => {
197197
expect(state.messages).toEqual([]);
198198
expect(state.status).toBe("idle");
199199
expect(state.streamingMessage).toBeUndefined();
200-
expect(state.error).toBeUndefined();
201200
expect(state.queuedMessages).toEqual([]);
202201

203202
manager.dispose();
@@ -873,126 +872,42 @@ test("watcher onChange does not cause status to flicker during lock release", as
873872
manager.dispose();
874873
});
875874

876-
test("error clearing: errors clear when sending new message", async () => {
877-
const chatsDir = await mkdtemp(join(tmpdir(), "chat-test-"));
878-
879-
try {
880-
// Create a manager with an agent that will fail
881-
const failingAgent: any = {
882-
chat: async () => {
883-
throw new Error("Test error");
884-
},
885-
};
886-
887-
const manager = new ChatManager({
888-
chatId: crypto.randomUUID(),
889-
chatsDirectory: chatsDir,
890-
});
891-
892-
// Track state changes before sending message
893-
let errorSeen = false;
894-
let errorCleared = false;
895-
const unsubscribe = manager.subscribe((state) => {
896-
if (state.error) {
897-
errorSeen = true;
898-
}
899-
if (errorSeen && !state.error) {
900-
errorCleared = true;
901-
}
902-
});
903-
904-
manager.setAgent(failingAgent);
905-
906-
// Send a message that will fail
907-
const message: StoredMessage = {
908-
id: crypto.randomUUID(),
909-
created_at: new Date().toISOString(),
910-
role: "user",
911-
parts: [{ type: "text", text: "Hello" }],
912-
mode: "run",
913-
metadata: undefined,
914-
};
915-
916-
await manager.sendMessages([message]);
917-
918-
// Wait for error state
919-
await new Promise((resolve) => setTimeout(resolve, 100));
920-
921-
// Should have seen an error
922-
expect(errorSeen).toBe(true);
923-
let state = manager.getState();
924-
expect(state.status).toBe("error");
925-
926-
// Now set a working agent
927-
const workingAgent = createMockAgent("Success!");
928-
manager.setAgent(workingAgent);
929-
930-
// Send another message
931-
const message2: StoredMessage = {
932-
id: crypto.randomUUID(),
933-
created_at: new Date().toISOString(),
934-
role: "user",
935-
parts: [{ type: "text", text: "Try again" }],
936-
mode: "run",
937-
metadata: undefined,
938-
};
939-
940-
await manager.sendMessages([message2]);
941-
942-
// Wait for completion
943-
await new Promise((resolve) => setTimeout(resolve, 300));
875+
test("onError callback is called when no agent is available", async () => {
876+
const chatId = crypto.randomUUID();
944877

945-
// Error should have been cleared at some point during the lifecycle
946-
// This is the key behavior - errors should clear when sending new messages
947-
expect(errorCleared).toBe(true);
878+
// Track errors via onError callback
879+
const errors: string[] = [];
880+
const onError = mock((error: string) => {
881+
errors.push(error);
882+
});
948883

949-
// Final state should have no error (watcher may still show error status briefly)
950-
state = manager.getState();
951-
expect(state.error).toBeUndefined();
884+
const manager = new ChatManager({
885+
chatId,
886+
chatsDirectory: tempDir,
887+
onError,
888+
});
952889

953-
unsubscribe();
954-
manager.dispose();
955-
} finally {
956-
await rm(chatsDir, { recursive: true, force: true });
957-
}
958-
});
890+
// Don't set an agent, so it should fail when we try to send a message
959891

960-
test("error clearing: persisted errors don't load from disk", async () => {
961-
const chatsDir = await mkdtemp(join(tmpdir(), "chat-test-"));
962-
963-
try {
964-
const chatId = crypto.randomUUID();
965-
966-
// Manually create a chat with an error in the store
967-
const store = createDiskStore<StoredChat>(chatsDir, "id");
968-
const locked = await store.lock(chatId);
969-
try {
970-
await locked.set({
971-
id: chatId,
972-
created_at: new Date().toISOString(),
973-
updated_at: new Date().toISOString(),
974-
messages: [],
975-
error: "Old persisted error",
976-
});
977-
} finally {
978-
await locked.release();
979-
}
892+
// Send a message without an agent
893+
const message: StoredMessage = {
894+
id: crypto.randomUUID(),
895+
created_at: new Date().toISOString(),
896+
role: "user",
897+
parts: [{ type: "text", text: "Hello" }],
898+
mode: "run",
899+
metadata: undefined,
900+
};
980901

981-
// Create a new manager - it should clear the persisted error
982-
const manager = new ChatManager({
983-
chatId,
984-
chatsDirectory: chatsDir,
985-
});
902+
await manager.sendMessages([message]);
986903

987-
// Wait for initial load
988-
await new Promise((resolve) => setTimeout(resolve, 100));
904+
// Wait a bit for the error to be processed
905+
await new Promise((resolve) => setTimeout(resolve, 100));
989906

990-
const state = manager.getState();
991-
expect(state.error).toBeUndefined(); // Error should be cleared
992-
expect(state.loading).toBe(false);
907+
// Verify onError was called with the "no agent" error message
908+
expect(onError).toHaveBeenCalled();
909+
expect(errors.length).toBeGreaterThan(0);
910+
expect(errors[0]).toContain("agent is not available");
993911

994-
manager.dispose();
995-
} finally {
996-
await rm(chatsDir, { recursive: true, force: true });
997-
}
912+
manager.dispose();
998913
});

packages/blink/src/local/chat-manager.ts

Lines changed: 17 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ export interface ChatState {
2525
readonly messages: StoredMessage[];
2626
readonly status: ChatStatus;
2727
readonly streamingMessage?: StoredMessage;
28-
readonly error?: string;
2928
readonly loading: boolean;
3029
readonly queuedMessages: StoredMessage[];
3130
}
@@ -43,6 +42,10 @@ export interface ChatManagerOptions {
4342
* Return true to include the message, false to exclude it.
4443
*/
4544
readonly filterMessages?: (message: StoredMessage) => boolean;
45+
/**
46+
* Optional callback invoked when an error occurs during chat operations.
47+
*/
48+
readonly onError?: (error: string) => void;
4649
}
4750

4851
type StateListener = (state: ChatState) => void;
@@ -57,6 +60,7 @@ export class ChatManager {
5760
private chatStore: Store<StoredChat>;
5861
private serializeMessage?: (message: UIMessage) => StoredMessage | undefined;
5962
private filterMessages?: (message: StoredMessage) => boolean;
63+
private onError?: (error: string) => void;
6064

6165
private chat: StoredChat;
6266
private loading = false;
@@ -82,6 +86,7 @@ export class ChatManager {
8286
this.chatStore = createDiskStore<StoredChat>(options.chatsDirectory, "id");
8387
this.serializeMessage = options.serializeMessage;
8488
this.filterMessages = options.filterMessages;
89+
this.onError = options.onError;
8590

8691
// Start disk watcher
8792
this.watcher = createDiskStoreWatcher<StoredChat>(options.chatsDirectory, {
@@ -122,21 +127,14 @@ export class ChatManager {
122127

123128
const diskValue = event.value;
124129

125-
let newStatus = event.value?.error ? "error" : "idle";
126-
if (event.locked) {
127-
newStatus = "streaming";
128-
}
130+
let newStatus: ChatStatus = event.locked ? "streaming" : "idle";
129131
const shouldEmit =
130132
this.chat.updated_at !== diskValue?.updated_at ||
131133
this.status !== newStatus;
132134

133-
// Clear persisted errors - they're stale from disk
134-
this.chat = {
135-
...diskValue,
136-
error: undefined,
137-
};
135+
this.chat = diskValue;
138136
this.streamingMessage = undefined;
139-
this.status = newStatus as ChatStatus;
137+
this.status = newStatus;
140138

141139
if (shouldEmit) {
142140
this.notifyListeners();
@@ -154,14 +152,11 @@ export class ChatManager {
154152
if (!chat) {
155153
return;
156154
}
157-
// Clear any persisted errors on load - they're stale
158-
this.chat = {
159-
...chat,
160-
error: undefined,
161-
};
155+
this.chat = chat;
162156
})
163157
.catch((err) => {
164-
this.chat.error = err instanceof Error ? err.message : String(err);
158+
const errorMessage = err instanceof Error ? err.message : String(err);
159+
this.onError?.(errorMessage);
165160
})
166161
.finally(() => {
167162
this.loading = false;
@@ -190,7 +185,6 @@ export class ChatManager {
190185
updated_at: this.chat?.updated_at,
191186
status: this.status,
192187
streamingMessage: this.streamingMessage,
193-
error: this.chat?.error,
194188
loading: this.loading,
195189
queuedMessages: this.queue,
196190
};
@@ -298,22 +292,6 @@ export class ChatManager {
298292
* Send a message to the agent
299293
*/
300294
async sendMessages(messages: StoredMessage[]): Promise<void> {
301-
// Clear any previous errors when sending a new message (persist to disk)
302-
if (this.chat.error) {
303-
const locked = await this.chatStore.lock(this.chatId);
304-
try {
305-
const current = await locked.get();
306-
this.chat = {
307-
...current,
308-
error: undefined,
309-
updated_at: new Date().toISOString(),
310-
};
311-
await locked.set(this.chat);
312-
} finally {
313-
await locked.release();
314-
}
315-
}
316-
317295
this.status = "idle";
318296
this.notifyListeners();
319297

@@ -331,8 +309,6 @@ export class ChatManager {
331309
}
332310

333311
async start(): Promise<void> {
334-
// Clear error when explicitly starting
335-
this.chat.error = undefined;
336312
this.status = "idle";
337313
this.notifyListeners();
338314
// Do not await this - it will block the server.
@@ -347,19 +323,16 @@ export class ChatManager {
347323

348324
private async processQueueOrRun(): Promise<void> {
349325
if (!this.agent) {
350-
// Set error state instead of throwing
351-
this.chat.error =
326+
const errorMessage =
352327
"The agent is not available. Please wait for the build to succeed.";
353-
this.status = "error";
328+
this.onError?.(errorMessage);
354329
this.queue = []; // Clear the queue
355-
this.notifyListeners();
356330
return;
357331
}
358332
if (this.isProcessingQueue) {
359333
return;
360334
}
361335
this.isProcessingQueue = true;
362-
this.chat.error = undefined;
363336

364337
let locked: LockedStoreEntry<StoredChat> | undefined;
365338
try {
@@ -501,15 +474,12 @@ export class ChatManager {
501474
}
502475
}
503476
} catch (err: any) {
504-
this.chat.error = err instanceof Error ? err.message : String(err);
477+
const errorMessage = err instanceof Error ? err.message : String(err);
478+
this.onError?.(errorMessage);
505479
} finally {
506480
this.isProcessingQueue = false;
507481
this.streamingMessage = undefined;
508-
if (this.chat.error) {
509-
this.status = "error";
510-
} else {
511-
this.status = "idle";
512-
}
482+
this.status = "idle";
513483

514484
if (locked) {
515485
this.chat.updated_at = new Date().toISOString();

packages/blink/src/local/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export interface StoredChat {
77
created_at: string;
88
updated_at: string;
99
messages: StoredMessage[];
10-
error?: string;
1110
}
1211

1312
export type StoredMessageMetadata = {

packages/blink/src/react/use-chat.test.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const Harness: React.FC<HarnessProps> = ({ options, onUpdate }) => {
2323
result.status,
2424
result.messages.length,
2525
result.streamingMessage,
26-
result.error,
2726
result.queuedMessages.length,
2827
]);
2928
return null;
@@ -106,7 +105,6 @@ test("initializes with empty state for non-existent chat", async () => {
106105
expect(r.messages).toEqual([]);
107106
expect(r.status).toBe("idle");
108107
expect(r.streamingMessage).toBeUndefined();
109-
expect(r.error).toBeUndefined();
110108

111109
app.unmount();
112110
});

0 commit comments

Comments
 (0)