Skip to content

Commit 71f80b9

Browse files
committed
🤖 fix: move retry-state ownership from streamWithHistory to work boundaries
Replace the coupled lastAutoRetryOptions + lastAutoRetryAgentInitiated pair with a single lastAutoRetryRequest (StartupRetrySendOptions), and move ownership from the low-level stream runner to higher-level work boundaries. This fixes manual /compact followed by a failed follow-up dispatch leaving stale compact-agent retry state that could cause no-tools confusion. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_ <!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh -->
1 parent fda7812 commit 71f80b9

File tree

3 files changed

+273
-59
lines changed

3 files changed

+273
-59
lines changed

‎src/node/services/agentSession.continueMessageAgentId.test.ts‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test, mock, afterEach } from "bun:test";
2-
import { buildContinueMessage } from "@/common/types/message";
2+
import { buildContinueMessage, type StartupRetrySendOptions } from "@/common/types/message";
33
import type { FilePart, SendMessageOptions } from "@/common/orpc/types";
44
import { AgentSession } from "./agentSession";
55
import type { Config } from "@/node/config";
@@ -25,6 +25,7 @@ interface SessionInternals {
2525
scheduleStartupRecovery: () => void;
2626
startupRecoveryPromise: Promise<void> | null;
2727
startupRecoveryScheduled: boolean;
28+
lastAutoRetryRequest?: StartupRetrySendOptions;
2829
}
2930

