mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
feat(gateway): project passive QR setup sessions
This commit is contained in:
@@ -11132,6 +11132,7 @@ public struct SystemAgentChatParams: Codable, Sendable {
|
||||
public let message: String?
|
||||
public let wizardanswer: [String: AnyCodable]?
|
||||
public let wizardcancel: [String: AnyCodable]?
|
||||
public let pollstepid: String?
|
||||
public let welcomevariant: AnyCodable?
|
||||
public let reset: Bool?
|
||||
public let context: [String: AnyCodable]?
|
||||
@@ -11142,6 +11143,7 @@ public struct SystemAgentChatParams: Codable, Sendable {
|
||||
message: String? = nil,
|
||||
wizardanswer: [String: AnyCodable]? = nil,
|
||||
wizardcancel: [String: AnyCodable]? = nil,
|
||||
pollstepid: String? = nil,
|
||||
welcomevariant: AnyCodable? = nil,
|
||||
reset: Bool? = nil,
|
||||
context: [String: AnyCodable]? = nil,
|
||||
@@ -11151,6 +11153,7 @@ public struct SystemAgentChatParams: Codable, Sendable {
|
||||
self.message = message
|
||||
self.wizardanswer = wizardanswer
|
||||
self.wizardcancel = wizardcancel
|
||||
self.pollstepid = pollstepid
|
||||
self.welcomevariant = welcomevariant
|
||||
self.reset = reset
|
||||
self.context = context
|
||||
@@ -11162,6 +11165,7 @@ public struct SystemAgentChatParams: Codable, Sendable {
|
||||
case message
|
||||
case wizardanswer = "wizardAnswer"
|
||||
case wizardcancel = "wizardCancel"
|
||||
case pollstepid = "pollStepId"
|
||||
case welcomevariant = "welcomeVariant"
|
||||
case reset
|
||||
case context
|
||||
@@ -11174,6 +11178,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
public let reply: String
|
||||
public let sensitive: Bool?
|
||||
public let wizardinputpending: Bool?
|
||||
public let wizardsettling: Bool?
|
||||
public let action: AnyCodable
|
||||
public let agentdraft: String?
|
||||
public let agentid: String?
|
||||
@@ -11187,6 +11192,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
reply: String,
|
||||
sensitive: Bool? = nil,
|
||||
wizardinputpending: Bool? = nil,
|
||||
wizardsettling: Bool? = nil,
|
||||
action: AnyCodable,
|
||||
agentdraft: String? = nil,
|
||||
agentid: String? = nil,
|
||||
@@ -11199,6 +11205,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
self.reply = reply
|
||||
self.sensitive = sensitive
|
||||
self.wizardinputpending = wizardinputpending
|
||||
self.wizardsettling = wizardsettling
|
||||
self.action = action
|
||||
self.agentdraft = agentdraft
|
||||
self.agentid = agentid
|
||||
@@ -11213,6 +11220,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
case reply
|
||||
case sensitive
|
||||
case wizardinputpending = "wizardInputPending"
|
||||
case wizardsettling = "wizardSettling"
|
||||
case action
|
||||
case agentdraft = "agentDraft"
|
||||
case agentid = "agentId"
|
||||
|
||||
+27
-4
@@ -136,10 +136,33 @@ snapshot, so re-read them on every reconnect.
|
||||
|
||||
### Present system-agent QR codes
|
||||
|
||||
`GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE` and the QR wizard-step shape are
|
||||
reserved until system-agent QR production and Gateway projection are both
|
||||
available. Clients should not advertise this capability yet; the contract alone
|
||||
does not make existing Gateway methods emit QR steps.
|
||||
Advertise `GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE` only when the client can
|
||||
render a QR image. A capable `openclaw.chat` session can then receive a QR
|
||||
`step` through the same wizard-step contract used for other setup controls:
|
||||
|
||||
```json
|
||||
{
|
||||
"step": {
|
||||
"id": "setup-qr",
|
||||
"type": "qr",
|
||||
"title": "Scan QR code",
|
||||
"message": "Scan the code to continue.",
|
||||
"qrDataUrl": "data:image/png;base64,...",
|
||||
"expiresInMs": 120000,
|
||||
"executor": "client"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The dependency owns completion of a QR step. Do not send `wizardAnswer` for
|
||||
it. Keep the image visible for at most `expiresInMs`, and observe progress by
|
||||
calling `openclaw.chat` with the same `sessionId` and `pollStepId: step.id`.
|
||||
A response with `wizardSettling: true` means the owner is still finishing;
|
||||
continue polling with bounded backoff until the next step or terminal reply.
|
||||
|
||||
QR capability is bound to the in-memory session. If reconnect negotiation
|
||||
changes it, the Gateway returns typed session-invalidated details. Discard the
|
||||
old QR and call `openclaw.chat` with `reset: true` before continuing.
|
||||
|
||||
## Recover state after reconnect
|
||||
|
||||
|
||||
@@ -56,6 +56,12 @@ describe("OpenClaw chat params protocol", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a passive wizard step poll", () => {
|
||||
expect(validateSystemAgentChatParams({ sessionId: "session-1", pollStepId: "setup-qr" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe page ids and unknown context fields", () => {
|
||||
expect(validateSystemAgentChatParams({ ...base, context: { page: "channels?tab=all" } })).toBe(
|
||||
false,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const SystemAgentWizardCancelSchema = closedObject({
|
||||
* OpenClaw chat lets clients (macOS app onboarding, future UIs) hold the
|
||||
* setup/repair conversation over the gateway. The gateway live-tests the
|
||||
* configured inference route before creating a session. Omitting `message`
|
||||
* returns the welcome/greeting for a verified fresh session without input.
|
||||
* returns the welcome/greeting unless `pollStepId` observes an active wizard step.
|
||||
*/
|
||||
export const SystemAgentChatParamsSchema = closedObject({
|
||||
sessionId: NonEmptyString,
|
||||
@@ -24,6 +24,8 @@ export const SystemAgentChatParamsSchema = closedObject({
|
||||
wizardAnswer: Type.Optional(WizardAnswerSchema),
|
||||
/** Direct client control for cancelling the currently rendered hosted wizard. */
|
||||
wizardCancel: Type.Optional(SystemAgentWizardCancelSchema),
|
||||
/** Observe one active wizard step without answering it. */
|
||||
pollStepId: Type.Optional(NonEmptyString),
|
||||
/** Seeds a purpose-specific first greeting for a fresh conversation. */
|
||||
welcomeVariant: Type.Optional(
|
||||
Type.Union([Type.Literal("onboarding"), Type.Literal("new-agent")]),
|
||||
@@ -86,6 +88,8 @@ export const SystemAgentChatResultSchema = closedObject({
|
||||
sensitive: Type.Optional(Type.Boolean()),
|
||||
/** The hosted wizard will consume the next message as its current step answer. */
|
||||
wizardInputPending: Type.Optional(Type.Boolean()),
|
||||
/** The hosted wizard is settling external work and has no answerable step. */
|
||||
wizardSettling: Type.Optional(Type.Boolean()),
|
||||
action: Type.Union([
|
||||
Type.Literal("none"),
|
||||
// The user asked to talk to their agent; clients should move to their
|
||||
|
||||
@@ -23,6 +23,7 @@ import { createLazyGatewayCronState } from "./server-cron-lazy.js";
|
||||
import { createGatewayCronReconciliation } from "./server-cron-reconciled.js";
|
||||
import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js";
|
||||
import { createGatewayServerLiveState } from "./server-live-state.js";
|
||||
import { retireAndDisposeSystemAgentSessions } from "./server-methods/system-agent-session-lifecycle.js";
|
||||
import type { GatewayRequestContext } from "./server-methods/types.js";
|
||||
import type { GatewayCloseOptions } from "./server-public.js";
|
||||
import type { prepareGatewayKernelState } from "./server-runtime-state-prepare.js";
|
||||
@@ -85,6 +86,9 @@ export async function prepareGatewayLifecycle(params: {
|
||||
nodeDesktopStreamBroker,
|
||||
bindDeviceNodeControl,
|
||||
workerPlacementRuntime,
|
||||
systemAgentSessions,
|
||||
wizardSessions,
|
||||
pluginGatewayContext,
|
||||
} = runtime;
|
||||
workerGatewayEndpoint.resolve = transportBridge.getWorkerIngressEndpoint;
|
||||
const subscribeSessionMessageEvents: GatewayRequestContext["subscribeSessionMessageEvents"] = (
|
||||
@@ -368,6 +372,20 @@ export async function prepareGatewayLifecycle(params: {
|
||||
maintenanceTimer: null,
|
||||
retainedPluginCleanupHandle: null,
|
||||
};
|
||||
let systemAgentSessionsStopPromise: Promise<void> | null = null;
|
||||
const systemAgentSessionsResident = residentRegistry.register({
|
||||
name: "system-agent-sessions",
|
||||
start: () => undefined,
|
||||
stop: () => {
|
||||
systemAgentSessionsStopPromise ??= retireAndDisposeSystemAgentSessions({
|
||||
sessions: systemAgentSessions,
|
||||
wizardSessions,
|
||||
approvalManager: pluginGatewayContext.current?.systemAgentApprovalManager,
|
||||
});
|
||||
return systemAgentSessionsStopPromise;
|
||||
},
|
||||
});
|
||||
systemAgentSessionsResident.start();
|
||||
const clearPostReadyMaintenanceTimer = () => {
|
||||
if (!postReadyState.maintenanceTimer) {
|
||||
return;
|
||||
@@ -389,6 +407,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
lifecycle.closePreludeStarted = true;
|
||||
// Fence background owners before any awaited close step can tear down the
|
||||
// plugin/channel or shared-state runtime they still need.
|
||||
void systemAgentSessionsResident.stop();
|
||||
void stopOutboundDeliveryRecoveryForClose();
|
||||
void stopMediaCleanupForClose();
|
||||
runtimeState.stopGatewayUpdateCheck();
|
||||
@@ -415,6 +434,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
stopOutboundDeliveryRecoveryForClose(),
|
||||
stopMediaCleanupForClose(),
|
||||
stopConfigReloaderForClose().catch(() => {}),
|
||||
systemAgentSessionsResident.stop(),
|
||||
]);
|
||||
};
|
||||
const runClosePrelude = async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
SystemAgentChatQuestion,
|
||||
SystemAgentWizardCancel,
|
||||
WizardAnswer,
|
||||
WizardStep,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
// Shared server-method types define the client, context, response, and handler
|
||||
// contracts used by every gateway RPC method module.
|
||||
@@ -148,32 +149,29 @@ type SystemAgentHistoryTurn = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type GatewaySystemAgentChatReply = {
|
||||
text: string;
|
||||
action: "none" | "exit" | "open-tui" | "open-setup";
|
||||
sensitive?: boolean;
|
||||
wizardInputPending?: boolean;
|
||||
wizardSettling?: boolean;
|
||||
question?: SystemAgentChatQuestion;
|
||||
step?: WizardStep;
|
||||
};
|
||||
|
||||
type GatewaySystemAgentSession = {
|
||||
engine: {
|
||||
handle: (
|
||||
message: string,
|
||||
options?: { uiContext?: { page: string } },
|
||||
) => Promise<{
|
||||
text: string;
|
||||
action: "none" | "exit" | "open-tui" | "open-setup";
|
||||
sensitive?: boolean;
|
||||
question?: SystemAgentChatQuestion;
|
||||
}>;
|
||||
answerWizard: (answer: WizardAnswer) => Promise<{
|
||||
text: string;
|
||||
action: "none" | "exit" | "open-tui" | "open-setup";
|
||||
sensitive?: boolean;
|
||||
question?: SystemAgentChatQuestion;
|
||||
}>;
|
||||
cancelWizard: (cancel: SystemAgentWizardCancel) => Promise<{
|
||||
text: string;
|
||||
action: "none" | "exit" | "open-tui" | "open-setup";
|
||||
sensitive?: boolean;
|
||||
question?: SystemAgentChatQuestion;
|
||||
}>;
|
||||
) => Promise<GatewaySystemAgentChatReply>;
|
||||
answerWizard: (answer: WizardAnswer) => Promise<GatewaySystemAgentChatReply>;
|
||||
cancelWizard: (cancel: SystemAgentWizardCancel) => Promise<GatewaySystemAgentChatReply>;
|
||||
pollStep: (stepId: string) => Promise<GatewaySystemAgentChatReply>;
|
||||
seedHistory: (turns: readonly SystemAgentHistoryTurn[]) => void;
|
||||
historyLength: () => number;
|
||||
historySince: (index: number) => SystemAgentHistoryTurn[];
|
||||
hasPendingQrCode: () => boolean;
|
||||
getPendingOperatorProposal: () => { operation: SystemAgentOperation; hash: string } | null;
|
||||
resolveOperatorApproval: (
|
||||
decision: "allow-once" | "allow-always" | "deny" | null,
|
||||
@@ -187,6 +185,8 @@ type GatewaySystemAgentSession = {
|
||||
welcomeAuditSequence?: number;
|
||||
lastUsedAt: number;
|
||||
ownerKey: string;
|
||||
/** QR presentation support negotiated when the session was created. */
|
||||
supportsQrCode: boolean;
|
||||
pendingApproval?: { id: string; proposalHash: string };
|
||||
};
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ function makeEngine() {
|
||||
const handle = vi.fn();
|
||||
const answerWizard = vi.fn();
|
||||
const cancelWizard = vi.fn();
|
||||
const pollStep = vi.fn();
|
||||
return {
|
||||
answerWizard,
|
||||
cancelWizard,
|
||||
handle,
|
||||
engine: { answerWizard, cancelWizard, handle },
|
||||
pollStep,
|
||||
engine: { answerWizard, cancelWizard, handle, pollStep },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +27,7 @@ describe("system-agent chat input", () => {
|
||||
message: "5",
|
||||
wizardAnswer: { stepId: "channel", value: "twitch" },
|
||||
},
|
||||
error: "Send either message or wizardAnswer, not both.",
|
||||
error: "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId.",
|
||||
},
|
||||
{
|
||||
input: {
|
||||
@@ -33,7 +35,7 @@ describe("system-agent chat input", () => {
|
||||
wizardAnswer: { stepId: "secret", value: "not-forwarded" },
|
||||
delegation: { agentId: "main", sessionKey: "agent:main:main" },
|
||||
},
|
||||
error: "Delegated OpenClaw sessions cannot submit structured wizard answers.",
|
||||
error: "Delegated OpenClaw sessions cannot answer or poll structured wizard steps.",
|
||||
},
|
||||
{
|
||||
input: {
|
||||
@@ -41,7 +43,7 @@ describe("system-agent chat input", () => {
|
||||
wizardAnswer: { stepId: "channel", value: "twitch" },
|
||||
reset: true,
|
||||
},
|
||||
error: "A wizard answer cannot reset its OpenClaw chat session.",
|
||||
error: "A wizard answer or poll cannot reset its OpenClaw chat session.",
|
||||
},
|
||||
{
|
||||
input: {
|
||||
@@ -49,7 +51,7 @@ describe("system-agent chat input", () => {
|
||||
message: "cancel",
|
||||
wizardCancel: { stepId: "channel" },
|
||||
},
|
||||
error: "Send wizardCancel without a message or wizardAnswer.",
|
||||
error: "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId.",
|
||||
},
|
||||
{
|
||||
input: {
|
||||
@@ -57,7 +59,7 @@ describe("system-agent chat input", () => {
|
||||
wizardAnswer: { stepId: "channel", value: "twitch" },
|
||||
wizardCancel: { stepId: "channel" },
|
||||
},
|
||||
error: "Send wizardCancel without a message or wizardAnswer.",
|
||||
error: "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId.",
|
||||
},
|
||||
{
|
||||
input: {
|
||||
@@ -116,6 +118,25 @@ describe("system-agent chat input", () => {
|
||||
expect(handle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a passive wizard poll without answering the step", async () => {
|
||||
const { engine, answerWizard, pollStep } = makeEngine();
|
||||
pollStep.mockResolvedValue({
|
||||
text: "Setup is still finishing this QR operation.",
|
||||
action: "none",
|
||||
wizardSettling: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runSystemAgentChatInput({
|
||||
engine,
|
||||
input: { sessionId: "s1", pollStepId: "qr-step" },
|
||||
}),
|
||||
).resolves.toMatchObject({ wizardSettling: true });
|
||||
|
||||
expect(pollStep).toHaveBeenCalledWith("qr-step");
|
||||
expect(answerWizard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the enriched wizard step in the gateway result", () => {
|
||||
expect(
|
||||
buildSystemAgentChatResult({
|
||||
@@ -138,4 +159,17 @@ describe("system-agent chat input", () => {
|
||||
step: { id: "channel", type: "select" },
|
||||
});
|
||||
});
|
||||
|
||||
it("projects wizard settlement state", () => {
|
||||
expect(
|
||||
buildSystemAgentChatResult({
|
||||
sessionId: "s1",
|
||||
reply: {
|
||||
text: "Setup is still finishing this QR operation.",
|
||||
action: "none",
|
||||
wizardSettling: true,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ wizardSettling: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,24 +7,33 @@ import type { SystemAgentChatEngine } from "../../system-agent/chat-engine.js";
|
||||
type SystemAgentChatReply = Awaited<ReturnType<SystemAgentChatEngine["handle"]>>;
|
||||
type SystemAgentChatEngineInput = Pick<
|
||||
SystemAgentChatEngine,
|
||||
"answerWizard" | "cancelWizard" | "handle"
|
||||
"answerWizard" | "cancelWizard" | "handle" | "pollStep"
|
||||
>;
|
||||
|
||||
export function getSystemAgentChatInputError(params: SystemAgentChatParams): string | undefined {
|
||||
if (params.message !== undefined && params.wizardAnswer !== undefined) {
|
||||
return "Send either message or wizardAnswer, not both.";
|
||||
}
|
||||
if (params.wizardAnswer !== undefined && params.delegation !== undefined) {
|
||||
return "Delegated OpenClaw sessions cannot submit structured wizard answers.";
|
||||
}
|
||||
if (params.wizardAnswer !== undefined && params.reset === true) {
|
||||
return "A wizard answer cannot reset its OpenClaw chat session.";
|
||||
const inputCount = [
|
||||
params.message,
|
||||
params.wizardAnswer,
|
||||
params.wizardCancel,
|
||||
params.pollStepId,
|
||||
].filter((value) => value !== undefined).length;
|
||||
if (inputCount > 1) {
|
||||
return "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId.";
|
||||
}
|
||||
if (
|
||||
params.wizardCancel !== undefined &&
|
||||
(params.message !== undefined || params.wizardAnswer !== undefined)
|
||||
(params.wizardAnswer !== undefined || params.pollStepId !== undefined) &&
|
||||
params.delegation !== undefined
|
||||
) {
|
||||
return "Send wizardCancel without a message or wizardAnswer.";
|
||||
return "Delegated OpenClaw sessions cannot answer or poll structured wizard steps.";
|
||||
}
|
||||
if (
|
||||
(params.wizardAnswer !== undefined || params.pollStepId !== undefined) &&
|
||||
params.reset === true
|
||||
) {
|
||||
return "A wizard answer or poll cannot reset its OpenClaw chat session.";
|
||||
}
|
||||
if (params.pollStepId !== undefined && (params.welcomeVariant || params.context)) {
|
||||
return "A wizard poll cannot include welcome or UI context.";
|
||||
}
|
||||
if (params.wizardCancel !== undefined && params.delegation !== undefined) {
|
||||
return "Delegated OpenClaw sessions cannot cancel hosted wizards.";
|
||||
@@ -39,6 +48,9 @@ export async function runSystemAgentChatInput(params: {
|
||||
engine: SystemAgentChatEngineInput;
|
||||
input: SystemAgentChatParams;
|
||||
}): Promise<SystemAgentChatReply | undefined> {
|
||||
if (params.input.pollStepId !== undefined) {
|
||||
return await params.engine.pollStep(params.input.pollStepId);
|
||||
}
|
||||
if (params.input.wizardAnswer !== undefined) {
|
||||
return await params.engine.answerWizard(params.input.wizardAnswer);
|
||||
}
|
||||
@@ -82,6 +94,7 @@ export function buildSystemAgentChatResult(params: {
|
||||
: {}),
|
||||
...(params.reply.sensitive === true ? { sensitive: true } : {}),
|
||||
...(params.reply.wizardInputPending === true ? { wizardInputPending: true } : {}),
|
||||
...(params.reply.wizardSettling === true ? { wizardSettling: true } : {}),
|
||||
...(params.reply.question ? { question: params.reply.question } : {}),
|
||||
...(params.reply.step ? { step: params.reply.step } : {}),
|
||||
...(params.proposalId ? { needsApproval: true, proposalId: params.proposalId } : {}),
|
||||
|
||||
@@ -87,6 +87,7 @@ function discardableSessions(dispose: () => Promise<void>): Map<string, SystemAg
|
||||
welcome: "welcome text",
|
||||
lastUsedAt: 1,
|
||||
ownerKey: "device:device-test",
|
||||
supportsQrCode: false,
|
||||
},
|
||||
],
|
||||
]) as unknown as Map<string, SystemAgentChatSession>;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import {
|
||||
assertSystemAgentSessionStoreActive,
|
||||
disposeSystemAgentSessionsForOwner,
|
||||
retireAndDisposeSystemAgentSessions,
|
||||
} from "./system-agent-session-lifecycle.js";
|
||||
import type { GatewayRequestContext } from "./types.js";
|
||||
|
||||
type Sessions = GatewayRequestContext["systemAgentSessions"];
|
||||
type Session = Sessions extends Map<string, infer Value> ? Value : never;
|
||||
|
||||
function session(ownerKey: string, dispose: () => Promise<void>): Session {
|
||||
return {
|
||||
ownerKey,
|
||||
engine: { dispose },
|
||||
} as unknown as Session;
|
||||
}
|
||||
|
||||
describe("system-agent session lifecycle", () => {
|
||||
it("removes one connection owner synchronously and joins its disposal", async () => {
|
||||
const release = createDeferred<void>();
|
||||
const sessions = new Map([
|
||||
["connection-session", session("connection:one", async () => await release.promise)],
|
||||
["device-session", session("device:two", async () => undefined)],
|
||||
]) as Sessions;
|
||||
|
||||
const disposal = disposeSystemAgentSessionsForOwner({
|
||||
sessions,
|
||||
ownerKey: "connection:one",
|
||||
});
|
||||
|
||||
expect(sessions.has("connection-session")).toBe(false);
|
||||
expect(sessions.has("device-session")).toBe(true);
|
||||
release.resolve();
|
||||
await expect(disposal).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("retires admission before cancelling and joining every setup owner", async () => {
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const cancel = vi.fn(() => true);
|
||||
const sessions = new Map([["session", session("device:one", dispose)]]) as Sessions;
|
||||
const wizardSessions = new Map([
|
||||
[
|
||||
"wizard",
|
||||
{
|
||||
cancel,
|
||||
whenSettled: async () => undefined,
|
||||
},
|
||||
],
|
||||
]) as unknown as GatewayRequestContext["wizardSessions"];
|
||||
|
||||
await retireAndDisposeSystemAgentSessions({ sessions, wizardSessions });
|
||||
|
||||
expect(() => assertSystemAgentSessionStoreActive(sessions)).toThrow("shutting down");
|
||||
expect(sessions.size).toBe(0);
|
||||
expect(wizardSessions.size).toBe(0);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { GatewayRequestContext } from "./types.js";
|
||||
|
||||
type SystemAgentSessions = GatewayRequestContext["systemAgentSessions"];
|
||||
type WizardSessions = GatewayRequestContext["wizardSessions"];
|
||||
type ApprovalManager = NonNullable<GatewayRequestContext["systemAgentApprovalManager"]>;
|
||||
|
||||
const retiredStores = new WeakSet<SystemAgentSessions>();
|
||||
const pendingDisposals = new WeakMap<SystemAgentSessions, Set<Promise<void>>>();
|
||||
|
||||
export function assertSystemAgentSessionStoreActive(sessions: SystemAgentSessions): void {
|
||||
if (retiredStores.has(sessions)) {
|
||||
throw new Error("OpenClaw session owner is shutting down.");
|
||||
}
|
||||
}
|
||||
|
||||
function trackDisposal(sessions: SystemAgentSessions, disposal: Promise<void>): Promise<void> {
|
||||
let pending = pendingDisposals.get(sessions);
|
||||
if (!pending) {
|
||||
pending = new Set();
|
||||
pendingDisposals.set(sessions, pending);
|
||||
}
|
||||
pending.add(disposal);
|
||||
void disposal.finally(() => pending?.delete(disposal)).catch(() => undefined);
|
||||
return disposal;
|
||||
}
|
||||
|
||||
function expireSessionApproval(
|
||||
session: SystemAgentSessions extends Map<string, infer Session> ? Session : never,
|
||||
approvalManager: ApprovalManager | undefined,
|
||||
reason: string,
|
||||
): void {
|
||||
if (session.pendingApproval) {
|
||||
approvalManager?.expire(session.pendingApproval.id, reason);
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeSystemAgentSessionsForOwner(params: {
|
||||
sessions: SystemAgentSessions;
|
||||
ownerKey: string;
|
||||
approvalManager?: ApprovalManager;
|
||||
}): Promise<void> {
|
||||
const disposals: Promise<void>[] = [];
|
||||
for (const [sessionId, session] of params.sessions) {
|
||||
if (session.ownerKey !== params.ownerKey) {
|
||||
continue;
|
||||
}
|
||||
params.sessions.delete(sessionId);
|
||||
expireSessionApproval(session, params.approvalManager, "session-owner-disconnected");
|
||||
disposals.push(session.engine.dispose());
|
||||
}
|
||||
const disposal = Promise.allSettled(disposals).then((results) => {
|
||||
const errors = results.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, `Failed to dispose ${errors.length} OpenClaw session(s)`);
|
||||
}
|
||||
});
|
||||
return trackDisposal(params.sessions, disposal);
|
||||
}
|
||||
|
||||
export function retireAndDisposeSystemAgentSessions(params: {
|
||||
sessions: SystemAgentSessions;
|
||||
wizardSessions: WizardSessions;
|
||||
approvalManager?: ApprovalManager;
|
||||
}): Promise<void> {
|
||||
retiredStores.add(params.sessions);
|
||||
const disposals: Promise<void>[] = [...(pendingDisposals.get(params.sessions) ?? [])];
|
||||
for (const [sessionId, session] of params.sessions) {
|
||||
params.sessions.delete(sessionId);
|
||||
expireSessionApproval(session, params.approvalManager, "gateway-shutdown");
|
||||
disposals.push(session.engine.dispose());
|
||||
}
|
||||
for (const [sessionId, session] of params.wizardSessions) {
|
||||
params.wizardSessions.delete(sessionId);
|
||||
session.cancel();
|
||||
disposals.push(session.whenSettled());
|
||||
}
|
||||
const disposal = Promise.allSettled(disposals).then((results) => {
|
||||
const errors = results.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, `Failed to settle ${errors.length} Gateway setup owner(s)`);
|
||||
}
|
||||
});
|
||||
return trackDisposal(params.sessions, disposal);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ vi.mock("../../system-agent/greeting.js", () => ({
|
||||
type FakeEngine = {
|
||||
answerWizard: ReturnType<typeof vi.fn>;
|
||||
cancelWizard: ReturnType<typeof vi.fn>;
|
||||
pollStep: ReturnType<typeof vi.fn>;
|
||||
hasPendingQrCode: ReturnType<typeof vi.fn>;
|
||||
handle: ReturnType<typeof vi.fn>;
|
||||
seedHistory: ReturnType<typeof vi.fn>;
|
||||
historyLength: ReturnType<typeof vi.fn>;
|
||||
@@ -57,6 +59,10 @@ function makeEngine(): FakeEngine {
|
||||
cancelWizard: vi.fn(async () => {
|
||||
throw new SystemAgentWizardAnswerError("No hosted wizard is awaiting cancellation.");
|
||||
}),
|
||||
pollStep: vi.fn(async () => {
|
||||
throw new SystemAgentWizardAnswerError("The hosted wizard step is no longer active.");
|
||||
}),
|
||||
hasPendingQrCode: vi.fn(() => false),
|
||||
handle: vi.fn(async () => ({ text: "did the thing", action: "none" })),
|
||||
seedHistory: vi.fn(),
|
||||
historyLength: vi.fn(() => 0),
|
||||
@@ -112,12 +118,14 @@ function makeContext(sessions: Map<string, SystemAgentChatSession>): GatewayRequ
|
||||
function seededSession(params?: {
|
||||
engine?: FakeEngine;
|
||||
ownerKey?: string;
|
||||
supportsQrCode?: boolean;
|
||||
}): SystemAgentChatSession {
|
||||
return {
|
||||
engine: params?.engine ?? makeEngine(),
|
||||
welcome: "welcome text",
|
||||
lastUsedAt: 1,
|
||||
ownerKey: params?.ownerKey ?? "device:device-test",
|
||||
supportsQrCode: params?.supportsQrCode ?? false,
|
||||
} as unknown as SystemAgentChatSession;
|
||||
}
|
||||
|
||||
@@ -154,6 +162,28 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("openclaw.chat session ownership", () => {
|
||||
it("invalidates a session when reconnect negotiation changes QR support", async () => {
|
||||
const engine = makeEngine();
|
||||
const sessions = new Map<string, SystemAgentChatSession>([
|
||||
["qr-session", seededSession({ engine, supportsQrCode: true })],
|
||||
]);
|
||||
|
||||
const result = await callChat(makeContext(sessions), {
|
||||
sessionId: "qr-session",
|
||||
message: "status",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
details: { code: "system_agent_session_invalidated" },
|
||||
},
|
||||
});
|
||||
expect(engine.handle).not.toHaveBeenCalled();
|
||||
expect(engine.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("binds a new non-delegated session and rejects another principal", async () => {
|
||||
const sessions = new Map<string, SystemAgentChatSession>();
|
||||
const context = makeContext(sessions);
|
||||
|
||||
@@ -218,6 +218,7 @@ function seededSession(overrides?: Partial<SystemAgentChatSession>): SystemAgent
|
||||
welcome: "welcome text",
|
||||
lastUsedAt: 1,
|
||||
ownerKey: "device:device-test",
|
||||
supportsQrCode: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1056,6 +1057,27 @@ describe("openclaw.chat", () => {
|
||||
expect([sessions.has("new-1"), sessions.has("new-2")]).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("does not evict the newest active QR operation for each owner", async () => {
|
||||
const sessions = new Map<string, SystemAgentChatSession>();
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const protectedSession = seededSession({
|
||||
lastUsedAt: index,
|
||||
ownerKey: `device:owner-${index}`,
|
||||
});
|
||||
vi.spyOn(protectedSession.engine, "hasPendingQrCode").mockReturnValue(true);
|
||||
sessions.set(`protected-${index}`, protectedSession);
|
||||
}
|
||||
stubEngineOverview();
|
||||
|
||||
const result = await callChat(makeContext(sessions), { sessionId: "new-session" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "UNAVAILABLE" } });
|
||||
expect(sessions.size).toBe(8);
|
||||
expect([...sessions.keys()]).toEqual(
|
||||
Array.from({ length: 8 }, (_value, index) => `protected-${index}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("resets a session on request", async () => {
|
||||
stubEngineOverview();
|
||||
transcriptStoreMocks.readTranscriptTail.mockReturnValue([]);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
// OpenClaw gateway methods host the setup/repair conversation for clients.
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
hasGatewayClientCap,
|
||||
} from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import {
|
||||
buildSystemAgentInferenceUnavailableErrorDetails,
|
||||
buildSystemAgentSessionInvalidatedErrorDetails,
|
||||
@@ -62,6 +66,7 @@ import {
|
||||
getSystemAgentChatInputError,
|
||||
runSystemAgentChatInput,
|
||||
} from "./system-agent-chat-turn.js";
|
||||
import { assertSystemAgentSessionStoreActive } from "./system-agent-session-lifecycle.js";
|
||||
import type {
|
||||
GatewayClient,
|
||||
GatewayRequestContext,
|
||||
@@ -154,13 +159,27 @@ function resolveSystemAgentSessionOwnerKey(params: {
|
||||
async function evictOldestSession(
|
||||
sessions: Map<string, SystemAgentChatSession>,
|
||||
context: GatewayRequestContext,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
if (sessions.size < MAX_SYSTEM_AGENT_SESSIONS) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const protectedQrSessions = new Map<string, { key: string; lastUsedAt: number }>();
|
||||
for (const [key, session] of sessions) {
|
||||
if (!session.engine.hasPendingQrCode()) {
|
||||
continue;
|
||||
}
|
||||
const current = protectedQrSessions.get(session.ownerKey);
|
||||
if (!current || session.lastUsedAt >= current.lastUsedAt) {
|
||||
protectedQrSessions.set(session.ownerKey, { key, lastUsedAt: session.lastUsedAt });
|
||||
}
|
||||
}
|
||||
const protectedKeys = new Set([...protectedQrSessions.values()].map(({ key }) => key));
|
||||
let oldestKey: string | undefined;
|
||||
let oldestAt = Number.POSITIVE_INFINITY;
|
||||
for (const [key, session] of sessions) {
|
||||
if (protectedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (session.lastUsedAt < oldestAt) {
|
||||
oldestAt = session.lastUsedAt;
|
||||
oldestKey = key;
|
||||
@@ -171,9 +190,11 @@ async function evictOldestSession(
|
||||
if (oldest?.pendingApproval) {
|
||||
context.systemAgentApprovalManager?.expire(oldest.pendingApproval.id, "session-evicted");
|
||||
}
|
||||
await oldest?.engine.dispose();
|
||||
sessions.delete(oldestKey);
|
||||
await oldest?.engine.dispose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function persistEngineHistory(engine: SystemAgentChatSession["engine"], startIndex: number): void {
|
||||
@@ -521,6 +542,11 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
// it, concurrent first messages can create competing engines and lose
|
||||
// conversation state when the later initializer replaces the first.
|
||||
await getSystemAgentSessionQueue(sessions).enqueue(sessionId, async () => {
|
||||
assertSystemAgentSessionStoreActive(sessions);
|
||||
const supportsQrCode = hasGatewayClientCap(
|
||||
client?.connect.caps,
|
||||
GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE,
|
||||
);
|
||||
const ownerKey = resolveSystemAgentSessionOwnerKey({
|
||||
delegation: params.delegation,
|
||||
client,
|
||||
@@ -538,7 +564,21 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "OpenClaw session belongs to another caller."),
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "OpenClaw session belongs to another caller.", {
|
||||
details: buildSystemAgentSessionInvalidatedErrorDetails(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (boundSession && !params.reset && boundSession.supportsQrCode !== supportsQrCode) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"OpenClaw session capabilities changed; reset the session before continuing.",
|
||||
{ details: buildSystemAgentSessionInvalidatedErrorDetails() },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -556,7 +596,12 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
await existing?.engine.dispose();
|
||||
}
|
||||
let session = sessions.get(sessionId);
|
||||
if ((params.wizardAnswer !== undefined || params.wizardCancel !== undefined) && !session) {
|
||||
if (
|
||||
(params.wizardAnswer !== undefined ||
|
||||
params.wizardCancel !== undefined ||
|
||||
params.pollStepId !== undefined) &&
|
||||
!session
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -564,7 +609,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
params.wizardCancel !== undefined
|
||||
? "No active OpenClaw chat session is awaiting that wizard cancel."
|
||||
: "No active OpenClaw chat session is awaiting that wizard answer.",
|
||||
: "No active OpenClaw chat session is awaiting that wizard step.",
|
||||
{ details: buildSystemAgentSessionInvalidatedErrorDetails() },
|
||||
),
|
||||
);
|
||||
@@ -574,6 +619,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
const welcomeOnly =
|
||||
params.wizardAnswer === undefined &&
|
||||
params.wizardCancel === undefined &&
|
||||
params.pollStepId === undefined &&
|
||||
(params.message === undefined || !params.message.trim());
|
||||
if (!session) {
|
||||
const { verifySystemAgentInferenceWithFallback } =
|
||||
@@ -600,6 +646,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
// engine's setup path honors this via surface: "gateway".
|
||||
const engine = new SystemAgentChatEngine({
|
||||
surface: "gateway",
|
||||
supportsQrCode,
|
||||
verifiedInference: inference.binding,
|
||||
operatorApprovalOnly: params.delegation !== undefined,
|
||||
});
|
||||
@@ -645,8 +692,37 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, error.message));
|
||||
return;
|
||||
}
|
||||
if (!(await evictOldestSession(sessions, context))) {
|
||||
await engine.dispose().catch(() => undefined);
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"OpenClaw chat capacity is reserved for active QR operations; try again after one completes.",
|
||||
{ retryable: true },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const connectionId = client?.connId?.trim();
|
||||
if (
|
||||
ownerKey.startsWith("connection:") &&
|
||||
connectionId &&
|
||||
context.isConnectionActive?.(connectionId) === false
|
||||
) {
|
||||
await engine.dispose().catch(() => undefined);
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "OpenClaw connection is no longer active.", {
|
||||
details: buildSystemAgentSessionInvalidatedErrorDetails(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
assertSystemAgentSessionStoreActive(sessions);
|
||||
persistEngineHistory(engine, welcomeHistoryStart);
|
||||
await evictOldestSession(sessions, context);
|
||||
session = {
|
||||
engine,
|
||||
welcome,
|
||||
@@ -656,6 +732,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
: {}),
|
||||
lastUsedAt: Date.now(),
|
||||
ownerKey,
|
||||
supportsQrCode,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
if (welcomeOnly) {
|
||||
@@ -678,6 +755,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
if (
|
||||
params.wizardAnswer === undefined &&
|
||||
params.wizardCancel === undefined &&
|
||||
params.pollStepId === undefined &&
|
||||
(params.message === undefined || !params.message.trim())
|
||||
) {
|
||||
respond(
|
||||
@@ -712,7 +790,15 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
} catch (error) {
|
||||
persistEngineHistory(session.engine, historyStart);
|
||||
if (error instanceof SystemAgentWizardAnswerError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message));
|
||||
const options =
|
||||
params.pollStepId === undefined
|
||||
? undefined
|
||||
: { details: buildSystemAgentSessionInvalidatedErrorDetails() };
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, error.message, options),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isSystemAgentInferenceUnavailableError(error)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
MAX_PAYLOAD_BYTES,
|
||||
MAX_PREAUTH_PAYLOAD_BYTES,
|
||||
} from "../server-constants.js";
|
||||
import { disposeSystemAgentSessionsForOwner } from "../server-methods/system-agent-session-lifecycle.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
import {
|
||||
@@ -510,6 +511,13 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
}
|
||||
if (connectionKind === "gateway") {
|
||||
const context = buildRequestContext();
|
||||
void disposeSystemAgentSessionsForOwner({
|
||||
sessions: context.systemAgentSessions,
|
||||
ownerKey: `connection:${connId}`,
|
||||
approvalManager: context.systemAgentApprovalManager,
|
||||
}).catch((error) => {
|
||||
logGateway.warn(`OpenClaw session cleanup failed: ${formatError(error)}`);
|
||||
});
|
||||
cleanupTalkConnection(connId, logGateway);
|
||||
context.unsubscribeAllSessionEvents(connId);
|
||||
// Detach (or, with a zero grace period, kill) any PTY shells this
|
||||
|
||||
@@ -74,6 +74,7 @@ export class SystemAgentChatEngine {
|
||||
private disposed = false;
|
||||
private disposal: Promise<void> | null = null;
|
||||
private persistentApplySettlement: Promise<void> | null = null;
|
||||
private retainedPollReplies = new Map<string, SystemAgentChatReply>();
|
||||
|
||||
constructor(
|
||||
private readonly options: SystemAgentChatEngineOptions,
|
||||
@@ -203,6 +204,65 @@ export class SystemAgentChatEngine {
|
||||
return await turn;
|
||||
}
|
||||
|
||||
/** Observe a passive wizard step while keeping its continuation in the turn queue. */
|
||||
async pollStep(stepId: string): Promise<SystemAgentChatReply> {
|
||||
this.assertActive();
|
||||
const retained = this.retainedPollReplies.get(stepId);
|
||||
if (retained) {
|
||||
this.retainedPollReplies.delete(stepId);
|
||||
if (retained.text) {
|
||||
this.history.push({ role: "assistant", text: retained.text });
|
||||
}
|
||||
return { ...retained };
|
||||
}
|
||||
const observation = this.turnQueue.then(async () => {
|
||||
this.assertActive();
|
||||
const queuedRetained = this.retainedPollReplies.get(stepId);
|
||||
if (queuedRetained) {
|
||||
return { ...queuedRetained };
|
||||
}
|
||||
const result = await this.wizard.pollStep(stepId);
|
||||
this.assertActive();
|
||||
const reply = this.wizard.decorateReply({ text: result.text, action: "none" });
|
||||
if (
|
||||
reply.step === undefined &&
|
||||
reply.wizardInputPending !== true &&
|
||||
reply.wizardSettling !== true
|
||||
) {
|
||||
this.retainedPollReplies.set(stepId, { ...reply });
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
this.turnQueue = observation.catch(() => undefined);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const outcome = await Promise.race([
|
||||
observation.then((reply) => ({ reply })),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(resolve, 0);
|
||||
}),
|
||||
]);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (outcome) {
|
||||
if (
|
||||
outcome.reply.text &&
|
||||
outcome.reply.step === undefined &&
|
||||
outcome.reply.wizardInputPending !== true &&
|
||||
outcome.reply.wizardSettling !== true
|
||||
) {
|
||||
this.history.push({ role: "assistant", text: outcome.reply.text });
|
||||
this.retainedPollReplies.delete(stepId);
|
||||
}
|
||||
return outcome.reply;
|
||||
}
|
||||
return {
|
||||
text: "Setup is still finishing this QR operation.",
|
||||
action: "none",
|
||||
wizardSettling: true,
|
||||
};
|
||||
}
|
||||
|
||||
async answerWizard(answer: WizardAnswer): Promise<SystemAgentChatReply> {
|
||||
this.assertActive();
|
||||
const turn = this.turnQueue.then(async () => {
|
||||
|
||||
@@ -80,6 +80,44 @@ describe("SystemAgentChatEngine wizard", () => {
|
||||
expect(done.step).toBeUndefined();
|
||||
});
|
||||
|
||||
it("polls owner completion without answering the QR step", async () => {
|
||||
let settleOwner!: () => void;
|
||||
const owner = new Promise<void>((resolve) => {
|
||||
settleOwner = resolve;
|
||||
});
|
||||
let releaseRunner!: () => void;
|
||||
const runnerGate = new Promise<void>((resolve) => {
|
||||
releaseRunner = resolve;
|
||||
});
|
||||
let runnerReachedGate = false;
|
||||
const engine = createQrEngine(async (_channel, prompter) => {
|
||||
await prompter.qrCode?.({
|
||||
title: "Link a device",
|
||||
message: "Scan this QR code and approve the device.",
|
||||
text: QR_TEXT,
|
||||
dismissed: owner,
|
||||
});
|
||||
runnerReachedGate = true;
|
||||
await runnerGate;
|
||||
});
|
||||
|
||||
const prompt = await engine.handle("connect telegram");
|
||||
const stepId = expectDefined(prompt.step, "QR step").id;
|
||||
settleOwner();
|
||||
await vi.waitFor(() => expect(runnerReachedGate).toBe(true));
|
||||
|
||||
const settling = await engine.pollStep(stepId);
|
||||
expect(settling).toMatchObject({ wizardSettling: true });
|
||||
expect(settling).not.toHaveProperty("step");
|
||||
|
||||
releaseRunner();
|
||||
await vi.waitFor(() => expect(engine.hasPendingQrCode()).toBe(false));
|
||||
const completed = await engine.pollStep(stepId);
|
||||
expect(completed.text).toContain("telegram is configured");
|
||||
expect(completed).not.toHaveProperty("wizardSettling");
|
||||
expect(completed).not.toHaveProperty("step");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "chat command",
|
||||
|
||||
@@ -86,6 +86,7 @@ type ActiveWizardBridge = {
|
||||
expiryKind: "presentation" | "cancellation" | undefined;
|
||||
qrExpiresAtMs: number | undefined;
|
||||
qrStepId: string | undefined;
|
||||
passiveQrStepId: string | undefined;
|
||||
qrExpired: boolean;
|
||||
kind: "channel" | "skills" | "search" | "gateway" | "memory-import";
|
||||
label: string;
|
||||
@@ -233,6 +234,22 @@ export class ChatWizardHost {
|
||||
return { ...(await this.pump()), userHistoryText: "Cancel" };
|
||||
}
|
||||
|
||||
/** Observe a QR-owned step without answering the dependency-owned prompt. */
|
||||
async pollStep(stepId: string): Promise<ChatWizardResult> {
|
||||
this.expireActiveQrIfNeeded();
|
||||
const bridge = this.bridge;
|
||||
if (!bridge) {
|
||||
throw new SystemAgentWizardAnswerError("The hosted wizard step is no longer active.");
|
||||
}
|
||||
if (bridge.step?.id === stepId) {
|
||||
return { text: renderWizardStep(bridge.step), configWritten: false };
|
||||
}
|
||||
if (bridge.passiveQrStepId !== stepId) {
|
||||
throw new SystemAgentWizardAnswerError("The hosted wizard poll targets a stale step.");
|
||||
}
|
||||
return this.renderPendingQrOwner(bridge) ?? (await this.pump());
|
||||
}
|
||||
|
||||
async resolveReply(text: string): Promise<ChatWizardResult | null> {
|
||||
const bridge = this.bridge;
|
||||
if (!bridge) {
|
||||
@@ -416,6 +433,7 @@ export class ChatWizardHost {
|
||||
expiryKind: undefined,
|
||||
qrExpiresAtMs: undefined,
|
||||
qrStepId: undefined,
|
||||
passiveQrStepId: undefined,
|
||||
qrExpired: false,
|
||||
kind: params.kind,
|
||||
label: params.label,
|
||||
@@ -467,6 +485,7 @@ export class ChatWizardHost {
|
||||
? "presentation"
|
||||
: "cancellation";
|
||||
bridge.qrStepId = bridge.step?.id;
|
||||
bridge.passiveQrStepId = bridge.step?.id;
|
||||
const expiresAtMs = bridge.qrExpiresAtMs;
|
||||
bridge.expiryTimer = setTimeout(
|
||||
() => this.expireQr(bridge, expiresAtMs),
|
||||
|
||||
Reference in New Issue
Block a user