fix(ui): keep custodian transcript aligned after inference errors (#111562)

* fix(ui): reconcile invalidated custodian sessions

* test(ui): preserve custodian page harness type
This commit is contained in:
Peter Steinberger
2026-07-19 16:00:08 -07:00
committed by GitHub
parent a0c3067b54
commit c5f9addb52
9 changed files with 235 additions and 21 deletions
+1
View File
@@ -1,4 +1,5 @@
export * from "./clawhub-trust-error-details.js";
export * from "./system-agent-error-details.js";
export { readMissingScopeError, readMissingScopeErrorDetails } from "./gateway-error-details.js";
export * from "./session-icon.js";
export * from "./terminal-validators.js";
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
buildSystemAgentSessionInvalidatedErrorDetails,
readSystemAgentSessionInvalidatedErrorDetails,
SystemAgentErrorDetailCodes,
} from "./system-agent-error-details.js";
describe("system-agent error details", () => {
it("round-trips a session invalidation marker", () => {
const details = buildSystemAgentSessionInvalidatedErrorDetails();
expect(details).toEqual({ code: "system_agent_session_invalidated" });
expect(readSystemAgentSessionInvalidatedErrorDetails(details)).toEqual(details);
});
it.each([undefined, null, [], {}, { code: "other" }])(
"rejects an unrelated details payload: %j",
(details) => {
expect(readSystemAgentSessionInvalidatedErrorDetails(details)).toBeUndefined();
},
);
it("exports the stable marker", () => {
expect(SystemAgentErrorDetailCodes.SESSION_INVALIDATED).toBe(
"system_agent_session_invalidated",
);
});
});
@@ -0,0 +1,22 @@
/** Structured system-agent details carried in gateway error payloads. */
export const SystemAgentErrorDetailCodes = {
SESSION_INVALIDATED: "system_agent_session_invalidated",
} as const;
export type SystemAgentSessionInvalidatedErrorDetails = {
code: typeof SystemAgentErrorDetailCodes.SESSION_INVALIDATED;
};
export function buildSystemAgentSessionInvalidatedErrorDetails(): SystemAgentSessionInvalidatedErrorDetails {
return { code: SystemAgentErrorDetailCodes.SESSION_INVALIDATED };
}
export function readSystemAgentSessionInvalidatedErrorDetails(
details: unknown,
): SystemAgentSessionInvalidatedErrorDetails | undefined {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return undefined;
}
const code = (details as { code?: unknown }).code;
return code === SystemAgentErrorDetailCodes.SESSION_INVALIDATED ? { code } : undefined;
}
@@ -431,6 +431,7 @@ describe("openclaw.chat", () => {
message: "OpenClaw requires working inference: no configured model",
},
});
expect(call.error).not.toHaveProperty("details");
expect(sessions.size).toBe(0);
});
@@ -832,6 +833,7 @@ describe("openclaw.chat", () => {
error: {
code: "UNAVAILABLE",
message: expect.stringContaining("working inference"),
details: { code: "system_agent_session_invalidated" },
},
});
expect(dispose).toHaveBeenCalledOnce();
+9 -1
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
// OpenClaw gateway methods host the setup/repair conversation for clients.
import {
buildSystemAgentSessionInvalidatedErrorDetails,
ErrorCodes,
errorShape,
validateSystemAgentChatParams,
@@ -600,6 +601,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
// A failed inference turn invalidates this conversation. Remove the
// exact engine before cleanup so a retry must pass the live gate and
// cannot resume partial proposal or CLI-session state.
// Initialization failures stay unmarked because no live session existed.
if (sessions.get(sessionId)?.engine === session.engine) {
sessions.delete(sessionId);
}
@@ -608,7 +610,13 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
} catch {
// The inference error is authoritative; cleanup stays best-effort.
}
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, error.message));
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, error.message, {
details: buildSystemAgentSessionInvalidatedErrorDetails(),
}),
);
return;
}
persistEngineHistory(session.engine, historyStart);
+2
View File
@@ -1907,6 +1907,8 @@ export const en: TranslationMap = {
earlier: "Earlier",
requestFailed: "OpenClaw could not reply. Try again.",
connectionChanged: "The Gateway connection changed. Retry to continue this setup.",
sessionRestarted:
"{error} OpenClaw started a fresh session; earlier messages remain for context.",
unsupportedGateway: "Update the Gateway to continue setup with OpenClaw.",
history: {
button: "History",
@@ -0,0 +1,128 @@
/* @vitest-environment jsdom */
import { GatewayProtocolRequestError } from "@openclaw/gateway-client/browser";
import { buildSystemAgentSessionInvalidatedErrorDetails } from "@openclaw/gateway-protocol";
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import { createContext, mountPage } from "./custodian-page.test-harness.ts";
type MountedCustodianPage = Awaited<ReturnType<typeof mountPage>>["page"];
async function sendMessage(page: MountedCustodianPage, message: string): Promise<void> {
const composer = page.querySelector<HTMLTextAreaElement>("textarea")!;
composer.value = message;
composer.dispatchEvent(new Event("input"));
await page.updateComplete;
page.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
}
describe("custodian page session lifecycle", () => {
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
it("starts fresh after the gateway invalidates the live session", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
sessionId: "engine-session-before-error",
reply: "Welcome.",
action: "none",
})
.mockRejectedValueOnce(
new GatewayProtocolRequestError({
code: "UNAVAILABLE",
message: "OpenClaw inference became unavailable.",
details: buildSystemAgentSessionInvalidatedErrorDetails(),
}),
)
.mockResolvedValueOnce({
sessionId: "engine-session-after-error",
reply: "Fresh session ready.",
action: "none",
});
const { context } = createContext(request);
const { page } = await mountPage(context);
await waitForFast(() => expect(page.textContent).toContain("Welcome."));
await sendMessage(page, "status please");
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
await waitForFast(() => expect(page.textContent).toContain("Fresh session ready."));
expect(request.mock.calls[2]?.[1]).toMatchObject({
sessionId: expect.stringMatching(/^control-ui-onboarding-/),
});
expect(request.mock.calls[2]?.[1]?.sessionId).not.toBe("engine-session-before-error");
expect(request.mock.calls[2]?.[1]).not.toHaveProperty("message");
expect(page.textContent).toContain("Earlier");
expect(page.textContent).toContain("started a fresh session");
});
it("keeps the live session after an error that does not invalidate it", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
sessionId: "engine-session-that-survives",
reply: "Welcome.",
action: "none",
})
.mockRejectedValueOnce(
new GatewayProtocolRequestError({
code: "UNAVAILABLE",
message: "Temporary request failure.",
}),
)
.mockResolvedValueOnce({
sessionId: "engine-session-that-survives",
reply: "Still together.",
action: "none",
});
const { context } = createContext(request);
const { page } = await mountPage(context);
await waitForFast(() => expect(page.textContent).toContain("Welcome."));
await sendMessage(page, "first try");
await waitForFast(() => expect(page.textContent).toContain("Temporary request failure."));
await sendMessage(page, "second try");
await waitForFast(() => expect(page.textContent).toContain("Still together."));
expect(request.mock.calls[2]?.[1]).toMatchObject({
sessionId: "engine-session-that-survives",
message: "second try",
});
});
it("stops after one rotation when the fresh session failure is also marked", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
sessionId: "engine-session-before-outage",
reply: "Welcome.",
action: "none",
})
.mockRejectedValueOnce(
new GatewayProtocolRequestError({
code: "UNAVAILABLE",
message: "The live session was lost.",
details: buildSystemAgentSessionInvalidatedErrorDetails(),
}),
)
.mockRejectedValueOnce(
new GatewayProtocolRequestError({
code: "UNAVAILABLE",
message: "Inference is still unavailable.",
details: buildSystemAgentSessionInvalidatedErrorDetails(),
}),
);
const { context } = createContext(request);
const { page } = await mountPage(context);
await waitForFast(() => expect(page.textContent).toContain("Welcome."));
await sendMessage(page, "status please");
await waitForFast(() => expect(page.textContent).toContain("Inference is still unavailable."));
expect(request).toHaveBeenCalledTimes(3);
expect(request.mock.calls[2]?.[1]).not.toHaveProperty("message");
});
});
+18 -20
View File
@@ -25,6 +25,12 @@ import { renderMessageGroup } from "../chat/components/chat-message.ts";
import { renderCustodianChangeHistory } from "./custodian-history.ts";
import { renderCustodianQuestionCard } from "./custodian-question-card.ts";
import * as eventNudgeState from "./event-nudge.ts";
import {
isCustodianSessionInvalidatedError,
sessionVariant,
type CustodianSessionVariant,
welcomeVariant,
} from "./session-lifecycle.ts";
import { parseCustodianQuestion, type CustodianStructuredQuestion } from "./structured-question.ts";
import {
createCustodianSessionId,
@@ -76,7 +82,7 @@ export class CustodianPage extends OpenClawLightDomElement {
private requestEpoch = 0;
private nextMessageId = 1;
private retryParams: SystemAgentChatParams | null = null;
private sessionVariant: "onboarding" | "new-agent" | "caretaker" | null = null;
private sessionVariant: CustodianSessionVariant | null = null;
private sessionClient: GatewayBrowserClient | null = null;
private sessionOwnershipKey: string | null = null;
private sessionStarted = false;
@@ -121,20 +127,6 @@ export class CustodianPage extends OpenClawLightDomElement {
}
}
private currentSessionVariant(): "onboarding" | "new-agent" | "caretaker" {
return this.onboarding ? "onboarding" : this.newAgentIntent ? "new-agent" : "caretaker";
}
private welcomeVariant(): Pick<SystemAgentChatParams, "welcomeVariant"> {
if (this.onboarding) {
return { welcomeVariant: "onboarding" };
}
if (this.newAgentIntent) {
return { welcomeVariant: "new-agent" };
}
return {};
}
/**
* Transcript rows are durable and admin-scoped, but the live engine session
* owns wizard and approval state. Rotate only that volatile state when its
@@ -151,7 +143,7 @@ export class CustodianPage extends OpenClawLightDomElement {
private startSession(
client: GatewayBrowserClient,
variant: "onboarding" | "new-agent" | "caretaker",
variant: CustodianSessionVariant,
loadTranscript: boolean,
): void {
this.sessionId = createCustodianSessionId();
@@ -161,7 +153,7 @@ export class CustodianPage extends OpenClawLightDomElement {
this.sessionStarted = true;
void this.initializeSession(
client,
{ sessionId: this.sessionId, ...this.welcomeVariant() },
{ sessionId: this.sessionId, ...welcomeVariant(variant) },
loadTranscript,
);
}
@@ -182,7 +174,7 @@ export class CustodianPage extends OpenClawLightDomElement {
private rotateVolatileSession(
client: GatewayBrowserClient,
variant: "onboarding" | "new-agent" | "caretaker",
variant: CustodianSessionVariant,
): void {
this.answeredQuestions = retireCustodianQuestions(this.messages, this.answeredQuestions);
this.retryParams = null;
@@ -207,7 +199,7 @@ export class CustodianPage extends OpenClawLightDomElement {
this.resetHistory();
}
}
const variant = this.currentSessionVariant();
const variant = sessionVariant(this.onboarding, this.newAgentIntent);
const variantChanged = this.sessionStarted && this.sessionVariant !== variant;
const ownershipKey = this.currentSessionOwnershipKey();
const clientReplaced =
@@ -467,6 +459,12 @@ export class CustodianPage extends OpenClawLightDomElement {
} catch (error) {
if (epoch === this.requestEpoch && client === this.activeClient) {
this.error = custodianErrorMessage(error);
if (params.message !== undefined && isCustodianSessionInvalidatedError(error)) {
// Adopt a new id before another visible turn; retained rows are not live context.
// Welcome requests never rotate, so even a mis-marked outage stops after one attempt.
this.rotateVolatileSession(client, sessionVariant(this.onboarding, this.newAgentIntent));
this.error = t("custodian.sessionRestarted", { error: custodianErrorMessage(error) });
}
}
// A failed user turn may still have reached the agent and acted; there is
// no turn idempotency, so never keep it replayable (or its raw text).
@@ -515,7 +513,7 @@ export class CustodianPage extends OpenClawLightDomElement {
this.input = "";
const reply = this.requestReply(client, {
sessionId: this.sessionId,
...this.welcomeVariant(),
...welcomeVariant(sessionVariant(this.onboarding, this.newAgentIntent)),
message,
});
const replyEpoch = this.requestEpoch;
@@ -0,0 +1,25 @@
import {
readSystemAgentSessionInvalidatedErrorDetails,
type SystemAgentChatParams,
} from "@openclaw/gateway-protocol";
export type CustodianSessionVariant = "onboarding" | "new-agent" | "caretaker";
export function sessionVariant(
onboarding: boolean,
newAgentIntent: boolean,
): CustodianSessionVariant {
return onboarding ? "onboarding" : newAgentIntent ? "new-agent" : "caretaker";
}
export function welcomeVariant(
variant: CustodianSessionVariant,
): Pick<SystemAgentChatParams, "welcomeVariant"> {
return variant === "caretaker" ? {} : { welcomeVariant: variant };
}
export function isCustodianSessionInvalidatedError(error: unknown): boolean {
const details =
error && typeof error === "object" ? (error as { details?: unknown }).details : undefined;
return readSystemAgentSessionInvalidatedErrorDetails(details) !== undefined;
}