3031
describe("AgentSession continue-message agentId fallback", () => {
@@ -136,6 +137,109 @@ describe("AgentSession continue-message agentId fallback", () => {
136137
session.dispose();
137138
});
138139

140+
test("dispatchPendingFollowUp rewrites stale compact retry state to the reconstructed follow-up", async () => {
141+
const aiService: AIService = {
142+
on() {
143+
return this;
144+
},
145+
off() {
146+
return this;
147+
},
148+
isStreaming: () => false,
149+
stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })),
150+
} as unknown as AIService;
151+
152+
const legacyFollowUp = {
153+
text: "follow up retry",
154+
model: "openai:gpt-4o",
155+
agentId: undefined as unknown as string,
156+
mode: "plan" as const,
157+
thinkingLevel: "high" as const,
158+
};
159+
160+
const mockSummaryMessage = {
161+
id: "summary-retry-state",
162+
role: "assistant" as const,
163+
parts: [{ type: "text" as const, text: "Compaction summary" }],
164+
metadata: {
165+
muxMetadata: {
166+
type: "compaction-summary" as const,
167+
pendingFollowUp: legacyFollowUp,
168+
},
169+
},
170+
} satisfies MuxMessage;
171+
172+
const { historyService, cleanup } = await createTestHistoryService();
173+
historyCleanup = cleanup;
174+
await historyService.appendToHistory("ws", mockSummaryMessage);
175+
176+
const initStateManager: InitStateManager = {
177+
on() {
178+
return this;
179+
},
180+
off() {
181+
return this;
182+
},
183+
} as unknown as InitStateManager;
184+
185+
const backgroundProcessManager: BackgroundProcessManager = {
186+
cleanup: mock(() => Promise.resolve()),
187+
setMessageQueued: mock(() => undefined),
188+
} as unknown as BackgroundProcessManager;
189+
190+
const config: Config = {
191+
srcDir: "/tmp",
192+
getSessionDir: mock(() => "/tmp"),
193+
} as unknown as Config;
194+
195+
const session = new AgentSession({
196+
workspaceId: "ws",
197+
config,
198+
historyService,
199+
aiService,
200+
initStateManager,
201+
backgroundProcessManager,
202+
});
203+
204+
const internals = session as unknown as SessionInternals;
205+
internals.lastAutoRetryRequest = {
206+
model: "openai:gpt-4o-mini",
207+
agentId: "compact",
208+
toolPolicy: [{ regex_match: ".*", action: "disable" }],
209+
agentInitiated: true,
210+
};
211+
internals.sendMessage = mock(() =>
212+
Promise.resolve({
213+
success: false as const,
214+
error: { type: "runtime_start_failed", message: "startup failed" },
215+
})
216+
);
217+
218+
let dispatchError: unknown;
219+
try {
220+
await internals.dispatchPendingFollowUp();
221+
} catch (error) {
222+
dispatchError = error;
223+
}
224+
225+
expect(dispatchError).toBeInstanceOf(Error);
226+
if (!(dispatchError instanceof Error)) {
227+
throw new Error("Expected dispatchPendingFollowUp to throw when sendMessage fails");
228+
}
229+
expect(dispatchError.message).toContain("Failed to dispatch pending follow-up");
230+
expect(internals.lastAutoRetryRequest).toEqual(
231+
expect.objectContaining({
232+
model: "openai:gpt-4o",
233+
agentId: "plan",
234+
thinkingLevel: "high",
235+
}) as StartupRetrySendOptions
236+
);
237+
expect(internals.lastAutoRetryRequest?.toolPolicy).toBeUndefined();
238+
expect(internals.lastAutoRetryRequest?.agentInitiated).toBeUndefined();
239+
240+
session.dispose();
241+
});
242+
139243
test("dispatchPendingFollowUp throws when history read fails", async () => {
140244
const aiService: AIService = {
141245
on() {

‎src/node/services/agentSession.startupAutoRetry.test.ts‎

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { HistoryService } from "./historyService";
88
import type { Config } from "@/node/config";
99
import type { InitStateManager } from "./initStateManager";
1010
import type { WorkspaceChatMessage, SendMessageOptions } from "@/common/orpc/types";
11-
import { createMuxMessage } from "@/common/types/message";
11+
import { createMuxMessage, type StartupRetrySendOptions } from "@/common/types/message";
1212
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
1313
import type { WorkspaceMetadata } from "@/common/types/workspace";
1414
import { Ok } from "@/common/types/result";
@@ -142,8 +142,11 @@ describe("AgentSession startup auto-retry recovery", () => {
142142
const scheduledEvent = events.find((event) => event.type === "auto-retry-scheduled");
143143
expect(scheduledEvent).toBeDefined();
144144

145-
const retryOptions = (session as unknown as { lastAutoRetryOptions?: SendMessageOptions })
146-
.lastAutoRetryOptions;
145+
const retryOptions = (
146+
session as unknown as {
147+
lastAutoRetryRequest?: StartupRetrySendOptions;
148+
}
149+
).lastAutoRetryRequest;
147150
expect(retryOptions).toBeDefined();
148151
if (!retryOptions) {
149152
throw new Error("Expected startup auto-retry options to be captured");
@@ -454,8 +457,11 @@ describe("AgentSession startup auto-retry recovery", () => {
454457
).startupAutoRetryCheckPromise;
455458
await startupCheckPromise;
456459

457-
const retryOptions = (session as unknown as { lastAutoRetryOptions?: SendMessageOptions })
458-
.lastAutoRetryOptions;
460+
const retryOptions = (
461+
session as unknown as {
462+
lastAutoRetryRequest?: StartupRetrySendOptions;
463+
}
464+
).lastAutoRetryRequest;
459465
expect(retryOptions).toBeDefined();
460466
if (!retryOptions) {
461467
throw new Error("Expected startup retry options");
@@ -714,7 +720,7 @@ describe("AgentSession startup auto-retry recovery", () => {
714720
persistStartupAutoRetryAbandon: (reason: string, userMessageId?: string) => Promise<void>;
715721
retryActiveStream: () => Promise<void>;
716722
getAutoRetryPreferencePath: () => string;
717-
lastAutoRetryOptions?: SendMessageOptions;
723+
lastAutoRetryRequest?: StartupRetrySendOptions;
718724
resumeStream: (
719725
options: SendMessageOptions
720726
) => Promise<{ success: true; data: { started: boolean } }>;
@@ -726,7 +732,7 @@ describe("AgentSession startup auto-retry recovery", () => {
726732
const preferencePath = privateSession.getAutoRetryPreferencePath();
727733
expect(await Bun.file(preferencePath).exists()).toBe(true);
728734

729-
privateSession.lastAutoRetryOptions = {
735+
privateSession.lastAutoRetryRequest = {
730736
model: "anthropic:claude-sonnet-4-5",
731737
agentId: "exec",
732738
};
@@ -752,13 +758,13 @@ describe("AgentSession startup auto-retry recovery", () => {
752758

753759
const privateSession = session as unknown as {
754760
retryActiveStream: () => Promise<void>;
755-
lastAutoRetryOptions?: SendMessageOptions;
761+
lastAutoRetryRequest?: StartupRetrySendOptions;
756762
resumeStream: (
757763
options: SendMessageOptions
758764
) => Promise<{ success: true; data: { started: boolean } }>;
759765
};
760766

761-
privateSession.lastAutoRetryOptions = {
767+
privateSession.lastAutoRetryRequest = {
762768
model: "anthropic:claude-sonnet-4-5",
763769
agentId: "exec",
764770
};
@@ -789,7 +795,7 @@ describe("AgentSession startup auto-retry recovery", () => {
789795

790796
const privateSession = session as unknown as {
791797
retryActiveStream: () => Promise<void>;
792-
lastAutoRetryOptions?: SendMessageOptions;
798+
lastAutoRetryRequest?: StartupRetrySendOptions;
793799
activeStreamFailureHandled: boolean;
794800
resumeStream: (
795801
options: SendMessageOptions
@@ -799,7 +805,7 @@ describe("AgentSession startup auto-retry recovery", () => {
799805
>;
800806
};
801807

802-
privateSession.lastAutoRetryOptions = {
808+
privateSession.lastAutoRetryRequest = {
803809
model: "anthropic:claude-sonnet-4-5",
804810
agentId: "exec",
805811
};
@@ -834,7 +840,7 @@ describe("AgentSession startup auto-retry recovery", () => {
834840

835841
const privateSession = session as unknown as {
836842
retryActiveStream: () => Promise<void>;
837-
lastAutoRetryOptions?: SendMessageOptions;
843+
lastAutoRetryRequest?: StartupRetrySendOptions;
838844
activeStreamFailureHandled: boolean;
839845
resumeStream: (
840846
options: SendMessageOptions
@@ -844,7 +850,7 @@ describe("AgentSession startup auto-retry recovery", () => {
844850
>;
845851
};
846852

847-
privateSession.lastAutoRetryOptions = {
853+
privateSession.lastAutoRetryRequest = {
848854
model: "anthropic:claude-sonnet-4-5",
849855
agentId: "exec",
850856
};
@@ -872,6 +878,94 @@ describe("AgentSession startup auto-retry recovery", () => {
872878
session.dispose();
873879
});
874880

881+
test("retryActiveStream resumes the reconstructed follow-up after compaction handoff send fails", async () => {
882+
const workspaceId = "startup-retry-follow-up-handoff";
883+
const { session, historyService, cleanup } = await createSessionBundle(workspaceId);
884+
cleanups.push(cleanup);
885+
886+
const appendResult = await historyService.appendToHistory(
887+
workspaceId,
888+
createMuxMessage("summary-follow-up", "assistant", "Compaction summary", {
889+
muxMetadata: {
890+
type: "compaction-summary",
891+
pendingFollowUp: {
892+
text: "resume the original work",
893+
model: "openai:gpt-4o",
894+
agentId: "exec",
895+
thinkingLevel: "high",
896+
},
897+
},
898+
})
899+
);
900+
expect(appendResult.success).toBe(true);
901+
902+
const privateSession = session as unknown as {
903+
dispatchPendingFollowUp: () => Promise<boolean>;
904+
retryActiveStream: () => Promise<void>;
905+
lastAutoRetryRequest?: StartupRetrySendOptions;
906+
sendMessage: (
907+
message: string,
908+
options?: SendMessageOptions,
909+
internal?: { synthetic?: boolean }
910+
) => Promise<
911+
{ success: true } | { success: false; error: { type: string; message?: string } }
912+
>;
913+
resumeStream: (
914+
options: SendMessageOptions,
915+
internal?: { agentInitiated?: boolean }
916+
) => Promise<{ success: true; data: { started: boolean } }>;
917+
};
918+
919+
privateSession.lastAutoRetryRequest = {
920+
model: "anthropic:claude-sonnet-4-5",
921+
agentId: "compact",
922+
toolPolicy: [{ regex_match: ".*", action: "disable" }],
923+
agentInitiated: true,
924+
};
925+
privateSession.sendMessage = mock(() =>
926+
Promise.resolve({
927+
success: false as const,
928+
error: { type: "runtime_start_failed", message: "startup failed" },
929+
})
930+
);
931+
932+
let dispatchError: unknown;
933+
try {
934+
await privateSession.dispatchPendingFollowUp();
935+
} catch (error) {
936+
dispatchError = error;
937+
}
938+
expect(dispatchError).toBeInstanceOf(Error);
939+
expect((dispatchError as Error).message).toContain("Failed to dispatch pending follow-up");
940+
941+
const resumeStreamMock = mock(
942+
(_options: SendMessageOptions, _internal?: { agentInitiated?: boolean }) =>
943+
Promise.resolve({ success: true as const, data: { started: true } })
944+
);
945+
privateSession.resumeStream = resumeStreamMock;
946+
947+
await privateSession.retryActiveStream();
948+
949+
expect(resumeStreamMock).toHaveBeenCalledTimes(1);
950+
const firstCall = resumeStreamMock.mock.calls[0];
951+
expect(firstCall).toBeDefined();
952+
const [optionsArg, internalArg] = firstCall as unknown as [
953+
SendMessageOptions,
954+
{ agentInitiated?: boolean } | undefined,
955+
];
956+
expect(optionsArg).toEqual(
957+
expect.objectContaining({
958+
model: "openai:gpt-4o",
959+
agentId: "exec",
960+
thinkingLevel: "high",
961+
}) as SendMessageOptions
962+
);
963+
expect(optionsArg.toolPolicy).toBeUndefined();
964+
expect(internalArg?.agentInitiated).toBeUndefined();
965+
966+
session.dispose();
967+
});
968+
875969
test("persists startup abandon marker for pre-stream user aborts", async () => {
876970
const workspaceId = "startup-retry-pre-stream-abort";
877971
const { historyService, config, cleanup } = await createTestHistoryService();

0 commit comments

Comments
 (0)