Skip to content

Commit 138e498

Browse files
committed
fix(webapp): stop the agent's retry duplicating the failed message
Retry appended the last user message again, so the failed turn stayed in the transcript and its text was sent twice. It now regenerates once the agent has started answering, and otherwise re-sends the failed turn under its own id.
1 parent 2cb53b7 commit 138e498

3 files changed

Lines changed: 83 additions & 8 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createTranscriptOrder, orderTranscript } from "./message-order";
1616
import { appendRunFilters } from "./navigate-target";
1717
import { pendingNavigateIntents } from "./pending-intents";
1818
import type { AgentPageContext } from "./page-context-types";
19+
import { retryAction } from "./retry-action";
1920
import {
2021
fetchChatTranscript,
2122
hasOpenInvestigation,
@@ -149,6 +150,7 @@ export function DashboardAgentChat({
149150
messages: rawMessages,
150151
setMessages,
151152
sendMessage,
153+
regenerate,
152154
status,
153155
stop: aiStop,
154156
error,
@@ -192,15 +194,15 @@ export function DashboardAgentChat({
192194
);
193195

194196
const retry = useCallback(() => {
195-
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
196-
const text = lastUserMessage?.parts
197-
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
198-
.map((p) => p.text)
199-
.join("\n")
200-
.trim();
197+
const action = retryAction(messages);
198+
if (!action) return;
201199
clearError();
202-
if (text) void sendMessage({ text });
203-
}, [messages, sendMessage, clearError]);
200+
if (action.kind === "regenerate") {
201+
void regenerate();
202+
return;
203+
}
204+
void sendMessage({ text: action.text, messageId: action.messageId });
205+
}, [messages, sendMessage, regenerate, clearError]);
204206

205207
const resolveUri = useTriggerUriResolver(actionPath);
206208

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from "vitest";
2+
import { retryAction } from "./retry-action";
3+
4+
function user(id: string, ...texts: string[]) {
5+
return { id, role: "user", parts: texts.map((text) => ({ type: "text", text })) };
6+
}
7+
8+
function assistant(id: string, text: string) {
9+
return { id, role: "assistant", parts: [{ type: "text", text }] };
10+
}
11+
12+
describe("retryAction", () => {
13+
it("re-sends the failed turn under its own id, never as a new message", () => {
14+
expect(retryAction([assistant("a1", "hi"), user("u2", "why is it slow?")])).toEqual({
15+
kind: "resend",
16+
messageId: "u2",
17+
text: "why is it slow?",
18+
});
19+
});
20+
21+
it("joins the failed turn's text parts", () => {
22+
expect(retryAction([user("u1", "one", "two")])).toMatchObject({ text: "one\ntwo" });
23+
});
24+
25+
it("regenerates when the agent already started answering", () => {
26+
expect(retryAction([user("u1", "why?"), assistant("a1", "partial")])).toEqual({
27+
kind: "regenerate",
28+
});
29+
});
30+
31+
it("does nothing on an empty transcript, where there is no turn to retry", () => {
32+
expect(retryAction([])).toBeNull();
33+
});
34+
35+
it("does nothing when the failed turn carries no text to re-send", () => {
36+
expect(retryAction([{ id: "u1", role: "user", parts: [] }])).toBeNull();
37+
});
38+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* What the retry button should do after a failed turn.
3+
*
4+
* `regenerate()` sends no message at all — the agent trims its trailing assistant and re-runs
5+
* from its own history. That is only safe once the agent has answered, because a turn can also
6+
* fail before the message reaches it (a rejected or dropped `.in` append), and on the
7+
* head-started first turn the agent would then have no history to run on.
8+
*/
9+
10+
export type RetryMessage = {
11+
id: string;
12+
role: string;
13+
parts?: readonly { type: string; text?: string }[];
14+
};
15+
16+
export type RetryAction =
17+
/** Built-in retry: the agent owns the turn and drops its own partial answer. */
18+
| { kind: "regenerate" }
19+
/** Re-send under the same id, so the message lands even if it never did, and never twice. */
20+
| { kind: "resend"; messageId: string; text: string }
21+
| null;
22+
23+
export function retryAction(messages: readonly RetryMessage[]): RetryAction {
24+
const last = messages[messages.length - 1];
25+
if (!last) return null;
26+
if (last.role !== "user") return { kind: "regenerate" };
27+
28+
const text = (last.parts ?? [])
29+
.filter((part) => part.type === "text")
30+
.map((part) => part.text ?? "")
31+
.join("\n")
32+
.trim();
33+
34+
return text ? { kind: "resend", messageId: last.id, text } : null;
35+
}

0 commit comments

Comments
 (0)