mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user