fix(ui): deliver failed steer retries as a new turn when the session is idle (#122919)

A steer rejected on the run/leaf fence parks as a failed steered queue row.
Retry previously refused whenever the target run was gone, leaving the
user's message permanently stuck with 'that run is no longer active'.
Retry now converts the parked row into a plain queued send when the session
is idle, so the text delivers as a fresh turn; active-run retries keep the
original steer binding, and disconnected retries keep the existing error.
This commit is contained in:
Peter Steinberger
2026-08-12 19:40:06 -07:00
committed by GitHub
parent c0b7ebd73e
commit c98b841b41
2 changed files with 73 additions and 13 deletions
+28 -12
View File
@@ -168,7 +168,7 @@ export function moveQueuedChatMessage(host: ChatHost, id: string, toIndex: numbe
}
export async function retryQueuedChatMessage(host: ChatHost, id: string) {
const item = host.chatQueue.find((entry) => entry.id === id);
let item = host.chatQueue.find((entry) => entry.id === id);
if (
!item ||
item.pendingRunId ||
@@ -180,23 +180,39 @@ export async function retryQueuedChatMessage(host: ChatHost, id: string) {
return;
}
if (item.kind === "steered") {
if (!host.connected || !host.client || !hasAbortableSessionRun(host)) {
if (!host.connected || !host.client) {
setChatError(host, t("chat.sendErrors.steerRunNoLongerActive"));
return;
}
const retry = updateQueuedMessage(host, id, (entry) => ({
...entry,
sendAttempts: 0,
sendError: undefined,
sendRequestStartedAtMs: undefined,
sendState: "waiting-idle",
}));
if (!retry) {
if (hasAbortableSessionRun(host)) {
const retry = updateQueuedMessage(host, id, (entry) => ({
...entry,
sendAttempts: 0,
sendError: undefined,
sendRequestStartedAtMs: undefined,
sendState: "waiting-idle",
}));
if (!retry) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
await steerQueuedChatMessageLifecycle(host, id, steerSendDependencies);
return;
}
const converted = updateQueuedMessage(host, id, (entry) => {
const {
kind: _kind,
pendingRunId: _pendingRunId,
steerTargetRunId: _steerTargetRunId,
...queued
} = entry;
return resetRetryState(queued, reconnectSafeQueuedSendState(host));
});
if (!converted) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
await steerQueuedChatMessageLifecycle(host, id, steerSendDependencies);
return;
item = converted;
}
let outbox = findStoredOutbox(host, item.id);
if (!outbox) {
+45 -1
View File
@@ -8797,13 +8797,14 @@ describe("handleSendChat", () => {
]);
expect(host.lastError).toBe("no active turn to steer");
host.chatRunId = null;
host.connected = false;
await retryQueuedChatMessage(host, original.id);
expect(payloads).toHaveLength(1);
expect(host.lastError).toBe(
"This steer still targets the previous run, but that run is no longer active.",
);
host.connected = true;
host.chatRunId = "active-run";
host.chatDisplayedLeafEntryId = "leaf-advanced-during-tool-work";
await retryQueuedChatMessage(host, original.id);
@@ -8821,6 +8822,49 @@ describe("handleSendChat", () => {
]);
});
it("retries a failed steer as a new turn when the session is idle", async () => {
const payloads: Array<Record<string, unknown>> = [];
const original = {
id: "idle-failed-steer",
text: "deliver this as a new turn",
createdAt: 1,
kind: "steered" as const,
sendAttempts: 1,
sendError: "The session switched branches — review and resend.",
sendRequestStartedAtMs: 123,
sendRunId: "previous-steer-request",
sendState: "failed" as const,
steerTargetRunId: "previous-run",
sessionKey: "agent:main:main",
agentId: "main",
};
const host = makeChatHost({
requestHandlers: {
"chat.history": idleChatHistory(original.sessionKey),
"chat.send": (params: unknown) => {
payloads.push(requireRecord(params, "idle steer retry payload"));
return { status: "ok", runId: "new-turn" };
},
},
chatError: "The session switched branches — review and resend.",
chatQueue: [original],
sessionKey: original.sessionKey,
});
expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true);
await retryQueuedChatMessage(host, original.id);
expect(payloads).toHaveLength(1);
expect(payloads[0]).toMatchObject({ message: original.text });
expect(payloads[0]).not.toHaveProperty("expectedRunId");
expect(payloads[0]).not.toHaveProperty("expectedLeafEntryId");
expect(payloads[0]).not.toHaveProperty("queueMode");
expect(payloads[0]?.idempotencyKey).not.toBe(original.sendRunId);
expect(host.chatQueue).toEqual([]);
expect(host.chatError).toBeNull();
expect(host.lastError).toBeNull();
});
it("fails a restored steer that predates durable target identity", async () => {
const original = {
id: "legacy-targetless-steer",