Skip to content

Commit e9d2478

Browse files
edwinhuclaude
andcommitted
feat: add retry logic and better error messages for reply commands
Based on investigation showing race conditions and UI state conflicts causing intermittent reply failures: - Add withRetry() helper for exponential backoff (3 attempts, 500ms base) - Add error field to ReplyResult interface - Provide descriptive error messages for each failure point - Display error details in CLI and MCP tool outputs This helps diagnose failures caused by: - Existing compose windows blocking UI - Draft recovery overlays - Reply button not immediately interactable Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0a28519 commit e9d2478

3 files changed

Lines changed: 64 additions & 22 deletions

File tree

src/cli.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,7 @@ async function cmdReply(options: CliOptions) {
859859
success(`Draft saved (${result.draftId})`);
860860
}
861861
} else {
862-
error("Failed to create reply");
862+
error(result.error || "Failed to create reply");
863863
}
864864

865865
await disconnect(conn);
@@ -890,7 +890,7 @@ async function cmdReplyAll(options: CliOptions) {
890890
success(`Draft saved (${result.draftId})`);
891891
}
892892
} else {
893-
error("Failed to create reply-all");
893+
error(result.error || "Failed to create reply-all");
894894
}
895895

896896
await disconnect(conn);
@@ -928,7 +928,7 @@ async function cmdForward(options: CliOptions) {
928928
success(`Draft saved (${result.draftId})`);
929929
}
930930
} else {
931-
error("Failed to create forward");
931+
error(result.error || "Failed to create forward");
932932
}
933933

934934
await disconnect(conn);

src/mcp/tools.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,7 @@ export async function replyHandler(args: z.infer<typeof ReplySchema>): Promise<T
636636
const result = await replyToThread(conn, args.threadId, args.body, send);
637637

638638
if (!result.success) {
639-
throw new Error("Failed to create reply");
639+
throw new Error(result.error || "Failed to create reply");
640640
}
641641

