mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
feat(ui): present system-agent setup QR codes
This commit is contained in:
@@ -460,6 +460,7 @@ describe("GatewayBrowserClient", () => {
|
||||
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
|
||||
GATEWAY_CLIENT_CAPS.INLINE_WIDGETS,
|
||||
GATEWAY_CLIENT_CAPS.UI_COMMANDS,
|
||||
GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE,
|
||||
]);
|
||||
expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]);
|
||||
});
|
||||
@@ -1209,6 +1210,7 @@ describe("GatewayBrowserClient", () => {
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(onRecoveryScopeChange).toHaveBeenCalledOnce());
|
||||
expect(client.authenticatedDeviceId).toBe("device-1");
|
||||
expect(client.recoveryScopeReady).toBe(true);
|
||||
expect(client.recoveryScope).toBe("device-recovery-scope");
|
||||
expect(client.recoveryScope).not.toContain("stored-device-token");
|
||||
|
||||
@@ -309,6 +309,7 @@ async function buildGatewayConnectDevice(params: {
|
||||
|
||||
export class GatewayBrowserClient {
|
||||
private readonly client: GatewayProtocolClient<ConnectPlan>;
|
||||
private authenticatedDeviceIdValue: string | null = null;
|
||||
inboundActivitySeq = 0;
|
||||
private lastInboundActivityAtMs: number | null = null;
|
||||
private tickWatchTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -390,6 +391,10 @@ export class GatewayBrowserClient {
|
||||
return this.opts.url;
|
||||
}
|
||||
|
||||
get authenticatedDeviceId(): string | null {
|
||||
return this.authenticatedDeviceIdValue;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.client.start();
|
||||
}
|
||||
@@ -500,6 +505,7 @@ export class GatewayBrowserClient {
|
||||
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
|
||||
GATEWAY_CLIENT_CAPS.INLINE_WIDGETS,
|
||||
GATEWAY_CLIENT_CAPS.UI_COMMANDS,
|
||||
GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE,
|
||||
],
|
||||
auth: buildGatewayConnectAuth(selectedAuth),
|
||||
userAgent: navigator.userAgent,
|
||||
@@ -516,6 +522,7 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
|
||||
private handleConnectHello(hello: GatewayHelloOk, plan: ConnectPlan) {
|
||||
this.authenticatedDeviceIdValue = plan.deviceIdentity?.deviceId ?? null;
|
||||
this.startTickWatch(hello);
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
|
||||
@@ -6,9 +6,13 @@ import { renderSensitiveInput } from "./sensitive-input.ts";
|
||||
import "../styles/wizard-step-controls.css";
|
||||
|
||||
type WizardStepOption = NonNullable<WizardStep["options"]>[number];
|
||||
type WizardQrStep = Extract<WizardStep, { type: "qr" }>;
|
||||
export type WizardStepPresentation =
|
||||
| WizardStep
|
||||
| (Omit<WizardQrStep, "qrDataUrl"> & { qrDataUrl?: undefined; expiresInMs: 0 });
|
||||
|
||||
type WizardStepControlsProps = {
|
||||
step: WizardStep;
|
||||
step: WizardStepPresentation;
|
||||
/** Current draft answer; owned by the caller so it survives re-renders. */
|
||||
value: unknown;
|
||||
/** Disables every control while an answer is in flight. */
|
||||
@@ -55,7 +59,7 @@ function renderOptionBody(option: WizardStepOption, presentation?: "channels", s
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDeviceCode(step: WizardStep) {
|
||||
function renderDeviceCode(step: WizardStepPresentation) {
|
||||
const deviceCode = step.deviceCode;
|
||||
if (!deviceCode) {
|
||||
return nothing;
|
||||
@@ -170,6 +174,44 @@ function renderContinueStep(props: WizardStepControlsProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderQrStep(props: WizardStepControlsProps) {
|
||||
const dataUrl = props.step.qrDataUrl;
|
||||
const active =
|
||||
typeof dataUrl === "string" &&
|
||||
dataUrl.startsWith("data:image/png;base64,") &&
|
||||
typeof props.step.expiresInMs === "number" &&
|
||||
props.step.expiresInMs > 0;
|
||||
return html`
|
||||
${renderMessage(props)}
|
||||
${active
|
||||
? html`<img class="wizard-step__qr" src=${dataUrl} alt=${t("custodian.setupQrCodeAlt")} />
|
||||
<div class=${stepClass(props, "actions")}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onAnswer(false)}
|
||||
>
|
||||
${t("common.cancel")}
|
||||
</button>
|
||||
${renderAnswerButton(props, t("modelSetup.wizard.continue"), () =>
|
||||
props.onAnswer(true),
|
||||
)}
|
||||
</div>`
|
||||
: html`<div class="muted" role="status">${t("custodian.setupQrCodeExpired")}</div>
|
||||
<div class=${stepClass(props, "actions")}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onAnswer(false)}
|
||||
>
|
||||
${t("common.cancel")}
|
||||
</button>
|
||||
</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderProgressStep(props: WizardStepControlsProps) {
|
||||
return html`
|
||||
<div class="wizard-step__progress" role="status" aria-live="polite">
|
||||
@@ -326,10 +368,8 @@ export function renderWizardStepControls(
|
||||
return props.step.executor === "gateway"
|
||||
? renderProgressStep(props)
|
||||
: renderContinueStep(props);
|
||||
// QR steps need a client-owned presentation; clients without one must not
|
||||
// turn them into an answerable generic step.
|
||||
case "qr":
|
||||
return nothing;
|
||||
return renderQrStep(props);
|
||||
// These show whatever the step supplies behind a single Continue.
|
||||
case "note":
|
||||
case "action":
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Control UI proves the generic system-agent QR wizard step through a mocked Gateway.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import qrcode from "qrcode";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(executablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "custodian-qr-code");
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
describeE2e("Custodian QR wizard step", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("advertises, renders, and acknowledges the shared QR step", async () => {
|
||||
const qrDataUrl = await qrcode.toDataURL("https://openclaw.ai/qr-proof", {
|
||||
margin: 2,
|
||||
width: 560,
|
||||
});
|
||||
const qrResponse = {
|
||||
sessionId: "e2e-system-agent-qr",
|
||||
reply: "Scan this code to continue setup.",
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: "setup-qr",
|
||||
type: "qr",
|
||||
title: "Link Signal",
|
||||
message: "Scan the code, then continue.",
|
||||
qrDataUrl,
|
||||
expiresInMs: 30 * 60 * 1000,
|
||||
executor: "client",
|
||||
},
|
||||
};
|
||||
if (captureProof) {
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
}
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 844, width: 390 },
|
||||
...(captureProof
|
||||
? { recordVideo: { dir: proofDir, size: { height: 844, width: 390 } } }
|
||||
: {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "openclaw.chat"],
|
||||
methodResponses: {
|
||||
"openclaw.chat": {
|
||||
cases: [
|
||||
{
|
||||
match: { wizardAnswer: { stepId: "setup-qr", value: true } },
|
||||
response: {
|
||||
sessionId: "e2e-system-agent-qr",
|
||||
reply: "Signal is configured.",
|
||||
action: "none",
|
||||
},
|
||||
},
|
||||
{ match: { pollStepId: "setup-qr" }, response: qrResponse },
|
||||
{ response: qrResponse },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await page.goto(`${server.baseUrl}custodian?onboarding=1`, {
|
||||
timeout: 60_000,
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
)?.status(),
|
||||
).toBe(200);
|
||||
const image = page.getByAltText("QR code for setup");
|
||||
await image.waitFor();
|
||||
|
||||
expect(await image.getAttribute("src")).toBe(qrDataUrl);
|
||||
await expect
|
||||
.poll(() => image.evaluate((node: HTMLImageElement) => node.naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
|
||||
.toBe(true);
|
||||
|
||||
if (captureProof) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(proofDir, "qr-step.png"),
|
||||
});
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByText("Signal is configured.").waitFor();
|
||||
const requests = await gateway.getRequests("openclaw.chat");
|
||||
expect(requests.at(-1)?.params).toMatchObject({
|
||||
sessionId: "e2e-system-agent-qr",
|
||||
wizardAnswer: { stepId: "setup-qr", value: true },
|
||||
});
|
||||
expect(await image.count()).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2304,6 +2304,8 @@ export const en: TranslationMap = {
|
||||
cancel: "Cancel",
|
||||
send: "Send",
|
||||
thinking: "OpenClaw is thinking",
|
||||
setupQrCodeAlt: "QR code for setup",
|
||||
setupQrCodeExpired: "This QR code expired. Waiting for the setup result…",
|
||||
earlier: "Earlier",
|
||||
requestFailed: "OpenClaw could not reply. Try again.",
|
||||
connectionChanged: "The Gateway connection changed. Retry to continue this setup.",
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { GatewayProtocolRequestError } from "@openclaw/gateway-client/browser";
|
||||
import { buildSystemAgentSessionInvalidatedErrorDetails } from "@openclaw/gateway-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { waitForFast } from "../../test-helpers/wait-for.ts";
|
||||
import { createContext, mountPage } from "./custodian-page.test-harness.ts";
|
||||
|
||||
const QR_DATA_URL = "data:image/png;base64,AAAA";
|
||||
const SESSION_ID = "qr-session";
|
||||
|
||||
function qrResult(expiresInMs = 60_000, stepId = "qr-step") {
|
||||
return {
|
||||
sessionId: SESSION_ID,
|
||||
reply: "Scan this code, then continue.",
|
||||
action: "none" as const,
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: stepId,
|
||||
type: "qr" as const,
|
||||
title: "Link a device",
|
||||
message: "Scan this QR code, then continue.",
|
||||
qrDataUrl: QR_DATA_URL,
|
||||
expiresInMs,
|
||||
executor: "client" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function terminalResult(reply = "Signal is configured.", sessionId = SESSION_ID) {
|
||||
return { sessionId, reply, action: "none" as const };
|
||||
}
|
||||
|
||||
describe("custodian QR wizard step", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValue("00000000-0000-4000-8000-000000000001");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders and acknowledges QR through the generic wizard answer", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(terminalResult("Device linked."));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
|
||||
await waitForFast(() => expect(page.querySelector(".wizard-step__qr")).not.toBeNull());
|
||||
const image = page.querySelector<HTMLImageElement>(".wizard-step__qr");
|
||||
expect(image?.getAttribute("src")).toBe(QR_DATA_URL);
|
||||
expect(page.textContent).not.toContain(QR_DATA_URL);
|
||||
|
||||
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(request.mock.calls[1]?.[1]).toEqual({
|
||||
sessionId: SESSION_ID,
|
||||
wizardAnswer: { stepId: "qr-step", value: true },
|
||||
});
|
||||
await waitForFast(() =>
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels QR setup through the typed wizard answer", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(terminalResult("Signal setup cancelled."));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
|
||||
await waitForFast(() => expect(page.querySelector(".wizard-step__qr")).not.toBeNull());
|
||||
const cancelButton = Array.from(
|
||||
page.querySelectorAll<HTMLButtonElement>(".custodian__wizard-step .btn"),
|
||||
).find((button) => button.textContent?.trim() === "Cancel");
|
||||
cancelButton?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(request.mock.calls[1]?.[1]).toEqual({
|
||||
sessionId: SESSION_ID,
|
||||
wizardAnswer: { stepId: "qr-step", value: false },
|
||||
});
|
||||
await waitForFast(() => expect(page.textContent).toContain("Signal setup cancelled."));
|
||||
});
|
||||
|
||||
it("keeps polling when acknowledgement advances directly to another QR", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(qrResult(60_000, "qr-step-2"))
|
||||
.mockResolvedValueOnce(terminalResult("Both devices are linked."));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
expect(page.store.messages.some((message) => message.step?.id === "qr-step-2")).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
|
||||
expect(request.mock.calls[2]?.[1]).toEqual({
|
||||
sessionId: SESSION_ID,
|
||||
pollStepId: "qr-step-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the QR and resumes polling when Continue was definitely unsent", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockRejectedValueOnce(new Error("socket send failed"))
|
||||
.mockResolvedValueOnce(terminalResult());
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const continueButton = page.querySelector<HTMLButtonElement>(
|
||||
".custodian__wizard-step .btn.primary",
|
||||
);
|
||||
await waitForFast(() => expect(continueButton?.disabled).toBe(false));
|
||||
continueButton?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(page.querySelector<HTMLImageElement>(".wizard-step__qr")?.src).toContain(QR_DATA_URL);
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl === QR_DATA_URL)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(request.mock.calls[2]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.textContent).toContain("Signal is configured.");
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it("scrubs the QR but keeps polling when Continue delivery is uncertain", async () => {
|
||||
vi.useFakeTimers();
|
||||
let rejectAcknowledgement!: (error: Error) => void;
|
||||
const acknowledgement = new Promise<never>((_resolve, reject) => {
|
||||
rejectAcknowledgement = reject;
|
||||
});
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: { onSent?: () => void },
|
||||
): Promise<never> => {
|
||||
options?.onSent?.();
|
||||
return await acknowledgement;
|
||||
},
|
||||
)
|
||||
.mockResolvedValueOnce(terminalResult());
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const continueButton = page.querySelector<HTMLButtonElement>(
|
||||
".custodian__wizard-step .btn.primary",
|
||||
);
|
||||
await waitForFast(() => expect(continueButton?.disabled).toBe(false));
|
||||
continueButton?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await waitForFast(() => expect(page.querySelector(".wizard-step__qr")).toBeNull());
|
||||
await waitForFast(() =>
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false),
|
||||
);
|
||||
rejectAcknowledgement(new Error("connection closed after send"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(page.store.error).toContain("connection closed after send");
|
||||
expect(page.querySelector('[role="alert"]')).not.toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request.mock.calls[2]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.textContent).toContain("Signal is configured.");
|
||||
expect(page.store.hasUnresolvedQuestion()).toBe(false);
|
||||
expect(page.store.error).toBeNull();
|
||||
expect(page.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a failed acknowledgement error when recovery returns the active QR", async () => {
|
||||
vi.useFakeTimers();
|
||||
let rejectAcknowledgement!: (error: Error) => void;
|
||||
const acknowledgement = new Promise<never>((_resolve, reject) => {
|
||||
rejectAcknowledgement = reject;
|
||||
});
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: { onSent?: () => void },
|
||||
): Promise<never> => {
|
||||
options?.onSent?.();
|
||||
return await acknowledgement;
|
||||
},
|
||||
)
|
||||
.mockResolvedValueOnce(qrResult(30_000));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const continueButton = page.querySelector<HTMLButtonElement>(
|
||||
".custodian__wizard-step .btn.primary",
|
||||
);
|
||||
await waitForFast(() => expect(continueButton?.disabled).toBe(false));
|
||||
continueButton?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
rejectAcknowledgement(new Error("connection closed after send"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(page.store.error).toContain("connection closed after send");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request.mock.calls[2]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.store.error).toBeNull();
|
||||
expect(page.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(page.querySelector<HTMLImageElement>(".wizard-step__qr")?.src).toContain(QR_DATA_URL);
|
||||
});
|
||||
|
||||
it("polls without duplicating the QR transcript and shows owner completion", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(qrResult(30_000))
|
||||
.mockResolvedValueOnce(terminalResult());
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const messageCount = page.store.messages.length;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request.mock.calls[1]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.store.messages).toHaveLength(messageCount);
|
||||
const qrMessages = page.store.messages.filter((message) => message.step?.id === "qr-step");
|
||||
expect(qrMessages).toHaveLength(1);
|
||||
expect(qrMessages[0]?.step?.expiresInMs).toBe(30_000);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request.mock.calls[2]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.store.messages).toHaveLength(messageCount + 1);
|
||||
expect(page.textContent).toContain("Signal is configured.");
|
||||
expect(page.querySelector(".wizard-step__qr")).toBeNull();
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries a transient QR poll failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockRejectedValueOnce(new Error("temporary poll failure"))
|
||||
.mockResolvedValueOnce(terminalResult());
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(request.mock.calls[2]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.textContent).toContain("Signal is configured.");
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it("resumes polling a pending QR step after a same-client reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(terminalResult());
|
||||
const { context, setGatewaySnapshot } = createContext(request, ["openclaw.chat"], {
|
||||
connectionId: "connection-1",
|
||||
deviceId: "device-1",
|
||||
processInstanceId: "gateway-process-1",
|
||||
});
|
||||
setGatewaySnapshot({
|
||||
selfUser: { id: "profile-1", email: "owner@example.com" },
|
||||
});
|
||||
const hello = context.gateway.snapshot.hello;
|
||||
const { page } = await mountPage(context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
setGatewaySnapshot({ phase: "reconnecting", hello: null, selfUser: null });
|
||||
await page.updateComplete;
|
||||
expect(page.querySelector(".wizard-step__qr")).toBeNull();
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false);
|
||||
|
||||
setGatewaySnapshot({
|
||||
phase: "connected",
|
||||
hello,
|
||||
selfUser: { id: "profile-1", email: "owner@example.com" },
|
||||
});
|
||||
await page.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls[1]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.textContent).toContain("Signal is configured.");
|
||||
});
|
||||
|
||||
it("keeps a device-owned QR through late presence and a same-user reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(terminalResult());
|
||||
const { context, setGatewaySnapshot } = createContext(request, ["openclaw.chat"], {
|
||||
connectionId: "connection-1",
|
||||
deviceId: "device-1",
|
||||
processInstanceId: "gateway-process-1",
|
||||
});
|
||||
const { page } = await mountPage(context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(page.querySelector(".wizard-step__qr")).not.toBeNull();
|
||||
|
||||
setGatewaySnapshot({
|
||||
selfUser: { id: "profile-1", email: "owner@example.com" },
|
||||
});
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(page.querySelector(".wizard-step__qr")).not.toBeNull();
|
||||
|
||||
const hello = context.gateway.snapshot.hello;
|
||||
if (!hello) {
|
||||
throw new Error("expected connected Gateway hello");
|
||||
}
|
||||
setGatewaySnapshot({
|
||||
client: {
|
||||
request,
|
||||
authenticatedDeviceId: "device-2",
|
||||
} as unknown as GatewayBrowserClient,
|
||||
hello: {
|
||||
...hello,
|
||||
server: { connId: "connection-2" },
|
||||
},
|
||||
selfUser: { id: "profile-1", email: "owner@example.com" },
|
||||
});
|
||||
await page.updateComplete;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls[1]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.textContent).toContain("Signal is configured.");
|
||||
});
|
||||
|
||||
it("starts fresh when late presence identifies a different user", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(terminalResult("Fresh session ready.", "replacement-session"));
|
||||
const { context, setGatewaySnapshot } = createContext(request, ["openclaw.chat"], {
|
||||
connectionId: "connection-1",
|
||||
deviceId: "device-1",
|
||||
processInstanceId: "gateway-process-1",
|
||||
});
|
||||
const { page } = await mountPage(context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
setGatewaySnapshot({ selfUser: { id: "profile-1", email: "owner@example.com" } });
|
||||
await page.updateComplete;
|
||||
const hello = context.gateway.snapshot.hello;
|
||||
if (!hello) {
|
||||
throw new Error("expected connected Gateway hello");
|
||||
}
|
||||
setGatewaySnapshot({
|
||||
client: {
|
||||
request,
|
||||
authenticatedDeviceId: "device-2",
|
||||
} as unknown as GatewayBrowserClient,
|
||||
hello: { ...hello, server: { connId: "connection-2" } },
|
||||
selfUser: { id: "profile-2", email: "other@example.com" },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls[1]?.[1]).not.toHaveProperty("pollStepId");
|
||||
expect(request.mock.calls[1]?.[1]?.sessionId).not.toBe(SESSION_ID);
|
||||
expect(page.textContent).toContain("Fresh session ready.");
|
||||
});
|
||||
|
||||
it("resumes the same QR session after a client replacement", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(terminalResult());
|
||||
const { context, setGatewaySnapshot } = createContext(request, ["openclaw.chat"], {
|
||||
connectionId: "connection-1",
|
||||
deviceId: "device-1",
|
||||
deviceToken: "device-token-1",
|
||||
processInstanceId: "gateway-process-1",
|
||||
});
|
||||
const { page } = await mountPage(context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const hello = context.gateway.snapshot.hello;
|
||||
if (!hello) {
|
||||
throw new Error("expected connected Gateway hello");
|
||||
}
|
||||
|
||||
setGatewaySnapshot({
|
||||
client: {
|
||||
request,
|
||||
authenticatedDeviceId: "device-1",
|
||||
} as unknown as GatewayBrowserClient,
|
||||
hello: {
|
||||
...hello,
|
||||
auth: {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
deviceToken: "device-token-2",
|
||||
},
|
||||
server: { connId: "connection-2" },
|
||||
},
|
||||
});
|
||||
await page.updateComplete;
|
||||
expect(page.querySelector(".wizard-step__qr")).toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls[1]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.textContent).toContain("Signal is configured.");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "starts fresh when a connection-owned QR loses its owner",
|
||||
nextProcessInstanceId: "gateway-process-1",
|
||||
},
|
||||
{
|
||||
name: "starts fresh when a device-owned QR loses its Gateway process",
|
||||
deviceId: "device-1",
|
||||
deviceToken: "device-token-1",
|
||||
nextProcessInstanceId: "gateway-process-2",
|
||||
},
|
||||
])("$name", async ({ deviceId, deviceToken, nextProcessInstanceId }) => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockResolvedValueOnce(terminalResult("Fresh session ready.", "replacement-session"));
|
||||
const { context, setGatewaySnapshot } = createContext(request, ["openclaw.chat"], {
|
||||
connectionId: "connection-1",
|
||||
...(deviceId ? { deviceId } : {}),
|
||||
...(deviceToken ? { deviceToken } : {}),
|
||||
processInstanceId: "gateway-process-1",
|
||||
});
|
||||
const { page } = await mountPage(context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const hello = context.gateway.snapshot.hello;
|
||||
if (!hello) {
|
||||
throw new Error("expected connected Gateway hello");
|
||||
}
|
||||
|
||||
setGatewaySnapshot({
|
||||
client: {
|
||||
request,
|
||||
authenticatedDeviceId: deviceId ?? null,
|
||||
} as unknown as GatewayBrowserClient,
|
||||
hello: {
|
||||
...hello,
|
||||
server: { connId: "connection-2" },
|
||||
snapshot: { processInstanceId: nextProcessInstanceId },
|
||||
},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls[1]?.[1]).not.toHaveProperty("pollStepId");
|
||||
expect(request.mock.calls[1]?.[1]?.sessionId).not.toBe(SESSION_ID);
|
||||
expect(page.textContent).toContain("Fresh session ready.");
|
||||
});
|
||||
|
||||
it("scrubs the QR and starts fresh after poll session invalidation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockRejectedValueOnce(
|
||||
new GatewayProtocolRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "QR session was evicted.",
|
||||
details: buildSystemAgentSessionInvalidatedErrorDetails(),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(terminalResult("Fresh session ready.", "replacement-session"));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(request.mock.calls[2]?.[1]).not.toHaveProperty("pollStepId");
|
||||
expect(request.mock.calls[2]?.[1]?.sessionId).not.toBe(SESSION_ID);
|
||||
expect(page.textContent).toContain("Fresh session ready.");
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a stale acknowledgement callback after session rotation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockRejectedValueOnce(
|
||||
new GatewayProtocolRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "QR session was evicted.",
|
||||
details: buildSystemAgentSessionInvalidatedErrorDetails(),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(terminalResult("Fresh session ready.", "replacement-session"));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(request.mock.calls[2]?.[1]).not.toHaveProperty("pollStepId");
|
||||
expect(page.textContent).toContain("Fresh session ready.");
|
||||
});
|
||||
|
||||
it("scrubs expired image bytes while a result poll is still pending", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pendingPoll = new Promise<never>(() => {});
|
||||
const request = vi.fn().mockResolvedValueOnce(qrResult(2_000)).mockReturnValueOnce(pendingPoll);
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(page.querySelector(".wizard-step__qr")).toBeNull();
|
||||
expect(page.textContent).toContain("This QR code expired.");
|
||||
expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps polling and offers typed cancellation after QR expiry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pendingResult = {
|
||||
sessionId: SESSION_ID,
|
||||
reply: "Setup is still finishing the QR attempt.",
|
||||
action: "none" as const,
|
||||
wizardInputPending: true,
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult(1_000))
|
||||
.mockResolvedValueOnce(pendingResult)
|
||||
.mockResolvedValueOnce(pendingResult)
|
||||
.mockResolvedValueOnce(terminalResult("Signal setup cancelled."));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
expect(page.querySelector(".wizard-step__qr")).toBeNull();
|
||||
expect(page.textContent).toContain("This QR code expired.");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(request.mock.calls.filter((call) => call[1]?.pollStepId === "qr-step")).toHaveLength(2);
|
||||
|
||||
const cancelButton = Array.from(
|
||||
page.querySelectorAll<HTMLButtonElement>(".custodian__wizard-step .btn"),
|
||||
).find((button) => button.textContent?.trim() === "Cancel");
|
||||
cancelButton?.click();
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(4));
|
||||
expect(request.mock.calls[3]?.[1]).toEqual({
|
||||
sessionId: SESSION_ID,
|
||||
wizardAnswer: { stepId: "qr-step", value: false },
|
||||
});
|
||||
await waitForFast(() => expect(page.textContent).toContain("Signal setup cancelled."));
|
||||
});
|
||||
});
|
||||
@@ -40,9 +40,16 @@ export function createContext(
|
||||
agentsList?: ApplicationContext["agents"]["state"]["agentsList"];
|
||||
channelsSnapshot?: ChannelsStatusSnapshot | null;
|
||||
gatewayCapabilities?: string[];
|
||||
connectionId?: string;
|
||||
deviceId?: string;
|
||||
deviceToken?: string;
|
||||
processInstanceId?: string;
|
||||
} = {},
|
||||
): ContextHarness {
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const client = {
|
||||
request,
|
||||
authenticatedDeviceId: options.deviceId ?? null,
|
||||
} as unknown as GatewayBrowserClient;
|
||||
let snapshot: ApplicationGatewaySnapshot = {
|
||||
client,
|
||||
phase: "connected",
|
||||
@@ -51,13 +58,21 @@ export function createContext(
|
||||
hello: {
|
||||
type: "hello-ok" as const,
|
||||
protocol: 1,
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
auth: {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
...(options.deviceToken ? { deviceToken: options.deviceToken } : {}),
|
||||
},
|
||||
features: {
|
||||
methods,
|
||||
capabilities: options.gatewayCapabilities ?? [
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
],
|
||||
},
|
||||
...(options.connectionId ? { server: { connId: options.connectionId } } : {}),
|
||||
...(options.processInstanceId
|
||||
? { snapshot: { processInstanceId: options.processInstanceId } }
|
||||
: {}),
|
||||
},
|
||||
assistantAgentId: "main",
|
||||
sessionKey: "main",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type SystemAgentChatResult,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { WizardStep } from "../../api/types.ts";
|
||||
import { selectApplicationSession } from "../../app/agent-selection.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -14,17 +13,30 @@ import {
|
||||
isGatewayCapabilityAdvertised,
|
||||
isGatewayMethodAdvertised,
|
||||
} from "../../lib/gateway-methods.ts";
|
||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
import { pathForCustodianAgentHandoff } from "./custodian-navigation.ts";
|
||||
import { custodianWizardSubmission, initialCustodianWizardValue } from "./custodian-wizard-step.ts";
|
||||
import {
|
||||
CustodianQrScheduler,
|
||||
custodianWizardSubmission,
|
||||
findCustodianQrStep,
|
||||
initialCustodianWizardValue,
|
||||
replaceCustodianQrStep,
|
||||
scrubCustodianQrSteps,
|
||||
} from "./custodian-wizard-step.ts";
|
||||
import * as eventNudgeState from "./event-nudge.ts";
|
||||
import {
|
||||
custodianChatParams,
|
||||
hasCustodianUserInput,
|
||||
isCustodianSessionInvalidatedError,
|
||||
resolveCustodianConfiguredInferenceState,
|
||||
resolveCustodianSessionContinuity,
|
||||
type CustodianConfiguredInferenceState,
|
||||
type CustodianSessionContinuity,
|
||||
type CustodianSessionVariant,
|
||||
} from "./session-lifecycle.ts";
|
||||
import { parseCustodianQuestion, type CustodianStructuredQuestion } from "./structured-question.ts";
|
||||
import { parseCustodianQuestion } from "./structured-question.ts";
|
||||
import {
|
||||
createCustodianAssistantMessage,
|
||||
createCustodianSessionId,
|
||||
createCustodianTranscriptMessages,
|
||||
custodianErrorMessage,
|
||||
@@ -37,16 +49,6 @@ import {
|
||||
const SYSTEM_AGENT_CHAT_TIMEOUT_MS = 190_000;
|
||||
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
|
||||
|
||||
function hasCustodianUserInput(params: SystemAgentChatParams): boolean {
|
||||
return (
|
||||
params.message !== undefined ||
|
||||
params.wizardAnswer !== undefined ||
|
||||
params.wizardCancel !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
type StoreListener = () => void;
|
||||
type ConfiguredInferenceState = "unresolved" | "required" | "ready";
|
||||
type CustodianSetupIssue = "missing" | "unavailable";
|
||||
|
||||
/** One process-local conversation owner shared by the full page and dock surface. */
|
||||
@@ -80,17 +82,32 @@ export class CustodianSessionStore {
|
||||
private nextMessageId = 1;
|
||||
private retryParams: SystemAgentChatParams | null = null;
|
||||
private sessionClient: GatewayBrowserClient | null = null;
|
||||
private sessionOwnershipKey: string | null = null;
|
||||
private sessionContinuity: CustodianSessionContinuity | null = null;
|
||||
private lastContinuity: CustodianSessionContinuity | null = null;
|
||||
private sessionStarted = false;
|
||||
private lastHelloDeviceToken = "";
|
||||
private configuredInferenceState: ConfiguredInferenceState = "unresolved";
|
||||
private configuredInferenceState: CustodianConfiguredInferenceState = "unresolved";
|
||||
private eventNudgeClosed = false;
|
||||
private gatewayCleanup: (() => void) | null = null;
|
||||
private agentCleanup: (() => void) | null = null;
|
||||
private eventCleanup: (() => void) | null = null;
|
||||
private readonly listeners = new Set<StoreListener>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly qrScheduler = new CustodianQrScheduler({
|
||||
onExpire: (stepId, notify) => {
|
||||
this.messages = scrubCustodianQrSteps(this.messages, stepId);
|
||||
if (notify) {
|
||||
this.emit();
|
||||
}
|
||||
},
|
||||
onPoll: (client, stepId) => {
|
||||
void this.requestReply(
|
||||
client,
|
||||
{ sessionId: this.sessionId, pollStepId: stepId },
|
||||
{ pollStepId: stepId },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
subscribe(listener: StoreListener): () => void {
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
@@ -146,9 +163,7 @@ export class CustodianSessionStore {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
hasRealUserTurn(): boolean {
|
||||
return this.messages.some((message) => message.role === "user");
|
||||
}
|
||||
hasRealUserTurn = () => this.messages.some((message) => message.role === "user");
|
||||
|
||||
get activeVariant(): CustodianSessionVariant {
|
||||
return this.variant;
|
||||
@@ -164,9 +179,7 @@ export class CustodianSessionStore {
|
||||
);
|
||||
}
|
||||
|
||||
canRetry(): boolean {
|
||||
return this.retryParams !== null && !hasCustodianUserInput(this.retryParams);
|
||||
}
|
||||
canRetry = () => this.retryParams !== null && !hasCustodianUserInput(this.retryParams);
|
||||
|
||||
get setupRequired(): boolean {
|
||||
return this.setupIssue !== null;
|
||||
@@ -218,6 +231,7 @@ export class CustodianSessionStore {
|
||||
params: SystemAgentChatParams,
|
||||
displayText: string,
|
||||
questionReply: boolean,
|
||||
onSent?: () => void,
|
||||
): Promise<eventNudgeState.CustodianSendOutcome> {
|
||||
const questionState = [this.answeredQuestions, this.questionReplyUncertain] as const;
|
||||
if (questionReply) {
|
||||
@@ -238,7 +252,7 @@ export class CustodianSessionStore {
|
||||
];
|
||||
this.input = "";
|
||||
this.emit();
|
||||
const reply = this.requestReply(client, params);
|
||||
const reply = this.requestReply(client, params, { onSent });
|
||||
const replyEpoch = this.requestEpoch;
|
||||
const outcome = await reply;
|
||||
if (questionReply && this.requestEpoch === replyEpoch) {
|
||||
@@ -327,12 +341,22 @@ export class CustodianSessionStore {
|
||||
return;
|
||||
}
|
||||
const displayText = message.step.sensitive ? t("custodian.sensitiveReply") : submission.display;
|
||||
const sessionContinuity = this.sessionContinuity;
|
||||
const settleQrAcknowledgement =
|
||||
message.step.type === "qr"
|
||||
? this.qrScheduler.beginAcknowledgement(
|
||||
client,
|
||||
message.step.id,
|
||||
() => this.sessionContinuity === sessionContinuity && this.activeClient === client,
|
||||
)
|
||||
: undefined;
|
||||
void this.sendUserTurn(
|
||||
client,
|
||||
{ sessionId: this.sessionId, wizardAnswer: submission.answer },
|
||||
displayText,
|
||||
true,
|
||||
);
|
||||
() => settleQrAcknowledgement?.("sent"),
|
||||
).then(settleQrAcknowledgement);
|
||||
}
|
||||
|
||||
cancelWizardStep(message: CustodianMessage): void {
|
||||
@@ -380,23 +404,18 @@ export class CustodianSessionStore {
|
||||
this.context?.navigate("model-setup");
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
private emit = () => this.listeners.forEach((listener) => listener());
|
||||
|
||||
private currentSessionOwnershipKey(): string {
|
||||
private currentSessionContinuity(): CustodianSessionContinuity {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return "";
|
||||
return { key: "", ownerKey: null, authenticatedUserKey: null, processInstanceId: null };
|
||||
}
|
||||
const { gatewayUrl, token, password, bootstrapToken } = context.gateway.connection;
|
||||
const auth = context.gateway.snapshot.hello?.auth;
|
||||
if (auth) {
|
||||
this.lastHelloDeviceToken = auth.deviceToken ?? "";
|
||||
}
|
||||
return JSON.stringify([gatewayUrl, token, password, bootstrapToken, this.lastHelloDeviceToken]);
|
||||
return (this.lastContinuity = resolveCustodianSessionContinuity({
|
||||
connection: context.gateway.connection,
|
||||
snapshot: context.gateway.snapshot,
|
||||
previous: this.lastContinuity,
|
||||
}));
|
||||
}
|
||||
|
||||
private startSession(
|
||||
@@ -407,7 +426,7 @@ export class CustodianSessionStore {
|
||||
this.sessionId = createCustodianSessionId();
|
||||
this.sessionVariant = variant;
|
||||
this.sessionClient = client;
|
||||
this.sessionOwnershipKey = this.currentSessionOwnershipKey();
|
||||
this.sessionContinuity = this.currentSessionContinuity();
|
||||
this.sessionStarted = true;
|
||||
void this.initializeSession(
|
||||
client,
|
||||
@@ -425,10 +444,7 @@ export class CustodianSessionStore {
|
||||
this.abandonedTurnOutcomeUnknown = true;
|
||||
}
|
||||
|
||||
private rotateVolatileSession(
|
||||
client: GatewayBrowserClient,
|
||||
variant: CustodianSessionVariant,
|
||||
): void {
|
||||
private rotateVolatileSession(client: GatewayBrowserClient, variant: CustodianSessionVariant) {
|
||||
this.answeredQuestions = retireCustodianQuestions(this.messages, this.answeredQuestions);
|
||||
this.retryParams = null;
|
||||
this.input = "";
|
||||
@@ -450,18 +466,21 @@ export class CustodianSessionStore {
|
||||
const client = snapshot.phase === "connected" ? snapshot.client : null;
|
||||
const chatSupported =
|
||||
client !== null && canCallGatewayMethod(snapshot, "openclaw.chat", "operator.admin");
|
||||
const configuredInferenceState = this.resolveConfiguredInferenceState();
|
||||
const configuredInferenceState = resolveCustodianConfiguredInferenceState(this.context);
|
||||
const inferenceStateChanged = configuredInferenceState !== this.configuredInferenceState;
|
||||
this.configuredInferenceState = configuredInferenceState;
|
||||
const variantChanged = this.sessionStarted && this.sessionVariant !== this.variant;
|
||||
const ownershipKey = this.currentSessionOwnershipKey();
|
||||
const continuity = this.currentSessionContinuity();
|
||||
const clientReplaced =
|
||||
this.sessionStarted &&
|
||||
client !== null &&
|
||||
this.sessionClient !== null &&
|
||||
client !== this.sessionClient;
|
||||
const ownershipChanged =
|
||||
this.sessionOwnershipKey !== null && ownershipKey !== this.sessionOwnershipKey;
|
||||
this.sessionContinuity !== null && continuity.key !== this.sessionContinuity.key;
|
||||
const pendingQrStepId = this.wizardInputPending
|
||||
? this.messages.findLast((message) => message.step?.type === "qr")?.step?.id
|
||||
: undefined;
|
||||
if (
|
||||
client === this.activeClient &&
|
||||
!variantChanged &&
|
||||
@@ -472,6 +491,8 @@ export class CustodianSessionStore {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.qrScheduler.clear();
|
||||
this.messages = scrubCustodianQrSteps(this.messages);
|
||||
const requestWasPending = this.sending && this.retryParams !== null;
|
||||
const pendingParams = requestWasPending ? this.retryParams : null;
|
||||
this.activeClient = client;
|
||||
@@ -494,7 +515,14 @@ export class CustodianSessionStore {
|
||||
}
|
||||
this.chatAvailable = true;
|
||||
this.abandonPendingUserTurn(pendingParams);
|
||||
this.rotateVolatileSession(client, this.currentSessionVariant());
|
||||
if (pendingQrStepId && continuity.ownerKey && continuity.processInstanceId) {
|
||||
this.retryParams = null;
|
||||
this.error = null;
|
||||
this.sessionClient = client;
|
||||
this.qrScheduler.schedulePoll(client, pendingQrStepId);
|
||||
return;
|
||||
}
|
||||
this.rotateVolatileSession(client, this.variant);
|
||||
return;
|
||||
} else if (requestWasPending) {
|
||||
if (pendingParams?.message === undefined) {
|
||||
@@ -526,35 +554,17 @@ export class CustodianSessionStore {
|
||||
if (!this.retryParams) {
|
||||
this.error = requestWasPending ? this.error : null;
|
||||
}
|
||||
const pendingStep = this.wizardInputPending
|
||||
? this.messages.findLast((message) => message.step !== null)?.step
|
||||
: null;
|
||||
if (pendingStep?.type === "qr") {
|
||||
// A reconnect invalidates the old timer, but the Gateway still owns the QR session.
|
||||
this.qrScheduler.schedulePoll(client, pendingStep.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.clearConversation();
|
||||
this.startSession(client, this.currentSessionVariant(), true);
|
||||
}
|
||||
|
||||
private resolveConfiguredInferenceState(): ConfiguredInferenceState {
|
||||
const context = this.context;
|
||||
if (!context || context.gateway.snapshot.phase !== "connected") {
|
||||
return "unresolved";
|
||||
}
|
||||
const agentsList = context.agents.state.agentsList;
|
||||
if (!agentsList) {
|
||||
return "unresolved";
|
||||
}
|
||||
const selectedId = normalizeAgentId(
|
||||
context.gateway.snapshot.assistantAgentId ?? agentsList.defaultId ?? "",
|
||||
);
|
||||
const selectedAgent = agentsList.agents.find(
|
||||
(agent) => normalizeAgentId(agent.id) === selectedId,
|
||||
);
|
||||
if (!selectedAgent) {
|
||||
return "unresolved";
|
||||
}
|
||||
return selectedAgent.model?.primary?.trim() ? "ready" : "required";
|
||||
}
|
||||
|
||||
private currentSessionVariant(): CustodianSessionVariant {
|
||||
return this.variant;
|
||||
this.startSession(client, this.variant, true);
|
||||
}
|
||||
|
||||
private async initializeSession(
|
||||
@@ -599,6 +609,7 @@ export class CustodianSessionStore {
|
||||
}
|
||||
|
||||
private clearConversation(): void {
|
||||
this.qrScheduler.clear();
|
||||
this.messages = [];
|
||||
this.dismissedQuestions = new Set();
|
||||
this.answeredQuestions = new Set();
|
||||
@@ -612,27 +623,10 @@ export class CustodianSessionStore {
|
||||
this.earlierBoundaryAfterId = null;
|
||||
}
|
||||
|
||||
private appendAssistant(
|
||||
reply: string,
|
||||
question: CustodianStructuredQuestion | null,
|
||||
step: WizardStep | null,
|
||||
): void {
|
||||
this.messages = [
|
||||
...this.messages,
|
||||
{
|
||||
id: this.nextMessageId++,
|
||||
role: "assistant",
|
||||
text: reply,
|
||||
at: Date.now(),
|
||||
question,
|
||||
step,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async requestReply(
|
||||
client: GatewayBrowserClient,
|
||||
params: SystemAgentChatParams,
|
||||
options?: { pollStepId?: string; onSent?: () => void },
|
||||
): Promise<eventNudgeState.CustodianSendOutcome> {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
@@ -648,19 +642,25 @@ export class CustodianSessionStore {
|
||||
this.requestAbort?.abort();
|
||||
const requestAbort = new AbortController();
|
||||
this.requestAbort = requestAbort;
|
||||
const pollStepId = options?.pollStepId;
|
||||
const epoch = ++this.requestEpoch;
|
||||
let delivery: eventNudgeState.CustodianSendDelivery = "unsent";
|
||||
this.sending = true;
|
||||
this.error = null;
|
||||
if (hasCustodianUserInput(params)) {
|
||||
this.setupIssue = null;
|
||||
if (!pollStepId) {
|
||||
this.sending = true;
|
||||
this.error = null;
|
||||
if (hasCustodianUserInput(params)) {
|
||||
this.setupIssue = null;
|
||||
}
|
||||
this.retryParams = params;
|
||||
this.emit();
|
||||
}
|
||||
this.retryParams = params;
|
||||
this.emit();
|
||||
try {
|
||||
const result = await client.request<SystemAgentChatResult>("openclaw.chat", params, {
|
||||
timeoutMs: SYSTEM_AGENT_CHAT_TIMEOUT_MS,
|
||||
onSent: () => (delivery = "sent"),
|
||||
onSent: () => {
|
||||
delivery = "sent";
|
||||
options?.onSent?.();
|
||||
},
|
||||
signal: requestAbort.signal,
|
||||
});
|
||||
delivery = "received";
|
||||
@@ -668,17 +668,49 @@ export class CustodianSessionStore {
|
||||
return "sent";
|
||||
}
|
||||
this.sessionId = result.sessionId;
|
||||
// An authoritative recovery response supersedes a failed QR acknowledgement.
|
||||
this.error = null;
|
||||
if (pollStepId && result.step?.type === "qr" && result.step.id === pollStepId) {
|
||||
this.messages = replaceCustodianQrStep(this.messages, result.step);
|
||||
this.wizardInputPending = result.wizardInputPending === true;
|
||||
this.qrScheduler.scheduleStep(client, result.step);
|
||||
return "sent";
|
||||
}
|
||||
if (pollStepId && result.wizardInputPending === true && result.step === undefined) {
|
||||
// An externally owned QR can outlive its presentation. Keep observations short and
|
||||
// poll again so Cancel remains responsive while the owner settles in the background.
|
||||
this.messages = scrubCustodianQrSteps(this.messages, pollStepId);
|
||||
this.questionReplyUncertain = false;
|
||||
this.wizardInputPending = true;
|
||||
this.qrScheduler.schedulePoll(client, pollStepId);
|
||||
return "sent";
|
||||
}
|
||||
this.qrScheduler.clear();
|
||||
if (pollStepId) {
|
||||
this.messages = scrubCustodianQrSteps(this.messages, pollStepId);
|
||||
this.questionReplyUncertain = false;
|
||||
}
|
||||
this.sensitive = result.sensitive === true;
|
||||
this.wizardInputPending = result.wizardInputPending === true;
|
||||
this.retryParams = null;
|
||||
this.setupIssue = null;
|
||||
[this.retryParams, this.setupIssue] = [null, null];
|
||||
const step = result.step ?? null;
|
||||
const question = step ? null : parseCustodianQuestion(result.question);
|
||||
this.wizardValue = step ? initialCustodianWizardValue(step) : undefined;
|
||||
this.wizardSecretVisible = false;
|
||||
const silentReply = SILENT_REPLY_PATTERN.test(result.reply);
|
||||
if (!silentReply || question || step) {
|
||||
this.appendAssistant(silentReply ? "" : result.reply, question, step);
|
||||
this.messages = [
|
||||
...this.messages,
|
||||
createCustodianAssistantMessage({
|
||||
id: this.nextMessageId++,
|
||||
text: silentReply ? "" : result.reply,
|
||||
question,
|
||||
step,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (step?.type === "qr") {
|
||||
this.qrScheduler.scheduleStep(client, step);
|
||||
}
|
||||
if (result.action === "open-agent") {
|
||||
let sessionKey = context.gateway.snapshot.sessionKey?.trim();
|
||||
@@ -711,6 +743,21 @@ export class CustodianSessionStore {
|
||||
}
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
if (pollStepId) {
|
||||
if (epoch === this.requestEpoch && client === this.activeClient) {
|
||||
if (isCustodianSessionInvalidatedError(error)) {
|
||||
this.qrScheduler.clear();
|
||||
this.messages = scrubCustodianQrSteps(this.messages, pollStepId);
|
||||
this.rotateVolatileSession(client, this.variant);
|
||||
return "sent";
|
||||
}
|
||||
const step = findCustodianQrStep(this.messages, pollStepId);
|
||||
if (step) {
|
||||
this.qrScheduler.schedulePoll(client, step.id);
|
||||
}
|
||||
}
|
||||
return eventNudgeState.classifyCustodianSendFailure(error, delivery);
|
||||
}
|
||||
if (epoch === this.requestEpoch && client === this.activeClient) {
|
||||
this.error = custodianErrorMessage(error);
|
||||
const details =
|
||||
@@ -723,7 +770,7 @@ export class CustodianSessionStore {
|
||||
: null;
|
||||
if (hasCustodianUserInput(params) && isCustodianSessionInvalidatedError(error)) {
|
||||
// Retained transcript rows are display context only; the next turn needs a fresh id.
|
||||
this.rotateVolatileSession(client, this.currentSessionVariant());
|
||||
this.rotateVolatileSession(client, this.variant);
|
||||
this.error = t("custodian.sessionRestarted", { error: custodianErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
@@ -736,7 +783,7 @@ export class CustodianSessionStore {
|
||||
if (this.requestAbort === requestAbort) {
|
||||
this.requestAbort = null;
|
||||
}
|
||||
if (epoch === this.requestEpoch) {
|
||||
if (!pollStepId && epoch === this.requestEpoch) {
|
||||
this.sending = false;
|
||||
}
|
||||
this.emit();
|
||||
|
||||
@@ -8,24 +8,20 @@ const options = [
|
||||
{ label: "Twitch", value: "twitch" },
|
||||
];
|
||||
|
||||
type TestStepPatch =
|
||||
| { type?: "select"; initialValue?: unknown }
|
||||
| { type: "multiselect"; initialValue?: unknown }
|
||||
| { type: "confirm"; initialValue?: unknown }
|
||||
| { type: "text"; initialValue?: unknown }
|
||||
| { type: "action"; initialValue?: unknown };
|
||||
type NonQrWizardStep = Exclude<WizardStep, { type: "qr" }>;
|
||||
|
||||
function step(patch: TestStepPatch): WizardStep {
|
||||
switch (patch.type) {
|
||||
case "multiselect":
|
||||
return { id: "step", options, ...patch };
|
||||
case "confirm":
|
||||
case "text":
|
||||
case "action":
|
||||
return { id: "step", ...patch };
|
||||
default:
|
||||
return { id: "step", type: "select", options, ...patch };
|
||||
}
|
||||
function step(patch: Partial<NonQrWizardStep>): NonQrWizardStep {
|
||||
return { id: "step", type: "select", options, ...patch };
|
||||
}
|
||||
|
||||
function qrStep(): WizardStep {
|
||||
return {
|
||||
id: "step",
|
||||
type: "qr",
|
||||
executor: "client",
|
||||
qrDataUrl: "data:image/png;base64,AAAA",
|
||||
expiresInMs: 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Custodian rich wizard answers", () => {
|
||||
@@ -59,6 +55,14 @@ describe("Custodian rich wizard answers", () => {
|
||||
answer: { stepId: "step" },
|
||||
display: "Continue",
|
||||
});
|
||||
expect(custodianWizardSubmission(qrStep(), undefined)).toEqual({
|
||||
answer: { stepId: "step", value: true },
|
||||
display: "Continue",
|
||||
});
|
||||
expect(custodianWizardSubmission(qrStep(), false)).toEqual({
|
||||
answer: { stepId: "step", value: false },
|
||||
display: "Cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it("copies multiselect defaults and rejects values outside the step", () => {
|
||||
|
||||
@@ -1,23 +1,157 @@
|
||||
import { resolveSafeTimeoutDelayMs } from "@openclaw/gateway-client/browser";
|
||||
import type { WizardAnswer } from "@openclaw/gateway-protocol";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { WizardStep } from "../../api/types.ts";
|
||||
import type { WizardStepPresentation } from "../../components/wizard-step-controls.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { CustodianSendOutcome } from "./event-nudge.ts";
|
||||
import type { CustodianMessage } from "./transcript.ts";
|
||||
|
||||
type CustodianWizardSubmission = {
|
||||
answer: WizardAnswer;
|
||||
display: string;
|
||||
};
|
||||
|
||||
function findOption(step: WizardStep, value: unknown) {
|
||||
const SYSTEM_AGENT_QR_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
export class CustodianQrScheduler {
|
||||
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private expiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stepId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly callbacks: {
|
||||
onExpire: (stepId: string, notify: boolean) => void;
|
||||
onPoll: (client: GatewayBrowserClient, stepId: string) => void;
|
||||
},
|
||||
) {}
|
||||
|
||||
clear(): void {
|
||||
if (this.pollTimer !== null) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
if (this.expiryTimer !== null) {
|
||||
clearTimeout(this.expiryTimer);
|
||||
this.expiryTimer = null;
|
||||
}
|
||||
this.stepId = null;
|
||||
}
|
||||
|
||||
beginAcknowledgement(
|
||||
client: GatewayBrowserClient,
|
||||
stepId: string,
|
||||
isCurrent: () => boolean,
|
||||
): (outcome: CustodianSendOutcome) => void {
|
||||
if (this.pollTimer !== null) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
let delivered = false;
|
||||
return (outcome) => {
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (outcome !== "rejected" && !delivered) {
|
||||
delivered = true;
|
||||
if (this.stepId === stepId) {
|
||||
this.clear();
|
||||
}
|
||||
this.callbacks.onExpire(stepId, true);
|
||||
}
|
||||
if (outcome !== "sent") {
|
||||
this.schedulePoll(client, stepId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scheduleStep(client: GatewayBrowserClient, step: WizardStep): void {
|
||||
this.clear();
|
||||
if (step.type !== "qr") {
|
||||
return;
|
||||
}
|
||||
this.stepId = step.id;
|
||||
const expiresInMs = step.expiresInMs;
|
||||
if (typeof expiresInMs === "number" && Number.isFinite(expiresInMs)) {
|
||||
if (expiresInMs <= 0) {
|
||||
this.callbacks.onExpire(step.id, false);
|
||||
} else {
|
||||
this.expiryTimer = setTimeout(
|
||||
() => {
|
||||
this.expiryTimer = null;
|
||||
this.callbacks.onExpire(step.id, true);
|
||||
},
|
||||
resolveSafeTimeoutDelayMs(expiresInMs, { minMs: 0 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
this.schedulePoll(client, step.id);
|
||||
}
|
||||
|
||||
schedulePoll(client: GatewayBrowserClient, stepId: string): void {
|
||||
if (this.pollTimer !== null) {
|
||||
clearTimeout(this.pollTimer);
|
||||
}
|
||||
this.stepId = stepId;
|
||||
this.pollTimer = setTimeout(() => {
|
||||
this.pollTimer = null;
|
||||
this.callbacks.onPoll(client, stepId);
|
||||
}, SYSTEM_AGENT_QR_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function scrubCustodianQrSteps(
|
||||
messages: readonly CustodianMessage[],
|
||||
stepId?: string,
|
||||
): CustodianMessage[] {
|
||||
return messages.map((message) => {
|
||||
const step = message.step;
|
||||
if (step?.type !== "qr" || (stepId !== undefined && step.id !== stepId)) {
|
||||
return message;
|
||||
}
|
||||
const { qrDataUrl: _qrDataUrl, ...scrubbedStep } = step;
|
||||
return { ...message, step: { ...scrubbedStep, expiresInMs: 0 } };
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceCustodianQrStep(
|
||||
messages: readonly CustodianMessage[],
|
||||
step: WizardStep,
|
||||
): CustodianMessage[] {
|
||||
return messages.map((message) => (message.step?.id === step.id ? { ...message, step } : message));
|
||||
}
|
||||
|
||||
export function findCustodianQrStep(
|
||||
messages: readonly CustodianMessage[],
|
||||
stepId: string,
|
||||
): WizardStepPresentation | null {
|
||||
return (
|
||||
messages.findLast((message) => message.step?.type === "qr" && message.step.id === stepId)
|
||||
?.step ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function findOption(step: WizardStepPresentation, value: unknown) {
|
||||
return step.options?.find((option) => Object.is(option.value, value));
|
||||
}
|
||||
|
||||
/** Build the typed answer sent by a client rendering the current wizard step. */
|
||||
export function custodianWizardSubmission(
|
||||
step: WizardStep,
|
||||
step: WizardStepPresentation,
|
||||
value: unknown,
|
||||
): CustodianWizardSubmission | null {
|
||||
if (step.type === "note" || step.type === "action" || step.type === "progress") {
|
||||
return { answer: { stepId: step.id }, display: t("common.continue") };
|
||||
return {
|
||||
answer: { stepId: step.id },
|
||||
display: t("common.continue"),
|
||||
};
|
||||
}
|
||||
if (step.type === "qr") {
|
||||
const confirmed = value !== false;
|
||||
return {
|
||||
answer: { stepId: step.id, value: confirmed },
|
||||
display: t(confirmed ? "common.continue" : "common.cancel"),
|
||||
};
|
||||
}
|
||||
if (step.type === "text") {
|
||||
return typeof value === "string"
|
||||
@@ -53,7 +187,7 @@ export function custodianWizardSubmission(
|
||||
};
|
||||
}
|
||||
|
||||
export function initialCustodianWizardValue(step: WizardStep): unknown {
|
||||
export function initialCustodianWizardValue(step: WizardStepPresentation): unknown {
|
||||
return step.type === "multiselect"
|
||||
? Array.isArray(step.initialValue)
|
||||
? [...step.initialValue]
|
||||
|
||||
@@ -3,8 +3,138 @@ import {
|
||||
type SystemAgentChatParams,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import { inferBasePathFromPathname, routeIdFromPath } from "../../app-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type {
|
||||
ApplicationGatewayConnection,
|
||||
ApplicationGatewaySnapshot,
|
||||
} from "../../app/gateway.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
|
||||
export type CustodianSessionVariant = "onboarding" | "new-agent" | "caretaker";
|
||||
export type CustodianConfiguredInferenceState = "unresolved" | "required" | "ready";
|
||||
|
||||
export type CustodianSessionContinuity = {
|
||||
key: string;
|
||||
ownerKey: string | null;
|
||||
authenticatedUserKey: string | null;
|
||||
processInstanceId: string | null;
|
||||
};
|
||||
|
||||
function readRecordString(value: unknown, key: string): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const field = (value as Record<string, unknown>)[key];
|
||||
return typeof field === "string" && field.trim() ? field : null;
|
||||
}
|
||||
|
||||
function resolveCustodianOwner(params: {
|
||||
hello: ApplicationGatewaySnapshot["hello"];
|
||||
snapshot: ApplicationGatewaySnapshot;
|
||||
previous: CustodianSessionContinuity | null;
|
||||
}): Pick<CustodianSessionContinuity, "ownerKey" | "authenticatedUserKey"> {
|
||||
if (!params.hello) {
|
||||
return {
|
||||
ownerKey: params.previous?.ownerKey ?? null,
|
||||
authenticatedUserKey: params.previous?.authenticatedUserKey ?? null,
|
||||
};
|
||||
}
|
||||
const userId = params.snapshot.selfUser?.email?.trim() || params.snapshot.selfUser?.id.trim();
|
||||
const deviceId = params.snapshot.client?.authenticatedDeviceId?.trim();
|
||||
const userOwner = userId ? `user:${userId}` : null;
|
||||
const deviceOwner = deviceId ? `device:${deviceId}` : null;
|
||||
const connectionOwner = params.hello.server?.connId
|
||||
? `connection:${params.hello.server.connId}`
|
||||
: null;
|
||||
const previousOwner = params.previous?.ownerKey ?? null;
|
||||
const previousUser = params.previous?.authenticatedUserKey ?? null;
|
||||
if (userOwner) {
|
||||
if (previousUser && previousUser !== userOwner) {
|
||||
return { ownerKey: userOwner, authenticatedUserKey: userOwner };
|
||||
}
|
||||
// Presence may identify the authenticated user after setup starts. Remember
|
||||
// that identity for reconnects without rotating the live session it now owns.
|
||||
return {
|
||||
ownerKey: previousOwner ?? userOwner,
|
||||
authenticatedUserKey: userOwner,
|
||||
};
|
||||
}
|
||||
if (previousUser) {
|
||||
return { ownerKey: previousOwner, authenticatedUserKey: previousUser };
|
||||
}
|
||||
if (previousOwner?.startsWith("device:")) {
|
||||
const ownerKey =
|
||||
deviceOwner === previousOwner
|
||||
? previousOwner
|
||||
: (deviceOwner ?? connectionOwner ?? previousOwner);
|
||||
return { ownerKey, authenticatedUserKey: null };
|
||||
}
|
||||
if (previousOwner?.startsWith("connection:")) {
|
||||
const ownerKey =
|
||||
connectionOwner === previousOwner
|
||||
? previousOwner
|
||||
: (deviceOwner ?? connectionOwner ?? previousOwner);
|
||||
return { ownerKey, authenticatedUserKey: null };
|
||||
}
|
||||
return {
|
||||
ownerKey: deviceOwner ?? connectionOwner ?? previousOwner,
|
||||
authenticatedUserKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pins continuity to authenticated lineage so later presence cannot rotate live setup. */
|
||||
export function resolveCustodianSessionContinuity(params: {
|
||||
connection: ApplicationGatewayConnection;
|
||||
snapshot: ApplicationGatewaySnapshot;
|
||||
previous: CustodianSessionContinuity | null;
|
||||
}): CustodianSessionContinuity {
|
||||
const hello = params.snapshot.hello;
|
||||
const processInstanceId = hello
|
||||
? readRecordString(hello.snapshot, "processInstanceId")
|
||||
: (params.previous?.processInstanceId ?? null);
|
||||
const { ownerKey, authenticatedUserKey } = resolveCustodianOwner({
|
||||
hello,
|
||||
snapshot: params.snapshot,
|
||||
previous: params.previous,
|
||||
});
|
||||
const { gatewayUrl, token, password, bootstrapToken } = params.connection;
|
||||
return {
|
||||
ownerKey,
|
||||
authenticatedUserKey,
|
||||
processInstanceId,
|
||||
key: JSON.stringify([gatewayUrl, token, password, bootstrapToken, ownerKey, processInstanceId]),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasCustodianUserInput(params: SystemAgentChatParams): boolean {
|
||||
return (
|
||||
params.message !== undefined ||
|
||||
params.wizardAnswer !== undefined ||
|
||||
params.wizardCancel !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCustodianConfiguredInferenceState(
|
||||
context: ApplicationContext | null,
|
||||
): CustodianConfiguredInferenceState {
|
||||
if (!context || context.gateway.snapshot.phase !== "connected") {
|
||||
return "unresolved";
|
||||
}
|
||||
const agentsList = context.agents.state.agentsList;
|
||||
if (!agentsList) {
|
||||
return "unresolved";
|
||||
}
|
||||
const selectedId = normalizeAgentId(
|
||||
context.gateway.snapshot.assistantAgentId ?? agentsList.defaultId ?? "",
|
||||
);
|
||||
const selectedAgent = agentsList.agents.find(
|
||||
(agent) => normalizeAgentId(agent.id) === selectedId,
|
||||
);
|
||||
if (!selectedAgent) {
|
||||
return "unresolved";
|
||||
}
|
||||
return selectedAgent.model?.primary?.trim() ? "ready" : "required";
|
||||
}
|
||||
|
||||
export function sessionVariant(
|
||||
onboarding: boolean,
|
||||
|
||||
@@ -4,8 +4,10 @@ import type {
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import { html, nothing } from "lit";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { WizardStep } from "../../api/types.ts";
|
||||
import { renderWizardStepControls } from "../../components/wizard-step-controls.ts";
|
||||
import {
|
||||
renderWizardStepControls,
|
||||
type WizardStepPresentation,
|
||||
} from "../../components/wizard-step-controls.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { MessageGroup } from "../../lib/chat/chat-types.ts";
|
||||
import { renderChatDivider } from "../chat/components/chat-divider.ts";
|
||||
@@ -21,9 +23,22 @@ export type CustodianMessage = {
|
||||
text: string;
|
||||
at: number;
|
||||
question: CustodianStructuredQuestion | null;
|
||||
step: WizardStep | null;
|
||||
step: WizardStepPresentation | null;
|
||||
};
|
||||
|
||||
export function createCustodianAssistantMessage(params: {
|
||||
id: number;
|
||||
text: string;
|
||||
question: CustodianMessage["question"];
|
||||
step: CustodianMessage["step"];
|
||||
}): CustodianMessage {
|
||||
return {
|
||||
...params,
|
||||
role: "assistant",
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasUnresolvedCustodianQuestion(
|
||||
messages: readonly CustodianMessage[],
|
||||
dismissedQuestions: ReadonlySet<string>,
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.wizard-step__qr {
|
||||
display: block;
|
||||
width: min(100%, 360px);
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.wizard-step__options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user