Skip to content

Commit 1d6b662

Browse files
committed
🤖 fix: preserve retry intent for internal stream retries
Arm lastAutoRetryRequest inside the direct internal retry helpers that materially redefine the resumable request before calling streamWithHistory(). This keeps compaction 1M-context retries and exec hard-restart retries resumable even when startup fails before the retry stream begins. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh -->
1 parent 71f80b9 commit 1d6b662

File tree

2 files changed

+292
-0
lines changed

2 files changed

+292
-0
lines changed

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

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,290 @@ describe("AgentSession startup auto-retry recovery", () => {
966966
session.dispose();
967967
});
968968

969+
test("compaction retry failure preserves the adjusted 1M-context retry request", async () => {
970+
const workspaceId = "startup-retry-compaction-adjusted-request";
971+
const { session, cleanup } = await createSessionBundle(workspaceId);
972+
cleanups.push(cleanup);
973+
974+
const baseOptions: SendMessageOptions = {
975+
model: "anthropic:claude-sonnet-4-5",
976+
agentId: "compact",
977+
};
978+
const retriedOptions: SendMessageOptions = {
979+
...baseOptions,
980+
providerOptions: {
981+
anthropic: {
982+
use1MContext: true,
983+
use1MContextModels: [baseOptions.model],
984+
},
985+
},
986+
};
987+
988+
const privateSession = session as unknown as {
989+
maybeRetryCompactionOnContextExceeded: (data: {
990+
messageId: string;
991+
errorType?: string;
992+
}) => Promise<boolean>;
993+
lastAutoRetryRequest?: StartupRetrySendOptions;
994+
activeCompactionRequest?: {
995+
id: string;
996+
modelString: string;
997+
options?: SendMessageOptions;
998+
source?: "idle-compaction" | "auto-compaction";
999+
};
1000+
activeStreamContext?: {
1001+
modelString: string;
1002+
options?: SendMessageOptions;
1003+
agentInitiated?: boolean;
1004+
openaiTruncationModeOverride?: "auto" | "disabled";
1005+
providersConfig: unknown;
1006+
};
1007+
supports1MContextRetry: (modelString: string) => boolean;
1008+
is1MContextEnabledForModel: (
1009+
modelString: string,
1010+
options?: SendMessageOptions,
1011+
providersConfig?: unknown
1012+
) => boolean;
1013+
withAnthropic1MContext: (
1014+
modelString: string,
1015+
options?: SendMessageOptions
1016+
) => SendMessageOptions | null;
1017+
finalizeCompactionRetry: (messageId: string) => Promise<void>;
1018+
streamWithHistory: (
1019+
modelString: string,
1020+
options?: SendMessageOptions,
1021+
openaiTruncationModeOverride?: "auto" | "disabled",
1022+
disablePostCompactionAttachments?: boolean,
1023+
agentInitiated?: boolean
1024+
) => Promise<
1025+
| { success: true; data: undefined }
1026+
| { success: false; error: { type: "runtime_start_failed"; message: string } }
1027+
>;
1028+
};
1029+
1030+
privateSession.lastAutoRetryRequest = {
1031+
model: "openai:gpt-4o-mini",
1032+
agentId: "compact",
1033+
agentInitiated: true,
1034+
};
1035+
privateSession.activeCompactionRequest = {
1036+
id: "compaction-request-1",
1037+
modelString: baseOptions.model,
1038+
options: baseOptions,
1039+
source: "auto-compaction",
1040+
};
1041+
privateSession.activeStreamContext = {
1042+
modelString: baseOptions.model,
1043+
options: baseOptions,
1044+
agentInitiated: true,
1045+
providersConfig: null,
1046+
};
1047+
privateSession.supports1MContextRetry = mock(() => true);
1048+
privateSession.is1MContextEnabledForModel = mock(() => false);
1049+
privateSession.withAnthropic1MContext = mock(() => retriedOptions);
1050+
privateSession.finalizeCompactionRetry = mock(() => Promise.resolve());
1051+
const streamWithHistoryMock = mock(() =>
1052+
Promise.resolve({
1053+
success: false as const,
1054+
error: {
1055+
type: "runtime_start_failed" as const,
1056+
message: "retry startup failed",
1057+
},
1058+
})
1059+
);
1060+
privateSession.streamWithHistory = streamWithHistoryMock;
1061+
1062+
const retried = await privateSession.maybeRetryCompactionOnContextExceeded({
1063+
messageId: "assistant-retry-failure",
1064+
errorType: "context_exceeded",
1065+
});
1066+
1067+
expect(retried).toBe(false);
1068+
expect(streamWithHistoryMock).toHaveBeenCalledTimes(1);
1069+
expect(privateSession.lastAutoRetryRequest?.model).toBe(baseOptions.model);
1070+
expect(privateSession.lastAutoRetryRequest?.agentId).toBe("compact");
1071+
expect(privateSession.lastAutoRetryRequest?.providerOptions?.anthropic?.use1MContext).toBe(
1072+
true
1073+
);
1074+
expect(
1075+
privateSession.lastAutoRetryRequest?.providerOptions?.anthropic?.use1MContextModels
1076+
).toEqual([baseOptions.model]);
1077+
expect(privateSession.lastAutoRetryRequest?.agentInitiated).toBe(true);
1078+
1079+
session.dispose();
1080+
});
1081+
1082+
test("exec-subagent hard-restart retry failure preserves the rebuilt continuation request", async () => {
1083+
const workspaceId = "startup-retry-hard-restart-request";
1084+
const { historyService, config, cleanup } = await createTestHistoryService();
1085+
cleanups.push(cleanup);
1086+
1087+
const appendSnapshotResult = await historyService.appendToHistory(
1088+
workspaceId,
1089+
createMuxMessage("snapshot-1", "user", "<snapshot>", {
1090+
timestamp: Date.now(),
1091+
synthetic: true,
1092+
fileAtMentionSnapshot: ["token"],
1093+
})
1094+
);
1095+
expect(appendSnapshotResult.success).toBe(true);
1096+
1097+
const appendPromptResult = await historyService.appendToHistory(
1098+
workspaceId,
1099+
createMuxMessage("user-1", "user", "Do the thing", {
1100+
timestamp: Date.now(),
1101+
})
1102+
);
1103+
expect(appendPromptResult.success).toBe(true);
1104+
1105+
const parentWorkspaceId = "startup-retry-hard-restart-parent";
1106+
const childWorkspaceMetadata: WorkspaceMetadata = {
1107+
id: workspaceId,
1108+
name: "child",
1109+
projectName: "project",
1110+
projectPath: "/tmp/project",
1111+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
1112+
aiSettingsByAgent: {
1113+
[WORKSPACE_DEFAULTS.agentId]: {
1114+
model: "openai:gpt-4o",
1115+
thinkingLevel: "medium",
1116+
},
1117+
},
1118+
parentWorkspaceId,
1119+
agentId: "exec",
1120+
};
1121+
const parentWorkspaceMetadata: WorkspaceMetadata = {
1122+
...childWorkspaceMetadata,
1123+
id: parentWorkspaceId,
1124+
name: "parent",
1125+
parentWorkspaceId: undefined,
1126+
};
1127+
1128+
const aiService: AIService = {
1129+
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
1130+
return this;
1131+
},
1132+
off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
1133+
return this;
1134+
},
1135+
isStreaming: mock(() => false),
1136+
stopStream: mock(() => Promise.resolve(Ok(undefined))),
1137+
streamMessage: mock(() => Promise.resolve(Ok(undefined))),
1138+
getWorkspaceMetadata: mock((id: string) => {
1139+
if (id === workspaceId) {
1140+
return Promise.resolve(Ok(childWorkspaceMetadata));
1141+
}
1142+
1143+
if (id === parentWorkspaceId) {
1144+
return Promise.resolve(Ok(parentWorkspaceMetadata));
1145+
}
1146+
1147+
return Promise.resolve({ success: false as const, error: "unknown workspace" });
1148+
}),
1149+
} as unknown as AIService;
1150+
1151+
const initStateManager: InitStateManager = {
1152+
on() {
1153+
return this;
1154+
},
1155+
off() {
1156+
return this;
1157+
},
1158+
} as unknown as InitStateManager;
1159+
1160+
const backgroundProcessManager: BackgroundProcessManager = {
1161+
cleanup: mock(() => Promise.resolve()),
1162+
setMessageQueued: mock(() => undefined),
1163+
} as unknown as BackgroundProcessManager;
1164+
1165+
const session = new AgentSession({
1166+
workspaceId,
1167+
config,
1168+
historyService,
1169+
aiService,
1170+
initStateManager,
1171+
backgroundProcessManager,
1172+
});
1173+
1174+
const baseOptions: SendMessageOptions = {
1175+
model: "openai:gpt-4o",
1176+
agentId: "exec",
1177+
additionalSystemInstructions: "Follow the existing plan.",
1178+
experiments: {
1179+
execSubagentHardRestart: true,
1180+
},
1181+
};
1182+
1183+
const privateSession = session as unknown as {
1184+
maybeHardRestartExecSubagentOnContextExceeded: (data: {
1185+
messageId: string;
1186+
errorType?: string;
1187+
}) => Promise<boolean>;
1188+
lastAutoRetryRequest?: StartupRetrySendOptions;
1189+
activeStreamContext?: {
1190+
modelString: string;
1191+
options?: SendMessageOptions;
1192+
agentInitiated?: boolean;
1193+
openaiTruncationModeOverride?: "auto" | "disabled";
1194+
providersConfig: unknown;
1195+
};
1196+
activeStreamUserMessageId?: string;
1197+
streamWithHistory: (
1198+
modelString: string,
1199+
options?: SendMessageOptions,
1200+
openaiTruncationModeOverride?: "auto" | "disabled",
1201+
disablePostCompactionAttachments?: boolean,
1202+
agentInitiated?: boolean
1203+
) => Promise<
1204+
| { success: true; data: undefined }
1205+
| { success: false; error: { type: "runtime_start_failed"; message: string } }
1206+
>;
1207+
};
1208+
1209+
privateSession.lastAutoRetryRequest = {
1210+
model: "openai:gpt-4o-mini",
1211+
agentId: "exec",
1212+
agentInitiated: true,
1213+
};
1214+
privateSession.activeStreamContext = {
1215+
modelString: baseOptions.model,
1216+
options: baseOptions,
1217+
agentInitiated: true,
1218+
providersConfig: null,
1219+
};
1220+
privateSession.activeStreamUserMessageId = "user-1";
1221+
const streamWithHistoryMock = mock(() =>
1222+
Promise.resolve({
1223+
success: false as const,
1224+
error: {
1225+
type: "runtime_start_failed" as const,
1226+
message: "hard restart startup failed",
1227+
},
1228+
})
1229+
);
1230+
privateSession.streamWithHistory = streamWithHistoryMock;
1231+
1232+
const retried = await privateSession.maybeHardRestartExecSubagentOnContextExceeded({
1233+
messageId: "assistant-hard-restart-failure",
1234+
errorType: "context_exceeded",
1235+
});
1236+
1237+
expect(retried).toBe(false);
1238+
expect(streamWithHistoryMock).toHaveBeenCalledTimes(1);
1239+
expect(privateSession.lastAutoRetryRequest?.model).toBe(baseOptions.model);
1240+
expect(privateSession.lastAutoRetryRequest?.agentId).toBe("exec");
1241+
expect(privateSession.lastAutoRetryRequest?.experiments?.execSubagentHardRestart).toBe(true);
1242+
expect(privateSession.lastAutoRetryRequest?.agentInitiated).toBe(true);
1243+
expect(privateSession.lastAutoRetryRequest?.additionalSystemInstructions).toContain(
1244+
"Context limit reached"
1245+
);
1246+
expect(privateSession.lastAutoRetryRequest?.additionalSystemInstructions).toContain(
1247+
"Follow the existing plan."
1248+
);
1249+
1250+
session.dispose();
1251+
});
1252+
9691253
test("persists startup abandon marker for pre-stream user aborts", async () => {
9701254
const workspaceId = "startup-retry-pre-stream-abort";
9711255
const { historyService, config, cleanup } = await createTestHistoryService();

src/node/services/agentSession.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3278,8 +3278,15 @@ export class AgentSession {
32783278

32793279
// Capture attribution before finalizeCompactionRetry() clears active stream state.
32803280
const retryAgentInitiated = this.activeStreamContext?.agentInitiated;
3281+
const retryOptionsForResume = retryOptions ?? {
3282+
model: context.modelString,
3283+
agentId: WORKSPACE_DEFAULTS.agentId,
3284+
};
32813285

32823286
await this.finalizeCompactionRetry(data.messageId);
3287+
this.setAutoRetryResumeState(
3288+
pickStartupRetrySendOptions(retryOptionsForResume, retryAgentInitiated)
3289+
);
32833290
this.setTurnPhase(TurnPhase.PREPARING);
32843291
let retryResult: Result<void, SendMessageError>;
32853292
try {
@@ -3658,6 +3665,7 @@ export class AgentSession {
36583665
},
36593666
};
36603667

3668+
this.setAutoRetryResumeState(pickStartupRetrySendOptions(retryOptions, context.agentInitiated));
36613669
this.setTurnPhase(TurnPhase.PREPARING);
36623670
let retryResult: Result<void, SendMessageError>;
36633671
try {

0 commit comments

Comments
 (0)