diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 5f3bb621a773..d28a8bc37759 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -560,9 +560,14 @@ const setupWizard: ChannelSetupWizard = { Setup code that already owns a QR-backed operation can pass its raw QR text and completion promise through `WizardPrompter.qrCode`. OpenClaw renders and transports a bounded image; the plugin remains the authority for success, failure, and cancellation. ```typescript - const link = startDeviceLink(); + if (!prompter.qrCode) { + throw new Error( + "This setup host cannot present QR credentials. Use the plugin's native setup flow.", + ); + } - if (prompter.qrCode) { + const link = startDeviceLink(); + try { await prompter.qrCode({ title: "Link a device", message: "Scan the code and approve the device.", @@ -570,21 +575,13 @@ const setupWizard: ChannelSetupWizard = { expiresAtMs: link.expiresAtMs, dismissed: link.finished, }); - } else { - try { - await prompter.note( - `Open this device-link URI with your non-QR setup flow:\n${link.uri}`, - "Link a device", - ); - await link.finished; - } catch (error) { - link.cancel(); - throw error; - } + } catch (error) { + link.cancel(); + throw error; } ``` - `dismissed` is required and must settle with the producer operation. `qrCode(...)` returns `Promise` only after that promise settles; there is no separate user Continue acknowledgement. Provide a non-QR fallback when `prompter.qrCode` is unavailable. + `dismissed` is required and must settle with the producer operation. `qrCode(...)` returns `Promise` only after that promise settles; there is no separate user Continue acknowledgement. Check capability before starting a credential-bearing operation. When `prompter.qrCode` is unavailable, route the operator to a plugin-native setup flow instead of putting the raw link URI in prompt text. diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index 038be9df5c2f..4dcc8d2507c1 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -331,7 +331,9 @@ export class SystemAgentChatEngine { this.router.clearForInferenceLoss(); delete this.agentSession.cliSession; if (cancelWizard) { - void this.wizard.dispose(); + // Inference loss terminates the conversation. Start the aggregate owner + // disposal now so later Gateway/TUI cleanup joins the producer settlement. + void this.dispose().catch(() => undefined); } this.history.splice(0); throw new SystemAgentInferenceUnavailableError("conversation", failures); diff --git a/src/system-agent/chat-wizard-host.test.ts b/src/system-agent/chat-wizard-host.test.ts index 2d2a44e72b4b..03947d056e25 100644 --- a/src/system-agent/chat-wizard-host.test.ts +++ b/src/system-agent/chat-wizard-host.test.ts @@ -10,6 +10,7 @@ import { CANCEL_HINT, countCancelHints, expectDefined, + SystemAgentInferenceUnavailableError, SystemAgentWizardAnswerError, type OpenClawConfig, type WizardPrompter, @@ -319,6 +320,81 @@ describe("SystemAgentChatEngine wizard", () => { expect(disposed).toBe(true); }); + it("retains QR cleanup after inference loss clears the active bridge", async () => { + const baseConfig = { + agents: { defaults: { model: "openai/gpt-5.5" } }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "test-key", + auth: "api-key", + models: [], + }, + }, + }, + } satisfies OpenClawConfig; + const changedConfig = { + agents: { defaults: { model: "anthropic/claude-opus-4-8" } }, + } satisfies OpenClawConfig; + const verifiedInference = await createAmbientVerifiedBinding(baseConfig); + let currentConfig: OpenClawConfig = baseConfig; + let cleanupStarted = false; + let releaseCleanup!: () => void; + const cleanup = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const engine = new SystemAgentChatEngine({ + surface: "gateway", + supportsQrCode: true, + verifiedInference, + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { + readConfigFileSnapshot: vi.fn(async () => configSnapshot(currentConfig)) as never, + loadOverview: fakeOverviewLoader(), + }, + runChannelSetupWizard: async (_channel, prompter, _beforePersistentApply, signal) => { + const owner = new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new Error("QR owner aborted", { cause: signal.reason })), + { once: true }, + ); + }); + try { + await prompter.qrCode?.({ + title: "Link a device", + message: "Scan this QR code and approve the device.", + text: QR_TEXT, + dismissed: owner, + }); + } finally { + cleanupStarted = true; + await cleanup; + } + }, + }); + + await engine.handle("connect telegram"); + currentConfig = changedConfig; + await expect(engine.handle("status")).rejects.toBeInstanceOf( + SystemAgentInferenceUnavailableError, + ); + await vi.waitFor(() => expect(cleanupStarted).toBe(true)); + + let disposed = false; + const disposal = engine.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + expect(disposed).toBe(false); + + releaseCleanup(); + await disposal; + expect(disposed).toBe(true); + }); + it("scrubs an expired QR while its owner remains cancellable", async () => { vi.useFakeTimers(); vi.setSystemTime(1_800_000_000_000);