fix(system-agent): retain QR cleanup on inference loss

This commit is contained in:
jesse-merhi
2026-08-12 21:29:17 +10:00
parent dc563ae81f
commit 42eaae358f
3 changed files with 90 additions and 15 deletions
+11 -14
View File
@@ -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<void>` 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<void>` 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.
</Accordion>
<Accordion title="Shared allowFrom prompts">
+3 -1
View File
@@ -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);
+76
View File
@@ -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<void>((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<void>((_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);