Skip to content

Commit 0d834cb

Browse files
committed
Allow retrying provider authentication failures
- Detect provider auth failures in persisted assistant responses - Retry with a fresh provider message ID while preserving the transcript message - Update orchestration contracts, chat UI state, and coverage
1 parent 9e94c8a commit 0d834cb

11 files changed

Lines changed: 342 additions & 17 deletions

File tree

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,71 @@ describe("ProviderCommandReactor", () => {
607607
});
608608
});
609609

610+
it("uses a fresh provider message id when retrying a persisted user message", async () => {
611+
const harness = await createHarness();
612+
const threadId = ThreadId.make("thread-1");
613+
const messageId = asMessageId("user-message-retry");
614+
const now = "2026-01-01T00:00:00.000Z";
615+
616+
await Effect.runPromise(
617+
harness.engine.dispatch({
618+
type: "thread.turn.start",
619+
commandId: CommandId.make("cmd-turn-before-retry"),
620+
threadId,
621+
message: {
622+
messageId,
623+
role: "user",
624+
text: "retry this request",
625+
attachments: [],
626+
},
627+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
628+
runtimeMode: "approval-required",
629+
createdAt: now,
630+
}),
631+
);
632+
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
633+
634+
await Effect.runPromise(
635+
harness.engine.dispatch({
636+
type: "thread.session.set",
637+
commandId: CommandId.make("cmd-mark-turn-failed"),
638+
threadId,
639+
session: {
640+
threadId,
641+
status: "error",
642+
providerName: "codex",
643+
providerInstanceId: ProviderInstanceId.make("codex"),
644+
runtimeMode: "approval-required",
645+
activeTurnId: null,
646+
lastError: "authentication expired",
647+
updatedAt: "2026-01-01T00:00:01.000Z",
648+
},
649+
createdAt: "2026-01-01T00:00:01.000Z",
650+
}),
651+
);
652+
await Effect.runPromise(
653+
harness.engine.dispatch({
654+
type: "thread.turn.retry",
655+
commandId: CommandId.make("cmd-turn-retry"),
656+
threadId,
657+
createdAt: "2026-01-01T00:00:02.000Z",
658+
}),
659+
);
660+
661+
await waitFor(() => harness.sendTurn.mock.calls.length === 2);
662+
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ messageId });
663+
expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
664+
threadId,
665+
input: "retry this request",
666+
});
667+
const retryProviderMessageId = (
668+
harness.sendTurn.mock.calls[1]?.[0] as { messageId?: MessageId } | undefined
669+
)?.messageId;
670+
expect(retryProviderMessageId).toBeDefined();
671+
expect(retryProviderMessageId).not.toBe(messageId);
672+
await harness.drain();
673+
});
674+
610675
it("reacts to thread.goal.set by ensuring a session and setting the provider goal", async () => {
611676
const harness = await createHarness();
612677
const now = "2026-01-01T00:00:00.000Z";

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,7 @@ const make = Effect.gen(function* () {
854854
const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: {
855855
readonly threadId: ThreadId;
856856
readonly messageId: MessageId;
857+
readonly providerMessageId?: MessageId;
857858
readonly messageText: string;
858859
readonly attachments?: ReadonlyArray<ChatAttachment>;
859860
readonly skills?: ReadonlyArray<ChatSkillReference>;
@@ -998,7 +999,7 @@ const make = Effect.gen(function* () {
998999

9991000
return {
10001001
threadId: input.threadId,
1001-
messageId: input.messageId,
1002+
messageId: input.providerMessageId ?? input.messageId,
10021003
...(normalizedInput ? { input: normalizedInput } : {}),
10031004
...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
10041005
...(input.skills !== undefined && input.skills.length > 0 ? { skills: input.skills } : {}),
@@ -1282,6 +1283,9 @@ const make = Effect.gen(function* () {
12821283
const sendTurnRequest = yield* buildSendTurnRequestForThread({
12831284
threadId: event.payload.threadId,
12841285
messageId: event.payload.messageId,
1286+
...(event.payload.providerMessageId !== undefined
1287+
? { providerMessageId: event.payload.providerMessageId }
1288+
: {}),
12851289
messageText: message.text,
12861290
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
12871291
...(event.payload.skills !== undefined ? { skills: event.payload.skills } : {}),

apps/server/src/orchestration/decider.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {
22
DEFAULT_PROJECT_KIND,
3+
MessageId,
34
type OrchestrationCommand,
45
type OrchestrationEvent,
56
type OrchestrationReadModel,
67
} from "@threadlines/contracts";
78
import * as DateTime from "effect/DateTime";
89
import * as Effect from "effect/Effect";
910
import { areFilesystemPathsEqual } from "@threadlines/shared/path";
11+
import { findProviderAuthRetryUserMessageIndex } from "@threadlines/shared/providerAuth";
1012

1113
import { OrchestrationCommandInvariantError } from "./Errors.ts";
1214
import {
@@ -716,13 +718,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
716718
detail: `Thread '${command.threadId}' already has a turn in flight and cannot retry.`,
717719
});
718720
}
719-
if (!session || session.lastError === null) {
721+
const providerAuthRetryUserMessageIndex = findProviderAuthRetryUserMessageIndex(
722+
targetThread.messages,
723+
);
724+
if (!session || (session.lastError === null && providerAuthRetryUserMessageIndex === null)) {
720725
return yield* new OrchestrationCommandInvariantError({
721726
commandType: command.type,
722727
detail: `Thread '${command.threadId}' has no failed turn to retry.`,
723728
});
724729
}
725-
const lastUserMessage = targetThread.messages.findLast((message) => message.role === "user");
730+
const lastUserMessage =
731+
providerAuthRetryUserMessageIndex === null
732+
? targetThread.messages.findLast((message) => message.role === "user")
733+
: targetThread.messages[providerAuthRetryUserMessageIndex];
726734
if (!lastUserMessage) {
727735
return yield* new OrchestrationCommandInvariantError({
728736
commandType: command.type,
@@ -731,9 +739,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
731739
}
732740
// Mirrors thread.turn.start, but re-points at the persisted last user
733741
// message instead of appending a new one: the transcript keeps a single
734-
// bubble and attachments are reused as stored. lastError is carried
735-
// forward (as in turn.start) so the failure stays visible until the
736-
// provider actually accepts the turn.
742+
// bubble and attachments are reused as stored. A projected lastError is
743+
// carried forward (as in turn.start); providers that encode auth failure
744+
// as a completed assistant response can legitimately keep it null.
737745
const retrySessionEvent: Omit<OrchestrationEvent, "sequence"> = {
738746
...withEventBase({
739747
aggregateKind: "thread",
@@ -771,6 +779,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
771779
payload: {
772780
threadId: command.threadId,
773781
messageId: lastUserMessage.id,
782+
// Claude uses the provider-facing message id as a command UUID and
783+
// ignores a repeated UUID after reporting it completed. Keep the
784+
// persisted transcript message id stable while giving every retry
785+
// attempt a fresh provider command identity.
786+
providerMessageId: MessageId.make(crypto.randomUUID()),
774787
runtimeMode: targetThread.runtimeMode,
775788
interactionMode: targetThread.interactionMode,
776789
...(lastUserMessage.skills !== undefined ? { skills: lastUserMessage.skills } : {}),

apps/server/src/orchestration/decider.turnRetry.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ describe("decider turn retry", () => {
152152
payload: {
153153
threadId,
154154
messageId: MessageId.make("message-user-2"),
155+
providerMessageId: expect.any(String),
155156
runtimeMode: "full-access",
156157
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
157158
skills: [
@@ -162,6 +163,10 @@ describe("decider turn retry", () => {
162163
],
163164
},
164165
});
166+
if (events[1]?.type !== "thread.turn-start-requested") {
167+
throw new Error("Expected retry turn start event.");
168+
}
169+
expect(events[1].payload.providerMessageId).not.toBe(events[1].payload.messageId);
165170
});
166171

167172
it("emits events the projector can apply", async () => {
@@ -247,6 +252,63 @@ describe("decider turn retry", () => {
247252
).rejects.toThrow("has no failed turn to retry");
248253
});
249254

255+
it("retries a completed Claude authentication response without duplicating the user message", async () => {
256+
const authMessageId = MessageId.make("message-assistant-auth");
257+
const settledAuthThread = makeThread({
258+
latestTurn: {
259+
turnId: TurnId.make("turn-auth"),
260+
state: "completed",
261+
requestedAt: "2026-01-01T00:00:01.000Z",
262+
startedAt: "2026-01-01T00:00:02.000Z",
263+
completedAt: "2026-01-01T00:00:04.000Z",
264+
assistantMessageId: authMessageId,
265+
},
266+
messages: [
267+
...makeThread().messages,
268+
{
269+
id: authMessageId,
270+
role: "assistant",
271+
text: "Not logged in · Please run /login",
272+
turnId: TurnId.make("turn-auth"),
273+
streaming: false,
274+
createdAt: "2026-01-01T00:00:04.000Z",
275+
updatedAt: "2026-01-01T00:00:04.000Z",
276+
},
277+
],
278+
session: {
279+
...failedSession,
280+
status: "ready",
281+
lastError: null,
282+
},
283+
});
284+
285+
const decided = await Effect.runPromise(
286+
decideOrchestrationCommand({
287+
command: retryCommand,
288+
readModel: makeReadModel(settledAuthThread),
289+
}),
290+
);
291+
const events = Array.isArray(decided) ? decided : [decided];
292+
293+
expect(events).toHaveLength(2);
294+
expect(events[0]).toMatchObject({
295+
type: "thread.session-set",
296+
payload: {
297+
session: {
298+
status: "starting",
299+
lastError: null,
300+
},
301+
},
302+
});
303+
expect(events[1]).toMatchObject({
304+
type: "thread.turn-start-requested",
305+
payload: {
306+
messageId: MessageId.make("message-user-2"),
307+
providerMessageId: expect.any(String),
308+
},
309+
});
310+
});
311+
250312
it("rejects retrying a thread without user messages", async () => {
251313
const emptyThread = makeThread({ messages: [] });
252314

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
backgroundRunCommandsMatch,
2525
deriveComposerSendState,
2626
deriveDetectedBackgroundRunLabel,
27+
deriveFailedTurnRetryMessageId,
2728
deriveProviderBackgroundRuns,
2829
deriveProviderAuthReconnectPrompt,
2930
desktopCapturedScreenshotToFile,
@@ -39,6 +40,7 @@ import {
3940
reconcileMountedTerminalThreadIds,
4041
resolveSendEnvMode,
4142
shouldConfirmTerminalKill,
43+
shouldOfferFailedTurnRetry,
4244
shouldRefreshThreadDetailAfterEventLoopStall,
4345
shouldWriteThreadErrorToCurrentServerThread,
4446
THREAD_DETAIL_STALL_REFRESH_COOLDOWN_MS,
@@ -474,6 +476,79 @@ describe("deriveProviderAuthReconnectPrompt", () => {
474476
});
475477
});
476478

479+
describe("deriveFailedTurnRetryMessageId", () => {
480+
const userMessage = {
481+
id: MessageId.make("message-user"),
482+
role: "user" as const,
483+
text: "retry me",
484+
};
485+
486+
it("targets a session failure or a completed provider-auth response", () => {
487+
expect(
488+
deriveFailedTurnRetryMessageId({
489+
messages: [userMessage],
490+
sessionLastError: "Provider process exited",
491+
}),
492+
).toBe(userMessage.id);
493+
expect(
494+
deriveFailedTurnRetryMessageId({
495+
messages: [
496+
userMessage,
497+
{
498+
id: MessageId.make("message-auth"),
499+
role: "assistant",
500+
text: "Not logged in · Please run /login",
501+
},
502+
],
503+
sessionLastError: null,
504+
}),
505+
).toBe(userMessage.id);
506+
expect(
507+
deriveFailedTurnRetryMessageId({
508+
messages: [
509+
userMessage,
510+
{
511+
id: MessageId.make("message-success"),
512+
role: "assistant",
513+
text: "Request completed.",
514+
},
515+
],
516+
sessionLastError: null,
517+
}),
518+
).toBeNull();
519+
});
520+
});
521+
522+
describe("shouldOfferFailedTurnRetry", () => {
523+
it("uses the session lifecycle instead of a stale projected turn", () => {
524+
const failedMessageId = MessageId.make("message-user");
525+
526+
expect(
527+
shouldOfferFailedTurnRetry({
528+
isServerThread: true,
529+
failedMessageId,
530+
orchestrationStatus: "stopped",
531+
}),
532+
).toBe(true);
533+
expect(
534+
shouldOfferFailedTurnRetry({
535+
isServerThread: true,
536+
failedMessageId,
537+
orchestrationStatus: "ready",
538+
}),
539+
).toBe(true);
540+
for (const orchestrationStatus of ["starting", "running"] as const) {
541+
expect(
542+
shouldOfferFailedTurnRetry({
543+
isServerThread: true,
544+
failedMessageId,
545+
orchestrationStatus,
546+
}),
547+
).toBe(false);
548+
}
549+
});
550+
});
551+
477552
describe("isRetryableThreadError", () => {
478553
function runtimeErrorActivity(input: {
479554
id: string;

apps/web/src/components/ChatView.logic.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
type EnvironmentId,
3+
type MessageId,
34
type OrchestrationRevertPlan,
45
ProjectId,
56
type ModelSelection,
@@ -10,6 +11,7 @@ import {
1011
type TurnId,
1112
} from "@threadlines/contracts";
1213
import {
14+
findProviderAuthRetryUserMessageIndex,
1315
isProviderAuthErrorMessage,
1416
providerAuthReconnectCommand,
1517
} from "@threadlines/shared/providerAuth";
@@ -1527,6 +1529,37 @@ export function deriveProviderAuthReconnectPrompt(input: {
15271529
return null;
15281530
}
15291531

1532+
export function deriveFailedTurnRetryMessageId(input: {
1533+
readonly messages: ReadonlyArray<Pick<ChatMessage, "id" | "role" | "text">>;
1534+
readonly sessionLastError: string | null | undefined;
1535+
}): MessageId | null {
1536+
const lastUserMessage = input.messages.findLast((message) => message.role === "user");
1537+
if (!lastUserMessage) {
1538+
return null;
1539+
}
1540+
if (input.sessionLastError?.trim()) {
1541+
return lastUserMessage.id;
1542+
}
1543+
1544+
const authRetryUserMessageIndex = findProviderAuthRetryUserMessageIndex(input.messages);
1545+
return authRetryUserMessageIndex === null
1546+
? null
1547+
: (input.messages[authRetryUserMessageIndex]?.id ?? null);
1548+
}
1549+
1550+
export function shouldOfferFailedTurnRetry(input: {
1551+
readonly isServerThread: boolean;
1552+
readonly failedMessageId: MessageId | null;
1553+
readonly orchestrationStatus: ThreadSession["orchestrationStatus"] | null | undefined;
1554+
}): boolean {
1555+
return (
1556+
input.isServerThread &&
1557+
input.failedMessageId !== null &&
1558+
input.orchestrationStatus !== "starting" &&
1559+
input.orchestrationStatus !== "running"
1560+
);
1561+
}
1562+
15301563
/** Error classes where re-running the same turn would reproduce the failure;
15311564
* those surfaces get their own affordances (auth reconnect, usage reset). */
15321565
const NON_RETRYABLE_RUNTIME_ERROR_CLASSES: ReadonlySet<string> = new Set([

0 commit comments

Comments
 (0)