642642
if (send) {
@@ -668,7 +668,7 @@ export async function replyAllHandler(args: z.infer<typeof ReplyAllSchema>): Pro
668668
const result = await replyAllToThread(conn, args.threadId, args.body, send);
669669

670670
if (!result.success) {
671-
throw new Error("Failed to create reply-all");
671+
throw new Error(result.error || "Failed to create reply-all");
672672
}
673673

674674
if (send) {
@@ -700,7 +700,7 @@ export async function forwardHandler(args: z.infer<typeof ForwardSchema>): Promi
700700
const result = await forwardThread(conn, args.threadId, args.toEmail, args.body, send);
701701

702702
if (!result.success) {
703-
throw new Error("Failed to create forward");
703+
throw new Error(result.error || "Failed to create forward");
704704
}
705705

706706
if (send) {

src/reply.ts

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
export interface ReplyResult {
2121
success: boolean;
2222
draftId?: string;
23+
error?: string;
2324
}
2425

2526
/**
@@ -32,39 +33,71 @@ async function completeDraft(
3233
): Promise<ReplyResult> {
3334
if (send) {
3435
const sent = await sendDraft(conn);
35-
return { success: sent };
36+
if (!sent) {
37+
return { success: false, error: "Failed to send draft" };
38+
}
39+
return { success: true };
3640
}
3741

3842
const saved = await saveDraft(conn);
39-
return { success: saved, draftId: draftKey };
43+
if (!saved) {
44+
return { success: false, error: "Failed to save draft" };
45+
}
46+
return { success: true, draftId: draftKey };
47+
}
48+
49+
/**
50+
* Retry a function with exponential backoff
51+
*/
52+
async function withRetry<T>(
53+
fn: () => Promise<T | null>,
54+
maxRetries: number = 3,
55+
baseDelay: number = 500
56+
): Promise<T | null> {
57+
for (let attempt = 0; attempt < maxRetries; attempt++) {
58+
const result = await fn();
59+
if (result !== null) {
60+
return result;
61+
}
62+
if (attempt < maxRetries - 1) {
63+
const delay = baseDelay * Math.pow(2, attempt);
64+
await new Promise(r => setTimeout(r, delay));
65+
}
66+
}
67+
return null;
4068
}
4169

4270
/**
4371
* Reply to a thread
4472
*
4573
* Uses Superhuman's native REPLY_POP_OUT command which properly sets up
4674
* threading (threadId, inReplyTo, references), recipients, and subject.
75+
* Includes retry logic for transient UI state failures.
4776
*
4877
* @param conn - The Superhuman connection
4978
* @param threadId - The thread ID to reply to
5079
* @param body - The reply body text
5180
* @param send - If true, send immediately; if false, save as draft
52-
* @returns Result with success status and optional draft ID
81+
* @returns Result with success status, optional draft ID, and error message if failed
5382
*/
5483
export async function replyToThread(
5584
conn: SuperhumanConnection,
5685
threadId: string,
5786
body: string,
5887
send: boolean = false
5988
): Promise<ReplyResult> {
60-
const draftKey = await openReplyCompose(conn, threadId);
89+
// Retry opening compose in case of transient UI state issues
90+
const draftKey = await withRetry(() => openReplyCompose(conn, threadId), 3, 500);
6191
if (!draftKey) {
62-
return { success: false };
92+
return {
93+
success: false,
94+
error: "Failed to open reply compose (UI may be blocked by existing compose window or overlay)"
95+
};
6396
}
6497

6598
const bodySet = await setBody(conn, textToHtml(body));
6699
if (!bodySet) {
67-
return { success: false };
100+
return { success: false, error: "Failed to set reply body" };
68101
}
69102

70103
return completeDraft(conn, draftKey, send);
@@ -75,28 +108,32 @@ export async function replyToThread(
75108
*
76109
* Uses Superhuman's native REPLY_ALL_POP_OUT command which properly sets up
77110
* threading (threadId, inReplyTo, references), all recipients (To and Cc),
78-
* and subject automatically.
111+
* and subject automatically. Includes retry logic for transient UI state failures.
79112
*
80113
* @param conn - The Superhuman connection
81114
* @param threadId - The thread ID to reply to
82115
* @param body - The reply body text
83116
* @param send - If true, send immediately; if false, save as draft
84-
* @returns Result with success status and optional draft ID
117+
* @returns Result with success status, optional draft ID, and error message if failed
85118
*/
86119
export async function replyAllToThread(
87120
conn: SuperhumanConnection,
88121
threadId: string,
89122
body: string,
90123
send: boolean = false
91124
): Promise<ReplyResult> {
92-
const draftKey = await openReplyAllCompose(conn, threadId);
125+
// Retry opening compose in case of transient UI state issues
126+
const draftKey = await withRetry(() => openReplyAllCompose(conn, threadId), 3, 500);
93127
if (!draftKey) {
94-
return { success: false };
128+
return {
129+
success: false,
130+
error: "Failed to open reply-all compose (UI may be blocked by existing compose window or overlay)"
131+
};
95132
}
96133

97134
const bodySet = await setBody(conn, textToHtml(body));
98135
if (!bodySet) {
99-
return { success: false };
136+
return { success: false, error: "Failed to set reply body" };
100137
}
101138

102139
return completeDraft(conn, draftKey, send);
@@ -107,13 +144,14 @@ export async function replyAllToThread(
107144
*
108145
* Uses Superhuman's native FORWARD_POP_OUT command which properly sets up
109146
* the forwarded message content, subject, and formatting.
147+
* Includes retry logic for transient UI state failures.
110148
*
111149
* @param conn - The Superhuman connection
112150
* @param threadId - The thread ID to forward
113151
* @param toEmail - The email address to forward to
114152
* @param body - The message body to include before the forwarded content
115153
* @param send - If true, send immediately; if false, save as draft
116-
* @returns Result with success status and optional draft ID
154+
* @returns Result with success status, optional draft ID, and error message if failed
117155
*/
118156
export async function forwardThread(
119157
conn: SuperhumanConnection,
@@ -122,20 +160,24 @@ export async function forwardThread(
122160
body: string,
123161
send: boolean = false
124162
): Promise<ReplyResult> {
125-
const draftKey = await openForwardCompose(conn, threadId);
163+
// Retry opening compose in case of transient UI state issues
164+
const draftKey = await withRetry(() => openForwardCompose(conn, threadId), 3, 500);
126165
if (!draftKey) {
127-
return { success: false };
166+
return {
167+
success: false,
168+
error: "Failed to open forward compose (UI may be blocked by existing compose window or overlay)"
169+
};
128170
}
129171

130172
const recipientAdded = await addRecipient(conn, toEmail);
131173
if (!recipientAdded) {
132-
return { success: false };
174+
return { success: false, error: "Failed to add forward recipient" };
133175
}
134176

135177
if (body) {
136178
const bodySet = await setBody(conn, textToHtml(body));
137179
if (!bodySet) {
138-
return { success: false };
180+
return { success: false, error: "Failed to set forward body" };
139181
}
140182
}
141183

0 commit comments

Comments
 (0)