From daee265f32de85b800c14a72d97b7657673db49c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 12:40:22 -0700 Subject: [PATCH 01/53] fix(openai): own queued realtime audio buffer snapshots --- .../realtime-audio-buffer-ownership.test.ts | 123 ++++++++++++++++++ .../openai/realtime-quicksilver-bridge.ts | 6 +- extensions/openai/realtime-voice-provider.ts | 6 +- 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 extensions/openai/realtime-audio-buffer-ownership.test.ts diff --git a/extensions/openai/realtime-audio-buffer-ownership.test.ts b/extensions/openai/realtime-audio-buffer-ownership.test.ts new file mode 100644 index 000000000000..0f496a264222 --- /dev/null +++ b/extensions/openai/realtime-audio-buffer-ownership.test.ts @@ -0,0 +1,123 @@ +import { once } from "node:events"; +import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; +import { describe, expect, it, vi } from "vitest"; +import WebSocket, { WebSocketServer } from "ws"; +import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +type RealtimeProviderKind = "native" | "gpt-live"; + +async function withRealtimeProvider( + kind: RealtimeProviderKind, + prepareAudio: (bridge: RealtimeVoiceBridge) => void, +): Promise>> { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected an available local realtime WebSocket address"); + } + const received: Array> = []; + server.once("connection", (socket) => { + socket.on("message", (payload) => { + const event = JSON.parse(payload.toString()) as Record; + received.push(event); + if (event.type === "session.update") { + socket.send( + JSON.stringify( + kind === "native" + ? { type: "session.updated" } + : { + type: "session.started", + session: { id: "fixture-live", expires_at: Math.floor(Date.now() / 1000) + 60 }, + }, + ), + ); + } + }); + }); + + const endpoint = `http://127.0.0.1:${address.port}`; + const bridge = + kind === "native" + ? buildOpenAIRealtimeVoiceProvider().createBridge({ + providerConfig: { + apiKey: "fixture-local", // pragma: allowlist secret + azureEndpoint: endpoint, + azureDeployment: "fixture-realtime", + }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }) + : new OpenAIQuicksilverVoiceBridge({ + providerConfig: {}, + model: "gpt-live-1-codex", + audioFormat: { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }, + resolveAuth: async () => ({ type: "api-key", token: "fixture-local" }), + webSocketFactory: (_url, options) => new WebSocket(endpoint, options), + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + try { + prepareAudio(bridge); + await bridge.connect(); + await vi.waitFor(() => { + expect(received.some((event) => event.type === audioEventType)).toBe(true); + }); + return received; + } finally { + bridge.close(); + for (const client of server.clients) { + client.terminate(); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +describe("OpenAI realtime queued audio buffer ownership", () => { + it.each(["native", "gpt-live"])( + "%s preserves each reusable producer frame until the real WebSocket is ready", + async (kind) => { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const received = await withRealtimeProvider(kind, (bridge) => { + const producerAllocation = Buffer.alloc(2 * 1024 * 1024, 0x7f); + const producerView = producerAllocation.subarray(0, 1); + bridge.sendAudio(producerView); + producerAllocation[0] = 0x41; + bridge.sendAudio(producerView); + producerAllocation[0] = 0; + }); + + expect(received.filter((event) => event.type === audioEventType)).toEqual([ + { type: audioEventType, audio: "fw==" }, + { type: audioEventType, audio: "QQ==" }, + ]); + }, + ); + + it.each(["native", "gpt-live"])( + "%s rejects oversized producer frames before allocating a queued copy", + async (kind) => { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const received = await withRealtimeProvider(kind, (bridge) => { + const oversized = Buffer.alloc(1024 * 1024 + 1); + const copyBuffer = vi.spyOn(Buffer, "from"); + try { + bridge.sendAudio(oversized); + expect(copyBuffer).not.toHaveBeenCalled(); + } finally { + copyBuffer.mockRestore(); + } + bridge.sendAudio(Buffer.from([0x7f])); + }); + + expect(received.filter((event) => event.type === audioEventType)).toEqual([ + { type: audioEventType, audio: "fw==" }, + ]); + }, + ); +}); diff --git a/extensions/openai/realtime-quicksilver-bridge.ts b/extensions/openai/realtime-quicksilver-bridge.ts index de8583cfcca1..6d43551f56df 100644 --- a/extensions/openai/realtime-quicksilver-bridge.ts +++ b/extensions/openai/realtime-quicksilver-bridge.ts @@ -568,8 +568,10 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge { ) { return; } - this.pendingAudio.push(audio); - this.pendingAudioBytes += audio.byteLength; + // Capture transports can recycle caller-owned views before the provider becomes ready. + const queuedAudio = Buffer.from(audio); + this.pendingAudio.push(queuedAudio); + this.pendingAudioBytes += queuedAudio.byteLength; } private resetTerminalState(): void { diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 9e8e448c65ab..907783437518 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -1684,8 +1684,10 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { ) { return; } - this.pendingAudio.push(audio); - this.pendingAudioBytes += audio.byteLength; + // Capture transports can recycle caller-owned views before the provider becomes ready. + const queuedAudio = Buffer.from(audio); + this.pendingAudio.push(queuedAudio); + this.pendingAudioBytes += queuedAudio.byteLength; } private clearPendingAudio(): void { From b2320c84ee95e73e810a055a8e0acf41097c3c88 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 22:45:56 +0800 Subject: [PATCH 02/53] fix(google): bound lazy realtime user messages --- extensions/google/index.test.ts | 193 ++++++++++++++++++++++++++++---- extensions/google/index.ts | 78 ++++++++++--- 2 files changed, 236 insertions(+), 35 deletions(-) diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 1126fdfc7ced..bfa1b5d5e0b6 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -13,13 +13,28 @@ import { requireRegisteredProvider, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts"; -import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; -import { describe, expect, it } from "vitest"; +import type { + RealtimeVoiceBridge, + RealtimeVoiceProviderPlugin, +} from "openclaw/plugin-sdk/realtime-voice"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import googlePlugin from "./index.js"; import googleProviderDiscovery from "./provider-discovery.js"; import { registerGoogleProvider } from "./provider-registration.js"; +const { createRealtimeBridgeMock } = vi.hoisted(() => ({ + createRealtimeBridgeMock: vi.fn(), +})); + +vi.mock("./realtime-voice-provider.js", () => ({ + buildGoogleRealtimeVoiceProvider: () => ({ + id: "google", + label: "Google Live Voice", + createBridge: createRealtimeBridgeMock, + }), +})); + const googleProviderPlugin = { register(api: Parameters[0]) { registerGoogleProvider(api); @@ -27,7 +42,63 @@ const googleProviderPlugin = { }, }; +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createMockRealtimeBridge(connectImpl: () => Promise = async () => {}) { + const connect = vi.fn(connectImpl); + const sendUserMessage = vi.fn(); + const close = vi.fn(); + const bridge: RealtimeVoiceBridge = { + supportsToolResultContinuation: false, + supportsToolResultSuppression: false, + connect, + sendAudio: vi.fn(), + setMediaTimestamp: vi.fn(), + sendUserMessage, + triggerGreeting: vi.fn(), + handleBargeIn: vi.fn(), + submitToolResult: vi.fn(), + acknowledgeMark: vi.fn(), + close, + isConnected: vi.fn(() => false), + }; + return { bridge, close, connect, sendUserMessage }; +} + +function createLazyRealtimeBridge(onError = vi.fn()) { + let realtimeProvider: RealtimeVoiceProviderPlugin | undefined; + googlePlugin.register( + createTestPluginApi({ + registerRealtimeVoiceProvider(provider) { + realtimeProvider = provider; + }, + }), + ); + const bridge = realtimeProvider?.createBridge({ + providerConfig: { apiKey: "gemini-key" }, + onAudio() {}, + onClearAudio() {}, + onError, + }); + if (!bridge) { + throw new Error("expected Google realtime bridge"); + } + return { bridge, onError }; +} + describe("google provider plugin hooks", () => { + beforeEach(() => { + createRealtimeBridgeMock.mockReset(); + }); + it("owns replay policy and reasoning mode for the direct Gemini provider", async () => { const { providers } = await registerProviderPlugin({ plugin: googleProviderPlugin, @@ -397,28 +468,110 @@ describe("google provider plugin hooks", () => { }); it("buffers early realtime audio while the lazy Google bridge loads", () => { - let realtimeProvider: RealtimeVoiceProviderPlugin | undefined; - googlePlugin.register( - createTestPluginApi({ - registerRealtimeVoiceProvider(provider) { - realtimeProvider = provider; - }, - }), - ); - - const bridge = realtimeProvider?.createBridge({ - providerConfig: { apiKey: "gemini-key" }, - onAudio() {}, - onClearAudio() {}, - }); - - if (!bridge) { - throw new Error("expected Google realtime bridge"); - } + const { bridge } = createLazyRealtimeBridge(); expect(bridge.supportsToolResultContinuation).toBe(false); expect(bridge.supportsToolResultSuppression).toBe(false); expect(bridge.sendAudio(Buffer.alloc(160))).toBeUndefined(); expect(bridge.setMediaTimestamp(20)).toBeUndefined(); expect(bridge.sendUserMessage?.("hello")).toBeUndefined(); }); + + it("preserves queued user messages until the loaded bridge is connected", async () => { + const connected = createDeferred(); + const loaded = createMockRealtimeBridge(() => connected.promise); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge } = createLazyRealtimeBridge(); + + bridge.sendUserMessage?.("before connect"); + const connectPromise = bridge.connect(); + await vi.waitFor(() => expect(loaded.connect).toHaveBeenCalledOnce()); + bridge.sendUserMessage?.("during connect"); + + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + connected.resolve(); + await connectPromise; + + expect(loaded.sendUserMessage.mock.calls.map(([text]) => text)).toEqual([ + "before connect", + "during connect", + ]); + }); + + it("rejects each user message beyond the lazy startup queue count", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge, onError } = createLazyRealtimeBridge(); + + for (let index = 0; index < 130; index += 1) { + bridge.sendUserMessage?.(`message-${index}`); + } + await bridge.connect(); + + expect(loaded.sendUserMessage).toHaveBeenCalledTimes(128); + expect(loaded.sendUserMessage.mock.calls.map(([text]) => text)).toEqual( + Array.from({ length: 128 }, (_, index) => `message-${index}`), + ); + expect(onError).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ message: expect.stringContaining("queue overflow") }), + ); + expect(onError).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ message: expect.stringContaining("queue overflow") }), + ); + }); + + it("bounds the lazy startup queue by aggregate UTF-8 bytes", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge, onError } = createLazyRealtimeBridge(); + const exactLimit = "🙂".repeat((256 * 1024) / 4); + + expect(Buffer.byteLength(exactLimit, "utf8")).toBe(256 * 1024); + bridge.sendUserMessage?.(exactLimit); + bridge.sendUserMessage?.("overflow"); + await bridge.connect(); + + expect(loaded.sendUserMessage).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).toHaveBeenCalledWith(exactLimit); + expect(onError).toHaveBeenCalledOnce(); + }); + + it("closes a bridge that loads after the lazy wrapper is closed", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge } = createLazyRealtimeBridge(); + + bridge.sendUserMessage?.("before connect"); + const connectPromise = bridge.connect(); + bridge.close(); + bridge.close(); + bridge.sendUserMessage?.("after close"); + await connectPromise; + + expect(loaded.connect).not.toHaveBeenCalled(); + expect(loaded.close).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("clears queued messages and ignores a late connect completion after close", async () => { + const connected = createDeferred(); + const loaded = createMockRealtimeBridge(() => connected.promise); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge } = createLazyRealtimeBridge(); + + bridge.sendUserMessage?.("before connect"); + const connectPromise = bridge.connect(); + await vi.waitFor(() => expect(loaded.connect).toHaveBeenCalledOnce()); + bridge.sendUserMessage?.("during connect"); + bridge.close(); + bridge.close(); + bridge.sendUserMessage?.("after close"); + connected.resolve(); + await connectPromise; + + expect(loaded.close).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/google/index.ts b/extensions/google/index.ts index a53362e59fbf..e7becf0a7c86 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -202,17 +202,31 @@ function resolveGoogleRealtimeEnvApiKey(): string | undefined { } const GOOGLE_REALTIME_LAZY_MAX_PENDING_AUDIO_CHUNKS = 320; +const GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGES = 128; +const GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGE_BYTES = 256 * 1024; function createLazyGoogleRealtimeVoiceBridge( req: RealtimeVoiceBridgeCreateRequest, ): RealtimeVoiceBridge { let bridge: RealtimeVoiceBridge | undefined; let bridgePromise: Promise | undefined; + let bridgeReady = false; + let bridgeClosed = false; let closed = false; let latestMediaTimestamp: number | undefined; let pendingGreeting: string | undefined; const pendingAudio: Buffer[] = []; const pendingUserMessages: string[] = []; + let pendingUserMessageBytes = 0; + // Loading and connecting finish on separate async boundaries. Keep close ownership + // here so either late completion closes the provider bridge exactly once. + const closeBridge = (loadedBridge = bridge) => { + if (!loadedBridge || bridgeClosed) { + return; + } + bridgeClosed = true; + loadedBridge.close(); + }; const loadBridge = async () => { if (!bridgePromise) { bridgePromise = loadGoogleRealtimeVoiceProvider().then((provider) => @@ -220,6 +234,9 @@ function createLazyGoogleRealtimeVoiceBridge( ); } bridge = await bridgePromise; + if (closed) { + closeBridge(bridge); + } return bridge; }; const requireBridge = () => { @@ -229,13 +246,18 @@ function createLazyGoogleRealtimeVoiceBridge( return bridge; }; const flushPending = (loadedBridge: RealtimeVoiceBridge) => { + if (closed) { + return; + } if (typeof latestMediaTimestamp === "number") { loadedBridge.setMediaTimestamp(latestMediaTimestamp); } for (const audio of pendingAudio.splice(0)) { loadedBridge.sendAudio(audio); } - for (const text of pendingUserMessages.splice(0)) { + const userMessages = pendingUserMessages.splice(0); + pendingUserMessageBytes = 0; + for (const text of userMessages) { loadedBridge.sendUserMessage?.(text); } if (pendingGreeting !== undefined) { @@ -252,45 +274,69 @@ function createLazyGoogleRealtimeVoiceBridge( connect: async () => { const loadedBridge = await loadBridge(); if (closed) { - loadedBridge.close(); + closeBridge(loadedBridge); return; } await loadedBridge.connect(); + if (closed) { + closeBridge(loadedBridge); + return; + } + bridgeReady = true; + // The provider drops user messages before setup completes, so the lazy wrapper + // owns them until connect resolves and the provider reports readiness. flushPending(loadedBridge); }, sendAudio: (audio) => { + if (closed) { + return; + } if (bridge) { bridge.sendAudio(audio); return; } - if (!closed) { - if (pendingAudio.length >= GOOGLE_REALTIME_LAZY_MAX_PENDING_AUDIO_CHUNKS) { - pendingAudio.shift(); - } - pendingAudio.push(audio); + if (pendingAudio.length >= GOOGLE_REALTIME_LAZY_MAX_PENDING_AUDIO_CHUNKS) { + pendingAudio.shift(); } + pendingAudio.push(audio); }, setMediaTimestamp: (ts) => { + if (closed) { + return; + } latestMediaTimestamp = ts; bridge?.setMediaTimestamp(ts); }, sendUserMessage: (text) => { - if (bridge) { + if (closed) { + return; + } + if (bridgeReady && bridge) { bridge.sendUserMessage?.(text); return; } - if (!closed) { - pendingUserMessages.push(text); + const messageBytes = Buffer.byteLength(text, "utf8"); + if ( + pendingUserMessages.length >= GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGES || + pendingUserMessageBytes + messageBytes > GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGE_BYTES + ) { + req.onError?.( + new Error("Google realtime voice pending user message queue overflow during startup"), + ); + return; } + pendingUserMessages.push(text); + pendingUserMessageBytes += messageBytes; }, triggerGreeting: (instructions) => { - if (bridge) { + if (closed) { + return; + } + if (bridgeReady && bridge) { bridge.triggerGreeting?.(instructions); return; } - if (!closed) { - pendingGreeting = instructions; - } + pendingGreeting = instructions; }, handleBargeIn: (options) => requireBridge().handleBargeIn?.(options), submitToolResult: (callId, result, options) => @@ -298,10 +344,12 @@ function createLazyGoogleRealtimeVoiceBridge( acknowledgeMark: () => requireBridge().acknowledgeMark(), close: () => { closed = true; + bridgeReady = false; pendingAudio.length = 0; pendingUserMessages.length = 0; + pendingUserMessageBytes = 0; pendingGreeting = undefined; - bridge?.close(); + closeBridge(); }, isConnected: () => bridge?.isConnected() ?? false, }; From c61388a0e02fda7467dc20daf3082e2a1b59ab7e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 00:01:30 +0800 Subject: [PATCH 03/53] test(docker): make line count assertion portable --- test/scripts/docker-build-helper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 64560863a5b2..cb468bd0b69c 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -2785,7 +2785,7 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" docker_e2e_run_with_harness image-name bash -lc true docker_e2e_run_detached_with_harness image-name -test "$(wc -l <"$TMPDIR/docker-run-seen")" = 2 +[[ $(wc -l <"$TMPDIR/docker-run-seen") -eq 2 ]] `; execFileSync("bash", ["-lc", script], { encoding: "utf8" }); From 9cb647e735c4977b91dc8441278514acdbf36f21 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 00:15:41 +0800 Subject: [PATCH 04/53] test(google): prove lazy realtime prompt delivery --- extensions/google/google.live.test.ts | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/extensions/google/google.live.test.ts b/extensions/google/google.live.test.ts index 7b31ba1668f5..2dbe396d8341 100644 --- a/extensions/google/google.live.test.ts +++ b/extensions/google/google.live.test.ts @@ -2,10 +2,12 @@ import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { resolveFfmpegBin } from "openclaw/plugin-sdk/media-runtime"; import { + createCapturedPluginRegistration, registerProviderPlugin, requireRegisteredProvider, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { normalizeTranscriptForMatch } from "openclaw/plugin-sdk/provider-test-contracts"; +import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-live"; import { describe, expect, it } from "vitest"; import plugin from "./index.js"; @@ -66,6 +68,33 @@ function hasTrustedFfmpegForLiveVoiceNote(): boolean { } } +async function waitForGoogleLive( + label: string, + predicate: () => boolean, + timeoutMs = 45_000, + describeState?: () => unknown, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) { + const state = describeState?.(); + throw new Error( + `Google live timeout waiting for ${label}${state === undefined ? "" : ` (${JSON.stringify(state)})`}`, + ); + } + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } +} + +function shortGoogleLiveError(error: Error): string { + return error.message + .replace(/https?:\/\/\S+/giu, "") + .replace(/\b[A-Za-z0-9_-]{24,}\b/gu, "") + .slice(0, 200); +} + const registerGooglePlugin = () => registerProviderPlugin({ plugin, @@ -73,6 +102,16 @@ const registerGooglePlugin = () => name: "Google Provider", }); +function registerGoogleRealtimeVoiceProvider() { + const captured = createCapturedPluginRegistration({ + id: "google", + name: "Google Provider", + source: "test", + }); + plugin.register(captured.api); + return requireRegisteredProvider(captured.realtimeVoiceProviders, "google"); +} + describeLive("google plugin live", () => { it.each(["gemini-3.6-flash", "gemini-3.5-flash-lite"])( "discovers and completes through %s", @@ -177,6 +216,94 @@ describeLive("google plugin live", () => { expect(normalized).toContain("pineapple"); }, 180_000); + it("delivers a queued prompt through the registered lazy realtime bridge", async () => { + const provider = registerGoogleRealtimeVoiceProvider(); + const finalAssistantTranscripts: string[] = []; + const errors: Error[] = []; + const closeReasons: string[] = []; + let outputAudioBytes = 0; + let assistantPartialCount = 0; + let lastAssistantOutputAt = 0; + let readyCount = 0; + const bridge: RealtimeVoiceBridge = provider.createBridge({ + providerConfig: { apiKey: GOOGLE_API_KEY }, + instructions: "Reply briefly and plainly.", + onAudio: (audio) => { + outputAudioBytes += audio.byteLength; + lastAssistantOutputAt = Date.now(); + }, + onClearAudio: () => {}, + onTranscript: (role, text, isFinal) => { + if (role !== "assistant") { + return; + } + if (isFinal) { + finalAssistantTranscripts.push(text); + } else { + assistantPartialCount += 1; + } + lastAssistantOutputAt = Date.now(); + }, + onReady: () => { + readyCount += 1; + }, + onError: (error) => errors.push(error), + onClose: (reason) => closeReasons.push(reason), + }); + const describeState = () => ({ + readyCount, + connected: bridge.isConnected(), + outputAudioBytes, + assistantPartialCount, + assistantIdleMs: lastAssistantOutputAt === 0 ? 0 : Date.now() - lastAssistantOutputAt, + assistantFinalCount: finalAssistantTranscripts.length, + errors: errors.map(shortGoogleLiveError), + closeReasons, + }); + + bridge.sendUserMessage?.("Reply with exactly: OpenClaw lazy bridge ready."); + try { + await bridge.connect(); + // Gemini 3.1 can omit transcription.finished. Wait for output to go idle + // before close terminalizes the buffered transcript. + await waitForGoogleLive( + "queued assistant response drain", + () => + outputAudioBytes > 0 && + assistantPartialCount > 0 && + Date.now() - lastAssistantOutputAt >= 1_000, + 45_000, + describeState, + ); + expect(readyCount).toBe(1); + expect(bridge.isConnected()).toBe(true); + expect(outputAudioBytes).toBeGreaterThan(0); + expect(assistantPartialCount).toBeGreaterThan(0); + expect(errors).toStrictEqual([]); + } finally { + bridge.close(); + bridge.close(); + } + + await waitForGoogleLive( + "queued final transcript and clean close", + () => finalAssistantTranscripts.length > 0 && closeReasons.length === 1, + 5_000, + describeState, + ); + expect( + finalAssistantTranscripts.some((text) => { + const normalized = normalizeTranscriptForMatch(text); + return ( + normalized.includes("openclaw") && + normalized.includes("lazy") && + normalized.includes("bridge") + ); + }), + ).toBe(true); + expect(closeReasons).toEqual(["completed"]); + }, 120_000); + it("runs Gemini web search through the registered provider tool", async () => { const provider = createGeminiWebSearchProvider(); const tool = provider.createTool?.({ From e9b959aaaa4a8ae74371723f654bafe2d5b0bbb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:03:33 -0700 Subject: [PATCH 05/53] fix(cli): preserve machine output through fatal terminal restores (#117487) Co-authored-by: Peter Steinberger --- src/cli/config-output-mode.ts | 5 +- src/cli/machine-output-modes.test.ts | 20 ++-- src/cli/one-shot-exit.test.ts | 82 +++++++++++++ src/cli/run-main.exit.test.ts | 89 +++++++------- src/cli/run-main.ts | 6 +- src/index.ts | 6 +- ...handled-rejections.fatal-detection.test.ts | 44 ++++++- src/infra/unhandled-rejections.ts | 4 +- src/runtime.test.ts | 109 +++++++++++++++++- src/runtime.ts | 18 ++- 10 files changed, 313 insertions(+), 70 deletions(-) diff --git a/src/cli/config-output-mode.ts b/src/cli/config-output-mode.ts index b67fda4d8116..cb9f1d85599d 100644 --- a/src/cli/config-output-mode.ts +++ b/src/cli/config-output-mode.ts @@ -43,9 +43,10 @@ function resolveConfigSubcommand(argv: readonly string[]): string | null { return null; } -/** Config get reserves stdout for the requested value, including bare scalar output. */ +/** Config values, paths, and schemas reserve stdout for machine-consumed output. */ export function isConfigMachineOutput(argv: readonly string[]): boolean { - return resolveConfigSubcommand(argv) === "get"; + const subcommand = resolveConfigSubcommand(argv); + return subcommand === "get" || subcommand === "file" || subcommand === "schema"; } /** Config set uses --json as a parser alias except when dry-run emits a JSON report. */ diff --git a/src/cli/machine-output-modes.test.ts b/src/cli/machine-output-modes.test.ts index 258d4b8c6c5c..020f4b068d28 100644 --- a/src/cli/machine-output-modes.test.ts +++ b/src/cli/machine-output-modes.test.ts @@ -67,23 +67,21 @@ describe("built-in machine-output resolvers", () => { ).toBe(true); }); - it("reserves raw cron scratch and config get output", () => { + it("reserves raw cron scratch output", () => { expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job"])).toBe(true); - expect(isConfigMachineOutput(["node", "openclaw", "config", "get", "gateway.port"])).toBe(true); + }); + + it.each(["get", "file", "schema"])("reserves config %s machine output", (subcommand) => { + expect(isConfigMachineOutput(["node", "openclaw", "config", subcommand])).toBe(true); expect( - isConfigMachineOutput([ - "node", - "openclaw", - "config", - "--section", - "agents", - "get", - "gateway.port", - ]), + isConfigMachineOutput(["node", "openclaw", "config", "--section", "agents", subcommand]), ).toBe(true); }); it("treats config set --json as parse-only except for JSON dry-run reports", () => { + expect(isConfigMachineOutput(["node", "openclaw", "config", "set", "gateway.port"])).toBe( + false, + ); expect( isConfigSetJsonParseOnly([ "node", diff --git a/src/cli/one-shot-exit.test.ts b/src/cli/one-shot-exit.test.ts index 74d541a92951..be51b0228487 100644 --- a/src/cli/one-shot-exit.test.ts +++ b/src/cli/one-shot-exit.test.ts @@ -296,4 +296,86 @@ describe("one-shot CLI exit", () => { expect(result.stderr).toBe(""); expect(result.stdout).toHaveLength(payloadBytes); }); + + it.each([ + { name: "deferred hooks success", exitCode: 0, explicitRequest: true }, + { name: "deferred hooks failure", exitCode: 1, explicitRequest: true }, + { name: "automatic macOS system-CA success", exitCode: 0, explicitRequest: false }, + ])("keeps real dual-TTY JSON clean for $name", ({ exitCode, explicitRequest }) => { + const env = { ...process.env }; + delete env.VITEST; + delete env.VITEST_POOL_ID; + delete env.VITEST_WORKER_ID; + const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href; + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; + const script = ` + import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)}; + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + import { loggingState } from ${JSON.stringify(loggingStateUrl)}; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + loggingState.forceConsoleToStderr = true; + await runCliWithExitFinalization({ + run: async () => { + defaultRuntime.writeStdout(JSON.stringify({ ok: ${exitCode === 0} })); + ${explicitRequest ? `requestExitAfterOneShotOutput(defaultRuntime, ${exitCode});` : ""} + }, + onError: (error) => { throw error; }, + ${explicitRequest ? "" : 'env: { NODE_USE_SYSTEM_CA: "1" }, execArgv: [], platform: "darwin", markers: {},'} + }); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", env, timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(exitCode); + expect(result.signal).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ ok: exitCode === 0 }); + expect(result.stderr).toContain("\x1b[?25h"); + }); + + it.each([ + { name: "fatal unhandled rejection", errorCode: "ERR_OUT_OF_MEMORY", exitCode: 1 }, + { name: "invalid configuration rejection", errorCode: "INVALID_CONFIG", exitCode: 78 }, + ])("keeps real dual-TTY JSON clean after $name", ({ errorCode, exitCode }) => { + const env = { ...process.env }; + delete env.VITEST; + delete env.VITEST_POOL_ID; + delete env.VITEST_WORKER_ID; + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; + const unhandledRejectionsUrl = new URL("../infra/unhandled-rejections.ts", import.meta.url) + .href; + const script = ` + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + import { loggingState } from ${JSON.stringify(loggingStateUrl)}; + import { installUnhandledRejectionHandler } from ${JSON.stringify(unhandledRejectionsUrl)}; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + loggingState.forceConsoleToStderr = true; + installUnhandledRejectionHandler(); + defaultRuntime.writeJson({ ok: false }); + const error = Object.assign(new Error("expected fatal test"), { + code: ${JSON.stringify(errorCode)}, + }); + process.emit("unhandledRejection", error, Promise.resolve()); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", env, timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(exitCode); + expect(result.signal).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ ok: false }); + expect(result.stderr).toContain("\x1b[?25h"); + }); }); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index d4b3d3c76e90..457b01f53f3d 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -83,7 +83,7 @@ const loadPluginCliDescriptorsMock = vi.hoisted(() => const resolveManifestCommandAliasOwnerMock = vi.hoisted(() => vi.fn()); const resolveManifestToolOwnerMock = vi.hoisted(() => vi.fn()); const resolveManifestCliCommandSurfaceOwnerMock = vi.hoisted(() => vi.fn()); -const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); +const restoreRuntimeTerminalStateMock = vi.hoisted(() => vi.fn()); const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false)); const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => @@ -350,9 +350,13 @@ vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({ resolveManifestToolOwner: resolveManifestToolOwnerMock, })); -vi.mock("../../packages/terminal-core/src/restore.js", () => ({ - restoreTerminalState: restoreTerminalStateMock, -})); +vi.mock("../runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + restoreRuntimeTerminalState: restoreRuntimeTerminalStateMock, + }; +}); vi.mock("../infra/net/proxy-env.js", () => ({ hasEnvHttpProxyAgentConfigured: hasEnvHttpProxyAgentConfiguredMock, @@ -4071,43 +4075,48 @@ describe("runCli exit behavior", () => { ]); }); - it("restores terminal state before uncaught CLI exits", async () => { - buildProgramMock.mockReturnValueOnce({ - commands: [{ name: () => "status" }], - parseAsync: vi.fn().mockResolvedValueOnce(undefined), - }); - - const processOnSpy = vi.spyOn(process, "on"); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${String(code)})`); - }) as typeof process.exit); - - await runCli(["node", "openclaw", "status"]); - - const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1]; - if (typeof handler !== "function") { - throw new Error("uncaughtException handler was not registered"); - } - - try { - expect(() => handler(new Error("boom"))).toThrow("process.exit(1)"); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[openclaw] OpenClaw hit an unexpected runtime error.", - ); - expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] Reason: boom"); - expect(restoreTerminalStateMock).toHaveBeenCalledWith("uncaught exception", { - resumeStdinIfPaused: false, + it.each([false, true])( + "restores terminal state before uncaught CLI exits (machine output: %s)", + async (machineOutput) => { + buildProgramMock.mockReturnValueOnce({ + commands: [{ name: () => "status" }], + parseAsync: vi.fn().mockResolvedValueOnce(undefined), }); - } finally { - if (typeof handler === "function") { - process.off("uncaughtException", handler); + + const processOnSpy = vi.spyOn(process, "on"); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + await runCli(["node", "openclaw", "status"]); + + const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1]; + if (typeof handler !== "function") { + throw new Error("uncaughtException handler was not registered"); } - consoleErrorSpy.mockRestore(); - exitSpy.mockRestore(); - processOnSpy.mockRestore(); - } - }); + + try { + loggingState.forceConsoleToStderr = machineOutput; + expect(() => handler(new Error("boom"))).toThrow("process.exit(1)"); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[openclaw] OpenClaw hit an unexpected runtime error.", + ); + expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] Reason: boom"); + expect(restoreRuntimeTerminalStateMock).toHaveBeenCalledWith("uncaught exception", { + resumeStdinIfPaused: false, + }); + } finally { + loggingState.forceConsoleToStderr = false; + if (typeof handler === "function") { + process.off("uncaughtException", handler); + } + consoleErrorSpy.mockRestore(); + exitSpy.mockRestore(); + processOnSpy.mockRestore(); + } + }, + ); it("does not exit for transient uncaught CLI exceptions", async () => { buildProgramMock.mockReturnValueOnce({ @@ -4136,7 +4145,7 @@ describe("runCli exit behavior", () => { expect(consoleWarnSpy.mock.calls).toEqual([ ["[openclaw] Non-fatal uncaught exception (continuing):", hostUnreachable.stack], ]); - expect(restoreTerminalStateMock).not.toHaveBeenCalled(); + expect(restoreRuntimeTerminalStateMock).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); } finally { if (typeof handler === "function") { diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 988bc577cf4b..434488be503a 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -1456,7 +1456,7 @@ async function runCliWithPreparedOutputMode( isBenignUncaughtExceptionError, isUncaughtExceptionHandled, }, - { restoreTerminalState }, + { restoreRuntimeTerminalState }, ] = await startupTrace.measure("core-imports", () => Promise.all([ import("./program.js"), @@ -1464,7 +1464,7 @@ async function runCliWithPreparedOutputMode( import("./failure-output.js"), import("../infra/fatal-error-hooks.js"), import("../infra/unhandled-rejections.js"), - import("../../packages/terminal-core/src/restore.js"), + import("../runtime.js"), ]), ); const program = await startupTrace.measure("build-program", () => buildProgram()); @@ -1494,7 +1494,7 @@ async function runCliWithPreparedOutputMode( for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); } - restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState("uncaught exception", { resumeStdinIfPaused: false }); process.exit(1); }); diff --git a/src/index.ts b/src/index.ts index 18fef2569bb5..da3759e00c71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,7 +97,7 @@ if (!isMain) { } if (isMain) { - const { restoreTerminalState } = await import("../packages/terminal-core/src/restore.js"); + const { restoreRuntimeTerminalState } = await import("./runtime.js"); // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. // These log the error and exit gracefully instead of crashing without trace. @@ -124,7 +124,7 @@ if (isMain) { for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); } - restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState("uncaught exception", { resumeStdinIfPaused: false }); process.exit(1); }); @@ -145,7 +145,7 @@ if (isMain) { for (const message of runFatalErrorHooks({ reason: "legacy_cli_failure", error: err })) { console.error("[openclaw]", message); } - restoreTerminalState("legacy cli failure", { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState("legacy cli failure", { resumeStdinIfPaused: false }); process.exitCode = 1; }, }); diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index ef626866c0dd..1865d865f22a 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -2,12 +2,13 @@ import process from "node:process"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; -const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); +const restoreRuntimeTerminalStateMock = vi.hoisted(() => vi.fn()); -vi.mock("../../packages/terminal-core/src/restore.js", () => ({ - restoreTerminalState: restoreTerminalStateMock, +vi.mock("../runtime.js", () => ({ + restoreRuntimeTerminalState: restoreRuntimeTerminalStateMock, })); +import { loggingState } from "../logging/state.js"; import { resetFatalErrorHooksForTest } from "./fatal-error-hooks.js"; import { installUnhandledRejectionHandler, @@ -20,6 +21,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { let consoleErrorSpy: ReturnType; let consoleWarnSpy: ReturnType; let originalExit: typeof process.exit; + const originalForceConsoleToStderr = loggingState.forceConsoleToStderr; beforeAll(() => { originalExit = process.exit.bind(process); @@ -43,6 +45,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { afterEach(() => { vi.clearAllMocks(); + loggingState.forceConsoleToStderr = originalForceConsoleToStderr; consoleErrorSpy.mockRestore(); consoleWarnSpy.mockRestore(); }); @@ -71,16 +74,16 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { expectedRestoreReason?: string, ): void { exitCalls = []; - restoreTerminalStateMock.mockClear(); + restoreRuntimeTerminalStateMock.mockClear(); emitUnhandled(reason); expect(exitCalls).toEqual(expected); if (expectedRestoreReason) { - expect(restoreTerminalStateMock).toHaveBeenCalledWith(expectedRestoreReason, { + expect(restoreRuntimeTerminalStateMock).toHaveBeenCalledWith(expectedRestoreReason, { resumeStdinIfPaused: false, }); return; } - expect(restoreTerminalStateMock).not.toHaveBeenCalled(); + expect(restoreRuntimeTerminalStateMock).not.toHaveBeenCalled(); } describe("fatal errors", () => { @@ -105,6 +108,35 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { "Out of memory", ); }); + + it.each([ + { + name: "fatal runtime rejection", + errorCode: "ERR_OUT_OF_MEMORY", + exitCode: 1, + restoreReason: "fatal unhandled rejection", + }, + { + name: "invalid configuration rejection", + errorCode: "INVALID_CONFIG", + exitCode: 78, + restoreReason: "configuration error", + }, + ])( + "routes $name terminal resets through the machine-aware runtime owner", + ({ errorCode, exitCode, restoreReason }) => { + loggingState.forceConsoleToStderr = true; + + emitUnhandled( + Object.assign(new Error("expected machine-output failure"), { code: errorCode }), + ); + + expect(exitCalls).toEqual([exitCode]); + expect(restoreRuntimeTerminalStateMock).toHaveBeenCalledWith(restoreReason, { + resumeStdinIfPaused: false, + }); + }, + ); }); describe("scoped uncaught exception handlers", () => { diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 90701dceeacf..f16d40fa3a86 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -1,7 +1,7 @@ // Installs fatal and transient unhandled rejection/exception handlers. import process from "node:process"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js"; +import { restoreRuntimeTerminalState } from "../runtime.js"; import { isAbortError } from "./abort-signal.js"; import { collectErrorGraphCandidates, @@ -522,7 +522,7 @@ export function installUnhandledRejectionHandler(): void { for (const message of runFatalErrorHooks({ reason: hookReason, error })) { console.error("[openclaw]", message); } - restoreTerminalState(reason, { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState(reason, { resumeStdinIfPaused: false }); process.exit(exitCode); }; diff --git a/src/runtime.test.ts b/src/runtime.test.ts index c5234160e4ef..89ca513267cb 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -1,5 +1,5 @@ // Tests for terminal runtime helpers. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; // Mock dependencies vi.mock("../packages/terminal-core/src/progress-line.js", () => ({ @@ -10,8 +10,11 @@ vi.mock("../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState: vi.fn(), })); +import { restoreTerminalState } from "../packages/terminal-core/src/restore.js"; +import { loggingState } from "./logging/state.js"; import { createNonExitingRuntime, + defaultRuntime, ExitError, writeRuntimeJson, writeRuntimeStdout, @@ -119,3 +122,107 @@ describe("writeRuntimeStdout", () => { expect(runtime.log).toHaveBeenCalledWith("plain output"); }); }); + +describe("defaultRuntime terminal restoration", () => { + const originalForceConsoleToStderr = loggingState.forceConsoleToStderr; + const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + const originalStderrIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + + afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(restoreTerminalState).mockReset(); + loggingState.forceConsoleToStderr = originalForceConsoleToStderr; + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } + if (originalStderrIsTTY) { + Object.defineProperty(process.stderr, "isTTY", originalStderrIsTTY); + } else { + Reflect.deleteProperty(process.stderr, "isTTY"); + } + }); + + it.each([0, 1])("keeps machine-readable stdout clean on exit %i", async (exitCode) => { + const actualRestore = await vi.importActual< + typeof import("../packages/terminal-core/src/restore.js") + >("../packages/terminal-core/src/restore.js"); + vi.mocked(restoreTerminalState).mockImplementation(actualRestore.restoreTerminalState); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + const stdout: string[] = []; + const stderr: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as typeof process.exit); + loggingState.forceConsoleToStderr = true; + + defaultRuntime.writeJson({ ok: exitCode === 0 }); + expect(() => defaultRuntime.exit(exitCode)).toThrow(ExitError); + + expect(JSON.parse(stdout.join(""))).toEqual({ ok: exitCode === 0 }); + expect(stderr.join("")).toContain("\x1b[?25h"); + }); + + it("preserves stdout terminal restoration for human output", async () => { + const actualRestore = await vi.importActual< + typeof import("../packages/terminal-core/src/restore.js") + >("../packages/terminal-core/src/restore.js"); + vi.mocked(restoreTerminalState).mockImplementation(actualRestore.restoreTerminalState); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + const stdout: string[] = []; + const stderr: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ExitError(1); + }) as typeof process.exit); + loggingState.forceConsoleToStderr = false; + + defaultRuntime.writeStdout("operator-visible output"); + expect(() => defaultRuntime.exit(1)).toThrow(ExitError); + + expect(stdout.join("")).toContain("operator-visible output\n"); + expect(stdout.join("")).toContain("\x1b[?25h"); + expect(stderr).toEqual([]); + }); + + it("honors an explicitly selected reset stream in machine-output mode", async () => { + const actualRestore = await vi.importActual< + typeof import("../packages/terminal-core/src/restore.js") + >("../packages/terminal-core/src/restore.js"); + vi.mocked(restoreTerminalState).mockImplementation(actualRestore.restoreTerminalState); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const resetWrite = vi.fn(() => true); + const resetStream = { isTTY: true, write: resetWrite } as unknown as NodeJS.WriteStream; + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ExitError(1); + }) as typeof process.exit); + loggingState.forceConsoleToStderr = true; + + expect(() => defaultRuntime.exit(1, { resetStream })).toThrow(ExitError); + + expect(resetWrite).toHaveBeenCalledWith(expect.stringContaining("\x1b[?25h")); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).not.toHaveBeenCalled(); + }); +}); diff --git a/src/runtime.ts b/src/runtime.ts index 814be842b550..588a7ea92dc5 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,7 @@ // Re-exports terminal runtime helpers used by CLI command implementations. import { clearActiveProgressLine } from "../packages/terminal-core/src/progress-line.js"; import { restoreTerminalState } from "../packages/terminal-core/src/restore.js"; +import { loggingState } from "./logging/state.js"; export type RuntimeExitOptions = { /** Route ANSI terminal-reset bytes away from structured stdout when needed. */ @@ -96,12 +97,25 @@ function createRuntimeIo(): Pick[1]> = {}, +): void { + const resetStream = + options.resetStream ?? (loggingState.forceConsoleToStderr ? process.stderr : undefined); + restoreTerminalState(reason, { + ...options, + ...(resetStream ? { resetStream } : {}), + }); +} + export const defaultRuntime: OutputRuntimeEnv = { ...createRuntimeIo(), exit: (code, opts) => { - restoreTerminalState("runtime exit", { + restoreRuntimeTerminalState("runtime exit", { resumeStdinIfPaused: false, - resetStream: opts?.resetStream, + ...(opts?.resetStream ? { resetStream: opts.resetStream } : {}), }); process.exit(code); throw new Error("unreachable"); // satisfies tests when mocked From 29577fb03b5512e47e83617261fa9e8da02f3e66 Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Sun, 2 Aug 2026 02:04:05 +0900 Subject: [PATCH 06/53] fix(daemon): keep backslashes and quotes intact in generated systemd units (#117375) * fix(daemon): keep backslashes and quotes intact in generated systemd units systemdEscapeArg escaped only pairs of backslashes and rendered a quote as two backslashes plus a quote, so any value holding a lone backslash or a quote did not survive the readers in this module -- or systemd itself. The installed Linux service received a different value than the operator configured, with nothing reporting the difference. parseSystemdEnvAssignment also carried a private copy of the shared unquoting loop whose escape branch compared one character against a two-character literal, so it never unescaped anything. Service inspection reads through that path, so status and doctor reported the escaped form. Align the writer with serializeSystemdEnvironmentFileValue and route the reader through the shared splitter its three siblings already use. Adds round-trip tables mirroring the Windows cmd sibling. * fix(daemon): align systemd round-trip parser with current main --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- src/daemon/systemd-unit.test.ts | 40 ++++++++++++++++++++++++++++++++- src/daemon/systemd-unit.ts | 40 +++++++++------------------------ 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/daemon/systemd-unit.test.ts b/src/daemon/systemd-unit.test.ts index 7bdbd5822165..1ea4c8b37d42 100644 --- a/src/daemon/systemd-unit.test.ts +++ b/src/daemon/systemd-unit.test.ts @@ -1,6 +1,44 @@ // Systemd unit tests cover generated systemd unit files. import { describe, expect, it } from "vitest"; -import { buildSystemdUnit } from "./systemd-unit.js"; +import { + buildSystemdUnit, + parseSystemdEnvAssignments, + parseSystemdExecStart, + renderSystemdEnvAssignment, +} from "./systemd-unit.js"; + +// Values that need quoting, including the backslash and quote shapes the +// renderer has to escape for the module's own parsers to read them back. +const ROUND_TRIP_VALUES = [ + "plain", + "with space", + 'he said "hi"', + "back\\slash", + "C:\\\\srv\\\\bin", + 'mix \\ and " here', + "trailing\\", +]; + +describe("systemd unit value round-trips", () => { + it.each(ROUND_TRIP_VALUES)("round-trips %p through Environment=", (value) => { + const rendered = renderSystemdEnvAssignment("OPENCLAW_TOKEN", value); + expect(parseSystemdEnvAssignments(rendered)).toEqual([{ key: "OPENCLAW_TOKEN", value }]); + }); + + it.each(ROUND_TRIP_VALUES)("round-trips %p through ExecStart=", (value) => { + const unit = buildSystemdUnit({ + description: "OpenClaw Gateway", + programArguments: ["/usr/bin/openclaw", "gateway", value], + environment: {}, + }); + const execStart = unit.split("\n").find((line) => line.startsWith("ExecStart=")); + expect(parseSystemdExecStart(execStart?.slice("ExecStart=".length) ?? "")).toEqual([ + "/usr/bin/openclaw", + "gateway", + value, + ]); + }); +}); describe("buildSystemdUnit", () => { it("quotes arguments with whitespace", () => { diff --git a/src/daemon/systemd-unit.ts b/src/daemon/systemd-unit.ts index c1a61fd4480f..3353651a4cb2 100644 --- a/src/daemon/systemd-unit.ts +++ b/src/daemon/systemd-unit.ts @@ -16,9 +16,12 @@ function systemdEscapeArg(value: string): string { if (!/[\s"\\]/.test(value)) { return value; } - // systemd ExecStart/Environment parsing honors backslash escapes inside - // quotes; match that contract for round-trip parser tests. - return `"${value.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"')}"`; + // systemd ExecStart/Environment parsing consumes one backslash before the next + // character, so every backslash and quote must be escaped for the value to + // survive the round-trip byte-for-byte. Escaping only backslash pairs left a + // lone backslash unescaped, and the reader then swallowed the byte after it. + const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + return `"${escaped}"`; } function renderEnvLines(env: Record | undefined): string[] { @@ -110,38 +113,17 @@ function parseSystemdEnvAssignment(raw: string): { key: string; value: string } return null; } - const unquoted = (() => { - const quote = trimmed[0]; - if (!((quote === '"' || quote === "'") && trimmed.endsWith(quote))) { - return trimmed; - } - let out = ""; - let escapeNext = false; - // systemd quote parsing consumes one backslash before the next character. - for (const ch of trimmed.slice(1, -1)) { - if (escapeNext) { - out += ch; - escapeNext = false; - continue; - } - if (ch === "\\\\") { - escapeNext = true; - continue; - } - out += ch; - } - return out; - })(); - - const eq = unquoted.indexOf("="); + // The shared splitter already removes quotes and consumes escapes before an + // assignment reaches this helper. + const eq = trimmed.indexOf("="); if (eq <= 0) { return null; } - const key = unquoted.slice(0, eq).trim(); + const key = trimmed.slice(0, eq).trim(); if (!key) { return null; } - const value = unquoted.slice(eq + 1); + const value = trimmed.slice(eq + 1); return { key, value }; } From 17625e5cd2680656632972996ef8951ec0373ae3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:07:22 -0700 Subject: [PATCH 07/53] fix(msteams): preserve thread targets for structured sends (#117516) --- extensions/msteams/src/outbound.test.ts | 25 +++- extensions/msteams/src/outbound.ts | 4 +- extensions/msteams/src/send-context.test.ts | 44 +++--- extensions/msteams/src/send-context.ts | 27 ++-- .../src/send.threaded-attachments.test.ts | 128 +++++++++++++++++- extensions/msteams/src/send.ts | 66 +++------ 6 files changed, 209 insertions(+), 85 deletions(-) diff --git a/extensions/msteams/src/outbound.test.ts b/extensions/msteams/src/outbound.test.ts index f7765ac69e40..5c39e9242b26 100644 --- a/extensions/msteams/src/outbound.test.ts +++ b/extensions/msteams/src/outbound.test.ts @@ -297,14 +297,15 @@ describe("msteamsOutbound cfg threading", () => { const result = await requireSendPayload()({ cfg, - to: "conversation:abc", + to: "conversation:19:channel@thread.tacv2", + threadId: "presentation-thread-root", text: "Deploy finished", payload: rendered!, }); expect(mocks.sendAdaptiveCardMSTeams).toHaveBeenCalledWith({ cfg, - to: "conversation:abc", + to: "conversation:19:channel@thread.tacv2;messageid=presentation-thread-root", card: (rendered!.channelData!.msteams as { presentationCard: unknown }).presentationCard, }); expect(result).toEqual({ @@ -574,6 +575,26 @@ describe("msteamsOutbound cfg threading", () => { expect(Number.isNaN(Date.parse(pollRecord?.createdAt))).toBe(false); }); + it("forwards resolved channel thread ids to poll sends", async () => { + await requireSendPoll()({ + cfg, + to: "conversation:19:channel@thread.tacv2", + threadId: "poll-thread-root", + poll: { + question: "Ship it?", + options: ["Yes", "No"], + }, + }); + + expect(mocks.sendPollMSTeams).toHaveBeenCalledWith({ + cfg, + to: "conversation:19:channel@thread.tacv2;messageid=poll-thread-root", + question: "Ship it?", + options: ["Yes", "No"], + maxSelections: 1, + }); + }); + it("chunks outbound text without requiring MSTeams runtime initialization", () => { const chunker = msteamsOutbound.chunker; if (!chunker) { diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 163226dab3a4..e23740f8adaf 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -218,11 +218,11 @@ export const msteamsOutbound: ChannelOutboundAdapter = { mediaReadFile, }); }, - sendPoll: async ({ cfg, to, poll }) => { + sendPoll: async ({ cfg, to, poll, threadId }) => { const maxSelections = poll.maxSelections ?? 1; const result = await sendPollMSTeams({ cfg, - to, + to: resolveMSTeamsThreadTarget(to, threadId), question: poll.question, options: poll.options, maxSelections, diff --git a/extensions/msteams/src/send-context.test.ts b/extensions/msteams/src/send-context.test.ts index 727b28efc9b8..512dc6ac74ce 100644 --- a/extensions/msteams/src/send-context.test.ts +++ b/extensions/msteams/src/send-context.test.ts @@ -50,7 +50,7 @@ function channelRef(params?: Partial): StoredConver }; } -async function resolveMSTeamsProactiveReplyStyle(params: { +async function resolveMSTeamsProactiveReplyTarget(params: { cfg?: MSTeamsConfig; conversationId: string; ref: StoredConversationReference; @@ -76,12 +76,14 @@ async function resolveMSTeamsProactiveReplyStyle(params: { }, }, } as OpenClawConfig; - return ( - await resolveMSTeamsSendContext({ - cfg, - to: `conversation:${params.conversationId}`, - }) - ).replyStyle; + const context = await resolveMSTeamsSendContext({ + cfg, + to: `conversation:${params.conversationId}`, + }); + return { + replyStyle: context.replyStyle, + threadActivityId: context.threadActivityId, + }; } beforeEach(() => { @@ -155,6 +157,7 @@ describe("resolveMSTeamsSendContext", () => { conversationId: "19:channel@thread.tacv2", ref: { threadId: "explicit-root" }, replyStyle: "thread", + threadActivityId: "explicit-root", }); expect(sendContextMockState.store.get).toHaveBeenCalledWith("19:channel@thread.tacv2"); }); @@ -186,6 +189,7 @@ describe("resolveMSTeamsSendContext", () => { conversationId: "19:channel@thread.tacv2", ref: { threadId: "graph-root" }, replyStyle: "thread", + threadActivityId: "graph-root", }); expect(sendContextMockState.store.get).toHaveBeenCalledWith("19:channel@thread.tacv2"); }); @@ -248,27 +252,27 @@ describe("resolveMSTeamsSendContext", () => { }); }); -describe("resolveMSTeamsProactiveReplyStyle", () => { +describe("resolveMSTeamsProactiveReplyTarget", () => { it("uses thread for channel conversations with a stored thread root", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: {}, conversationId: "19:channel@thread.tacv2", ref: channelRef({ threadId: "thread-root-1" }), conversationType: "channel", }), - ).resolves.toBe("thread"); + ).resolves.toEqual({ replyStyle: "thread", threadActivityId: "thread-root-1" }); }); it("falls back to activityId for legacy channel references", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: {}, conversationId: "19:channel@thread.tacv2", ref: channelRef({ activityId: "legacy-root-1" }), conversationType: "channel", }), - ).resolves.toBe("thread"); + ).resolves.toEqual({ replyStyle: "thread", threadActivityId: "legacy-root-1" }); }); it("keeps configured top-level channel routing", async () => { @@ -284,44 +288,44 @@ describe("resolveMSTeamsProactiveReplyStyle", () => { }; await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg, conversationId: "19:channel@thread.tacv2", ref: channelRef({ threadId: "thread-root-1" }), conversationType: "channel", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); it("uses top-level when a channel has no stored thread root", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "19:channel@thread.tacv2", ref: channelRef(), conversationType: "channel", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); it("uses top-level for non-channel conversations", async () => { const ref = channelRef({ activityId: "activity-1" }); await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "19:group@thread.v2", ref, conversationType: "groupChat", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "a:personal", ref, conversationType: "personal", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); }); diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts index 1f60a705e402..b3b8c6c7fbff 100644 --- a/extensions/msteams/src/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -3,7 +3,6 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import { resolveChannelMediaMaxBytes, type MSTeamsConfig, - type MSTeamsReplyStyle, type OpenClawConfig, type PluginRuntime, } from "../runtime-api.js"; @@ -33,6 +32,12 @@ import { resolveMSTeamsCredentials } from "./token.js"; type MSTeamsConversationType = "personal" | "groupChat" | "channel"; +// Keep reply policy and the Connector thread suffix together so every proactive +// activity kind uses the same resolved destination instead of re-deriving it. +type MSTeamsProactiveReplyTarget = + | { replyStyle: "thread"; threadActivityId: string } + | { replyStyle: "top-level"; threadActivityId?: never }; + export type MSTeamsProactiveContext = { appId: string; conversationId: string; @@ -41,8 +46,6 @@ export type MSTeamsProactiveContext = { log: ReturnType; /** The type of conversation: personal (1:1), groupChat, or channel */ conversationType: MSTeamsConversationType; - /** Reply style resolved for proactive text/media sends. */ - replyStyle: MSTeamsReplyStyle; /** Teams SDK cloud/service endpoint used to validate proactive sends. */ sdkCloudOptions: MSTeamsSdkCloudOptions; /** Token provider for Graph API / SharePoint operations */ @@ -51,17 +54,17 @@ export type MSTeamsProactiveContext = { sharePointSiteId?: string; /** Resolved media max bytes from config (default: 100MB) */ mediaMaxBytes?: number; -}; +} & MSTeamsProactiveReplyTarget; -function resolveMSTeamsProactiveReplyStyle(params: { +function resolveMSTeamsProactiveReplyTarget(params: { cfg?: MSTeamsConfig; conversationId: string; ref: StoredConversationReference; conversationType: MSTeamsConversationType; -}): MSTeamsReplyStyle { +}): MSTeamsProactiveReplyTarget { const threadRootId = params.ref.threadId ?? params.ref.activityId; if (params.conversationType !== "channel" || !threadRootId) { - return "top-level"; + return { replyStyle: "top-level" }; } const routeConfig = resolveMSTeamsRouteConfig({ @@ -76,7 +79,7 @@ function resolveMSTeamsProactiveReplyStyle(params: { teamConfig: routeConfig.teamConfig, channelConfig: routeConfig.channelConfig, }); - return replyStyle; + return replyStyle === "thread" ? { replyStyle, threadActivityId: threadRootId } : { replyStyle }; } /** @@ -250,10 +253,10 @@ export async function resolveMSTeamsSendContext(params: { // An explicit messageid is a caller-owned destination. Ambient and stored // roots still obey route policy, but explicit channel roots must not be // flattened by a top-level default. - const replyStyle = + const replyTarget: MSTeamsProactiveReplyTarget = recipient.threadId && conversationType === "channel" - ? "thread" - : resolveMSTeamsProactiveReplyStyle({ + ? { replyStyle: "thread", threadActivityId: recipient.threadId } + : resolveMSTeamsProactiveReplyTarget({ cfg: msteamsCfg, conversationId, ref: safeRef, @@ -276,7 +279,7 @@ export async function resolveMSTeamsSendContext(params: { app, log, conversationType, - replyStyle, + ...replyTarget, sdkCloudOptions, tokenProvider, sharePointSiteId, diff --git a/extensions/msteams/src/send.threaded-attachments.test.ts b/extensions/msteams/src/send.threaded-attachments.test.ts index 42e24c5d746c..7d569635e156 100644 --- a/extensions/msteams/src/send.threaded-attachments.test.ts +++ b/extensions/msteams/src/send.threaded-attachments.test.ts @@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net"; import { Client as TeamsApiClient } from "@microsoft/teams.api"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; -import { sendMessageMSTeams } from "./send.js"; +import { sendAdaptiveCardMSTeams, sendMessageMSTeams, sendPollMSTeams } from "./send.js"; const serviceUrl = "https://smba.trafficmanager.net/amer"; const conversationId = "19:channel@thread.tacv2"; @@ -173,6 +173,15 @@ type AttachmentRoutingCase = { expectedConversationId: string; }; +type StructuredRoutingCase = { + label: string; + conversationType: "channel" | "groupChat" | "personal"; + replyStyle: "thread" | "top-level"; + threadActivityId?: string; + storedThreadId?: string; + expectedConversationId: string; +}; + const attachmentRoutingCases: AttachmentRoutingCase[] = [ { label: "channel attachment in its stored thread root", @@ -205,6 +214,69 @@ const attachmentRoutingCases: AttachmentRoutingCase[] = [ }, ]; +const structuredRoutingCases: StructuredRoutingCase[] = [ + { + label: "threaded channel", + conversationType: "channel", + replyStyle: "thread", + threadActivityId: "thread-root-1", + storedThreadId: "thread-root-1", + expectedConversationId: `${conversationId};messageid=thread-root-1`, + }, + { + label: "top-level channel", + conversationType: "channel", + replyStyle: "top-level", + storedThreadId: "thread-root-1", + expectedConversationId: conversationId, + }, + { + label: "group chat", + conversationType: "groupChat", + replyStyle: "top-level", + storedThreadId: "group-activity-1", + expectedConversationId: conversationId, + }, + { + label: "personal chat", + conversationType: "personal", + replyStyle: "top-level", + storedThreadId: "personal-activity-1", + expectedConversationId: conversationId, + }, +]; + +type StructuredSender = { + label: string; + send: (cfg: OpenClawConfig) => Promise; +}; + +const structuredSenders: StructuredSender[] = [ + { + label: "presentation card", + send: async (cfg) => + await sendAdaptiveCardMSTeams({ + cfg, + to: conversationId, + card: { + type: "AdaptiveCard", + version: "1.4", + body: [{ type: "TextBlock", text: "Deploy finished" }], + }, + }), + }, + { + label: "poll", + send: async (cfg) => + await sendPollMSTeams({ + cfg, + to: conversationId, + question: "Ship it?", + options: ["Yes", "No"], + }), + }, +]; + describe("Microsoft Teams SharePoint attachment thread routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -231,6 +303,9 @@ describe("Microsoft Teams SharePoint attachment thread routing", () => { }, conversationType, replyStyle, + ...(replyStyle === "thread" && conversationType === "channel" + ? { threadActivityId: threadId ?? activityId } + : {}), sdkCloudOptions: { cloud: "Public" }, tokenProvider: { getAccessToken: vi.fn(async () => "token") }, sharePointSiteId: "sharepoint-site-1", @@ -307,3 +382,54 @@ describe("Microsoft Teams SharePoint attachment thread routing", () => { }); }); }); + +describe.each(structuredSenders)("Microsoft Teams $label thread routing", ({ send }) => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(structuredRoutingCases)( + "sends to the resolved $label identity through the real Teams SDK", + async ({ + conversationType, + replyStyle, + threadActivityId, + storedThreadId, + expectedConversationId, + }) => { + await withRealTeamsSdkHttp(async ({ api, requests }) => { + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + app: { api }, + appId: "app-id", + conversationId, + ref: { + serviceUrl, + agent: { id: "28:bot", name: "OpenClaw", role: "bot" }, + user: { id: "29:user" }, + conversation: { id: conversationId, conversationType }, + activityId: "incoming-activity-1", + ...(storedThreadId ? { threadId: storedThreadId } : {}), + }, + conversationType, + replyStyle, + ...(threadActivityId ? { threadActivityId } : {}), + sdkCloudOptions: { cloud: "Public" }, + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await send({} as OpenClawConfig); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + path: `${new URL(serviceUrl).pathname}/v3/conversations/${expectedConversationId}/activities`, + body: { + type: "message", + conversation: { id: expectedConversationId, conversationType }, + attachments: [{ contentType: "application/vnd.microsoft.card.adaptive" }], + }, + }); + }); + }, + ); +}); diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index d65373328c79..0d3ef3896bb8 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -182,17 +182,7 @@ export async function sendMessageMSTeams( }); const messageText = formatMSTeamsMarkdown(text ?? "", tableMode); const ctx = await resolveMSTeamsSendContext({ cfg, to }); - const { - app, - conversationId, - ref, - log, - conversationType, - replyStyle, - tokenProvider, - sharePointSiteId, - sdkCloudOptions, - } = ctx; + const { conversationId, log, conversationType, tokenProvider, sharePointSiteId } = ctx; log.debug?.("sending proactive message", { conversationId, @@ -245,11 +235,9 @@ export async function sendMessageMSTeams( log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length }); const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams consent card send", - serviceUrlBoundary: sdkCloudOptions, }); // Store the activity ID so the accept handler can replace the consent @@ -326,15 +314,8 @@ export async function sendMessageMSTeams( attachments: [fileCardAttachment], }; const messageId = await sendProactiveActivityRaw({ - app, - ref, + ctx, activity, - // Only channel replies carry a thread root; top-level and group sends must stay unchanged. - threadActivityId: - replyStyle === "thread" && conversationType === "channel" - ? (ref.threadId ?? ref.activityId) - : undefined, - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent native file card", { @@ -428,41 +409,32 @@ async function sendTextWithMedia( } type ProactiveActivityParams = { - app: MSTeamsProactiveContext["app"]; - ref: MSTeamsProactiveContext["ref"]; + ctx: MSTeamsProactiveContext; activity: Record; errorPrefix: string; - serviceUrlBoundary: MSTeamsProactiveContext["sdkCloudOptions"]; }; -type ProactiveActivityRawParams = Omit & { - threadActivityId?: string; -}; +type ProactiveActivityRawParams = Omit; async function sendProactiveActivityRaw({ - app, - ref, + ctx, activity, - threadActivityId, - serviceUrlBoundary, }: ProactiveActivityRawParams): Promise { - const baseRef = buildConversationReference(ref); - const response = await sendMSTeamsActivityWithReference(app, baseRef, activity, { - ...(threadActivityId ? { threadActivityId } : {}), - serviceUrlBoundary, + const baseRef = buildConversationReference(ctx.ref); + const response = await sendMSTeamsActivityWithReference(ctx.app, baseRef, activity, { + ...(ctx.threadActivityId ? { threadActivityId: ctx.threadActivityId } : {}), + serviceUrlBoundary: ctx.sdkCloudOptions, }); return extractMessageId(response) ?? "unknown"; } async function sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix, - serviceUrlBoundary, }: ProactiveActivityParams): Promise { try { - return await sendProactiveActivityRaw({ app, ref, activity, serviceUrlBoundary }); + return await sendProactiveActivityRaw({ ctx, activity }); } catch (err) { const classification = classifyMSTeamsSendError(err); const hint = formatMSTeamsSendErrorHint(classification); @@ -481,10 +453,11 @@ export async function sendPollMSTeams( params: SendMSTeamsPollParams, ): Promise { const { cfg, to, question, options, maxSelections } = params; - const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({ + const ctx = await resolveMSTeamsSendContext({ cfg, to, }); + const { conversationId, log } = ctx; const pollCard = buildMSTeamsPollCard({ question, @@ -510,11 +483,9 @@ export async function sendPollMSTeams( // Send poll via proactive conversation (Adaptive Cards require direct activity send) const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams poll send", - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId }); @@ -533,10 +504,11 @@ export async function sendAdaptiveCardMSTeams( params: SendMSTeamsCardParams, ): Promise { const { cfg, to, card } = params; - const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({ + const ctx = await resolveMSTeamsSendContext({ cfg, to, }); + const { conversationId, log } = ctx; log.debug?.("sending adaptive card", { conversationId, @@ -556,11 +528,9 @@ export async function sendAdaptiveCardMSTeams( // Send card via proactive conversation const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams card send", - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent adaptive card", { conversationId, messageId }); From a14ada134f91c92f76d2bc886ae75b7c7dc0cc0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:15:10 -0700 Subject: [PATCH 08/53] fix(test): restore oxlint shard routing owner (#117531) --- scripts/test-projects.test-support.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 4f90fd44f76a..b0b8d7ed1aa7 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1986,6 +1986,7 @@ const EXACT_TOOLING_TARGETS = new Map([ ], ], ["scripts/run-vitest.mjs", ["run-vitest", "test-projects", "vitest-local-scheduling"]], + ["scripts/run-oxlint-shards.mjs", ["run-oxlint"]], ["scripts/docker-e2e-rerun.mjs", ["docker-e2e-helper-cli"]], ["scripts/openclaw-postpack.mjs", [TOOLING_VITEST_CONFIG]], ["scripts/openclaw-npm-prepublish-verify.ts", ["test/openclaw-npm-prepublish-verify.test.ts"]], From 8b994fc9e1708b3495778f929033322a03017a9b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 01:16:30 +0800 Subject: [PATCH 09/53] fix(gateway): keep canonical hooks on pristine startup path (#117493) --- .../shared/pristine-startup-state.test.ts | 45 +++++++++++++++++ .../doctor/shared/pristine-startup-state.ts | 49 ++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/commands/doctor/shared/pristine-startup-state.test.ts b/src/commands/doctor/shared/pristine-startup-state.test.ts index d9f668bda9a6..7513352f775e 100644 --- a/src/commands/doctor/shared/pristine-startup-state.test.ts +++ b/src/commands/doctor/shared/pristine-startup-state.test.ts @@ -148,6 +148,51 @@ describe("pristine startup state", () => { expect(canSkipPristineStartupStateMigrations(env)).toBe(true); }); + it("accepts canonical internal hook configuration", () => { + const env = createFixture({ + hooks: { + internal: { + enabled: true, + entries: { + "session-memory": { + enabled: true, + env: { OPENCLAW_HOOK_TEST: "enabled" }, + customOption: "value", + }, + }, + }, + }, + }); + + expect(planPristineStartupStateMigrations(env)).toEqual({ + skipAllStateMigrations: true, + skipCoreStateMigrations: true, + }); + }); + + it("retains migrations for legacy, external, and malformed hook configuration", () => { + const unsafeHooks = [ + { gmail: { account: "operator@example.com" } }, + { internal: { installs: { "session-memory": { source: "bundled" } } } }, + { internal: { handlers: [] } }, + { internal: { load: { extraDirs: ["/tmp/hooks"] } } }, + { internal: { enabled: "yes" } }, + { internal: { entries: [] } }, + { internal: { entries: { "session-memory": { enabled: "yes" } } } }, + { internal: { entries: { "session-memory": { env: { INVALID: true } } } } }, + ]; + + for (const hooks of unsafeHooks) { + expect( + planPristineStartupStateMigrations(createFixture({ hooks })), + JSON.stringify(hooks), + ).toEqual({ + skipAllStateMigrations: false, + skipCoreStateMigrations: false, + }); + } + }); + it("retains migrations for bundled plugins with doctor state surfaces", () => { const env = addBundledPlugin( createFixture({ plugins: { entries: { example: { enabled: true } } } }), diff --git a/src/commands/doctor/shared/pristine-startup-state.ts b/src/commands/doctor/shared/pristine-startup-state.ts index 74820aee496b..786088720a30 100644 --- a/src/commands/doctor/shared/pristine-startup-state.ts +++ b/src/commands/doctor/shared/pristine-startup-state.ts @@ -31,7 +31,6 @@ const STATEFUL_CONFIG_KEYS = new Set([ "cron", "discovery", "env", - "hooks", "marketplaces", "mcp", "media", @@ -48,6 +47,51 @@ const STATEFUL_CONFIG_KEYS = new Set([ "web", ]); +// Canonical internal entries have no legacy machine state to import. Keep every +// older or external hook shape on Doctor's full migration path. +function hasOnlyMigrationSafeInternalHooks(config: Record): boolean { + const hooks = config.hooks; + if (hooks === undefined) { + return true; + } + if (!isRecord(hooks) || Object.keys(hooks).some((key) => key !== "internal")) { + return false; + } + + const internal = hooks.internal; + if (internal === undefined) { + return true; + } + if ( + !isRecord(internal) || + Object.keys(internal).some((key) => !["enabled", "entries"].includes(key)) || + (internal.enabled !== undefined && typeof internal.enabled !== "boolean") + ) { + return false; + } + + if (internal.entries === undefined) { + return true; + } + if (!isRecord(internal.entries)) { + return false; + } + return Object.values(internal.entries).every((entry) => { + if (!isRecord(entry)) { + return false; + } + if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") { + return false; + } + if (entry.env === undefined) { + return true; + } + return ( + isRecord(entry.env) && Object.values(entry.env).every((value) => typeof value === "string") + ); + }); +} + function containsObjectKey(value: unknown, targetKey: string): boolean { if (Array.isArray(value)) { return value.some((entry) => containsObjectKey(entry, targetKey)); @@ -142,6 +186,9 @@ function configIsPristineCoreStateSafe(config: Record): boolean if ([...STATEFUL_CONFIG_KEYS].some((key) => Object.hasOwn(config, key))) { return false; } + if (!hasOnlyMigrationSafeInternalHooks(config)) { + return false; + } if (containsObjectKey(config.agents, "memorySearch")) { return false; } From 6dcb95a947fcdb7e27340a111020b876bf9e0438 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 01:18:43 +0800 Subject: [PATCH 10/53] test(memory): isolate mocked embedding providers --- .../memory-core/src/memory/manager.fts-only-reindex.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts index 92a261ca6929..4b0321cb7246 100644 --- a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts @@ -120,6 +120,8 @@ describe("memory manager FTS-only reindex", () => { ? undefined : { vector: { enabled: params.vectorEnabled } }; const cfg = { + // Provider construction is mocked here; avoid cold-loading real plugins during config resolution. + plugins: { enabled: false }, memory: { backend: "builtin", From 814a2ebb610455f2afb599b3cc1656ca1ac52e76 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 01:22:33 +0800 Subject: [PATCH 11/53] fix(google): flush lazy prompts on provider readiness --- extensions/google/index.test.ts | 19 +++++++++++++++++-- extensions/google/index.ts | 22 ++++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index bfa1b5d5e0b6..24a608875119 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -15,6 +15,7 @@ import { import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts"; import type { RealtimeVoiceBridge, + RealtimeVoiceBridgeCreateRequest, RealtimeVoiceProviderPlugin, } from "openclaw/plugin-sdk/realtime-voice"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -24,7 +25,7 @@ import googleProviderDiscovery from "./provider-discovery.js"; import { registerGoogleProvider } from "./provider-registration.js"; const { createRealtimeBridgeMock } = vi.hoisted(() => ({ - createRealtimeBridgeMock: vi.fn(), + createRealtimeBridgeMock: vi.fn<(req: RealtimeVoiceBridgeCreateRequest) => RealtimeVoiceBridge>(), })); vi.mock("./realtime-voice-provider.js", () => ({ @@ -94,6 +95,14 @@ function createLazyRealtimeBridge(onError = vi.fn()) { return { bridge, onError }; } +function signalRealtimeBridgeReady() { + const request = createRealtimeBridgeMock.mock.calls.at(-1)?.[0]; + if (!request) { + throw new Error("expected Google realtime bridge request"); + } + request.onReady?.(); +} + describe("google provider plugin hooks", () => { beforeEach(() => { createRealtimeBridgeMock.mockReset(); @@ -476,7 +485,7 @@ describe("google provider plugin hooks", () => { expect(bridge.sendUserMessage?.("hello")).toBeUndefined(); }); - it("preserves queued user messages until the loaded bridge is connected", async () => { + it("preserves queued user messages until the loaded bridge reports ready", async () => { const connected = createDeferred(); const loaded = createMockRealtimeBridge(() => connected.promise); createRealtimeBridgeMock.mockReturnValue(loaded.bridge); @@ -491,6 +500,9 @@ describe("google provider plugin hooks", () => { connected.resolve(); await connectPromise; + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + signalRealtimeBridgeReady(); + expect(loaded.sendUserMessage.mock.calls.map(([text]) => text)).toEqual([ "before connect", "during connect", @@ -506,6 +518,7 @@ describe("google provider plugin hooks", () => { bridge.sendUserMessage?.(`message-${index}`); } await bridge.connect(); + signalRealtimeBridgeReady(); expect(loaded.sendUserMessage).toHaveBeenCalledTimes(128); expect(loaded.sendUserMessage.mock.calls.map(([text]) => text)).toEqual( @@ -532,6 +545,7 @@ describe("google provider plugin hooks", () => { bridge.sendUserMessage?.(exactLimit); bridge.sendUserMessage?.("overflow"); await bridge.connect(); + signalRealtimeBridgeReady(); expect(loaded.sendUserMessage).toHaveBeenCalledOnce(); expect(loaded.sendUserMessage).toHaveBeenCalledWith(exactLimit); @@ -570,6 +584,7 @@ describe("google provider plugin hooks", () => { bridge.sendUserMessage?.("after close"); connected.resolve(); await connectPromise; + signalRealtimeBridgeReady(); expect(loaded.close).toHaveBeenCalledOnce(); expect(loaded.sendUserMessage).not.toHaveBeenCalled(); diff --git a/extensions/google/index.ts b/extensions/google/index.ts index e7becf0a7c86..619371579db0 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -230,7 +230,22 @@ function createLazyGoogleRealtimeVoiceBridge( const loadBridge = async () => { if (!bridgePromise) { bridgePromise = loadGoogleRealtimeVoiceProvider().then((provider) => - provider.createBridge(req), + provider.createBridge({ + ...req, + onReady: () => { + if (closed) { + return; + } + req.onReady?.(); + if (closed || !bridge) { + return; + } + bridgeReady = true; + // `connect()` and provider readiness are separate lifecycle facts. + // Release prompts only after the provider can accept user content. + flushPending(bridge); + }, + }), ); } bridge = await bridgePromise; @@ -280,12 +295,7 @@ function createLazyGoogleRealtimeVoiceBridge( await loadedBridge.connect(); if (closed) { closeBridge(loadedBridge); - return; } - bridgeReady = true; - // The provider drops user messages before setup completes, so the lazy wrapper - // owns them until connect resolves and the provider reports readiness. - flushPending(loadedBridge); }, sendAudio: (audio) => { if (closed) { From b9bf34a3cdf9054e67398ea20b437d7af4c34dca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:22:51 -0700 Subject: [PATCH 12/53] test(openai): dedupe realtime voice fixtures (#117503) --- .../openai/realtime-voice-provider.test.ts | 1090 ++++------------- 1 file changed, 231 insertions(+), 859 deletions(-) diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 151cc2f6801a..51705a4088ea 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -1,6 +1,10 @@ // Openai tests cover realtime voice provider plugin behavior. import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice"; -import type { RealtimeVoiceBridge, RealtimeVoiceTool } from "openclaw/plugin-sdk/realtime-voice"; +import type { + RealtimeVoiceBridge, + RealtimeVoiceBridgeCreateRequest, + RealtimeVoiceTool, +} from "openclaw/plugin-sdk/realtime-voice"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; @@ -192,6 +196,57 @@ function parseSent(socket: FakeWebSocketInstance): SentRealtimeEvent[] { return socket.sent.map((payload: string) => JSON.parse(payload) as SentRealtimeEvent); } +function createNativeBridge( + overrides: Partial = {}, +): RealtimeVoiceBridge { + return buildOpenAIRealtimeVoiceProvider().createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + ...overrides, + }); +} + +function requireSocket(index = 0): FakeWebSocketInstance { + const socket = FakeWebSocket.instances[index]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + return socket; +} + +function beginBridgeConnection( + bridge: RealtimeVoiceBridge, + socketIndex = 0, +): { connecting: Promise; socket: FakeWebSocketInstance } { + const connecting = bridge.connect(); + return { connecting, socket: requireSocket(socketIndex) }; +} + +function openSocket(socket: FakeWebSocketInstance): void { + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); +} + +function emitServerEvent(socket: FakeWebSocketInstance, event: Record): void { + socket.emit("message", Buffer.from(JSON.stringify(event))); +} + +function emitSessionUpdated(socket: FakeWebSocketInstance): void { + emitServerEvent(socket, { type: "session.updated" }); +} + +async function connectReadyBridge( + bridge: RealtimeVoiceBridge, + socketIndex = 0, +): Promise { + const { connecting, socket } = beginBridgeConnection(bridge, socketIndex); + openSocket(socket); + emitSessionUpdated(socket); + await connecting; + return socket; +} + function expectedResponseCreateEvent() { return expect.objectContaining({ type: "response.create", @@ -1458,32 +1513,23 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("waits for session.updated before draining audio and firing onReady", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onReady = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ instructions: "Be helpful.", language: "de", - onAudio: vi.fn(), - onClearAudio: vi.fn(), onReady, }); - const connecting = bridge.connect(); + const { connecting, socket } = beginBridgeConnection(bridge); let connectResolved = false; void connecting.then(() => { connectResolved = true; }); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); await Promise.resolve(); bridge.sendAudio(Buffer.from("before-ready")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.created" }))); + emitServerEvent(socket, { type: "session.created" }); expect(connectResolved).toBe(false); expect(onReady).not.toHaveBeenCalled(); @@ -1507,7 +1553,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(session).not.toHaveProperty("temperature"); expect(bridge.isConnected()).toBe(false); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; expect(connectResolved).toBe(true); @@ -1520,25 +1566,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("bounds queued audio by aggregate bytes before session readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + openSocket(socket); await Promise.resolve(); bridge.sendAudio(Buffer.alloc(512 * 1024, 0x01)); bridge.sendAudio(Buffer.alloc(512 * 1024, 0x02)); bridge.sendAudio(Buffer.from("overflow")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; const audioEvents = parseSent(socket).filter( @@ -1552,14 +1588,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("discards audio closed before the first connection and reconnects fresh", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); + const bridge = createNativeBridge({ onClose }); bridge.sendAudio(Buffer.from("queued-before-connect")); bridge.close(); @@ -1569,14 +1599,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(FakeWebSocket.instances).toHaveLength(0); expect(onClose).not.toHaveBeenCalled(); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to connect"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting, socket } = beginBridgeConnection(bridge); + openSocket(socket); + emitSessionUpdated(socket); await connecting; expect( @@ -1589,19 +1614,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not carry queued audio across terminal close and explicit reconnect", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const firstConnect = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); + const bridge = createNativeBridge(); + const { connecting: firstConnect, socket: firstSocket } = beginBridgeConnection(bridge); + openSocket(firstSocket); await Promise.resolve(); bridge.sendAudio(Buffer.from("queued-before-close")); @@ -1609,14 +1624,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { await firstConnect; bridge.sendAudio(Buffer.from("sent-after-close")); - const reconnecting = bridge.connect(); - const secondSocket = FakeWebSocket.instances[1]; - if (!secondSocket) { - throw new Error("expected bridge to reconnect"); - } - secondSocket.readyState = FakeWebSocket.OPEN; - secondSocket.emit("open"); - secondSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting: reconnecting, socket: secondSocket } = beginBridgeConnection(bridge, 1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); await reconnecting; expect( @@ -1626,25 +1636,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("shares an in-flight connection until session readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onReady = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onReady, - }); + const bridge = createNativeBridge({ onReady }); const firstConnect = bridge.connect(); const secondConnect = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const socket = requireSocket(); expect(FakeWebSocket.instances).toHaveLength(1); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(socket); + emitSessionUpdated(socket); await Promise.all([firstConnect, secondConnect]); expect(onReady).toHaveBeenCalledOnce(); @@ -1652,31 +1652,22 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("suppresses auto responses before draining queued initial greeting audio", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const bridgeRef: { current?: RealtimeVoiceBridge } = {}; const onReady = vi.fn(() => { bridgeRef.current?.triggerGreeting?.("Say exactly: hello from explicit speech."); }); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ instructions: "Be helpful.", - onAudio: vi.fn(), - onClearAudio: vi.fn(), onReady, }); bridgeRef.current = bridge; - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); await Promise.resolve(); bridge.sendAudio(Buffer.from("before-ready")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; const sent = parseSent(socket); @@ -1712,7 +1703,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(sent.filter((event) => event.type === "response.create")).toHaveLength(1); expect(onReady).toHaveBeenCalledTimes(1); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -1725,9 +1716,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("omits unsupported OpenAI tool names from GA session updates", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ tools: [ createRealtimeTool("1_lookup"), createRealtimeTool("calendar.lookup:next"), @@ -1737,45 +1726,26 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { createMalformedToolName(42), createUnreadableToolName(), ], - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); const tools = requireSession(socket).tools as Array<{ name?: string }>; expect(tools.map((tool) => tool.name)).toEqual(["1_lookup", "x".repeat(65)]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; }); it("rotates realtime bridges on provider max-duration events without reporting an error", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - onEvent, - }); - const connecting = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onError, onEvent }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(firstSocket); + emitSessionUpdated(firstSocket); await connecting; firstSocket.emit( @@ -1803,13 +1773,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { await vi.advanceTimersByTimeAsync(1000); await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); - const secondSocket = FakeWebSocket.instances[1]; - if (!secondSocket) { - throw new Error("expected bridge to reconnect"); - } - secondSocket.readyState = FakeWebSocket.OPEN; - secondSocket.emit("open"); - secondSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const secondSocket = requireSocket(1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); await vi.waitFor(() => expect(onEvent).toHaveBeenCalledWith({ @@ -1832,23 +1798,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("cancels a pending reconnect and allows a later explicit connect", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onError }); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(socket); + emitSessionUpdated(socket); await connecting; socket.readyState = FakeWebSocket.CLOSED; @@ -1863,14 +1818,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(FakeWebSocket.instances).toHaveLength(1); expect(onError).not.toHaveBeenCalled(); - const reconnecting = bridge.connect(); - const reconnectedSocket = FakeWebSocket.instances[1]; - if (!reconnectedSocket) { - throw new Error("expected bridge to reconnect after close"); - } - reconnectedSocket.readyState = FakeWebSocket.OPEN; - reconnectedSocket.emit("open"); - reconnectedSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting: reconnecting, socket: reconnectedSocket } = beginBridgeConnection( + bridge, + 1, + ); + openSocket(reconnectedSocket); + emitSessionUpdated(reconnectedSocket); await reconnecting; expect(bridge.isConnected()).toBe(true); @@ -1881,26 +1834,18 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("ignores late events from a socket replaced by reconnect", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClose = vi.fn(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onClose, onError, }); - const connecting = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(firstSocket); + emitSessionUpdated(firstSocket); await connecting; firstSocket.readyState = FakeWebSocket.CLOSED; @@ -1919,16 +1864,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(onError).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1000); - const secondSocket = FakeWebSocket.instances[1]; - if (!secondSocket) { - throw new Error("expected bridge to reconnect"); - } - secondSocket.readyState = FakeWebSocket.OPEN; - secondSocket.emit("open"); - secondSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const secondSocket = requireSocket(1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); await vi.waitFor(() => expect(bridge.isConnected()).toBe(true)); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(firstSocket); firstSocket.emit("error", new Error("late socket failure")); firstSocket.emit("close", 1006, Buffer.from("late socket close")); await vi.advanceTimersByTimeAsync(0); @@ -1943,27 +1884,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("exhausts retries when sockets open but never become provider-ready", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); const onError = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - onError, - onEvent, - }); - const connecting = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose, onError, onEvent }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(firstSocket); + emitSessionUpdated(firstSocket); await connecting; firstSocket.readyState = FakeWebSocket.CLOSED; @@ -1978,12 +1906,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }), ); await vi.advanceTimersByTimeAsync(1000 * 2 ** (attempt - 1)); - const retrySocket = FakeWebSocket.instances[attempt]; - if (!retrySocket) { - throw new Error(`expected reconnect socket ${attempt}`); - } - retrySocket.readyState = FakeWebSocket.OPEN; - retrySocket.emit("open"); + const retrySocket = requireSocket(attempt); + openSocket(retrySocket); retrySocket.emit( "message", Buffer.from( @@ -2010,8 +1934,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps Azure deployment bridges on deployment-compatible session payloads", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ + const bridge = createNativeBridge({ providerConfig: { apiKey: "sk-test", // pragma: allowlist secret azureEndpoint: "https://example.openai.azure.com/", @@ -2026,21 +1949,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { createRealtimeTool("calendar.lookup:next"), createRealtimeTool("x".repeat(65)), ], - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); expect(socket.args[0]).toBe( "wss://example.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=realtime-prod", ); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); await Promise.resolve(); const session = requireSession(socket); @@ -2065,7 +1981,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { const tools = session.tools as Array<{ name?: string }>; expect(tools.map((tool) => tool.name)).toEqual(["1_lookup"]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; bridge.triggerGreeting?.("Say hello."); @@ -2085,7 +2001,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expectedResponseCreateEvent(), ]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(parseSent(socket).at(-1)).toEqual({ type: "session.update", session: { @@ -2101,20 +2017,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("rejects connection when session configuration fails before readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.emit( "message", Buffer.from( @@ -2130,24 +2036,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("treats pre-ready auth errors as a single startup failure", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - onClose, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onError, onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.emit( "message", Buffer.from( @@ -2175,20 +2069,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("normalizes structured direct OpenAI startup auth errors", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.emit( "message", Buffer.from( @@ -2208,17 +2092,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("normalizes direct OpenAI socket handshake auth errors", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); socket.emit("error", new Error("Unexpected server response: 401")); @@ -2243,17 +2118,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }, ], ])("preserves %s startup auth errors", async (_label, providerConfig) => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ + const bridge = createNativeBridge({ providerConfig, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); socket.emit("error", new Error("Unexpected server response: 401")); @@ -2262,23 +2130,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps a retried connection ready after delayed startup failure close", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); - const failedConnect = bridge.connect(); - const failedSocket = FakeWebSocket.instances[0]; - if (!failedSocket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose }); + const { connecting: failedConnect, socket: failedSocket } = beginBridgeConnection(bridge); failedSocket.deferClose = true; - failedSocket.readyState = FakeWebSocket.OPEN; - failedSocket.emit("open"); + openSocket(failedSocket); failedSocket.emit( "message", Buffer.from( @@ -2292,14 +2149,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { await expect(failedConnect).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); expect(failedSocket.deferredClose).toBeDefined(); - const retryConnect = bridge.connect(); - const retrySocket = FakeWebSocket.instances[1]; - if (!retrySocket) { - throw new Error("expected bridge retry to create a websocket"); - } - retrySocket.readyState = FakeWebSocket.OPEN; - retrySocket.emit("open"); - retrySocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting: retryConnect, socket: retrySocket } = beginBridgeConnection(bridge, 1); + openSocket(retrySocket); + emitSessionUpdated(retrySocket); await retryConnect; expect(bridge.isConnected()).toBe(true); @@ -2309,20 +2161,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("rejects connection when the socket closes before session readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.close(1006, "session closed"); await expect(connecting).rejects.toThrow("OpenAI realtime connection closed before ready"); @@ -2331,19 +2173,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("does not report startup timeout shutdown as a clean close", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); const timeoutAssertion = expect(connecting).rejects.toThrow( "OpenAI realtime connection timeout", @@ -2357,22 +2189,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("can disable automatic audio turn responses for agent-routed voice loops", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: false, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(socket); + emitSessionUpdated(socket); await connecting; expectRecordFields( @@ -2386,24 +2209,11 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("can disable realtime response interruption while keeping audio responses enabled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: true, interruptResponseOnInputAudio: false, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); expectRecordFields( requireNestedRecord(requireSession(socket), ["audio", "input", "turn_detection"]), @@ -2416,26 +2226,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not locally clear playback on speech-start events when input interruption is disabled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClearAudio = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: true, interruptResponseOnInputAudio: false, onAudio, onClearAudio, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2463,25 +2262,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps assistant playback active on server VAD when automatic audio responses are disabled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClearAudio = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: false, onAudio, onClearAudio, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2509,24 +2297,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("can request PCM16 24 kHz realtime audio for Chrome command-pair bridges", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); const session = requireSession(socket); expect(requireNestedRecord(session, ["audio", "input", "format"])).toEqual({ @@ -2540,19 +2314,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("settles cleanly when closed before the websocket opens", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); bridge.close(); bridge.close(); @@ -2565,25 +2329,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("truncates externally interrupted playback after an immediate mark acknowledgement", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClearAudio = vi.fn(); - const bridge: ReturnType = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, onClearAudio, onMark: () => bridge.acknowledgeMark(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( @@ -2618,25 +2371,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("preserves FIFO playback acknowledgements after sustained output", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); const onMark = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), + const bridge = createNativeBridge({ onClearAudio, onMark, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); for (let index = 0; index < 300; index += 1) { @@ -2697,24 +2438,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("treats a later named mark as cumulative playback progress", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onMark = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onMark, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onMark }); + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); for (let index = 0; index < 3; index += 1) { @@ -2745,25 +2471,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("forwards current realtime output audio events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onTranscript = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onTranscript, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); const audio = Buffer.from("assistant audio"); socket.emit( @@ -2795,25 +2509,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("surfaces input transcription failures with their provider error details", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError, onEvent }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2838,23 +2537,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("preserves corrected final text from legacy realtime text events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onTranscript = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onTranscript, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onTranscript }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2875,26 +2560,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ["invalid alphabet", "not-base64!"], ["non-canonical pad bits", "ZE=="], ])("terminates the session for %s in output audio", async (_scenario, delta) => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onError = vi.fn(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onError, onClose, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2921,25 +2595,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("forwards Codex-compatible legacy realtime audio and transcript events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onTranscript = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onTranscript, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); const audio = Buffer.from("legacy assistant audio"); socket.emit( @@ -2988,26 +2650,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("emits tool calls from realtime conversation item done events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onToolCall = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onToolCall, - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onToolCall, onEvent }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -3039,24 +2685,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("deduplicates tool calls reported by arguments done and item done events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onToolCall = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onToolCall, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -3128,23 +2759,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ])( "uses authoritative completed tool arguments for $name", async ({ delta, finalArguments, expectedArguments }) => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onToolCall = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onToolCall, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -3181,24 +2798,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ); it("creates an explicit user item and response for manual speech", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onEvent }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); @@ -3229,7 +2831,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "conversation.item.create" }); expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "response.create" }); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -3242,22 +2844,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("defers manual response.create while a realtime response is active", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3276,30 +2864,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }, ]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); }); it("restores automatic audio responses when a manual response is rejected", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); @@ -3344,24 +2917,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps automatic audio suppressed for unrelated errors during a manual response", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); const sessionUpdatesBeforeError = parseSent(socket).filter( @@ -3383,7 +2941,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { sessionUpdatesBeforeError.length, ); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -3396,24 +2954,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("flushes a queued manual response after the prior request is rejected", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: first greeting."); const firstResponseCreate = parseSent(socket).findLast( @@ -3449,7 +2992,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ); expect(onError).toHaveBeenCalledWith(new Error("bad response request")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -3462,22 +3005,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not request a realtime response for continuing tool results", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); void bridge.submitToolResult("call_1", { status: "working" }, { willContinue: true }); @@ -3511,28 +3040,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_2" } })), ); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); }); it("does not request a realtime response for suppressed tool results", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); void bridge.submitToolResult( "call_1", @@ -3554,31 +3069,16 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not flush deferred response.create while a tool result is still continuing", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), ); void bridge.submitToolResult("call_1", { status: "working" }, { willContinue: true }); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(onError).not.toHaveBeenCalled(); expect(parseSent(socket).filter((event) => event.type === "response.create")).toEqual([]); @@ -3600,22 +3100,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("drains deferred response.create after response.cancelled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3628,24 +3114,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not send duplicate response.cancel while cancellation is pending", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onEvent }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3680,25 +3151,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("ignores zero-length playback barge-in without clearing audio", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), + const bridge = createNativeBridge({ onClearAudio, onEvent, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3730,25 +3189,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("force-cancels zero-length playback barge-in for agent handoff fallback", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), + const bridge = createNativeBridge({ onClearAudio, onEvent, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3785,26 +3232,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("allows immediate playback barge-in when the minimum audio window is zero", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); - const bridge = provider.createBridge({ + const bridge = createNativeBridge({ providerConfig: { apiKey: "sk-test", // pragma: allowlist secret minBargeInAudioEndMs: 0, }, - onAudio: vi.fn(), onClearAudio, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3836,24 +3272,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("drains deferred response.create after a no-active-response cancellation error", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3885,24 +3306,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("ignores a stale cancellation error after a newer manual response starts", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3928,7 +3334,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { throw new Error("expected response.cancel event id"); } void bridge.submitToolResult("call_1", { text: "done" }); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); const sessionUpdateCount = parseSent(socket).filter( (event) => event.type === "session.update", ).length; @@ -3952,7 +3358,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ); expect(parseSent(socket).at(-1)).toEqual(expectedResponseCreateEvent()); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), "restored turn detection", @@ -3965,22 +3371,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("resets deferred response guards after websocket reconnect", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3991,14 +3383,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { socket.emit("close", 1006, Buffer.from("transient drop")); await vi.advanceTimersByTimeAsync(1000); - const reconnectedSocket = FakeWebSocket.instances[1]; - if (!reconnectedSocket) { - throw new Error("expected bridge to reconnect"); - } - - reconnectedSocket.readyState = FakeWebSocket.OPEN; - reconnectedSocket.emit("open"); - reconnectedSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const reconnectedSocket = requireSocket(1); + openSocket(reconnectedSocket); + emitSessionUpdated(reconnectedSocket); bridge.sendUserMessage?.("Say hello after reconnect."); expect(parseSent(reconnectedSocket).slice(-3)).toEqual([ @@ -4016,24 +3403,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("turns active-response errors into a deferred response.create retry", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); void bridge.submitToolResult("call_1", { text: "done" }); const responseCreateEvent = parseSent(socket).findLast( @@ -4065,12 +3437,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }, ); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(onError).not.toHaveBeenCalled(); expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), From d60584ee2d1dc3755477b38234b37f8d11bc1b9c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 01:25:41 +0800 Subject: [PATCH 13/53] test(google): cover ready callback close precedence --- extensions/google/index.test.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 24a608875119..118cc4cadc5c 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -56,6 +56,7 @@ function createDeferred() { function createMockRealtimeBridge(connectImpl: () => Promise = async () => {}) { const connect = vi.fn(connectImpl); const sendUserMessage = vi.fn(); + const triggerGreeting = vi.fn(); const close = vi.fn(); const bridge: RealtimeVoiceBridge = { supportsToolResultContinuation: false, @@ -64,17 +65,17 @@ function createMockRealtimeBridge(connectImpl: () => Promise = async () => sendAudio: vi.fn(), setMediaTimestamp: vi.fn(), sendUserMessage, - triggerGreeting: vi.fn(), + triggerGreeting, handleBargeIn: vi.fn(), submitToolResult: vi.fn(), acknowledgeMark: vi.fn(), close, isConnected: vi.fn(() => false), }; - return { bridge, close, connect, sendUserMessage }; + return { bridge, close, connect, sendUserMessage, triggerGreeting }; } -function createLazyRealtimeBridge(onError = vi.fn()) { +function createLazyRealtimeBridge(onError = vi.fn(), onReady?: () => void) { let realtimeProvider: RealtimeVoiceProviderPlugin | undefined; googlePlugin.register( createTestPluginApi({ @@ -88,6 +89,7 @@ function createLazyRealtimeBridge(onError = vi.fn()) { onAudio() {}, onClearAudio() {}, onError, + onReady, }); if (!bridge) { throw new Error("expected Google realtime bridge"); @@ -589,4 +591,23 @@ describe("google provider plugin hooks", () => { expect(loaded.close).toHaveBeenCalledOnce(); expect(loaded.sendUserMessage).not.toHaveBeenCalled(); }); + + it("keeps close precedence when the readiness callback closes the lazy bridge", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const bridgeRef: { current?: RealtimeVoiceBridge } = {}; + const onReady = vi.fn(() => bridgeRef.current?.close()); + const { bridge } = createLazyRealtimeBridge(vi.fn(), onReady); + bridgeRef.current = bridge; + + bridge.sendUserMessage?.("queued prompt"); + bridge.triggerGreeting?.("queued greeting"); + await bridge.connect(); + signalRealtimeBridgeReady(); + + expect(onReady).toHaveBeenCalledOnce(); + expect(loaded.close).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + expect(loaded.triggerGreeting).not.toHaveBeenCalled(); + }); }); From 586e1fe10e11910e9ae96ad904730fb4be0fb3bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:26:33 -0700 Subject: [PATCH 14/53] refactor: dedupe secrets runtime snapshot fixtures (#117502) * test(secrets): dedupe runtime snapshot fixtures * test(secrets): preserve runtime auth-store fixture type --- src/secrets/runtime-state.test.ts | 1095 ++++++++++------------------- 1 file changed, 374 insertions(+), 721 deletions(-) diff --git a/src/secrets/runtime-state.test.ts b/src/secrets/runtime-state.test.ts index 1c132aea3179..d5e56e103c49 100644 --- a/src/secrets/runtime-state.test.ts +++ b/src/secrets/runtime-state.test.ts @@ -39,6 +39,40 @@ import { type PreparedSecretsRuntimeSnapshot, } from "./runtime-state.js"; +type PreparedSnapshotOverrides = Omit< + Partial, + "authStoreCredentialsRevision" | "webTools" +>; + +function preparedSnapshot( + overrides: PreparedSnapshotOverrides = {}, +): PreparedSecretsRuntimeSnapshot { + return { + sourceConfig: {}, + config: {}, + authStores: [], + authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), + warnings: [], + webTools: { + search: { providerSource: "none", diagnostics: [] }, + fetch: { providerSource: "none", diagnostics: [] }, + diagnostics: [], + }, + ...overrides, + }; +} + +function preparedGatewayAuthSnapshot( + agentDir: string, + port: number, + store: PreparedSecretsRuntimeSnapshot["authStores"][number]["store"], +): PreparedSecretsRuntimeSnapshot { + return preparedSnapshot({ + config: { gateway: { port } }, + authStores: [{ agentDir, store }], + }); +} + describe("secrets runtime state", () => { let envSnapshot: ReturnType; const autoCleanupTempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -81,18 +115,11 @@ describe("secrets runtime state", () => { }); it("exposes the active config pair for hot paths without requiring the full snapshot", () => { - const snapshot: PreparedSecretsRuntimeSnapshot = { + const snapshot = preparedSnapshot({ sourceConfig: { agents: { list: [{ id: "source" }] } }, config: { agents: { list: [{ id: "runtime" }] } }, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }; + }); activateSecretsRuntimeSnapshotState({ snapshot, @@ -115,18 +142,11 @@ describe("secrets runtime state", () => { provider: "default", id: "OPENCLAW_DEBUG_AUTH_TOKEN", }; - const snapshot: PreparedSecretsRuntimeSnapshot = { + const snapshot = preparedSnapshot({ sourceConfig: { gateway: { auth: { mode: "token", token: secretRef } } }, config: { gateway: { auth: { mode: "token", token: "resolved-debug-token" } } }, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }; + }); activateSecretsRuntimeSnapshotState({ snapshot, refreshContext: null, @@ -157,18 +177,11 @@ describe("secrets runtime state", () => { const initialConfig = { gateway: { port: 19_030 } } satisfies OpenClawConfig; const concurrentConfig = { gateway: { port: 19_031 } } satisfies OpenClawConfig; activateSecretsRuntimeSnapshotState({ - snapshot: { + snapshot: preparedSnapshot({ sourceConfig: initialConfig, config: initialConfig, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }, + }), refreshContext: null, refreshHandler: null, }); @@ -201,18 +214,11 @@ describe("secrets runtime state", () => { }, } satisfies OpenClawConfig; activateSecretsRuntimeSnapshotState({ - snapshot: { + snapshot: preparedSnapshot({ sourceConfig: initialSource, config: runtimeConfig, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }, + }), refreshContext: null, refreshHandler: null, runtimeSourceConfig: initialSource, @@ -273,8 +279,7 @@ describe("secrets runtime state", () => { }, agentDir, ); - const snapshot: PreparedSecretsRuntimeSnapshot = { - sourceConfig: {}, + const snapshot = preparedSnapshot({ config: {}, authStores: [ { @@ -286,14 +291,7 @@ describe("secrets runtime state", () => { }, }, ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }; + }); setRuntimeAuthProfileStoreSnapshot( { version: 1, @@ -318,28 +316,13 @@ describe("secrets runtime state", () => { it("removes candidate-only auth profiles when rolling config back", () => { const agentDir = "/tmp/openclaw-auth-rollback-cas"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", 19_001), refreshContext: null, @@ -392,18 +375,11 @@ describe("secrets runtime state", () => { profiles: AuthProfileStore["profiles"], port: number, state: Pick = {}, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [{ agentDir, store: { version: 1, profiles, ...state } }], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + ) => + preparedSnapshot({ + config: { gateway: { port } }, + authStores: [{ agentDir, store: { version: 1, profiles, ...state } }], + }); const predecessorProfiles = { "provider-a:default": profile("provider-a", "a-old"), "provider-b:default": profile("provider-b", "b-old"), @@ -492,28 +468,13 @@ describe("secrets runtime state", () => { it("preserves an auth rotation captured by the candidate", () => { const finalKey = "sk-candidate"; const agentDir = "/tmp/openclaw-auth-rollback-sk-candidate"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", 19_001), refreshContext: null, @@ -607,35 +568,15 @@ describe("secrets runtime state", () => { provider, key, }); - const snapshot = ( - aKey: string | null, - bKey: string, - port: number, - aExternal = false, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - ...(aKey === null ? {} : { "provider-a:default": profile("provider-a", aKey) }), - "provider-b:default": profile("provider-b", bKey), - }, - runtimeExternalProfileIds: aExternal ? ["provider-a:default"] : undefined, - }, + const snapshot = (aKey: string | null, bKey: string, port: number, aExternal = false) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + ...(aKey === null ? {} : { "provider-a:default": profile("provider-a", aKey) }), + "provider-b:default": profile("provider-b", bKey), }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeExternalProfileIds: aExternal ? ["provider-a:default"] : undefined, + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot(baselineAKey, "b-old", 19_001), refreshContext: null, @@ -690,29 +631,14 @@ describe("secrets runtime state", () => { { label: "inherited profile", runtimeLocalProfileIds: [], expected: "sk-candidate" }, ])("uses the effective owner token for a $label", ({ runtimeLocalProfileIds, expected }) => { const agentDir = `/tmp/openclaw-auth-effective-owner-${runtimeLocalProfileIds.length}`; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - runtimeLocalProfileIds, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeLocalProfileIds, + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", 19_001), refreshContext: null, @@ -755,27 +681,12 @@ describe("secrets runtime state", () => { profiles: AuthProfileStore["profiles"], externalProfileIds: string[], port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles, - runtimeExternalProfileIds: externalProfileIds, - }, - }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + ) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles, + runtimeExternalProfileIds: externalProfileIds, + }); const profileX = { type: "api_key" as const, provider: "openai", @@ -833,34 +744,15 @@ describe("secrets runtime state", () => { "handles baseline external to $candidateOwner with mutation=$mutateCandidateOwner", ({ candidateOwner, mutateCandidateOwner }) => { const agentDir = `/tmp/openclaw-auth-external-to-${candidateOwner}-${mutateCandidateOwner}`; - const snapshot = ( - key: string, - owner: "external" | "inherited" | "local", - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { type: "api_key", provider: "openai", key }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], - }, + const snapshot = (key: string, owner: "external" | "inherited" | "local", port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:x": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-external-old", "external", 19_001), refreshContext: null, @@ -919,40 +811,22 @@ describe("secrets runtime state", () => { key: string | null, owner: "external" | "inherited" | "local", port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - ...(key === null - ? {} - : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: [ - "anthropic:stable", - ...(owner === "local" ? ["openai:x"] : []), - ], + ) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + ...(key === null + ? {} + : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: ["anthropic:stable", ...(owner === "local" ? ["openai:x"] : [])], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot( baselineOwner === "absent" ? null : "sk-baseline", @@ -1003,40 +877,22 @@ describe("secrets runtime state", () => { key: string | null, owner: "external" | "inherited" | "local", port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - ...(key === null - ? {} - : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: [ - "anthropic:stable", - ...(owner === "local" ? ["openai:x"] : []), - ], + ) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + ...(key === null + ? {} + : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: ["anthropic:stable", ...(owner === "local" ? ["openai:x"] : [])], + }); const baseline = snapshot( baselineOwner === "absent" ? null : "sk-baseline", baselineOwner === "local" ? "local" : "inherited", @@ -1088,34 +944,15 @@ describe("secrets runtime state", () => { "preserves $currentOwner owner metadata when bytes equal the $candidateOwner candidate", ({ candidateOwner, currentOwner }) => { const agentDir = `/tmp/openclaw-auth-${candidateOwner}-${currentOwner}-equal-bytes`; - const snapshot = ( - key: string, - owner: "external" | "local", - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { type: "api_key", provider: "openai", key }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], - }, + const snapshot = (key: string, owner: "external" | "local", port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:x": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", candidateOwner, 19_001), refreshContext: null, @@ -1159,28 +996,13 @@ describe("secrets runtime state", () => { it("preserves an authoritative empty external overlay on rollback", () => { const agentDir = "/tmp/openclaw-auth-authoritative-empty-external"; - const snapshot = (authoritative: boolean, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: {}, - runtimeExternalProfileIds: [], - runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, - }, - }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + const snapshot = (authoritative: boolean, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: {}, + runtimeExternalProfileIds: [], + runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot(true, 19_001), refreshContext: null, @@ -1214,35 +1036,16 @@ describe("secrets runtime state", () => { it("does not import rejected external authority from a selected current credential", () => { const agentDir = "/tmp/openclaw-auth-rejected-external-authority"; - const snapshot = ( - key: string, - authoritative: boolean, - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { type: "api_key", provider: "openai", key }, - }, - runtimeLocalProfileIds: ["openai:x"], - runtimeExternalProfileIds: [], - runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, - }, + const snapshot = (key: string, authoritative: boolean, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:x": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeLocalProfileIds: ["openai:x"], + runtimeExternalProfileIds: [], + runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", false, 19_001), refreshContext: null, @@ -1282,29 +1085,14 @@ describe("secrets runtime state", () => { { current: "sk-external-refresh", expected: "sk-external-refresh" }, ])("keeps external profile ownership separate from main mutations", ({ current, expected }) => { const agentDir = `/tmp/openclaw-auth-external-owner-${current}`; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:external": { type: "api_key", provider: "openai", key }, - }, - runtimeExternalProfileIds: ["openai:external"], - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:external": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeExternalProfileIds: ["openai:external"], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", 19_001), refreshContext: null, @@ -1345,34 +1133,19 @@ describe("secrets runtime state", () => { it("removes a rejected candidate credential when its bounded lineage was evicted", () => { const agentDir = "/tmp/openclaw-auth-evicted-lineage"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - }, - runtimeLocalProfileIds: ["anthropic:stable", "openai:default"], + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeLocalProfileIds: ["anthropic:stable", "openai:default"], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", 19_001), refreshContext: null, @@ -1420,33 +1193,14 @@ describe("secrets runtime state", () => { id: "OPENAI_API_KEY", }; const candidateRef = { ...previousRef, id: "OPENAI_API_KEY_NEXT" }; - const snapshot = ( - key: string, - keyRef: typeof previousRef, - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key, keyRef }, - }, - runtimeLocalProfileIds: ["openai:default"], - }, + const snapshot = (key: string, keyRef: typeof previousRef, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key, keyRef }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeLocalProfileIds: ["openai:default"], + }); try { saveAuthProfileStore( snapshot("sk-old", previousRef, 19_001).authStores[0]!.store, @@ -1579,36 +1333,29 @@ describe("secrets runtime state", () => { expectMissing, }) => { const agentDir = `/tmp/openclaw-auth-store-removal-${label}`; - const snapshot = (includeStore: boolean, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: includeStore - ? [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: "sk-old", + const snapshot = (includeStore: boolean, port: number) => + preparedSnapshot({ + config: { gateway: { port } }, + authStores: includeStore + ? [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-old", + }, }, + runtimeLocalProfileIds: inheritsMainProfile ? [] : ["openai:default"], + runtimeInheritsMainState: inheritsMainState, }, - runtimeLocalProfileIds: inheritsMainProfile ? [] : ["openai:default"], - runtimeInheritsMainState: inheritsMainState, }, - }, - ] - : [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + ] + : [], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot(true, 19_001), refreshContext: null, @@ -1656,35 +1403,28 @@ describe("secrets runtime state", () => { it("does not resurrect a baseline external store after a new main profile is added", () => { const agentDir = "/tmp/openclaw-auth-external-store-omission-mutation"; - const snapshot = (includeStore: boolean, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: includeStore - ? [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { - type: "api_key", - provider: "openai", - key: "sk-external", + const snapshot = (includeStore: boolean, port: number) => + preparedSnapshot({ + config: { gateway: { port } }, + authStores: includeStore + ? [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:x": { + type: "api_key", + provider: "openai", + key: "sk-external", + }, }, + runtimeExternalProfileIds: ["openai:x"], }, - runtimeExternalProfileIds: ["openai:x"], }, - }, - ] - : [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + ] + : [], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot(true, 19_001), refreshContext: null, @@ -1721,28 +1461,13 @@ describe("secrets runtime state", () => { it("does not resurrect an auth store cleared after candidate activation", () => { const agentDir = "/tmp/openclaw-auth-post-activation-clear"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", 19_001), refreshContext: null, @@ -1783,32 +1508,13 @@ describe("secrets runtime state", () => { id: "OPENAI_API_KEY", }; const candidateRef = changedRef ? { ...previousRef, id: "OPENAI_API_KEY_NEXT" } : previousRef; - const snapshot = ( - key: string, - keyRef: typeof previousRef, - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key, keyRef }, - }, - }, + const snapshot = (key: string, keyRef: typeof previousRef, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key, keyRef }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", previousRef, 19_001), refreshContext: null, @@ -1852,28 +1558,13 @@ describe("secrets runtime state", () => { it("preserves live credentials when the captured predecessor is stale", () => { const agentDir = "/tmp/openclaw-auth-stale-predecessor-rollback"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot("sk-old", 19_011), refreshContext: null, @@ -1948,40 +1639,34 @@ describe("secrets runtime state", () => { runtimePort: number; apiKey: string; keyRef: string | typeof previousKeyRef; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - gateway: { port: params.sourcePort }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: params.keyRef, - models: [], + }) => + preparedSnapshot({ + sourceConfig: { + gateway: { port: params.sourcePort }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: params.keyRef, + models: [], + }, }, }, }, - }, - config: { - gateway: { port: params.runtimePort }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: params.apiKey, - models: [], + config: { + gateway: { port: params.runtimePort }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: params.apiKey, + models: [], + }, }, }, }, - }, - authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + authStores: [], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot({ sourcePort: 19_021, @@ -2092,61 +1777,51 @@ describe("secrets runtime state", () => { "restores resolved values when a same-ref $label was rejected", ({ keyRef, previousSourceConfig, candidateSourceConfig, evictLineage }) => { const agentDir = `/tmp/openclaw-auth-provider-dependency-${keyRef.provider}`; - const snapshot = (params: { - sourceConfig: OpenClawConfig; - apiKey: string; - port: number; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - ...params.sourceConfig, - gateway: { port: params.port }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: keyRef, - models: [], - }, - }, - }, - }, - config: { - ...params.sourceConfig, - gateway: { port: params.port }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: params.apiKey, - models: [], - }, - }, - }, - }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - keyRef, - key: params.apiKey, + const snapshot = (params: { sourceConfig: OpenClawConfig; apiKey: string; port: number }) => + preparedSnapshot({ + sourceConfig: { + ...params.sourceConfig, + gateway: { port: params.port }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: keyRef, + models: [], }, }, }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + config: { + ...params.sourceConfig, + gateway: { port: params.port }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: params.apiKey, + models: [], + }, + }, + }, + }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef, + key: params.apiKey, + }, + }, + }, + }, + ], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot({ sourceConfig: previousSourceConfig, apiKey: "sk-old", port: 19_031 }), refreshContext: null, @@ -2233,39 +1908,33 @@ describe("secrets runtime state", () => { owner: "inherited" | "local"; providerPath: string; port: number; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - gateway: { port: params.port }, - secrets: { - providers: { vault: { source: "file", path: params.providerPath } }, - }, - }, - config: { gateway: { port: params.port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: params.key, - keyRef, - }, - }, - runtimeLocalProfileIds: params.owner === "local" ? ["openai:default"] : [], + }) => + preparedSnapshot({ + sourceConfig: { + gateway: { port: params.port }, + secrets: { + providers: { vault: { source: "file", path: params.providerPath } }, }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + config: { gateway: { port: params.port } }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: params.key, + keyRef, + }, + }, + runtimeLocalProfileIds: params.owner === "local" ? ["openai:default"] : [], + }, + }, + ], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot({ key: "sk-old", @@ -2357,34 +2026,28 @@ describe("secrets runtime state", () => { keyRef: SecretRef; port: number; sourceConfig: OpenClawConfig; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { ...params.sourceConfig, gateway: { port: params.port } }, - config: { gateway: { port: params.port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: params.key, - keyRef: params.keyRef, + }) => + preparedSnapshot({ + sourceConfig: { ...params.sourceConfig, gateway: { port: params.port } }, + config: { gateway: { port: params.port } }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: params.key, + keyRef: params.keyRef, + }, }, + runtimeLocalProfileIds: ["openai:default"], }, - runtimeLocalProfileIds: ["openai:default"], }, - }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + ], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot({ key: "sk-old", @@ -2448,61 +2111,51 @@ describe("secrets runtime state", () => { "invalidates an absent-profile $currentOwner upsert under a rejected provider", (currentOwner) => { const agentDir = `/tmp/openclaw-auth-provider-absent-upsert-${currentOwner}`; - const snapshot = (params: { - includeProfile: boolean; - providerPath: string; - port: number; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - gateway: { port: params.port }, - secrets: { - providers: { vault: { source: "file", path: params.providerPath } }, - }, - }, - config: { gateway: { port: params.port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - ...(params.includeProfile - ? { - "openai:default": { - type: "api_key" as const, - provider: "openai", - key: "sk-current", - keyRef: { - source: "file" as const, - provider: "vault", - id: "openai-b", - }, - }, - } - : {}), - }, - runtimeExternalProfileIds: - params.includeProfile && currentOwner === "external" ? ["openai:default"] : [], - runtimeLocalProfileIds: [ - "anthropic:stable", - ...(params.includeProfile && currentOwner === "local" ? ["openai:default"] : []), - ], + const snapshot = (params: { includeProfile: boolean; providerPath: string; port: number }) => + preparedSnapshot({ + sourceConfig: { + gateway: { port: params.port }, + secrets: { + providers: { vault: { source: "file", path: params.providerPath } }, }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + config: { gateway: { port: params.port } }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", + }, + ...(params.includeProfile + ? { + "openai:default": { + type: "api_key" as const, + provider: "openai", + key: "sk-current", + keyRef: { + source: "file" as const, + provider: "vault", + id: "openai-b", + }, + }, + } + : {}), + }, + runtimeExternalProfileIds: + params.includeProfile && currentOwner === "external" ? ["openai:default"] : [], + runtimeLocalProfileIds: [ + "anthropic:stable", + ...(params.includeProfile && currentOwner === "local" ? ["openai:default"] : []), + ], + }, + }, + ], + }); activateSecretsRuntimeSnapshotState({ snapshot: snapshot({ includeProfile: false, From 9aceb1620d4fc21753e12247d745ef1f3244ac60 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:27:46 +1000 Subject: [PATCH 15/53] fix(system-agent): emit the wizard cancel hint once per message (#113731) * fix(system-agent): emit the wizard cancel hint once per message * refactor(wizard): own input wait semantics * refactor(wizard): declare step input requirements * refactor(wizard): infer input requirement by step type * docs: note wizard cancel hint fix * chore: follow release-owned changelog policy --- src/system-agent/chat-engine.test.ts | 54 ++++++++++++++++++ src/system-agent/chat-engine.ts | 15 ++++- src/wizard/session.test.ts | 18 +++++- src/wizard/session.ts | 65 +++++++++++++--------- ui/src/pages/channels/wizard-controller.ts | 23 +------- 5 files changed, 126 insertions(+), 49 deletions(-) diff --git a/src/system-agent/chat-engine.test.ts b/src/system-agent/chat-engine.test.ts index 429633cbfd5f..e86da12b4945 100644 --- a/src/system-agent/chat-engine.test.ts +++ b/src/system-agent/chat-engine.test.ts @@ -326,6 +326,9 @@ afterEach(() => { } }); +const CANCEL_HINT = "Say `cancel` to stop this setup."; +const countCancelHints = (text: string) => text.split(CANCEL_HINT).length - 1; + describe("SystemAgentChatEngine", () => { it("lets only an operator arm delegated persistent writes", async () => { useTempStateDir(); @@ -2048,10 +2051,61 @@ describe("SystemAgentChatEngine", () => { const invalid = await engine.handle("banana"); expect(invalid.text).toContain("Enter port 18789"); expect(invalid.text).toContain("Port"); + expect(countCancelHints(invalid.text)).toBe(1); + expect(invalid.text.endsWith(CANCEL_HINT)).toBe(true); const done = await engine.handle("18789"); expect(done.text).toContain("telegram is configured"); }); + it("hints cancel once per message, only while a step awaits an answer", async () => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.note("Open the linked-devices screen.", "Step 1"); + await prompter.note("Scan the code shown next.", "Step 2"); + await prompter.note("Keep the phone online.", "Step 3"); + await prompter.text({ message: "Phone number" }); + await prompter.note("Linked.", "Step 4"); + }, + }); + + // Three auto-answered notes concatenate into the prompt's message; the hint + // is the message's, not each step's. + const prompt = await engine.handle("connect telegram"); + expect(prompt.text).toContain("Step 3"); + expect(prompt.text).toContain("Phone number"); + expect(countCancelHints(prompt.text)).toBe(1); + expect(prompt.text.endsWith(CANCEL_HINT)).toBe(true); + expect(engine.historySince(0).at(-1)).toEqual({ role: "assistant", text: prompt.text }); + + const done = await engine.handle("+15551230000"); + expect(done.text).toContain("Step 4"); + expect(done.text).toContain("telegram is configured"); + expect(countCancelHints(done.text)).toBe(0); + }); + + it("drops the cancel hint from the cancellation message", async () => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ message: "Bot token" }); + }, + }); + + const prompt = await engine.handle("connect discord"); + expect(countCancelHints(prompt.text)).toBe(1); + + const cancelled = await engine.handle("cancel"); + expect(cancelled.text).toContain("cancelled"); + expect(countCancelHints(cancelled.text)).toBe(0); + }); + it("cancels a hosted wizard mid-flight", async () => { useTempStateDir(); const engine = new SystemAgentChatEngine({ diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index 59be5208a584..db8fff76174e 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -4,7 +4,7 @@ import { isSensitiveConfigPath } from "../config/sensitive-paths.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { RuntimeEnv } from "../runtime.js"; -import { WizardSession, type WizardStep } from "../wizard/session.js"; +import { WizardSession, wizardStepAwaitsInput, type WizardStep } from "../wizard/session.js"; import type { MemoryImportProviderOutcome, SetupMemoryImportOutcome, @@ -535,10 +535,11 @@ function renderWizardStep(step: WizardStep): string { default: break; } - lines.push("Say `cancel` to stop this setup."); return lines.filter(Boolean).join("\n"); } +const WIZARD_CANCEL_HINT = "Say `cancel` to stop this setup."; + /** Map a chat reply to a wizard step answer; null means "could not parse". */ function parseWizardAnswer(step: WizardStep, text: string): { value: unknown } | null { const trimmed = text.trim(); @@ -743,7 +744,15 @@ export class SystemAgentChatEngine { // Snapshot before resolving: wizard answers to sensitive steps (tokens, // passwords) must never enter the AI-visible history. const sensitiveTurn = this.wizardBridge?.step?.sensitive === true; - const reply = await this.resolveTurn(text, options); + const resolved = await this.resolveTurn(text, options); + // The hint belongs to the outgoing message, not to each rendered step: one + // turn can concatenate several auto-answered notes, and a wizard that just + // ended must not offer a cancel that can no longer happen. + const awaitedStep = this.wizardBridge?.step; + const reply: SystemAgentChatReply = + resolved.text && awaitedStep && wizardStepAwaitsInput(awaitedStep) + ? { ...resolved, text: `${resolved.text}\n${WIZARD_CANCEL_HINT}` } + : resolved; this.history.push({ role: "user", text: sensitiveTurn ? "" : redactSensitiveCommandText(text), diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts index 9d3af9bbca5b..6ac1ac95b03c 100644 --- a/src/wizard/session.test.ts +++ b/src/wizard/session.test.ts @@ -1,6 +1,7 @@ // Wizard session tests cover session creation and state transitions. + import { describe, expect, test, vi } from "vitest"; -import { WizardSession } from "./session.js"; +import { WizardSession, wizardStepAwaitsInput, type WizardStep } from "./session.js"; function noteRunner() { return new WizardSession(async (prompter) => { @@ -11,6 +12,21 @@ function noteRunner() { } describe("WizardSession", () => { + test.each([ + ["select", undefined, true], + ["multiselect", undefined, true], + ["text", undefined, true], + ["confirm", undefined, true], + ["action", "client", true], + ["action", "gateway", false], + ["note", undefined, false], + ["progress", undefined, false], + ] as const satisfies ReadonlyArray< + readonly [WizardStep["type"], WizardStep["executor"], boolean] + >)("classifies whether %s/%s awaits user input", (type, executor, expected) => { + expect(wizardStepAwaitsInput({ id: "step", type, executor })).toBe(expected); + }); + test("steps progress in order", async () => { const session = noteRunner(); diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 904871342308..185d1f3b5e52 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -1,34 +1,39 @@ // Wizard session helpers track onboarding session ids and state. import { randomUUID } from "node:crypto"; +import type { WizardStep as ProtocolWizardStep } from "../../packages/gateway-protocol/src/index.js"; import { createDeferred, type Deferred } from "../shared/deferred.js"; import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js"; // WizardSession exposes interactive setup as a step/answer protocol for remote // clients while reusing the same WizardPrompter contract as the local CLI. -type WizardStepOption = { - value: unknown; - label: string; - hint?: string; -}; +export type WizardStep = ProtocolWizardStep; -export type WizardStep = { - id: string; - type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action"; - title?: string; - message?: string; - format?: "plain"; - options?: WizardStepOption[]; - initialValue?: unknown; - placeholder?: string; - sensitive?: boolean; - executor?: "gateway" | "client"; - externalUrl?: string; - deviceCode?: { - code: string; - expiresInMinutes?: number; - message?: string; - }; -}; +type WizardStepInputRequirement = "always" | "never" | "client-executor"; + +const WIZARD_STEP_INPUT_REQUIREMENT_BY_TYPE = { + note: "never", + select: "always", + text: "always", + confirm: "always", + multiselect: "always", + progress: "never", + action: "client-executor", +} as const satisfies Record; + +/** Whether a step needs a user answer instead of client or gateway acknowledgement. */ +export function wizardStepAwaitsInput(step: WizardStep): boolean { + const requirement = WIZARD_STEP_INPUT_REQUIREMENT_BY_TYPE[step.type]; + switch (requirement) { + case "always": + return true; + case "never": + return false; + case "client-executor": + return step.executor === "client"; + } + const unhandledRequirement: never = requirement; + return unhandledRequirement; +} type WizardSessionStatus = "running" | "done" | "cancelled" | "error"; @@ -76,7 +81,12 @@ class WizardSessionPrompter implements WizardPrompter { } async note(message: string, title?: string): Promise { - await this.prompt({ type: "note", title, message, executor: "client" }); + await this.prompt({ + type: "note", + title, + message, + executor: "client", + }); } async deviceCode(params: { @@ -106,7 +116,12 @@ class WizardSessionPrompter implements WizardPrompter { } async plain(message: string): Promise { - await this.prompt({ type: "note", message, format: "plain", executor: "client" }); + await this.prompt({ + type: "note", + message, + format: "plain", + executor: "client", + }); } async select(params: { diff --git a/ui/src/pages/channels/wizard-controller.ts b/ui/src/pages/channels/wizard-controller.ts index a43146318f55..2cba18ccbff5 100644 --- a/ui/src/pages/channels/wizard-controller.ts +++ b/ui/src/pages/channels/wizard-controller.ts @@ -1,5 +1,6 @@ // Drives a gateway channel-setup wizard session (wizard.start flow "channels") // as a step/answer state machine for the Control UI wizard modal. +import type { WizardStep } from "../../api/types.ts"; import { isWizardNotFoundError } from "../../lib/gateway-errors.ts"; type WizardGatewayClient = { @@ -37,26 +38,8 @@ async function requestWithTimeout( } } -export type ChannelWizardStepOption = { - value: unknown; - label: string; - hint?: string; -}; - -export type ChannelWizardStep = { - id: string; - type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action"; - title?: string; - message?: string; - format?: "plain"; - options?: ChannelWizardStepOption[]; - initialValue?: unknown; - placeholder?: string; - sensitive?: boolean; - executor?: "gateway" | "client"; - externalUrl?: string; - deviceCode?: { code: string; expiresInMinutes?: number; message?: string }; -}; +export type ChannelWizardStepOption = NonNullable[number]; +export type ChannelWizardStep = WizardStep; type WizardNextResult = { sessionId?: string; From ace5d9ada7ad9b978ac1c6a08b645d110d15ee91 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 01:28:23 +0800 Subject: [PATCH 16/53] fix(ci): update canonical Kova performance pin (#117508) --- .github/workflows/openclaw-performance.yml | 2 +- test/scripts/openclaw-performance-workflow.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index b8a1b074873d..0185f411b367 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -70,7 +70,7 @@ env: OCM_VERSION: v0.2.29 OCM_LINUX_X64_SHA256: d966098d6ba2bc10891be3c76e162a37b07f28c4f51da75d2eb509886eb7e1cf KOVA_REPOSITORY: openclaw/Kova - KOVA_CANONICAL_CONFIG_REF: e2ff1b66e5597a0df2ddb50276257e36069513dd + KOVA_CANONICAL_CONFIG_REF: 283070760a16655b28835061774158b8b11b4aff KOVA_LEGACY_LIST_CONFIG_REF: f3d037b5b8aacd6adf8ef1dd2ea4c1d778ec7c6c PERFORMANCE_MODEL_ID: gpt-5.6-luna # Release matrices cold-build the candidate runtime before measurement. diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 71956fc3a3c0..0f1129dde2f9 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -84,7 +84,7 @@ describe("OpenClaw performance workflow", () => { it("pins the Kova evaluator with release validation contracts", () => { const workflow = readFileSync(WORKFLOW, "utf8"); - const canonicalKovaRef = "e2ff1b66e5597a0df2ddb50276257e36069513dd"; + const canonicalKovaRef = "283070760a16655b28835061774158b8b11b4aff"; const legacyKovaRef = "f3d037b5b8aacd6adf8ef1dd2ea4c1d778ec7c6c"; const install = findStep("Install OCM and Kova"); const installRun = install.run ?? ""; From 5118b0b915c05c902568fe6aef04834fc833bf5e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 01:29:54 +0800 Subject: [PATCH 17/53] perf(gateway): skip empty session recovery stores (#117498) * perf(gateway): skip inactive session recovery stores * chore: leave release notes to release workflow --- .../main-session-restart-recovery-shared.ts | 13 ++++- .../main-session-restart-recovery.test.ts | 58 +++++++++++++++++++ src/config/sessions/session-accessor.entry.ts | 2 + .../session-accessor.readonly.test.ts | 38 ++++++++++++ .../sessions/session-accessor.sqlite-entry.ts | 29 ++++++++++ .../sessions/session-accessor.sqlite.ts | 1 + src/config/sessions/session-accessor.ts | 1 + 7 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/agents/main-session-restart-recovery-shared.ts b/src/agents/main-session-restart-recovery-shared.ts index 6f6cb0d7376a..ac5a7a1876ed 100644 --- a/src/agents/main-session-restart-recovery-shared.ts +++ b/src/agents/main-session-restart-recovery-shared.ts @@ -4,7 +4,10 @@ import { type InternalSessionEntry as SessionEntry, resolveAllAgentSessionStoreTargetsSync, } from "../config/sessions.js"; -import type { SessionTranscriptTurnExpectedState } from "../config/sessions/session-accessor.js"; +import { + hasSessionEntriesByStatusReadOnly, + type SessionTranscriptTurnExpectedState, +} from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isMainRestartRecoveryCandidate } from "./main-session-recovery-state.js"; @@ -90,14 +93,18 @@ export async function resolveRestartRecoveryStorePaths(params: { }): Promise { const storePaths = new Set(); const stateDir = params.stateDir ?? resolveStateDir(process.env); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; for (const sessionsDir of await resolveAgentSessionDirs(stateDir)) { storePaths.add(path.join(sessionsDir, "sessions.json")); } if (params.cfg) { - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg, { env })) { storePaths.add(path.resolve(target.storePath)); } } - return [...storePaths].toSorted((a, b) => a.localeCompare(b)); + // Agent databases also hold auth and model-catalog state. Enter the writer + // lane only when the session owner proves that a running row may need repair. + return [...storePaths] + .filter((storePath) => hasSessionEntriesByStatusReadOnly({ env, storePath }, ["running"])) + .toSorted((a, b) => a.localeCompare(b)); } diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index dd307958cf1b..5ad066195b0f 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -44,6 +44,10 @@ import { isSessionWorkAdmissionActive, runExclusiveSessionLifecycleMutation, } from "../sessions/session-lifecycle-admission.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createDeferred } from "../test-utils/deferred.js"; @@ -2006,6 +2010,60 @@ describe("main-session-restart-recovery", () => { expect(store["agent:main:already-marked"]?.abortedLastRun).toBe(false); }); + it("does not create empty agent databases while scanning startup recovery", async () => { + const agentIds = Array.from({ length: 12 }, (_, index) => `agent-${index + 1}`); + const databasePaths = await Promise.all( + agentIds.map(async (agentId) => { + await makeSessionsDir(agentId); + return path.join(tmpDir, "agents", agentId, "agent", "openclaw-agent.sqlite"); + }), + ); + + await expect(markStartupOrphanedMainSessionsForRecovery({ stateDir: tmpDir })).resolves.toEqual( + { marked: 0, skipped: 0 }, + ); + for (const databasePath of databasePaths) { + await expect(fs.stat(databasePath)).rejects.toMatchObject({ code: "ENOENT" }); + } + }); + + it("does not enter the writer lane for agent databases without running sessions", async () => { + const agentIds = Array.from({ length: 12 }, (_, index) => `agent-${index + 1}`); + const env = { ...process.env, OPENCLAW_STATE_DIR: tmpDir }; + for (const agentId of agentIds) { + openOpenClawAgentDatabase({ + agentId, + env, + path: path.join(tmpDir, "agents", agentId, "agent", "openclaw-agent.sqlite"), + }); + } + closeOpenClawAgentDatabasesForTest(); + const applySessionEntryReplacements = vi.spyOn( + sessionAccessor, + "applySessionEntryReplacements", + ); + + try { + await expect( + markStartupOrphanedMainSessionsForRecovery({ stateDir: tmpDir }), + ).resolves.toEqual({ marked: 0, skipped: 0 }); + expect(applySessionEntryReplacements).not.toHaveBeenCalled(); + } finally { + applySessionEntryReplacements.mockRestore(); + } + }); + + it("keeps corrupt existing agent databases on the startup recovery error path", async () => { + await makeSessionsDir(); + const databasePath = path.join(tmpDir, "agents", "main", "agent", "openclaw-agent.sqlite"); + await fs.mkdir(path.dirname(databasePath), { recursive: true }); + await fs.writeFile(databasePath, "not a sqlite database"); + + await expect( + markStartupOrphanedMainSessionsForRecovery({ stateDir: tmpDir }), + ).rejects.toThrow(); + }); + it.each([ ["current owner before delayed stale registration", "current-first"], ["stale owner before current registration", "stale-first"], diff --git a/src/config/sessions/session-accessor.entry.ts b/src/config/sessions/session-accessor.entry.ts index fd8e4b063445..dd463fb07805 100644 --- a/src/config/sessions/session-accessor.entry.ts +++ b/src/config/sessions/session-accessor.entry.ts @@ -12,6 +12,7 @@ import { clearPluginOwnedSessionState } from "./plugin-host-cleanup.js"; import { countSqliteSessionEntryRowsReadOnly as countSessionEntryRowsReadOnly, copySqliteSessionOwnedStateForCanonicalRepair as copySessionOwnedStateForCanonicalRepair, + hasSqliteSessionEntriesByStatusReadOnly as hasSessionEntriesByStatusReadOnly, listSqliteSessionGenerationIdsForCanonicalRepair as listSessionGenerationIdsForCanonicalRepair, listSqliteSessionChildEntriesReadOnly as listSessionChildEntriesReadOnly, listSqliteSessionEntries, @@ -61,6 +62,7 @@ export { clearPluginOwnedSessionState }; export { countSessionEntryRowsReadOnly, copySessionOwnedStateForCanonicalRepair, + hasSessionEntriesByStatusReadOnly, listSessionGenerationIdsForCanonicalRepair, listSessionChildEntriesReadOnly, listSessionEntriesReadOnly, diff --git a/src/config/sessions/session-accessor.readonly.test.ts b/src/config/sessions/session-accessor.readonly.test.ts index 214678a32e87..5a052c000998 100644 --- a/src/config/sessions/session-accessor.readonly.test.ts +++ b/src/config/sessions/session-accessor.readonly.test.ts @@ -4,6 +4,7 @@ import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js" import { closeOpenClawAgentDatabasesForTest, isOpenClawAgentDatabaseOpen, + openOpenClawAgentDatabase, resolveOpenClawAgentSqlitePath, } from "../../state/openclaw-agent-db.js"; import { @@ -11,6 +12,7 @@ import { openOpenClawStateDatabase, } from "../../state/openclaw-state-db.js"; import { + hasSessionEntriesByStatusReadOnly, listSessionEntries, listSessionEntriesReadOnly, resolveTranscriptSessionKeyBySessionId, @@ -68,6 +70,42 @@ describe("session accessor readonly listing", () => { expect(countRegisteredAgentDatabases(env)).toBe(0); }); + it("probes lifecycle status without creating or registering a missing database", () => { + const stateDir = makeTempDir(tempDirs, "openclaw-session-readonly-status-missing-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const agentId = "worker-1"; + const databasePath = resolveOpenClawAgentSqlitePath({ agentId, env }); + clearRegisteredAgentDatabases(env); + + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["running"])).toBe(false); + expect(fs.existsSync(databasePath)).toBe(false); + expect(countRegisteredAgentDatabases(env)).toBe(0); + }); + + it("distinguishes non-session agent state from a running session row", async () => { + const stateDir = makeTempDir(tempDirs, "openclaw-session-readonly-status-existing-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const agentId = "worker-1"; + const databasePath = resolveOpenClawAgentSqlitePath({ agentId, env }); + openOpenClawAgentDatabase({ agentId, env, path: databasePath }); + closeOpenClawAgentDatabasesForTest(); + clearRegisteredAgentDatabases(env); + + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["running"])).toBe(false); + expect(countRegisteredAgentDatabases(env)).toBe(0); + + await upsertSessionEntry( + { agentId, env, sessionKey: "agent:worker-1:main" }, + { sessionId: "session-1", status: "running", updatedAt: 10 }, + ); + closeOpenClawAgentDatabasesForTest(); + clearRegisteredAgentDatabases(env); + + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["running"])).toBe(true); + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["done"])).toBe(false); + expect(countRegisteredAgentDatabases(env)).toBe(0); + }); + it("resolves a missing session identity without creating or registering a database", () => { const stateDir = makeTempDir(tempDirs, "openclaw-session-readonly-missing-identity-"); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index d12f1f94b158..9653af3dc7b9 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -297,6 +297,35 @@ export function countSqliteSessionEntryRowsReadOnly(scope: SessionEntryListScope return result.found ? result.value : 0; } +/** + * Proves whether a durable store has a row in one of the requested lifecycle states. + * Unknown existing schemas stay eligible so the writable owner can surface or repair them. + */ +export function hasSqliteSessionEntriesByStatusReadOnly( + scope: Partial>, + statuses: readonly SessionEntryStatus[], +): boolean { + const selectedStatuses = [...new Set(statuses)]; + if (selectedStatuses.length === 0) { + return false; + } + const resolved = resolveSqliteScope({ ...scope, sessionKey: "" }); + const result = withOpenClawAgentDatabaseReadOnly((database) => { + const db = getSessionKysely(database.db); + return Boolean( + executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("session_nodes") + .select("session_key") + .where("status", "in", selectedStatuses) + .limit(1), + ), + ); + }, toDatabaseOptions(resolved)); + return result.found ? result.value : result.reason !== "database-missing"; +} + function listSqliteSessionEntriesFromDatabase( database: Pick, resolved: ResolvedSqliteScope, diff --git a/src/config/sessions/session-accessor.sqlite.ts b/src/config/sessions/session-accessor.sqlite.ts index 324ae6189c97..14f9038da6d2 100644 --- a/src/config/sessions/session-accessor.sqlite.ts +++ b/src/config/sessions/session-accessor.sqlite.ts @@ -1,6 +1,7 @@ // Stable SQLite accessor surface. Domain owners live in the focused modules below. export { countSqliteSessionEntryRowsReadOnly, + hasSqliteSessionEntriesByStatusReadOnly, listSqliteSessionEntries, listSqliteSessionChildEntriesReadOnly, listSqliteSessionEntriesReadOnly, diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 2ae27420ac88..987c61b05848 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -121,6 +121,7 @@ export type { export { countSessionEntryRowsReadOnly, copySessionOwnedStateForCanonicalRepair, + hasSessionEntriesByStatusReadOnly, listSessionGenerationIdsForCanonicalRepair, clearPluginOwnedSessionState, listSessionChildEntriesReadOnly, From fb6f60a704fc239dea46cbd02db67f92ef5d5749 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:37:02 -0700 Subject: [PATCH 18/53] fix(gateway): preserve Responses usage details (#117533) * fix(gateway): preserve Responses usage details * fix(gateway): keep Responses usage type internal --- src/agents/usage.ts | 41 +++++++++++++++ src/gateway/open-responses.schema.ts | 7 +++ src/gateway/openai-agent-run-usage.ts | 27 ++++++++++ src/gateway/openai-http.ts | 39 +------------- src/gateway/openresponses-http.test.ts | 70 +++++++++++++++++++++++++- src/gateway/openresponses-http.ts | 38 ++------------ 6 files changed, 149 insertions(+), 73 deletions(-) create mode 100644 src/gateway/openai-agent-run-usage.ts diff --git a/src/agents/usage.ts b/src/agents/usage.ts index c0a7e51b0e80..649666cc9368 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -73,6 +73,18 @@ export type OpenAiChatCompletionsUsage = { completion_tokens_details?: { reasoning_tokens: number }; }; +/** OpenAI Responses compatible usage shape. */ +type OpenAiResponsesUsage = { + input_tokens: number; + input_tokens_details: { + cached_tokens: number; + cache_write_tokens: number; + }; + output_tokens: number; + output_tokens_details: { reasoning_tokens: number }; + total_tokens: number; +}; + /** Assistant usage snapshot with token counts and computed cost buckets. */ export type AssistantUsageSnapshot = Usage; @@ -272,6 +284,35 @@ export function toOpenAiChatCompletionsUsage( }; } +/** + * Maps normalized usage to OpenAI Responses `usage` fields. + * + * Responses reports cache reads and writes as subsets of `input_tokens`, so + * recombine OpenClaw's separately priced buckets and retain their details. + * Reasoning tokens remain a detail of `output_tokens`, not an extra bucket. + */ +export function toOpenAiResponsesUsage(usage: NormalizedUsage | undefined): OpenAiResponsesUsage { + const input = Math.max(0, usage?.input ?? 0); + const output = Math.max(0, usage?.output ?? 0); + const cacheRead = Math.max(0, usage?.cacheRead ?? 0); + const cacheWrite = Math.max(0, usage?.cacheWrite ?? 0); + const reasoningTokens = Math.max(0, usage?.reasoningTokens ?? 0); + const inputTokens = input + cacheRead + cacheWrite; + const componentTotal = inputTokens + output; + const aggregateTotal = Math.max(0, usage?.total ?? 0); + + return { + input_tokens: inputTokens, + input_tokens_details: { + cached_tokens: cacheRead, + cache_write_tokens: cacheWrite, + }, + output_tokens: output, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + total_tokens: Math.max(componentTotal, aggregateTotal), + }; +} + /** Derive prompt/context tokens from normalized input and cache buckets. */ export function derivePromptTokens(usage?: { input?: number; diff --git a/src/gateway/open-responses.schema.ts b/src/gateway/open-responses.schema.ts index 05bde4ece540..4a48e78b0145 100644 --- a/src/gateway/open-responses.schema.ts +++ b/src/gateway/open-responses.schema.ts @@ -279,7 +279,14 @@ export type OutputItem = z.infer; const UsageSchema = z.object({ input_tokens: z.number().int().nonnegative(), + input_tokens_details: z.object({ + cached_tokens: z.number().int().nonnegative(), + cache_write_tokens: z.number().int().nonnegative(), + }), output_tokens: z.number().int().nonnegative(), + output_tokens_details: z.object({ + reasoning_tokens: z.number().int().nonnegative(), + }), total_tokens: z.number().int().nonnegative(), }); diff --git a/src/gateway/openai-agent-run-usage.ts b/src/gateway/openai-agent-run-usage.ts new file mode 100644 index 000000000000..3ac384fa13e9 --- /dev/null +++ b/src/gateway/openai-agent-run-usage.ts @@ -0,0 +1,27 @@ +/** Shared agent-run usage selection for OpenAI-compatible Gateway endpoints. */ +import { + hasNonzeroUsage, + normalizeUsage, + type NormalizedUsage, + type UsageLike, +} from "../agents/usage.js"; + +type AgentRunUsageMeta = { + usage?: UsageLike; + lastCallUsage?: UsageLike; +}; + +/** Prefer a nonzero aggregate snapshot, then the latest model-call snapshot. */ +export function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { + const agentMeta = (result as { meta?: { agentMeta?: AgentRunUsageMeta } } | null)?.meta + ?.agentMeta; + const aggregate = normalizeUsage(agentMeta?.usage); + if (hasNonzeroUsage(aggregate)) { + return aggregate; + } + const lastCall = normalizeUsage(agentMeta?.lastCallUsage); + if (hasNonzeroUsage(lastCall)) { + return lastCall; + } + return aggregate ?? lastCall; +} diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 47e9c046acf5..b42d3213965e 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -13,13 +13,7 @@ import { isClientToolNameConflictError } from "../agents/agent-tool-definition-a import type { AgentStreamParams, ClientToolDefinition } from "../agents/command/shared-types.js"; import type { ImageContent } from "../agents/command/types.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; -import { - hasNonzeroUsage, - normalizeUsage, - toOpenAiChatCompletionsUsage, - type NormalizedUsage, - type OpenAiChatCompletionsUsage, -} from "../agents/usage.js"; +import { toOpenAiChatCompletionsUsage, type OpenAiChatCompletionsUsage } from "../agents/usage.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommandFromIngress } from "../commands/agent.js"; import type { GatewayHttpChatCompletionsConfig } from "../config/types.gateway.js"; @@ -67,6 +61,7 @@ import { resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; +import { resolveAgentRunUsage } from "./openai-agent-run-usage.js"; import { resolveOpenAiCompatError, validateOpenAiSamplingParams } from "./openai-compat-errors.js"; import { isToolChoiceConstraintSatisfied, @@ -766,42 +761,12 @@ function resolveAgentResponseCommentary(result: unknown): string { .join("\n\n"); } -type AgentUsageMeta = { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; -}; - type PendingToolCall = { id?: unknown; name?: unknown; arguments?: unknown; }; -function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { - const agentMeta = ( - result as { - meta?: { - agentMeta?: { - usage?: AgentUsageMeta; - lastCallUsage?: AgentUsageMeta; - }; - }; - } | null - )?.meta?.agentMeta; - const primary = normalizeUsage(agentMeta?.usage); - if (hasNonzeroUsage(primary)) { - return primary; - } - const fallback = normalizeUsage(agentMeta?.lastCallUsage); - if (hasNonzeroUsage(fallback)) { - return fallback; - } - return primary ?? fallback; -} - function resolveStopReasonAndPendingToolCalls(meta: unknown): { stopReason: string | undefined; pendingToolCalls: Array<{ id: string; name: string; arguments: string }> | undefined; diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 95a98e03cd93..9d7aad072a13 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -1135,7 +1135,15 @@ describe("OpenResponses HTTP API (e2e)", () => { mockAgentOnce([{ text: "ok" }], { agentMeta: { - usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 }, + usage: { + input: 3, + output: 5, + cacheRead: 1, + cacheWrite: 2, + reasoningTokens: 4, + total: 7, + }, + lastCallUsage: { input: 100, output: 100, total: 200 }, }, }); const resUsage = await postResponses(port, { @@ -1145,7 +1153,13 @@ describe("OpenResponses HTTP API (e2e)", () => { }); expect(resUsage.status).toBe(200); const usageJson = (await resUsage.json()) as Record; - expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 }); + expect(usageJson.usage).toEqual({ + input_tokens: 6, + input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 4 }, + total_tokens: 11, + }); await ensureResponseConsumed(resUsage); mockAgentOnce([{ text: "hello" }]); @@ -1158,6 +1172,13 @@ describe("OpenResponses HTTP API (e2e)", () => { const shapeJson = (await resShape.json()) as Record; expect(shapeJson.object).toBe("response"); expect(shapeJson.status).toBe("completed"); + expect(shapeJson.usage).toEqual({ + input_tokens: 0, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 0, + }); expect(Array.isArray(shapeJson.output)).toBe(true); const output = shapeJson.output as Array>; @@ -1281,6 +1302,51 @@ describe("OpenResponses HTTP API (e2e)", () => { } }); + it.each([ + { name: "missing aggregate", usage: undefined }, + { name: "zero aggregate", usage: { input: 0, output: 0, total: 0 } }, + ])("uses last-call usage in the terminal SSE response for $name", async ({ usage }) => { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "hello" }], + meta: { + agentMeta: { + ...(usage ? { usage } : {}), + lastCallUsage: { + input: 4, + output: 3, + cacheRead: 2, + cacheWrite: 1, + reasoningTokens: 2, + total: 9, + }, + }, + }, + } as never); + + const client = new OpenAI({ + apiKey: "test", + baseURL: `http://127.0.0.1:${enabledPort}/v1`, + defaultHeaders: { "x-openclaw-scopes": "operator.write" }, + maxRetries: 0, + }); + const response = await client.responses + .stream({ + model: "openclaw", + input: "hi", + }) + .finalResponse(); + + expect(response.status).toBe("completed"); + expect(response.usage).toEqual({ + input_tokens: 7, + input_tokens_details: { cached_tokens: 2, cache_write_tokens: 1 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 2 }, + total_tokens: 10, + }); + }); + it("flushes same-turn assistant microtasks before completing an official SDK stream", async () => { agentCommand.mockClear(); agentCommand.mockImplementationOnce(((opts: unknown) => { diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 702aa0cb4c26..ef7782184cea 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -12,6 +12,7 @@ import { resolveIntegerOption } from "@openclaw/normalization-core/number-coerci import { isClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import type { ImageContent } from "../agents/command/types.js"; import type { ClientToolDefinition } from "../agents/embedded-agent-runner/run/params.js"; +import { toOpenAiResponsesUsage } from "../agents/usage.js"; import { createDefaultDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.types.js"; import { agentCommandFromIngress } from "../commands/agent.js"; @@ -70,6 +71,7 @@ import { type StreamingEvent, type Usage, } from "./open-responses.schema.js"; +import { resolveAgentRunUsage } from "./openai-agent-run-usage.js"; import { resolveOpenAiCompatError } from "./openai-compat-errors.js"; import { isToolChoiceConstraintSatisfied, @@ -342,43 +344,11 @@ function applyToolChoice(params: { export { buildAgentPrompt } from "./openresponses-prompt.js"; function createEmptyUsage(): Usage { - return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; -} - -function toUsage( - value: - | { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - } - | undefined, -): Usage { - if (!value) { - return createEmptyUsage(); - } - const input = value.input ?? 0; - const output = value.output ?? 0; - const cacheRead = value.cacheRead ?? 0; - const cacheWrite = value.cacheWrite ?? 0; - const total = value.total ?? input + output + cacheRead + cacheWrite; - return { - input_tokens: Math.max(0, input), - output_tokens: Math.max(0, output), - total_tokens: Math.max(0, total), - }; + return toOpenAiResponsesUsage(undefined); } function extractUsageFromResult(result: unknown): Usage { - const meta = (result as { meta?: { agentMeta?: { usage?: unknown } } } | null)?.meta; - const usage = meta && typeof meta === "object" ? meta.agentMeta?.usage : undefined; - return toUsage( - usage as - | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } - | undefined, - ); + return toOpenAiResponsesUsage(resolveAgentRunUsage(result)); } type PendingToolCall = { id: string; name: string; arguments: string }; From bde1602e6e86fd91dd0bec3aae714e059e556c4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:38:43 -0700 Subject: [PATCH 19/53] refactor(agents): consolidate compaction and context-engine ownership (#117482) * refactor(agents): consolidate compaction and context-engine ownership * fix(agents): preserve prior summaries during compaction fallback * chore(ci): refresh merge proof after routing repair --- .../compaction-instructions.test.ts | 58 +- .../agent-hooks/compaction-instructions.ts | 34 +- .../compaction-safeguard-quality.ts | 15 +- .../compaction-safeguard-runtime.ts | 7 +- .../agent-hooks/compaction-safeguard.test.ts | 139 +++-- .../agent-hooks/compaction-safeguard.ts | 267 ++++----- src/agents/compaction-planning-projection.ts | 148 ++--- src/agents/compaction-planning-worker.ts | 127 ++--- src/agents/compaction-planning.ts | 191 ++----- src/agents/compaction-planning.worker.ts | 40 +- src/agents/compaction-real-conversation.ts | 34 +- src/agents/compaction-usage.ts | 72 +-- src/agents/compaction.ts | 218 +++---- .../run/preemptive-compaction.ts | 157 ++--- .../tool-result-truncation.ts | 538 +++++++----------- ...ded-agent-subscribe.handlers.compaction.ts | 168 +++--- src/context-engine/context-engine.test.ts | 39 ++ src/context-engine/delegate.ts | 48 +- src/context-engine/legacy.ts | 56 +- src/context-engine/quarantine-health.ts | 28 +- src/context-engine/registry.ts | 314 ++++------ src/context-engine/runtime-settings.ts | 41 +- 22 files changed, 986 insertions(+), 1753 deletions(-) diff --git a/src/agents/agent-hooks/compaction-instructions.test.ts b/src/agents/agent-hooks/compaction-instructions.test.ts index 67ab15bc003a..75c5545c70b6 100644 --- a/src/agents/agent-hooks/compaction-instructions.test.ts +++ b/src/agents/agent-hooks/compaction-instructions.test.ts @@ -1,9 +1,6 @@ /** Tests compaction instruction defaults, precedence, and split-turn composition. */ import { describe, expect, it } from "vitest"; -import { - resolveCompactionInstructions, - composeSplitTurnInstructions, -} from "./compaction-instructions.js"; +import { resolveCompactionInstructions } from "./compaction-instructions.js"; const DEFAULT_COMPACTION_INSTRUCTIONS = resolveCompactionInstructions(undefined, undefined); @@ -188,56 +185,3 @@ describe("resolveCompactionInstructions", () => { }); }); }); - -describe("composeSplitTurnInstructions", () => { - it("joins turn prefix, separator, and resolved instructions with double newlines", () => { - const result = composeSplitTurnInstructions("Turn prefix here", "Resolved instructions here"); - expect(result).toBe( - "Turn prefix here\n\nAdditional requirements:\n\nResolved instructions here", - ); - }); - - it("output contains the turn prefix verbatim", () => { - const prefix = "Summarize the last 5 messages."; - const result = composeSplitTurnInstructions(prefix, "Keep it short."); - expect(result).toContain(prefix); - }); - - it("output contains the resolved instructions verbatim", () => { - const instructions = "Write in Korean. Preserve persona."; - const result = composeSplitTurnInstructions("prefix", instructions); - expect(result).toContain(instructions); - }); - - it("output contains 'Additional requirements:' separator", () => { - const result = composeSplitTurnInstructions("a", "b"); - expect(result).toContain("Additional requirements:"); - }); - - it("KNOWN_EDGE: empty turnPrefix produces leading blank line", () => { - const result = composeSplitTurnInstructions("", "instructions"); - expect(result).toBe("\n\nAdditional requirements:\n\ninstructions"); - expect(result.startsWith("\n")).toBe(true); - }); - - it("KNOWN_EDGE: empty resolvedInstructions produces trailing blank area", () => { - const result = composeSplitTurnInstructions("prefix", ""); - expect(result).toBe("prefix\n\nAdditional requirements:\n\n"); - expect(result.endsWith("\n\n")).toBe(true); - }); - - it("does not deduplicate if instructions already contain 'Additional requirements:'", () => { - const instructions = "Additional requirements: keep it short."; - const result = composeSplitTurnInstructions("prefix", instructions); - const count = (result.match(/Additional requirements:/g) || []).length; - expect(count).toBe(2); - }); - - it("preserves multiline content in both inputs", () => { - const prefix = "Line 1\nLine 2"; - const instructions = "Rule A\nRule B\nRule C"; - const result = composeSplitTurnInstructions(prefix, instructions); - expect(result).toContain("Line 1\nLine 2"); - expect(result).toContain("Rule A\nRule B\nRule C"); - }); -}); diff --git a/src/agents/agent-hooks/compaction-instructions.ts b/src/agents/agent-hooks/compaction-instructions.ts index 6a1fc736e747..135c951f6e56 100644 --- a/src/agents/agent-hooks/compaction-instructions.ts +++ b/src/agents/agent-hooks/compaction-instructions.ts @@ -4,6 +4,7 @@ * Provides default language-preservation instructions and a precedence-based * resolver for customInstructions used during context compaction summaries. */ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; /** * Default instructions injected into every safeguard-mode compaction summary. @@ -22,22 +23,6 @@ const DEFAULT_COMPACTION_INSTRUCTIONS = */ const MAX_INSTRUCTION_LENGTH = 800; -function truncateUnicodeSafe(s: string, maxCodePoints: number): string { - const chars = Array.from(s); - if (chars.length <= maxCodePoints) { - return s; - } - return chars.slice(0, maxCodePoints).join(""); -} - -function normalize(s: string | undefined): string | undefined { - if (s == null) { - return undefined; - } - const trimmed = s.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - /** * Resolve compaction instructions with precedence: * event (SDK) → runtime (config) → DEFAULT constant. @@ -50,19 +35,8 @@ export function resolveCompactionInstructions( runtimeInstructions: string | undefined, ): string { const resolved = - normalize(eventInstructions) ?? - normalize(runtimeInstructions) ?? + normalizeOptionalString(eventInstructions) ?? + normalizeOptionalString(runtimeInstructions) ?? DEFAULT_COMPACTION_INSTRUCTIONS; - return truncateUnicodeSafe(resolved, MAX_INSTRUCTION_LENGTH); -} - -/** - * Compose split-turn instructions by combining the SDK's turn-prefix - * instructions with the resolved compaction instructions. - */ -export function composeSplitTurnInstructions( - turnPrefixInstructions: string, - resolvedInstructions: string, -): string { - return [turnPrefixInstructions, "Additional requirements:", resolvedInstructions].join("\n\n"); + return Array.from(resolved).slice(0, MAX_INSTRUCTION_LENGTH).join(""); } diff --git a/src/agents/agent-hooks/compaction-safeguard-quality.ts b/src/agents/agent-hooks/compaction-safeguard-quality.ts index 0d99dd74910d..3c9d14706b1e 100644 --- a/src/agents/agent-hooks/compaction-safeguard-quality.ts +++ b/src/agents/agent-hooks/compaction-safeguard-quality.ts @@ -94,10 +94,7 @@ function hasRequiredSummarySections(summary: string): boolean { } /** Return a structured fallback summary when model output is missing/invalid. */ -export function buildStructuredFallbackSummary( - previousSummary: string | undefined, - _summarizationInstructions?: CompactionSummarizationInstructions, -): string { +export function buildStructuredFallbackSummary(previousSummary: string | undefined): string { const trimmedPreviousSummary = previousSummary?.trim() ?? ""; if (trimmedPreviousSummary && hasRequiredSummarySections(trimmedPreviousSummary)) { return trimmedPreviousSummary; @@ -153,12 +150,10 @@ export function extractOpaqueIdentifiers(text: string): string[] { text.match( /([A-Fa-f0-9]{8,}|https?:\/\/\S+|\/[\w.-]{2,}(?:\/[\w.-]+)+|[A-Za-z]:\\[\w\\.-]+|[A-Za-z0-9._-]+\.[A-Za-z0-9._/-]+:\d{1,5}|\b\d{6,}\b)/g, ) ?? []; - return Array.from( - new Set( - matches - .map((value) => normalizeOpaqueIdentifier(sanitizeExtractedIdentifier(value))) - .filter((value) => value.length >= 4), - ), + return uniqueStrings( + matches + .map((value) => normalizeOpaqueIdentifier(sanitizeExtractedIdentifier(value))) + .filter((value) => value.length >= 4), ).slice(0, MAX_EXTRACTED_IDENTIFIERS); } diff --git a/src/agents/agent-hooks/compaction-safeguard-runtime.ts b/src/agents/agent-hooks/compaction-safeguard-runtime.ts index 13e393c9d008..539206ee9ed1 100644 --- a/src/agents/agent-hooks/compaction-safeguard-runtime.ts +++ b/src/agents/agent-hooks/compaction-safeguard-runtime.ts @@ -49,14 +49,9 @@ export function setCompactionSafeguardCancelReason( const current = getCompactionSafeguardRuntime(sessionManager); const trimmed = reason?.trim(); - if (!current) { - if (!trimmed) { - return; - } - setCompactionSafeguardRuntime(sessionManager, { cancelReason: trimmed }); + if (!current && !trimmed) { return; } - const next = { ...current }; if (trimmed) { next.cancelReason = trimmed; diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index c018126cca3f..ef3e50f25ad8 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -1439,9 +1439,7 @@ describe("compaction-safeguard recent-turn preservation", () => { }); it("does not force policy-off marker in fallback exact identifiers section", () => { - const summary = buildStructuredFallbackSummary(undefined, { - identifierPolicy: "off", - }); + const summary = buildStructuredFallbackSummary(undefined); expect(summary).toContain("## Exact identifiers"); expect(summary).toContain("None captured."); expect(summary).not.toContain("N/A (identifier policy off)."); @@ -2143,6 +2141,9 @@ describe("compaction-safeguard recent-turn preservation", () => { expect(summary).toContain("latest ask status"); expect(summary).toContain("latest assistant reply"); expect(mockSummarizeInStages).toHaveBeenCalledTimes(3); + expect(requireRecord(mockCallArg(mockSummarizeInStages, 1)).customInstructions).toContain( + "Additional requirements:", + ); }); it("keeps required headings when all turns are preserved and history is carried forward", async () => { @@ -2845,70 +2846,86 @@ describe("compaction-safeguard double-compaction guard", () => { ).toBe(true); }); - it("does not replay inter-session sessions_send branch turns as fallback history", async () => { - mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary")); + it.each([ + { assistantText: "bee reply", completed: true }, + { assistantText: " \t\n ", completed: false }, + ])( + "drops delegated branch turns only after meaningful terminal output ($completed)", + async ({ assistantText, completed }) => { + mockSummarizeInStages.mockReset(); + mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary")); - const now = Date.now(); - const sessionManager = { - ...stubSessionManager(), - getBranch: () => [ - { - type: "message", - id: "user-1", - parentId: null, - timestamp: new Date(now).toISOString(), - message: { - role: "user", - content: "say bee", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:pm", - sourceTool: "sessions_send", + const now = Date.now(); + const sessionManager = { + ...stubSessionManager(), + getBranch: () => [ + { + type: "message", + id: "user-1", + parentId: null, + timestamp: new Date(now).toISOString(), + message: { + role: "user", + content: "say bee", + provenance: { + kind: "inter_session", + sourceSessionKey: "agent:pm", + sourceTool: "sessions_send", + }, + timestamp: now, }, - timestamp: now, }, - }, - { - type: "message", - id: "assistant-1", - parentId: "user-1", - timestamp: new Date(now + 1).toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "bee reply" }], - timestamp: now + 1, + { + type: "message", + id: "assistant-1", + parentId: "user-1", + timestamp: new Date(now + 1).toISOString(), + message: { + role: "assistant", + content: [{ type: "text", text: assistantText }], + timestamp: now + 1, + }, }, + ], + } as ExtensionContext["sessionManager"]; + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-7", + tokensBefore: 38085, + fileOps: { read: [], edited: [], written: [] }, + settings: { reserveTokens: 4000 }, + isSplitTurn: true, }, - ], - } as ExtensionContext["sessionManager"]; - const model = createAnthropicModelFixture(); - setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); + customInstructions: "", + signal: new AbortController().signal, + }; + const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "dummy", + }); - const mockEvent = { - preparation: { - messagesToSummarize: [] as AgentMessage[], - turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-7", - tokensBefore: 38085, - fileOps: { read: [], edited: [], written: [] }, - settings: { reserveTokens: 4000 }, - isSplitTurn: true, - }, - customInstructions: "", - signal: new AbortController().signal, - }; - const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({ - sessionManager, - event: mockEvent, - apiKey: "dummy", - }); - - const compaction = expectCompactionResult(result); - expect(compaction.summary).toContain("No prior history."); - expect(mockSummarizeInStages).not.toHaveBeenCalled(); - expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled(); - }); + const compaction = expectCompactionResult(result); + if (completed) { + expect(compaction.summary).toContain("No prior history."); + expect(mockSummarizeInStages).not.toHaveBeenCalled(); + expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled(); + return; + } + expect(compaction.summary).toContain("branch summary"); + expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); + expect(getApiKeyAndHeadersMock).toHaveBeenCalledTimes(1); + const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); + expect( + requireArray(summarizeCall.messages).map((message) => requireRecord(message).role), + ).toEqual(["user", "assistant"]); + }, + ); it.each([ { toolName: "read", expectedRoles: ["user", "assistant", "toolResult"] }, diff --git a/src/agents/agent-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts index 3eb63ff3cd5f..636e1a1ff647 100644 --- a/src/agents/agent-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { extractSections } from "../../auto-reply/reply/post-compaction-context.js"; import { isAbortError } from "../../infra/abort-signal.js"; @@ -14,10 +15,8 @@ import { } from "../../plugins/compaction-provider.js"; import { normalizeInputProvenance } from "../../sessions/input-provenance.js"; import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js"; -import { - buildHistoryPrunePlanWithWorker, - computeAdaptiveChunkRatioWithWorker, -} from "../compaction-planning-worker.js"; +import { computeAdaptiveChunkRatioWithWorker } from "../compaction-planning-worker.js"; +import { buildHistoryPrunePlan } from "../compaction-planning.js"; import { hasMeaningfulConversationContent, isRealConversationMessage, @@ -44,10 +43,7 @@ import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES, readWorkspaceBootstrapFile, } from "../workspace-bootstrap-read.js"; -import { - composeSplitTurnInstructions, - resolveCompactionInstructions, -} from "./compaction-instructions.js"; +import { resolveCompactionInstructions } from "./compaction-instructions.js"; import { appendSummarySection, auditSummaryQuality, @@ -87,19 +83,6 @@ const compactionSafeguardDeps = { summarizeInStages, }; -function buildPreviousSummaryMessage(previousSummary: string): AgentMessage { - return { - role: "user", - content: [ - { - type: "text", - text: `\n${PREVIOUS_SUMMARY_REDISTILL_PREFIX}\n\n${previousSummary.trim()}\n`, - }, - ], - timestamp: 0, - } as AgentMessage; -} - function prependPreviousSummaryForRedistill(params: { messages: AgentMessage[]; previousSummary?: string; @@ -108,27 +91,27 @@ function prependPreviousSummaryForRedistill(params: { if (!previousSummary) { return params.messages; } - return [buildPreviousSummaryMessage(previousSummary), ...params.messages]; + return [ + { + role: "user", + content: [ + { + type: "text", + text: `\n${PREVIOUS_SUMMARY_REDISTILL_PREFIX}\n\n${previousSummary}\n`, + }, + ], + timestamp: 0, + } as AgentMessage, + ...params.messages, + ]; } -type SessionBranchEntry = { - type?: unknown; - message?: unknown; - customType?: unknown; - content?: unknown; - display?: unknown; - details?: unknown; - timestamp?: unknown; - summary?: unknown; - fromId?: unknown; -}; - function coerceTimestamp(value: unknown): number { const timestamp = typeof value === "string" ? Date.parse(value) : value; return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : 0; } -function sessionBranchEntryToMessage(entry: SessionBranchEntry): unknown { +function sessionBranchEntryToMessage(entry: Record): unknown { if (entry.type === "message" && entry.message && typeof entry.message === "object") { return entry.message; } @@ -154,17 +137,13 @@ function sessionBranchEntryToMessage(entry: SessionBranchEntry): unknown { } function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] { - const getBranch = (sessionManager as { getBranch?: unknown })?.getBranch; - if (typeof getBranch !== "function") { - return []; - } try { - const entries: unknown = getBranch.call(sessionManager); + const entries: unknown = (sessionManager as { getBranch?: () => unknown })?.getBranch?.(); return Array.isArray(entries) ? entries.flatMap((entry) => { const message = entry && typeof entry === "object" - ? sessionBranchEntryToMessage(entry as SessionBranchEntry) + ? sessionBranchEntryToMessage(entry as Record) : undefined; return message ? [message as AgentMessage] : []; }) @@ -174,42 +153,27 @@ function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] { } } -function isReplayUnsafeInterSessionInput(message: AgentMessage): boolean { - if ((message as { role?: unknown }).role !== "user") { - return false; - } - const provenance = normalizeInputProvenance((message as { provenance?: unknown }).provenance); - return provenance?.kind === "inter_session" && provenance.sourceTool === "sessions_send"; -} - function isSessionsSendToolName(value: unknown): boolean { - if (typeof value !== "string") { - return false; - } return ( - value - .trim() - .toLowerCase() + normalizeOptionalString(value) + ?.toLowerCase() .replace(/^(?:functions?|tools?)[./_-]/, "") === "sessions_send" ); } function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] { - const sendCallIds = new Set(); + const sendCallIds = new Set( + messages.flatMap((message) => + message.role === "assistant" + ? extractToolCallsFromAssistant(message) + .filter((call) => isSessionsSendToolName(call.name)) + .map((call) => call.id.trim()) + .filter(Boolean) + : [], + ), + ); const resultTextByCallId = new Map(); - for (const message of messages) { - if (message.role !== "assistant") { - continue; - } - for (const call of extractToolCallsFromAssistant(message)) { - const callId = call.id.trim(); - if (callId && isSessionsSendToolName(call.name)) { - sendCallIds.add(callId); - } - } - } - for (const message of messages) { if (message.role !== "toolResult") { continue; @@ -249,14 +213,10 @@ function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] { const resultText = callId ? resultTextByCallId.get(callId) : undefined; const resolved = Boolean(callId && resultTextByCallId.has(callId)); const requestText = JSON.stringify({ callId: callId || undefined, args: record.arguments }); + const resultSuffix = resolved ? `\nResult: ${resultText || "[empty]"}` : ""; return { type: "text", - text: - resolved && resultText - ? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: ${resultText}` - : resolved - ? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: [empty]` - : `sessions_send result missing; delivery call omitted from replay.\nRequest: ${requestText}`, + text: `sessions_send result ${resolved ? "received" : "missing"}; delivery call omitted from replay.\nRequest: ${requestText}${resultSuffix}`, }; }); return replaced ? [{ ...message, content } as AgentMessage] : [message]; @@ -296,6 +256,10 @@ function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): Agen return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type); })); const activeInput = sanitizedMessages[turnStart - 1]; + const activeInputProvenance = + activeInput?.role === "user" + ? normalizeInputProvenance((activeInput as { provenance?: unknown }).provenance) + : undefined; // A completed sessions_send target run is already delivered to its caller. // Require terminal text so compaction after tool output can still recover unfinished work. @@ -303,8 +267,8 @@ function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): Agen endsWithTerminalAssistantText && turnStart < sanitizedMessages.length && turnStart > 0 && - activeInput !== undefined && - isReplayUnsafeInterSessionInput(activeInput) + activeInputProvenance?.kind === "inter_session" && + activeInputProvenance.sourceTool === "sessions_send" ) { return sanitizedMessages.slice(0, turnStart - 1); } @@ -348,16 +312,13 @@ function assembleSuffix(parts: { fileOpsSummary?: string; workspaceContext?: string; }): string { - let suffix = Object.values(parts).reduce( + const suffix = Object.values(parts).reduce( (summary, section) => appendSummarySection(summary, section ?? ""), "", ); // Ensure leading separator so suffix does not merge with body (e.g. when body // ends without newline: "...## Exact identifiers## Tool Failures"). - if (suffix && !/^\s/.test(suffix)) { - suffix = `\n\n${suffix}`; - } - return suffix; + return suffix && !/^\s/.test(suffix) ? `\n\n${suffix}` : suffix; } type ToolFailure = { @@ -447,51 +408,42 @@ function buildCompactionSummaryHeaders(params: { }; } -function clampNonNegativeInt(value: unknown, fallback: number): number { +function clampNonNegativeInt( + value: unknown, + fallback: number, + max = Number.POSITIVE_INFINITY, +): number { const normalized = typeof value === "number" && Number.isFinite(value) ? value : fallback; - return Math.max(0, Math.floor(normalized)); + return Math.min(max, Math.max(0, Math.floor(normalized))); } function resolveRecentTurnsPreserve(value: unknown): number { - return Math.min( - MAX_RECENT_TURNS_PRESERVE, - clampNonNegativeInt(value, DEFAULT_RECENT_TURNS_PRESERVE), - ); + return clampNonNegativeInt(value, DEFAULT_RECENT_TURNS_PRESERVE, MAX_RECENT_TURNS_PRESERVE); } function resolveQualityGuardMaxRetries(value: unknown): number { - return Math.min( + return clampNonNegativeInt( + value, + DEFAULT_QUALITY_GUARD_MAX_RETRIES, MAX_QUALITY_GUARD_MAX_RETRIES, - clampNonNegativeInt(value, DEFAULT_QUALITY_GUARD_MAX_RETRIES), ); } -function normalizeFailureText(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -function truncateFailureText(text: string, maxChars: number): string { - if (text.length <= maxChars) { - return text; - } - return `${truncateUtf16Safe(text, Math.max(0, maxChars - 3))}...`; -} - function formatToolFailureMeta(details: unknown): string | undefined { if (!details || typeof details !== "object") { return undefined; } const record = details as Record; - const status = typeof record.status === "string" ? record.status : undefined; - const exitCode = - typeof record.exitCode === "number" && Number.isFinite(record.exitCode) - ? record.exitCode - : undefined; - const parts = [ - status ? `status=${status}` : "", - exitCode !== undefined ? `exitCode=${exitCode}` : "", - ]; - return parts.filter(Boolean).join(" ") || undefined; + return ( + [ + typeof record.status === "string" && record.status ? `status=${record.status}` : "", + typeof record.exitCode === "number" && Number.isFinite(record.exitCode) + ? `exitCode=${record.exitCode}` + : "", + ] + .filter(Boolean) + .join(" ") || undefined + ); } function collectToolFailures(messages: AgentMessage[]): ToolFailure[] { @@ -530,13 +482,14 @@ function collectToolFailures(messages: AgentMessage[]): ToolFailure[] { typeof toolResult.toolName === "string" && toolResult.toolName.trim() ? toolResult.toolName : "tool"; - const rawText = collectTextContentBlocks(toolResult.content).join("\n"); const meta = formatToolFailureMeta(toolResult.details); - const normalized = normalizeFailureText(rawText); - const summary = truncateFailureText( - normalized || (meta ? "failed" : "failed (no output)"), - MAX_TOOL_FAILURE_CHARS, - ); + const failureText = + collectTextContentBlocks(toolResult.content).join("\n").replace(/\s+/g, " ").trim() || + (meta ? "failed" : "failed (no output)"); + const summary = + failureText.length > MAX_TOOL_FAILURE_CHARS + ? `${truncateUtf16Safe(failureText, MAX_TOOL_FAILURE_CHARS - 3)}...` + : failureText; failures.push({ toolCallId, toolName, summary, meta }); } @@ -671,10 +624,7 @@ function extractMessageText(message: AgentMessage): string { } function formatNonTextPlaceholder(content: unknown): string | null { - if (content === null || content === undefined) { - return null; - } - if (typeof content === "string") { + if (content == null || typeof content === "string") { return null; } if (!Array.isArray(content)) { @@ -692,22 +642,21 @@ function formatNonTextPlaceholder(content: unknown): string | null { } typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1); } - if (typeCounts.size === 0) { - return null; - } - const parts = [...typeCounts.entries()].map(([type, count]) => - count > 1 ? `${type} x${count}` : type, - ); - return `[non-text content: ${parts.join(", ")}]`; + return typeCounts.size > 0 + ? `[non-text content: ${Array.from(typeCounts, ([type, count]) => + count > 1 ? `${type} x${count}` : type, + ).join(", ")}]` + : null; } function splitPreservedRecentTurns(params: { messages: AgentMessage[]; recentTurnsPreserve: number; }): { summarizableMessages: AgentMessage[]; preservedMessages: AgentMessage[] } { - const preserveTurns = Math.min( + const preserveTurns = clampNonNegativeInt( + params.recentTurnsPreserve, + 0, MAX_RECENT_TURNS_PRESERVE, - clampNonNegativeInt(params.recentTurnsPreserve, 0), ); if (preserveTurns <= 0) { return { summarizableMessages: params.messages, preservedMessages: [] }; @@ -722,18 +671,13 @@ function splitPreservedRecentTurns(params: { const userIndexes = conversationIndexes.filter( (index) => params.messages[index]?.role === "user", ); - const preservedIndexSet = new Set(); - if (userIndexes.length >= preserveTurns) { - const boundaryStartIndex = userIndexes[userIndexes.length - preserveTurns] ?? -1; - for (const index of conversationIndexes) { - if (index >= boundaryStartIndex) { - preservedIndexSet.add(index); - } - } - } else { - for (const userIndex of userIndexes) { - preservedIndexSet.add(userIndex); - } + const boundaryStartIndex = userIndexes.at(-preserveTurns); + const preservedIndexSet = new Set( + boundaryStartIndex === undefined + ? userIndexes + : conversationIndexes.filter((index) => index >= boundaryStartIndex), + ); + if (boundaryStartIndex === undefined) { for (const index of conversationIndexes.toReversed()) { preservedIndexSet.add(index); if (preservedIndexSet.size >= preserveTurns * 2) { @@ -791,12 +735,12 @@ function formatContextMessages(messages: AgentMessage[]): string[] { } else { return null; } - const text = extractMessageText(message); - const nonTextPlaceholder = formatNonTextPlaceholder( - (message as { content?: unknown }).content, - ); - const rendered = - text && nonTextPlaceholder ? `${text}\n${nonTextPlaceholder}` : text || nonTextPlaceholder; + const rendered = [ + extractMessageText(message), + formatNonTextPlaceholder((message as { content?: unknown }).content), + ] + .filter(Boolean) + .join("\n"); if (!rendered) { return null; } @@ -1107,14 +1051,13 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { let droppedSummary: string | undefined; if (tokensBefore !== undefined) { - const prunePlan = await buildHistoryPrunePlanWithWorker({ + const prunePlan = buildHistoryPrunePlan({ messagesToSummarize, turnPrefixMessages, tokensBefore, contextWindowTokens, maxHistoryShare, parts: 2, - signal, }); const { newContentTokens, maxHistoryTokens, pruned } = prunePlan; @@ -1171,18 +1114,15 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }); messagesToSummarize = summaryTargetMessages; const preservedTurnsSectionLocal = formatPreservedTurnsSection(preservedRecentMessages); - const latestUserAsk = extractLatestUserAsk([...messagesToSummarize, ...turnPrefixMessages]); - const identifierSeedText = [...messagesToSummarize, ...turnPrefixMessages] - .slice(-10) - .map((message) => extractMessageText(message)) - .filter(Boolean) - .join("\n"); - const identifiers = extractOpaqueIdentifiers(identifierSeedText); + const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; + const latestUserAsk = extractLatestUserAsk(allMessages); + const identifiers = extractOpaqueIdentifiers( + allMessages.slice(-10).map(extractMessageText).filter(Boolean).join("\n"), + ); // Use adaptive chunk ratio based on message sizes, reserving headroom for // the summarization prompt, system prompt, previous summary, and reasoning budget // that generateSummary adds on top of the serialized conversation chunk. - const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; const adaptiveRatio = await computeAdaptiveChunkRatioWithWorker({ messages: allMessages, contextWindow: contextWindowTokens, @@ -1196,7 +1136,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { // incorporates context from pruned messages instead of losing it entirely. const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary; - let summary = ""; let lastHistorySummary = ""; let lastSplitTurnSection = ""; let currentInstructions = structuredInstructions; @@ -1218,7 +1157,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { customInstructions: currentInstructions, previousSummary: effectivePreviousSummary, }) - : buildStructuredFallbackSummary(effectivePreviousSummary, summarizationInstructions); + : buildStructuredFallbackSummary(effectivePreviousSummary); summaryWithoutPreservedTurns = historySummary; if (preparation.isSplitTurn && turnPrefixMessages.length > 0) { @@ -1226,10 +1165,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { ...llmSummaryParams, messages: turnPrefixMessages, maxChunkTokens, - customInstructions: composeSplitTurnInstructions( - TURN_PREFIX_INSTRUCTIONS, - currentInstructions, - ), + customInstructions: `${TURN_PREFIX_INSTRUCTIONS}\n\nAdditional requirements:\n\n${currentInstructions}`, previousSummary: undefined, }); splitTurnSectionLocal = `**Turn Context (split turn):**\n\n${prefixSummary}`; @@ -1247,7 +1183,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { `Compaction safeguard: quality retry failed on attempt ${attempt + 1}; ` + `keeping last successful summary: ${formatErrorMessage(attemptError)}`, ); - summary = lastSuccessfulSummary; break; } throw attemptError; @@ -1260,7 +1195,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { messagesToSummarize.length > 0 || (preparation.isSplitTurn && turnPrefixMessages.length > 0); if (!qualityGuardEnabled || !canRegenerate) { - summary = summaryWithPreservedTurns; break; } const quality = auditSummaryQuality({ @@ -1269,7 +1203,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { latestAsk: latestUserAsk, identifierPolicy, }); - summary = summaryWithPreservedTurns; if (quality.ok || attempt >= totalAttempts - 1) { break; } @@ -1288,7 +1221,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { } // Cap history before suffixes so diagnostics and workspace rules survive. - return await finalizeSummary(lastHistorySummary || summary, { + return await finalizeSummary(lastHistorySummary || lastSuccessfulSummary || "", { splitTurnSection: lastSplitTurnSection, preservedTurnsSection: preservedTurnsSectionLocal, }); diff --git a/src/agents/compaction-planning-projection.ts b/src/agents/compaction-planning-projection.ts index 6825379cd912..2583ab6a93f6 100644 --- a/src/agents/compaction-planning-projection.ts +++ b/src/agents/compaction-planning-projection.ts @@ -126,28 +126,21 @@ function jsonLengthWithin( return length; } -function projectToolArguments( - value: unknown, - budget: ProjectionBudget, -): { value: Record; omittedChars: number; changed: boolean } { +function projectToolArguments(value: unknown, budget: ProjectionBudget): number | undefined { const length = jsonLengthWithin(value, budget.remainingChars); if (length !== undefined) { budget.remainingChars -= length; - return { value: {}, omittedChars: 0, changed: false }; + return undefined; } budget.remainingChars = 0; - return { - value: {}, - // Unmeasurable arguments must force an oversized plan, never understate token pressure. - omittedChars: - jsonLengthWithin(value, MAX_ARGUMENT_ESTIMATE_CHARS) ?? UNMEASURABLE_ARGUMENT_OMITTED_CHARS, - changed: true, - }; + // Unmeasurable arguments must force an oversized plan, never understate token pressure. + return ( + jsonLengthWithin(value, MAX_ARGUMENT_ESTIMATE_CHARS) ?? UNMEASURABLE_ARGUMENT_OMITTED_CHARS + ); } function projectContentBlock( block: unknown, - projectTextContent: boolean, budget: ProjectionBudget, ): { block: unknown; omittedChars: number; changed: boolean } { if (!block || typeof block !== "object") { @@ -162,9 +155,6 @@ function projectContentBlock( changed: true, }; } - if (!projectTextContent) { - return { block, omittedChars: 0, changed: false }; - } const hasText = typeof record.text === "string" && record.text.length > 0; const textIsModelVisible = type === "text" || ((type === "toolResult" || type === "tool_result") && hasText); @@ -172,50 +162,41 @@ function projectContentBlock( (type === "toolResult" || type === "tool_result") && !hasText && typeof record.content === "string"; - const projectedText = typeof record.text === "string" ? projectText(record.text, budget) : null; - const projectedContent = - typeof record.content === "string" ? projectText(record.content, budget) : null; - const projectedThinking = - type === "thinking" && typeof record.thinking === "string" - ? projectText(record.thinking, budget) - : null; - const projectedArguments = - type === "toolCall" ? projectToolArguments(record.arguments, budget) : undefined; - // Tool-call IDs are provider-generated and bounded in practice. Planning never uses them as - // durable keys, so malformed giant IDs do not justify an ID-remapping protocol here. - const hasPlanningIrrelevantSignature = - "textSignature" in record || "thinkingSignature" in record || "thoughtSignature" in record; - if ( - !projectedText && - !projectedContent && - !projectedThinking && - !projectedArguments?.changed && - !hasPlanningIrrelevantSignature - ) { - return { block, omittedChars: 0, changed: false }; - } - const next = { ...record }; + let next: Record | undefined; let omittedChars = 0; - if (projectedText) { - next.text = projectedText.text; - omittedChars += textIsModelVisible ? projectedText.omittedChars : 0; + for (const field of ["text", "content", "thinking"] as const) { + if (field === "thinking" && type !== "thinking") { + continue; + } + const value = record[field]; + const projected = typeof value === "string" ? projectText(value, budget) : null; + if (!projected) { + continue; + } + next ??= { ...record }; + next[field] = projected.text; + const modelVisible = + field === "thinking" || (field === "text" ? textIsModelVisible : contentIsModelVisible); + omittedChars += modelVisible ? projected.omittedChars : 0; } - if (projectedContent) { - next.content = projectedContent.text; - omittedChars += contentIsModelVisible ? projectedContent.omittedChars : 0; + if (type === "toolCall") { + const omittedArguments = projectToolArguments(record.arguments, budget); + if (omittedArguments !== undefined) { + next ??= { ...record }; + next.arguments = {}; + omittedChars += omittedArguments; + } } - if (projectedThinking) { - next.thinking = projectedThinking.text; - omittedChars += projectedThinking.omittedChars; + // Signatures never contribute model-visible compaction text and can dwarf the planning payload. + for (const signature of ["textSignature", "thinkingSignature", "thoughtSignature"]) { + if (signature in record) { + next ??= { ...record }; + delete next[signature]; + } } - if (projectedArguments?.changed) { - next.arguments = projectedArguments.value; - omittedChars += projectedArguments.omittedChars; - } - delete next.textSignature; - delete next.thinkingSignature; - delete next.thoughtSignature; - return { block: next, omittedChars, changed: true }; + return next + ? { block: next, omittedChars, changed: true } + : { block, omittedChars, changed: false }; } function projectStringFields( @@ -245,43 +226,24 @@ function projectStringFields( } function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentMessage { - const source = (() => { - switch (message.role) { - case "assistant": - return { - role: message.role, - content: message.content, - stopReason: message.stopReason, - timestamp: message.timestamp, - } as AgentMessage; - case "bashExecution": { - const { fullOutputPath: _, ...rest } = message; - return rest as AgentMessage; - } - case "compactionSummary": { - const { details: _, ...rest } = message; - return rest as AgentMessage; - } - case "custom": { - const { details: _, ...rest } = message; - return rest as AgentMessage; - } - default: - return message; - } - })(); - const currentOmittedChars = readCompactionPlanningOmittedChars(source); + let source = message; + if (message.role === "assistant") { + source = { + role: message.role, + content: message.content, + stopReason: message.stopReason, + timestamp: message.timestamp, + } as AgentMessage; + } else if (message.role === "bashExecution") { + const { fullOutputPath: _, ...rest } = message; + source = rest as AgentMessage; + } else if (message.role === "compactionSummary" || message.role === "custom") { + const { details: _, ...rest } = message; + source = rest as AgentMessage; + } const content = (source as { content?: unknown }).content; if (typeof content === "string") { - const projected = projectText(content, budget); - if (!projected) { - return source; - } - return { - ...(source as unknown as Record), - content: projected.text, - [OMITTED_CHARS_FIELD]: currentOmittedChars + projected.omittedChars, - } as unknown as AgentMessage; + return projectStringFields(source, ["content"], budget); } if (!Array.isArray(content)) { switch (source.role) { @@ -298,7 +260,7 @@ function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentM let omittedChars = 0; let changed = false; const projectedContent = content.map((block) => { - const projected = projectContentBlock(block, true, budget); + const projected = projectContentBlock(block, budget); omittedChars += projected.omittedChars; changed ||= projected.changed; return projected.block; @@ -309,7 +271,7 @@ function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentM return { ...(source as unknown as Record), content: projectedContent, - [OMITTED_CHARS_FIELD]: currentOmittedChars + omittedChars, + [OMITTED_CHARS_FIELD]: readCompactionPlanningOmittedChars(source) + omittedChars, } as unknown as AgentMessage; } diff --git a/src/agents/compaction-planning-worker.ts b/src/agents/compaction-planning-worker.ts index b80b28ae5ed0..ed139bcffc55 100644 --- a/src/agents/compaction-planning-worker.ts +++ b/src/agents/compaction-planning-worker.ts @@ -8,14 +8,12 @@ import { Worker } from "node:worker_threads"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { toErrorObject } from "../infra/errors.js"; import { - buildHistoryPrunePlan, buildOversizedFallbackPlan, buildStageSplitPlan, buildSummaryChunks, computeAdaptiveChunkRatio, projectCompactionMessagesForPlanning, sanitizeCompactionMessages, - type HistoryPrunePlan, type OversizedFallbackPlan, type StageSplitPlan, } from "./compaction-planning.js"; @@ -60,13 +58,13 @@ function runCompactionPlanningWorker(params: { timeoutMs?: number; workerUrl?: URL; }): Promise { - if (params.signal?.aborted) { - return Promise.reject( - toErrorObject( - params.signal.reason ?? new Error("compaction planning aborted"), - "Non-Error rejection", - ), + const abortError = () => + toErrorObject( + params.signal?.reason ?? new Error("compaction planning aborted"), + "Non-Error rejection", ); + if (params.signal?.aborted) { + return Promise.reject(abortError()); } const workerUrl = params.workerUrl ?? resolveCompactionPlanningWorkerUrl(); @@ -91,30 +89,11 @@ function runCompactionPlanningWorker(params: { return new Promise((resolve, reject) => { let settled = false; const timeout = setTimeout( - () => { - settle( - () => - reject( - new CompactionPlanningWorkerError("compaction planning worker timed out", "timeout"), - ), - true, - ); - }, + () => + fail(new CompactionPlanningWorkerError("compaction planning worker timed out", "timeout")), resolveTimerTimeoutMs(params.timeoutMs, COMPACTION_PLANNING_WORKER_TIMEOUT_MS), ); - - const abort = () => { - settle( - () => - reject( - toErrorObject( - params.signal?.reason ?? new Error("compaction planning aborted"), - "Non-Error rejection", - ), - ), - true, - ); - }; + const abort = () => fail(abortError()); const settle = (finish: () => void, terminate: boolean) => { if (settled) { @@ -129,6 +108,7 @@ function runCompactionPlanningWorker(params: { } finish(); }; + const fail = (error: Error, terminate = true) => settle(() => reject(error), terminate); params.signal?.addEventListener("abort", abort, { once: true }); @@ -143,20 +123,17 @@ function runCompactionPlanningWorker(params: { }); worker.once("error", (error) => { const message = error instanceof Error ? error.message : String(error); - settle(() => reject(new CompactionPlanningWorkerError(message, "unavailable")), true); + fail(new CompactionPlanningWorkerError(message, "unavailable")); }); worker.once("exit", (code) => { if (code === 0) { return; } - settle( - () => - reject( - new CompactionPlanningWorkerError( - `compaction planning worker exited with code ${code}`, - "unavailable", - ), - ), + fail( + new CompactionPlanningWorkerError( + `compaction planning worker exited with code ${code}`, + "unavailable", + ), false, ); }); @@ -222,14 +199,11 @@ export async function buildSummaryChunksWithWorker(params: { maxChunkTokens: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "summaryChunks", - messages: params.messages, - maxChunkTokens: params.maxChunkTokens, - }, - signal: params.signal, - fallback: (messages) => buildSummaryChunks({ messages, maxChunkTokens: params.maxChunkTokens }), + input: { kind: "summaryChunks", ...planningInput }, + signal, + fallback: (messages) => buildSummaryChunks({ ...planningInput, messages }), restore: (value, messages) => value.chunkIndexes.map((indexes) => restoreIndexedMessages(messages, indexes)), }); @@ -241,15 +215,11 @@ export async function buildOversizedFallbackPlanWithWorker(params: { contextWindow: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "oversizedFallback", - messages: params.messages, - contextWindow: params.contextWindow, - }, - signal: params.signal, - fallback: (messages) => - buildOversizedFallbackPlan({ messages, contextWindow: params.contextWindow }), + input: { kind: "oversizedFallback", ...planningInput }, + signal, + fallback: (messages) => buildOversizedFallbackPlan({ ...planningInput, messages }), restore: (value, messages) => ({ smallMessages: restoreIndexedMessages(messages, value.smallMessageIndexes), oversizedNotes: value.oversizedNotes, @@ -265,22 +235,11 @@ export async function buildStageSplitPlanWithWorker(params: { minMessagesForSplit?: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "stageSplit", - messages: params.messages, - maxChunkTokens: params.maxChunkTokens, - parts: params.parts, - minMessagesForSplit: params.minMessagesForSplit, - }, - signal: params.signal, - fallback: (messages) => - buildStageSplitPlan({ - messages, - maxChunkTokens: params.maxChunkTokens, - parts: params.parts, - minMessagesForSplit: params.minMessagesForSplit, - }), + input: { kind: "stageSplit", ...planningInput }, + signal, + fallback: (messages) => buildStageSplitPlan({ ...planningInput, messages }), restore: (value, messages) => value.mode === "split" ? { @@ -291,38 +250,17 @@ export async function buildStageSplitPlanWithWorker(params: { }); } -/** - * Builds a history-pruning plan on the owner thread. - * - * Pruning repairs tool-result pairs and returns exact retained/dropped messages, - * so a bounded selection projection cannot reconstruct every result faithfully. - */ -export async function buildHistoryPrunePlanWithWorker(params: { - messagesToSummarize: AgentMessage[]; - turnPrefixMessages: AgentMessage[]; - tokensBefore: number; - contextWindowTokens: number; - maxHistoryShare: number; - parts?: number; - signal?: AbortSignal; -}): Promise { - return buildHistoryPrunePlan(params); -} - /** Computes the adaptive compaction chunk ratio with worker fallback. */ export async function computeAdaptiveChunkRatioWithWorker(params: { messages: AgentMessage[]; contextWindow: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "adaptiveChunkRatio", - messages: params.messages, - contextWindow: params.contextWindow, - }, - signal: params.signal, - fallback: () => computeAdaptiveChunkRatio(params.messages, params.contextWindow), + input: { kind: "adaptiveChunkRatio", ...planningInput }, + signal, + fallback: () => computeAdaptiveChunkRatio(planningInput.messages, planningInput.contextWindow), restore: (value) => value.ratio, }); } @@ -330,7 +268,6 @@ export async function computeAdaptiveChunkRatioWithWorker(params: { const compactionPlanningWorkerTesting = { resolveCompactionPlanningWorkerUrl, runCompactionPlanningWorker, - CompactionPlanningWorkerError, }; if (process.env.VITEST || process.env.NODE_ENV === "test") { diff --git a/src/agents/compaction-planning.ts b/src/agents/compaction-planning.ts index a2c80d188788..99f43f1c43ea 100644 --- a/src/agents/compaction-planning.ts +++ b/src/agents/compaction-planning.ts @@ -44,7 +44,7 @@ export type OversizedFallbackPlan = { }; /** Token accounting and optional prune result for preserving context-window headroom. */ -export type HistoryPrunePlan = { +type HistoryPrunePlan = { summarizableTokens: number; newContentTokens: number; maxHistoryTokens: number; @@ -80,11 +80,7 @@ export function sanitizeCompactionMessages(messages: AgentMessage[]): AgentMessa } function estimateCompactionPlanningTokens(message: AgentMessage): number { - const omittedChars = readCompactionPlanningOmittedChars(message); - if (omittedChars === 0) { - return estimateTokens(message); - } - return estimateTokens(message) + Math.ceil(omittedChars / 4); + return estimateTokens(message) + Math.ceil(readCompactionPlanningOmittedChars(message) / 4); } /** Builds a bounded planning projection that preserves token pressure accounting. */ @@ -115,23 +111,9 @@ function groupCompactionMessages( let currentTokens = 0; let pendingToolCallIds = new Set(); - const finishCurrentGroup = () => { - if (current.length === 0) { - return; - } - groups.push({ messages: current, tokens: currentTokens }); - current = []; - currentTokens = 0; - }; - for (const [index, message] of messages.entries()) { - const messageTokens = perMessageTokens.at(index); - if (messageTokens === undefined) { - throw new Error("Compaction token estimates are out of sync with messages"); - } - current.push(message); - currentTokens += messageTokens; + currentTokens += perMessageTokens[index]!; if (message.role === "assistant") { const stopReason = (message as { stopReason?: unknown }).stopReason; @@ -152,41 +134,34 @@ function groupCompactionMessages( // A displaced user turn still belongs to an unfinished call/result batch; // splitting it would make one of the resulting provider transcripts invalid. if (pendingToolCallIds.size === 0) { - finishCurrentGroup(); + groups.push({ messages: current, tokens: currentTokens }); + current = []; + currentTokens = 0; } } - finishCurrentGroup(); + if (current.length > 0) { + groups.push({ messages: current, tokens: currentTokens }); + } return groups; } -/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */ -function splitMessagesByTokenShare( +/** Chunks atomic tool-call groups without splitting a provider-visible call/result pair. */ +function chunkCompactionMessageGroups( messages: AgentMessage[], - parts = DEFAULT_PARTS, + maxTokens: number, + perMessageTokens: number[], + maxChunks = Number.POSITIVE_INFINITY, ): AgentMessage[][] { - if (messages.length === 0) { - return []; - } - const normalizedParts = normalizeCompactionParts(parts, messages.length); - if (normalizedParts <= 1) { - return [messages]; - } - - // Sanitize the full array once and reuse per-message token counts; avoids the - // per-message [msg] wrap-and-clone that previously ran on every iteration. - const perMessageTokens = estimatePerMessageTokens(messages); - const totalTokens = perMessageTokens.reduce((sum, tokens) => sum + tokens, 0); - const targetTokens = totalTokens / normalizedParts; const chunks: AgentMessage[][] = []; let current: AgentMessage[] = []; let currentTokens = 0; for (const group of groupCompactionMessages(messages, perMessageTokens)) { if ( - chunks.length < normalizedParts - 1 && current.length > 0 && - currentTokens + group.tokens > targetTokens + chunks.length < maxChunks - 1 && + currentTokens + group.tokens > maxTokens ) { chunks.push(current); current = []; @@ -204,47 +179,26 @@ function splitMessagesByTokenShare( return chunks; } -/** Chunks messages by a max-token budget while applying the shared estimator safety margin. */ -function chunkMessagesByMaxTokens(messages: AgentMessage[], maxTokens: number): AgentMessage[][] { +/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */ +function splitMessagesByTokenShare( + messages: AgentMessage[], + parts = DEFAULT_PARTS, +): AgentMessage[][] { if (messages.length === 0) { return []; } - - // Apply safety margin to compensate for estimateTokens() underestimation - // (chars/4 heuristic misses multi-byte chars, special tokens, code tokens, etc.) - const effectiveMax = Math.max(1, Math.floor(maxTokens / SAFETY_MARGIN)); - - // Sanitize the full array once and reuse per-message token counts; avoids the - // per-message [msg] wrap-and-clone that previously ran on every iteration. + const normalizedParts = normalizeCompactionParts(parts, messages.length); + if (normalizedParts <= 1) { + return [messages]; + } const perMessageTokens = estimatePerMessageTokens(messages); - const chunks: AgentMessage[][] = []; - let currentChunk: AgentMessage[] = []; - let currentTokens = 0; - - for (const group of groupCompactionMessages(messages, perMessageTokens)) { - if (currentChunk.length > 0 && currentTokens + group.tokens > effectiveMax) { - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - - currentChunk.push(...group.messages); - currentTokens += group.tokens; - - if (group.tokens > effectiveMax) { - // A tool batch is indivisible even above the heuristic budget; the - // oversized-summary fallback can handle it without orphaning its result. - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - } - - if (currentChunk.length > 0) { - chunks.push(currentChunk); - } - - return chunks; + const totalTokens = perMessageTokens.reduce((sum, tokens) => sum + tokens, 0); + return chunkCompactionMessageGroups( + messages, + totalTokens / normalizedParts, + perMessageTokens, + normalizedParts, + ); } /** @@ -256,12 +210,8 @@ export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindo return BASE_CHUNK_RATIO; } - const totalTokens = estimateMessagesTokens(messages); - const avgTokens = totalTokens / messages.length; - - // Apply safety margin to account for estimation inaccuracy - const safeAvgTokens = avgTokens * SAFETY_MARGIN; - const avgRatio = safeAvgTokens / contextWindow; + const avgRatio = + ((estimateMessagesTokens(messages) / messages.length) * SAFETY_MARGIN) / contextWindow; // If average message is > 10% of context, reduce chunk ratio if (avgRatio > 0.1) { @@ -285,7 +235,13 @@ export function buildSummaryChunks(params: { }): AgentMessage[][] { // SECURITY: never feed toolResult.details or runtime-context transcript entries into summarization prompts. const safeMessages = sanitizeCompactionMessages(params.messages); - return chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens); + // The estimator can undercount Unicode/code tokens; indivisible tool batches may exceed this cap. + const effectiveMax = Math.max(1, Math.floor(params.maxChunkTokens / SAFETY_MARGIN)); + return chunkCompactionMessageGroups( + safeMessages, + effectiveMax, + estimatePerMessageTokens(safeMessages), + ); } /** Separates messages too large to summarize and emits compact placeholder notes for them. */ @@ -355,9 +311,8 @@ export function buildStageSplitPlan(params: { function pruneHistoryForContextShare(params: { messages: AgentMessage[]; maxContextTokens: number; - maxHistoryShare?: number; + maxHistoryShare: number; parts?: number; - mode?: "share" | "handoff"; }): { messages: AgentMessage[]; droppedMessagesList: AgentMessage[]; @@ -367,10 +322,7 @@ function pruneHistoryForContextShare(params: { keptTokens: number; budgetTokens: number; } { - const isHandoff = params.mode === "handoff"; - const defaultShare = isHandoff ? 0.2 : 0.5; // Stricter budget for handoff snapshots - const maxHistoryShare = params.maxHistoryShare ?? defaultShare; - const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare)); + const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * params.maxHistoryShare)); let keptMessages = params.messages; const allDroppedMessages: AgentMessage[] = []; let droppedChunks = 0; @@ -384,31 +336,15 @@ function pruneHistoryForContextShare(params: { if (chunks.length <= 1) { break; } - const [dropped, ...rest] = chunks; - if (!dropped) { - break; - } - const flatRest = rest.flat(); - - // After dropping a chunk, repair tool_use/tool_result pairing to handle - // orphaned tool_results (whose tool_use was in the dropped chunk). - // repairToolUseResultPairing drops orphaned tool_results, preventing - // "unexpected tool_use_id" errors from Anthropic's API. - const repairReport = repairToolUseResultPairing(flatRest); - const repairedKept = repairReport.messages; - - // Track orphaned tool_results as dropped (they were in kept but their tool_use was dropped) - const orphanedCount = repairReport.droppedOrphanCount; + const dropped = chunks[0]!; + // Dropping a call owner also drops orphaned results; providers reject replay without the pair. + const repairReport = repairToolUseResultPairing(chunks.slice(1).flat()); droppedChunks += 1; - droppedMessages += dropped.length + orphanedCount; + droppedMessages += dropped.length + repairReport.droppedOrphanCount; droppedTokens += estimateMessagesTokens(dropped); - // Note: We don't have the actual orphaned messages to add to droppedMessagesList - // since repairToolUseResultPairing doesn't return them. This is acceptable since - // the dropped messages are used for summarization, and orphaned tool_results - // without their tool_use context aren't useful for summarization anyway. allDroppedMessages.push(...dropped); - keptMessages = repairedKept; + keptMessages = repairReport.messages; } return { @@ -440,23 +376,16 @@ export function buildHistoryPrunePlan(params: { params.contextWindowTokens * params.maxHistoryShare * SAFETY_MARGIN, ); - if (newContentTokens <= maxHistoryTokens) { - return { - summarizableTokens, - newContentTokens, - maxHistoryTokens, - }; - } - - return { - summarizableTokens, - newContentTokens, - maxHistoryTokens, - pruned: pruneHistoryForContextShare({ - messages: params.messagesToSummarize, - maxContextTokens: params.contextWindowTokens, - maxHistoryShare: params.maxHistoryShare, - parts: params.parts, - }), - }; + const plan = { summarizableTokens, newContentTokens, maxHistoryTokens }; + return newContentTokens <= maxHistoryTokens + ? plan + : { + ...plan, + pruned: pruneHistoryForContextShare({ + messages: params.messagesToSummarize, + maxContextTokens: params.contextWindowTokens, + maxHistoryShare: params.maxHistoryShare, + parts: params.parts, + }), + }; } diff --git a/src/agents/compaction-planning.worker.ts b/src/agents/compaction-planning.worker.ts index e3867d5cc0d0..ed4d9606fc67 100644 --- a/src/agents/compaction-planning.worker.ts +++ b/src/agents/compaction-planning.worker.ts @@ -12,23 +12,9 @@ import type { AgentMessage } from "./runtime/index.js"; /** Serializable request accepted by the compaction planning worker. */ export type CompactionPlanningWorkerInput = - | { - kind: "summaryChunks"; - messages: AgentMessage[]; - maxChunkTokens: number; - } - | { - kind: "oversizedFallback"; - messages: AgentMessage[]; - contextWindow: number; - } - | { - kind: "stageSplit"; - messages: AgentMessage[]; - maxChunkTokens: number; - parts?: number; - minMessagesForSplit?: number; - } + | ({ kind: "summaryChunks" } & Parameters[0]) + | ({ kind: "oversizedFallback" } & Parameters[0]) + | ({ kind: "stageSplit" } & Parameters[0]) | { kind: "adaptiveChunkRatio"; messages: AgentMessage[]; @@ -46,15 +32,7 @@ export type CompactionPlanningWorkerValue = smallMessageIndexes: number[]; oversizedNotes: string[]; } - | { - kind: "stageSplit"; - mode: "single"; - } - | { - kind: "stageSplit"; - mode: "split"; - chunkIndexes: number[][]; - } + | ({ kind: "stageSplit" } & ({ mode: "single" } | { mode: "split"; chunkIndexes: number[][] })) | { kind: "adaptiveChunkRatio"; ratio: number; @@ -142,19 +120,13 @@ function planCompactionWorkerInput( /** Run one compaction planning request and return a serializable result. */ export function runCompactionPlanningWorkerInput(input: unknown): CompactionPlanningWorkerResult { if (!isWorkerInput(input)) { - return { - status: "failed", - error: "invalid compaction planning worker input", - }; + return { status: "failed", error: "invalid compaction planning worker input" }; } try { return { status: "ok", value: planCompactionWorkerInput(input) }; } catch (error) { - return { - status: "failed", - error: error instanceof Error ? error.message : String(error), - }; + return { status: "failed", error: error instanceof Error ? error.message : String(error) }; } } diff --git a/src/agents/compaction-real-conversation.ts b/src/agents/compaction-real-conversation.ts index edadf01cb876..c2e9cd162f89 100644 --- a/src/agents/compaction-real-conversation.ts +++ b/src/agents/compaction-real-conversation.ts @@ -17,17 +17,11 @@ const NON_CONVERSATION_BLOCK_TYPES = new Set([ function hasMeaningfulText(text: string): boolean { const trimmed = text.trim(); - if (!trimmed) { - return false; - } - if (isSilentReplyText(trimmed)) { + if (!trimmed || isSilentReplyText(trimmed)) { return false; } const heartbeat = stripHeartbeatToken(trimmed, { mode: "message" }); - if (heartbeat.didStrip) { - return heartbeat.text.trim().length > 0; - } - return true; + return !heartbeat.didStrip || heartbeat.text.trim().length > 0; } function isSummaryRole(role: unknown): boolean { @@ -73,22 +67,15 @@ function hasMeaningfulMessageContent(content: unknown): boolean { if (!block || typeof block !== "object") { continue; } - const type = (block as { type?: unknown }).type; - if (type !== "text") { + const { type, text } = block as { type?: unknown; text?: unknown }; + if (type === "text") { + if (typeof text === "string" && hasMeaningfulText(text)) { + return true; + } + } else if (typeof type !== "string" || !NON_CONVERSATION_BLOCK_TYPES.has(type)) { // Tool-call metadata and internal reasoning blocks do not make a // heartbeat-only transcript count as real conversation. - if (typeof type === "string" && NON_CONVERSATION_BLOCK_TYPES.has(type)) { - continue; - } sawMeaningfulNonTextBlock = true; - continue; - } - const text = (block as { text?: unknown }).text; - if (typeof text !== "string") { - continue; - } - if (hasMeaningfulText(text)) { - return true; } } return sawMeaningfulNonTextBlock; @@ -123,10 +110,7 @@ export function isRealConversationMessage( const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK); for (let i = index - 1; i >= start; i -= 1) { const candidate = messages[i]; - if (!candidate) { - continue; - } - if (isToolResultConversationAnchor(candidate)) { + if (candidate && isToolResultConversationAnchor(candidate)) { return true; } } diff --git a/src/agents/compaction-usage.ts b/src/agents/compaction-usage.ts index 22584ba9d3e1..e4993ce034f2 100644 --- a/src/agents/compaction-usage.ts +++ b/src/agents/compaction-usage.ts @@ -6,16 +6,8 @@ import type { AgentMessage } from "./runtime/index.js"; import { makeZeroUsageSnapshot } from "./usage.js"; function parseCompactionUsageTimestamp(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return null; + const timestamp = typeof value === "string" ? Date.parse(value) : value; + return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : null; } export function stripStaleAssistantUsageBeforeLatestCompaction( @@ -25,56 +17,46 @@ export function stripStaleAssistantUsageBeforeLatestCompaction entry?.role === "compactionSummary", + ); const hasCompactionSummary = latestCompactionSummaryIndex !== -1; if (!hasCompactionSummary && options.whenMissingCompactionSummary !== "zeroAssistantUsage") { return messages; } - const out = options.mutate ? messages : [...messages]; - let touched = false; - for (let i = 0; i < out.length; i += 1) { - const candidate = out[i] as + const latestCompactionTimestamp = parseCompactionUsageTimestamp( + (messages[latestCompactionSummaryIndex] as { timestamp?: unknown } | undefined)?.timestamp, + ); + let out = messages; + for (let i = 0; i < messages.length; i += 1) { + const candidate = messages[i] as | (AgentMessage & { usage?: unknown; timestamp?: unknown }) | undefined; - if (!candidate || candidate.role !== "assistant") { - continue; - } - if (!candidate.usage || typeof candidate.usage !== "object") { + if ( + candidate?.role !== "assistant" || + !candidate.usage || + typeof candidate.usage !== "object" + ) { continue; } const messageTimestamp = parseCompactionUsageTimestamp(candidate.timestamp); - const compactionTimestamp = latestCompactionTimestamp; - const hasTimestampBoundary = - hasCompactionSummary && compactionTimestamp !== null && messageTimestamp !== null; - const staleByMissingSummary = !hasCompactionSummary; - const staleByTimestamp = hasTimestampBoundary && messageTimestamp <= compactionTimestamp; - const staleByLegacyOrdering = - hasCompactionSummary && !hasTimestampBoundary && i < latestCompactionSummaryIndex; - if (!staleByMissingSummary && !staleByTimestamp && !staleByLegacyOrdering) { + const stale = + !hasCompactionSummary || + (latestCompactionTimestamp !== null && messageTimestamp !== null + ? messageTimestamp <= latestCompactionTimestamp + : i < latestCompactionSummaryIndex); + if (!stale) { continue; } // Session runtime expects assistant usage to stay structurally valid during // accounting. Keep stale snapshots present, but zeroed after compaction. - const candidateRecord = candidate as unknown as Record; - out[i] = { - ...candidateRecord, - usage: makeZeroUsageSnapshot(), - } as unknown as TMessage; - touched = true; + if (out === messages && !options.mutate) { + out = [...messages]; + } + out[i] = { ...candidate, usage: makeZeroUsageSnapshot() } as TMessage; } - return touched ? out : messages; + return out; } diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index 270a711b4390..47d3e30456fb 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -26,7 +26,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; import { isTimeoutError } from "./failover-error.js"; import type { AgentMessage, StreamFn, ThinkingLevel } from "./runtime/index.js"; import type { ExtensionContext } from "./sessions/index.js"; -import { generateSummary as agentGenerateSummary } from "./sessions/index.js"; +import { generateSummary } from "./sessions/index.js"; export { BASE_CHUNK_RATIO, @@ -71,18 +71,31 @@ export type CompactionSummarizationInstructions = { identifierInstructions?: string; }; +type CompactionSummaryParams = { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + headers?: Record; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + contextWindow: number; + customInstructions?: string; + summarizationInstructions?: CompactionSummarizationInstructions; + previousSummary?: string; + thinkingLevel?: ThinkingLevel; + streamFn?: StreamFn; +}; + function resolveIdentifierPreservationInstructions( instructions?: CompactionSummarizationInstructions, ): string | undefined { - const policy = instructions?.identifierPolicy ?? "strict"; - if (policy === "off") { + if (instructions?.identifierPolicy === "off") { return undefined; } - if (policy === "custom") { - const custom = instructions?.identifierInstructions?.trim(); - return custom && custom.length > 0 ? custom : IDENTIFIER_PRESERVATION_INSTRUCTIONS; - } - return IDENTIFIER_PRESERVATION_INSTRUCTIONS; + return instructions?.identifierPolicy === "custom" + ? instructions.identifierInstructions?.trim() || IDENTIFIER_PRESERVATION_INSTRUCTIONS + : IDENTIFIER_PRESERVATION_INSTRUCTIONS; } /** Combines identifier-preservation and caller-provided compaction instructions. */ @@ -92,32 +105,15 @@ function buildCompactionSummarizationInstructions( ): string | undefined { const custom = customInstructions?.trim(); const identifierPreservation = resolveIdentifierPreservationInstructions(instructions); - if (!identifierPreservation && !custom) { - return undefined; - } if (!custom) { return identifierPreservation; } - if (!identifierPreservation) { - return `Additional focus:\n${custom}`; - } - return `${identifierPreservation}\n\nAdditional focus:\n${custom}`; + return identifierPreservation + ? `${identifierPreservation}\n\nAdditional focus:\n${custom}` + : `Additional focus:\n${custom}`; } -async function summarizeChunks(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - headers?: Record; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - customInstructions?: string; - summarizationInstructions?: CompactionSummarizationInstructions; - previousSummary?: string; - thinkingLevel?: ThinkingLevel; - streamFn?: StreamFn; -}): Promise { +async function summarizeChunks(params: CompactionSummaryParams): Promise { if (params.messages.length === 0) { return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; } @@ -132,8 +128,7 @@ async function summarizeChunks(params: { params.customInstructions, params.summarizationInstructions, ); - let hasGeneratedChunk = false; - for (const chunk of chunks) { + for (const [completedChunks, chunk] of chunks.entries()) { try { summary = await retryAsync( () => @@ -158,43 +153,26 @@ async function summarizeChunks(params: { // Backoff must honor caller cancellation; otherwise an abort during // the sleep would stall compaction until the full delay elapses. sleep: (ms) => sleepWithAbort(ms, params.signal), - shouldRetry: (err) => { - // Stop retrying when the caller explicitly cancelled. - if (params.signal.aborted) { - return false; - } - // Preserve existing non-retry policy for real network/transport - // timeouts (e.g. "fetch failed", ETIMEDOUT) that are not AbortErrors. - if (!isAbortError(err) && isTimeoutError(err)) { - return false; - } - // Provider-side AbortErrors with signal not yet aborted are - // transient disconnects — retrying is correct. - return true; - }, + // Caller aborts and transport timeouts are terminal; provider-side + // AbortErrors without caller cancellation remain retryable. + shouldRetry: (err) => + !params.signal.aborted && (isAbortError(err) || !isTimeoutError(err)), }, ); - hasGeneratedChunk = true; } catch (err) { - // Propagate only when the caller explicitly cancelled. Provider-side - // AbortErrors (signal not aborted) fall through to partial/fallback paths. - if (params.signal.aborted) { - throw err; - } - // Real non-abort transport timeouts still propagate immediately. - if (!isAbortError(err) && isTimeoutError(err)) { - throw err; - } - // No chunk has succeeded yet — rethrow so summarizeWithFallback - // can run its existing "Context contained N messages" fallback. - if (!hasGeneratedChunk) { + // Caller aborts, transport timeouts, and failures before any completed + // chunk cannot produce a recoverable partial summary. + if ( + params.signal.aborted || + (!isAbortError(err) && isTimeoutError(err)) || + completedChunks === 0 + ) { throw err; } // At least one chunk succeeded — throw with the partial summary // attached so summarizeWithFallback can try the oversized-message // retry first and only fall back to the partial summary if that // also fails. - const completedChunks = chunks.indexOf(chunk); log.warn("chunk summarization failed after retries; partial summary available", { err, completedChunks, @@ -210,65 +188,22 @@ async function summarizeChunks(params: { return summary ?? DEFAULT_SUMMARY_FALLBACK; } -function generateSummary( - currentMessages: AgentMessage[], - model: NonNullable, - reserveTokens: number, - apiKey: string, - headers: Record | undefined, - signal: AbortSignal, - customInstructions?: string, - previousSummary?: string, - thinkingLevel?: ThinkingLevel, - streamFn?: StreamFn, -): Promise { - return agentGenerateSummary( - currentMessages, - model, - reserveTokens, - apiKey, - headers, - signal, - customInstructions, - previousSummary, - thinkingLevel, - streamFn, - ); -} - /** * Summarize with progressive fallback for handling oversized messages. * If full summarization fails, tries partial summarization excluding oversized messages. */ -async function summarizeWithFallbackResult(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - headers?: Record; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - contextWindow: number; - customInstructions?: string; - summarizationInstructions?: CompactionSummarizationInstructions; - previousSummary?: string; - thinkingLevel?: ThinkingLevel; - streamFn?: StreamFn; -}): Promise { +async function summarizeWithFallback(params: CompactionSummaryParams): Promise { const { messages, contextWindow } = params; if (messages.length === 0) { - return { - kind: "summary", - text: params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK, - }; + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; } // Try full summarization first let partialSummaryFallback: string | undefined; let lastError: unknown; try { - return { kind: "summary", text: await summarizeChunks(params) }; + return await summarizeChunks(params); } catch (err) { lastError = err; if (params.signal.aborted) { @@ -284,6 +219,7 @@ async function summarizeWithFallbackResult(params: { contextWindow, signal: params.signal, }); + const oversizedSuffix = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; // When nothing was oversized, `smallMessages` is the same transcript as the full attempt. // Re-summarizing it would duplicate the same failing API work (and duplicate warn logs). @@ -293,8 +229,7 @@ async function summarizeWithFallbackResult(params: { ...params, messages: smallMessages, }); - const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; - return { kind: "summary", text: partialSummary + notes }; + return partialSummary + oversizedSuffix; } catch (partialError) { lastError = partialError; if (params.signal.aborted) { @@ -306,15 +241,14 @@ async function summarizeWithFallbackResult(params: { // so the model knows large content was filtered. const retryPartial = (lastError as PartialSummaryError).partialSummary; if (retryPartial) { - const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; - partialSummaryFallback = retryPartial + notes; + partialSummaryFallback = retryPartial + oversizedSuffix; } } } // Final fallback: use best available partial summary, otherwise throw error if (partialSummaryFallback) { - return { kind: "summary", text: partialSummaryFallback }; + return partialSummaryFallback; } // All summarization attempts failed — throw error so caller knows compaction @@ -328,16 +262,10 @@ async function summarizeWithFallbackResult(params: { ); } -async function summarizeWithFallback( - params: Parameters[0], -): Promise { - return (await summarizeWithFallbackResult(params)).text; -} - /** Extracts a compact timestamp range from a chunk of messages for merge metadata. */ function extractChunkTimeRange(chunk: AgentMessage[]): string { - let earliest: number | undefined; - let latest: number | undefined; + let earliest = Number.POSITIVE_INFINITY; + let latest = 0; for (const message of chunk) { const timestamp = message.timestamp; if ( @@ -347,10 +275,10 @@ function extractChunkTimeRange(chunk: AgentMessage[]): string { ) { continue; } - earliest = earliest === undefined ? timestamp : Math.min(earliest, timestamp); - latest = latest === undefined ? timestamp : Math.max(latest, timestamp); + earliest = Math.min(earliest, timestamp); + latest = Math.max(latest, timestamp); } - if (earliest === undefined || latest === undefined) { + if (!Number.isFinite(earliest)) { return ""; } const format = (timestamp: number) => @@ -360,29 +288,15 @@ function extractChunkTimeRange(chunk: AgentMessage[]): string { } /** Summarizes history in multiple stages when a single pass would be too large. */ -export async function summarizeInStages(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - headers?: Record; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - contextWindow: number; - customInstructions?: string; - summarizationInstructions?: CompactionSummarizationInstructions; - previousSummary?: string; - parts?: number; - minMessagesForSplit?: number; - thinkingLevel?: ThinkingLevel; - streamFn?: StreamFn; -}): Promise { +export async function summarizeInStages( + params: CompactionSummaryParams & { + parts?: number; + minMessagesForSplit?: number; + }, +): Promise { const { messages } = params; if (messages.length === 0) { - return { - kind: "summary", - text: params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK, - }; + return { kind: "summary", text: await summarizeWithFallback(params) }; } const plan = await buildStageSplitPlanWithWorker({ @@ -394,18 +308,18 @@ export async function summarizeInStages(params: { }); if (plan.mode === "single") { - return summarizeWithFallbackResult(params); + return { kind: "summary", text: await summarizeWithFallback(params) }; } const partialSummaries: string[] = []; for (const [index, chunk] of plan.chunks.entries()) { try { - const result = await summarizeWithFallbackResult({ + const summary = await summarizeWithFallback({ ...params, messages: chunk, previousSummary: undefined, }); - partialSummaries.push(result.text); + partialSummaries.push(summary); } catch (err) { // A chunk summarization failed — fail the whole stages compaction. // This prevents silent infinite retry loops where compaction reports @@ -461,12 +375,14 @@ export async function summarizeInStages(params: { ? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\n${custom}` : MERGE_SUMMARIES_INSTRUCTIONS; - const mergedResult = await summarizeWithFallbackResult({ - ...params, - messages: summaryMessages, - customInstructions: mergeInstructions, - }); - return mergedResult; + return { + kind: "summary", + text: await summarizeWithFallback({ + ...params, + messages: summaryMessages, + customInstructions: mergeInstructions, + }), + }; } /** Resolves a positive context-window token count from model metadata. */ diff --git a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts index ce7250efa0b7..c02b4b979dea 100644 --- a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts @@ -50,18 +50,28 @@ export type LlmBoundaryTokenPressure = { renderedChars?: number; }; -function estimateStringTokenPressure(text: string, charsPerToken = ESTIMATED_CHARS_PER_TOKEN) { - return Math.ceil(estimateStringChars(text) / charsPerToken); +type TokenPressureMode = "general" | "tool-result"; + +function estimateStringTokenPressure( + text: string, + charsPerToken = ESTIMATED_CHARS_PER_TOKEN, + mode: TokenPressureMode = "general", +) { + const estimatedTokens = Math.ceil(estimateStringChars(text) / charsPerToken); + return mode === "tool-result" + ? Math.max(Math.ceil(text.length / TOOL_RESULT_CHARS_PER_TOKEN), estimatedTokens) + : estimatedTokens; } function estimateJsonPayloadTokenPressure( value: unknown, charsPerToken = JSON_PAYLOAD_CHARS_PER_TOKEN, + mode: TokenPressureMode = "general", ): number { try { const serialized = JSON.stringify(value); return typeof serialized === "string" - ? Math.ceil(estimateStringChars(serialized) / charsPerToken) + ? estimateStringTokenPressure(serialized, charsPerToken, mode) : 1; } catch { return 256; @@ -89,75 +99,26 @@ function estimateIdentifierTokenPressure( function estimateContentBlockTokenPressure( block: unknown, charsPerToken = ESTIMATED_CHARS_PER_TOKEN, + mode: TokenPressureMode = "general", ): number { if (typeof block === "string") { - return estimateStringTokenPressure(block, charsPerToken); + return estimateStringTokenPressure(block, charsPerToken, mode); } if (!isRecord(block)) { - return estimateJsonPayloadTokenPressure(block, charsPerToken); + return estimateJsonPayloadTokenPressure(block, charsPerToken, mode); } const type = block.type; - if (type === "text" && typeof block.text === "string") { - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(block.text, charsPerToken); - } - if (type === "thinking" && typeof block.thinking === "string") { - return ( - CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(block.thinking, charsPerToken) - ); + const text = type === "text" ? block.text : type === "thinking" ? block.thinking : undefined; + if (typeof text === "string") { + return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(text, charsPerToken, mode); } if (type === "image") { return IMAGE_BLOCK_TOKENS; } - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateJsonPayloadTokenPressure(block, charsPerToken); -} - -function estimateToolResultStringTokenPressure(text: string): number { - const conservativeToolResultEstimate = Math.ceil(text.length / TOOL_RESULT_CHARS_PER_TOKEN); - const cjkAwareEstimate = estimateStringTokenPressure(text); - return Math.max(conservativeToolResultEstimate, cjkAwareEstimate); -} - -function estimateToolResultJsonTokenPressure(value: unknown): number { - try { - const serialized = JSON.stringify(value); - return typeof serialized === "string" ? estimateToolResultStringTokenPressure(serialized) : 1; - } catch { - return 256; - } -} - -function estimateToolResultBlockTokenPressure(block: unknown): number { - if (typeof block === "string") { - return estimateToolResultStringTokenPressure(block); - } - if (!isRecord(block)) { - return estimateToolResultJsonTokenPressure(block); - } - - if (block.type === "text" && typeof block.text === "string") { - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.text); - } - if (block.type === "thinking" && typeof block.thinking === "string") { - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.thinking); - } - if (block.type === "image") { - return IMAGE_BLOCK_TOKENS; - } - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultJsonTokenPressure(block); -} - -function estimateToolResultContentTokenPressure(content: unknown): number { - if (typeof content === "string") { - return estimateToolResultStringTokenPressure(content); - } - if (Array.isArray(content)) { - return content.reduce((sum, block) => sum + estimateToolResultBlockTokenPressure(block), 0); - } - if (content !== undefined) { - return estimateToolResultJsonTokenPressure(content); - } - return 0; + return ( + CONTENT_BLOCK_OVERHEAD_TOKENS + estimateJsonPayloadTokenPressure(block, charsPerToken, mode) + ); } function estimateAssistantToolCallTokenPressure(block: Record): number { @@ -169,30 +130,36 @@ function estimateAssistantToolCallTokenPressure(block: Record): ); } -function estimateContentTokenPressure(content: unknown): number { +function estimateContentTokenPressure( + content: unknown, + mode: TokenPressureMode = "general", +): number { if (typeof content === "string") { - return estimateStringTokenPressure(content); + return estimateStringTokenPressure(content, ESTIMATED_CHARS_PER_TOKEN, mode); } if (Array.isArray(content)) { - return content.reduce((sum, block) => sum + estimateContentBlockTokenPressure(block), 0); + return content.reduce( + (sum, block) => + sum + estimateContentBlockTokenPressure(block, ESTIMATED_CHARS_PER_TOKEN, mode), + 0, + ); } if (content !== undefined) { - return estimateJsonPayloadTokenPressure(content); + return estimateJsonPayloadTokenPressure( + content, + mode === "tool-result" ? ESTIMATED_CHARS_PER_TOKEN : JSON_PAYLOAD_CHARS_PER_TOKEN, + mode, + ); } return 0; } -function isToolResultMessage(message: AgentMessage): boolean { - const record = message as unknown as { role?: unknown; type?: unknown }; - return record.role === "toolResult" || record.role === "tool" || record.type === "toolResult"; -} - function estimateMessageTokenPressure(message: AgentMessage): number { const record = message as unknown as Record; let tokens = MESSAGE_BOUNDARY_OVERHEAD_TOKENS; - if (isToolResultMessage(message)) { - tokens += estimateToolResultContentTokenPressure(record.content); + if (record.role === "toolResult" || record.role === "tool" || record.type === "toolResult") { + tokens += estimateContentTokenPressure(record.content, "tool-result"); tokens += estimateIdentifierTokenPressure(record.toolName ?? record.tool_name); return tokens; } @@ -207,18 +174,13 @@ function estimateMessageTokenPressure(message: AgentMessage): number { return tokens; } - if (record.role === "branchSummary") { + if (record.role === "branchSummary" || record.role === "compactionSummary") { const summary = typeof record.summary === "string" ? record.summary : ""; - tokens += estimateStringTokenPressure(BRANCH_SUMMARY_PREFIX + summary + BRANCH_SUMMARY_SUFFIX); - return tokens; - } - - if (record.role === "compactionSummary") { - const summary = typeof record.summary === "string" ? record.summary : ""; - tokens += estimateStringTokenPressure( - COMPACTION_SUMMARY_PREFIX + summary + COMPACTION_SUMMARY_SUFFIX, - ); - return tokens; + const [prefix, suffix] = + record.role === "branchSummary" + ? [BRANCH_SUMMARY_PREFIX, BRANCH_SUMMARY_SUFFIX] + : [COMPACTION_SUMMARY_PREFIX, COMPACTION_SUMMARY_SUFFIX]; + return tokens + estimateStringTokenPressure(prefix + summary + suffix); } if (record.role === "assistant") { @@ -255,6 +217,16 @@ function estimateMessageTokenPressure(message: AgentMessage): number { * optional system prompt, and current prompt text. The result intentionally * includes a safety margin because this path runs before provider tokenization. */ +function estimateRenderedPromptTokens(params: { systemPrompt?: string; prompt: string }): number { + const systemTokens = + typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0 + ? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt) + : 0; + return ( + systemTokens + MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt) + ); +} + export function estimateLlmBoundaryTokenPressure(params: { messages: AgentMessage[]; systemPrompt?: string; @@ -264,13 +236,10 @@ export function estimateLlmBoundaryTokenPressure(params: { (sum, message) => sum + estimateMessageTokenPressure(message), 0, ); - const systemTokens = - typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0 - ? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt) - : 0; - const promptTokens = - MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt); - return Math.max(0, Math.ceil((historyTokens + systemTokens + promptTokens) * SAFETY_MARGIN)); + return Math.max( + 0, + Math.ceil((historyTokens + estimateRenderedPromptTokens(params)) * SAFETY_MARGIN), + ); } /** Estimates only the rendered prompt/system portion when history has already been accounted for. */ @@ -278,13 +247,7 @@ export function estimateRenderedLlmBoundaryTokenPressure(params: { systemPrompt?: string; prompt: string; }): number { - const systemTokens = - typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0 - ? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt) - : 0; - const promptTokens = - MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt); - return Math.max(0, Math.ceil((systemTokens + promptTokens) * SAFETY_MARGIN)); + return Math.max(0, Math.ceil(estimateRenderedPromptTokens(params) * SAFETY_MARGIN)); } function normalizeLlmBoundaryTokenPressure( diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts index 004d040169b8..18bd25cc5f97 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -64,7 +64,7 @@ export function resolveCacheTtlPruningSettings( } catch { // Invalid durations retain the shipped five-minute default. } - const normalize = (value: string) => normalizeLowercaseStringOrEmpty(value); + const normalize = normalizeLowercaseStringOrEmpty; const deny = compileGlobPatterns({ raw: config.tools?.deny, normalize }); const allow = compileGlobPatterns({ raw: config.tools?.allow, normalize }); return { @@ -107,22 +107,26 @@ function cacheTtlMessageChars(message: AgentMessage): number { } const content = Array.isArray(message.content) ? message.content : []; return content.reduce((chars, block) => { + if (!isRecord(block)) { + return chars; + } const text = cacheTtlText(block, message.role !== "assistant"); if (text !== undefined) { return chars + estimateStringChars(text); } - if (isRecord(block) && block.type === "image") { + if (block.type === "image") { return chars + CACHE_TTL_IMAGE_CHARS; } - if (!isRecord(block) || message.role !== "assistant") { + if (message.role !== "assistant") { return chars; } const record = block as Record; if (record.type === "thinking" || record.type === "redacted_thinking") { - const values = [record.thinking, record.thinkingSignature]; - if (record.type === "redacted_thinking") { - values.push(record.data); - } + const values = [ + record.thinking, + record.thinkingSignature, + ...(record.type === "redacted_thinking" ? [record.data] : []), + ]; return values.reduce( (sum, value) => sum + (typeof value === "string" ? estimateStringChars(value) : 0), chars, @@ -209,11 +213,12 @@ export function pruneExpiredCacheTtlToolResults(params: { (next ??= messages.slice())[index] = projected; } } - if (totalChars / charWindow < 0.5 || !settings.hardClear) { - return next ?? messages; - } const output = next ?? messages; - if (eligible.reduce((sum, index) => sum + cacheTtlMessageChars(output[index]!), 0) < 50_000) { + if ( + totalChars / charWindow < 0.5 || + !settings.hardClear || + eligible.reduce((sum, index) => sum + cacheTtlMessageChars(output[index]!), 0) < 50_000 + ) { return output; } for (const index of eligible) { @@ -510,18 +515,10 @@ export function truncateToolResultMessage( const preserveSmallBlocks = smallBlockChars + largeBlockNoticeChars <= maxChars; const preservedChars = preserveSmallBlocks ? smallBlockChars : 0; const remainingBudget = Math.max(0, maxChars - preservedChars); - const reducibleChars = blockTextChars.reduce( - (sum, chars) => sum + (preserveSmallBlocks && chars > 0 && chars <= minKeepChars ? 0 : chars), - 0, - ); - const reducibleNoticeChars = blockTextChars.reduce( - (sum, chars, index) => - sum + - (preserveSmallBlocks && chars > 0 && chars <= minKeepChars - ? 0 - : (blockNoticeChars[index] ?? 0)), - 0, - ); + const reducibleChars = totalTextChars - preservedChars; + const reducibleNoticeChars = preserveSmallBlocks + ? largeBlockNoticeChars + : blockNoticeChars.reduce((sum, chars) => sum + chars, 0); const noticeScale = reducibleNoticeChars > 0 ? Math.min(1, remainingBudget / reducibleNoticeChars) : 0; const distributableBudget = Math.max(0, remainingBudget - reducibleNoticeChars); @@ -566,13 +563,7 @@ function isToolResultTextBlock( ); } -type ToolResultSpillDetails = { - path: string; - truncated: boolean; - chars?: number; -}; - -function getToolResultSpillDetails(message: AgentMessage): ToolResultSpillDetails | undefined { +function getToolResultSpillDetails(message: AgentMessage) { const details = (message as { details?: unknown }).details; if (!isRecord(details)) { return undefined; @@ -638,21 +629,6 @@ function resolveAggregateElisionMarkers( }; } -function formatAggregateElisionText( - remainingTextBudget: number, - spillMarkers: AggregateElisionMarkers | undefined, -): string { - if (remainingTextBudget <= 0) { - return ""; - } - for (const marker of [spillMarkers?.full, spillMarkers?.compact]) { - if (marker && estimateToolResultTextChars(marker) <= remainingTextBudget) { - return marker; - } - } - return sliceToolResultTextToBudget(AGGREGATE_ELISION_MARKER, remainingTextBudget); -} - /** Projects bounded tool-result history without mutating the transcript. */ export function truncateOversizedToolResultsInMessages( messages: AgentMessage[], @@ -667,50 +643,25 @@ export function truncateOversizedToolResultsInMessages( aggregatePressureEngaged: boolean; aggregateBudgetChars: number; } { - const maxChars = Math.max( - 1, - maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens), - ); - const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars( + const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets({ contextWindowTokens, - maxChars, + maxCharsOverride, aggregateMaxCharsOverride, - ); - const projectionKeys = projectionState - ? getToolResultProjectionKeys(messages, projectionState) - : []; - const hasFrozenProjectionBaseline = (projectionState?.frozen.size ?? 0) > 0; - const branch = messages.map((message, index) => { - const projectionKey = projectionKeys[index]; - const projectedMessage = projectionKey - ? projectionState?.replacements.get(projectionKey) - : undefined; - if (projectionKey && projectionState && !projectionState.sourceTextByKey.has(projectionKey)) { - projectionState.sourceTextByKey.set(projectionKey, getToolResultTextBlocks(message)); - } - const mergedMessage = projectedMessage - ? mergeProjectedToolResultMessage( - message, - projectedMessage, - projectionState?.sourceTextByKey.get(projectionKey ?? ""), - ) - : message; - return { - id: `message-${index}`, - type: "message", - message: mergedMessage, - aggregateEligible: - !projectionKey || - !projectionState?.frozen.has(projectionKey) || - (projectedMessage !== undefined && mergedMessage === message), - // Reduce frozen history first so steering cannot make fresh output disappear. - deferAggregateRecovery: - projectionKey !== undefined && - projectionState !== undefined && - hasFrozenProjectionBaseline && - !projectionState.frozen.has(projectionKey), - }; }); + const sourceBranch = messages.map((message, index) => ({ + id: `message-${index}`, + type: "message", + message, + })); + const projection = projectionState + ? projectToolResultBranch({ + branch: sourceBranch, + projectionState, + recordSources: true, + }) + : undefined; + const branch = projection?.branch ?? sourceBranch; + const projectionKeys = projection?.keys ?? []; const plan = buildToolResultReplacementPlan({ branch, maxChars, @@ -718,7 +669,7 @@ export function truncateOversizedToolResultsInMessages( minKeepChars: RECOVERY_MIN_KEEP_CHARS, protectTrailingToolResults: Boolean(projectionState), }); - const replacedBranch = applyToolResultReplacementsToBranch(branch, plan.replacements); + const replacedBranch = plan.branch; if (projectionState) { for (const [index, originalMessage] of messages.entries()) { const projectedMessage = replacedBranch[index]?.message; @@ -745,19 +696,26 @@ export function truncateOversizedToolResultsInMessages( }; } -function calculateRecoveryAggregateToolResultChars( - contextWindowTokens: number, - maxCharsOverride?: number, - aggregateMaxCharsOverride?: number, -): number { - return Math.max( +function resolveToolResultBudgets(params: { + contextWindowTokens: number; + maxCharsOverride?: number; + aggregateMaxCharsOverride?: number; +}): { maxChars: number; aggregateBudgetChars: number } { + const maxChars = Math.max( 1, - aggregateMaxCharsOverride ?? - resolveLiveToolResultAggregateMaxChars({ - contextWindowTokens, - perResultMaxChars: maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens), - }), + params.maxCharsOverride ?? calculateMaxToolResultChars(params.contextWindowTokens), ); + return { + maxChars, + aggregateBudgetChars: Math.max( + 1, + params.aggregateMaxCharsOverride ?? + resolveLiveToolResultAggregateMaxChars({ + contextWindowTokens: params.contextWindowTokens, + perResultMaxChars: maxChars, + }), + ), + }; } type ToolResultReductionPotential = { @@ -849,78 +807,74 @@ function mergeProjectedToolResultMessage( if (!Array.isArray(currentContent) || !Array.isArray(projectedContent)) { return projectedMessage; } - const projectedText = projectedContent.filter( - (block): block is { type: "text"; text: string } => - Boolean(block) && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", + const projectedText = projectedContent.flatMap((block) => + isRecord(block) && block.type === "text" && typeof block.text === "string" ? [block.text] : [], ); const currentText = getToolResultTextBlocks(message); - if (sourceText && currentText.some((text, index) => text !== sourceText[index])) { - return message; - } - const currentTextCount = currentContent.filter( - (block) => - Boolean(block) && typeof block === "object" && (block as { type?: unknown }).type === "text", - ).length; - if (currentTextCount !== projectedText.length) { + if ( + (sourceText && currentText.some((text, index) => text !== sourceText[index])) || + currentText.length !== projectedText.length + ) { return message; } let textIndex = 0; const mergedContent = currentContent.map((block) => { - if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "text") { + if (!isRecord(block) || block.type !== "text") { return block; } - const projectedBlock = projectedText[textIndex++]; - return projectedBlock ? Object.assign({}, block, { text: projectedBlock.text }) : block; + return Object.assign({}, block, { text: projectedText[textIndex++] }); }); return { ...message, content: mergedContent } as AgentMessage; } -function seedRecoveryBranchFromFrozenProjection(params: { +function projectToolResultBranch(params: { branch: ToolResultBranchEntry[]; projectionState: ToolResultPromptProjectionState; -}): ToolResultBranchEntry[] { + frozenOnly?: boolean; + recordSources?: boolean; +}): { branch: ToolResultBranchEntry[]; keys: Array } { const messageEntries = params.branch.filter( (entry): entry is ToolResultBranchEntry & { message: AgentMessage } => entry.type === "message" && entry.message !== undefined, ); - const projectionKeys = getToolResultProjectionKeys( + const keys = getToolResultProjectionKeys( messageEntries.map((entry) => entry.message), params.projectionState, ); const hasFrozenProjectionBaseline = params.projectionState.frozen.size > 0; let messageIndex = 0; - return params.branch.map((entry) => { - if (entry.type !== "message" || !entry.message) { - return entry; - } - const projectionKey = projectionKeys[messageIndex++]; - const projectedMessage = - projectionKey && params.projectionState.frozen.has(projectionKey) - ? params.projectionState.replacements.get(projectionKey) - : undefined; - const message = projectedMessage - ? mergeProjectedToolResultMessage( - entry.message, - projectedMessage, - projectionKey ? params.projectionState.sourceTextByKey.get(projectionKey) : undefined, - ) - : entry.message; - return { - ...entry, - message, - aggregateEligible: - !projectionKey || - !params.projectionState.frozen.has(projectionKey) || - (projectedMessage !== undefined && message === entry.message), - deferAggregateRecovery: - projectionKey !== undefined && - hasFrozenProjectionBaseline && - !params.projectionState.frozen.has(projectionKey), - }; - }); + return { + keys, + branch: params.branch.map((entry) => { + if (entry.type !== "message" || !entry.message) { + return entry; + } + const key = keys[messageIndex++]; + const frozen = key !== undefined && params.projectionState.frozen.has(key); + const projected = + key && (!params.frozenOnly || frozen) + ? params.projectionState.replacements.get(key) + : undefined; + if (key && params.recordSources && !params.projectionState.sourceTextByKey.has(key)) { + params.projectionState.sourceTextByKey.set(key, getToolResultTextBlocks(entry.message)); + } + const message = projected + ? mergeProjectedToolResultMessage( + entry.message, + projected, + key ? params.projectionState.sourceTextByKey.get(key) : undefined, + ) + : entry.message; + return { + ...entry, + message, + aggregateEligible: + !key || !frozen || (projected !== undefined && message === entry.message), + // Reduce frozen history first so steering cannot make fresh output disappear. + deferAggregateRecovery: key !== undefined && hasFrozenProjectionBaseline && !frozen, + }; + }), + }; } function getToolResultTextBlocks(message: AgentMessage): string[] { @@ -939,26 +893,22 @@ function buildAggregateToolResultReplacements(params: { spillSourceBranch?: ToolResultBranchEntry[]; aggregateBudgetChars: number; minKeepChars?: number; - protectTrailingToolResults?: boolean; + protectedEntryIds?: Set; }): { replacements: ToolResultReplacement[]; pressureExceeded: boolean } { const minKeepChars = params.minKeepChars ?? MIN_KEEP_CHARS; - const protectedEntryIds = params.protectTrailingToolResults - ? getTrailingToolResultEntryIds(params.branch) - : new Set(); const candidates = params.branch .flatMap((entry, index) => { const message = entry.message; return entry.type === "message" && message?.role === "toolResult" ? [ { - index, entryId: entry.id, message, spillSourceMessage: params.spillSourceBranch?.[index]?.message ?? message, textLength: getToolResultTextBudget(message), aggregateEligible: entry.aggregateEligible !== false, deferredByFreshProjection: entry.deferAggregateRecovery === true, - protectedByTrailingBatch: protectedEntryIds.has(entry.id), + protectedByTrailingBatch: params.protectedEntryIds?.has(entry.id) ?? false, }, ] : []; @@ -983,68 +933,53 @@ function buildAggregateToolResultReplacements(params: { let remainingReduction = totalChars - params.aggregateBudgetChars; const replacements = new Map(); - const aggregateRecoveryCandidates = candidates - .filter((item) => !item.deferredByFreshProjection && !item.protectedByTrailingBatch) - .toSorted((a, b) => a.index - b.index); - const recoveryCandidates = [ - ...aggregateRecoveryCandidates.filter((item) => item.aggregateEligible), - // Reuse frozen projections first to keep reduction shrink-only and cache-stable. - ...aggregateRecoveryCandidates.filter((item) => !item.aggregateEligible), - ...candidates.filter( - (item) => item.deferredByFreshProjection && !item.protectedByTrailingBatch, - ), - ]; - - // Spend aggregate reduction on older entries first so fresh tool output stays intact. - for (const candidate of recoveryCandidates) { - if (remainingReduction <= 0) { - break; - } - const reducibleChars = Math.max(0, candidate.textLength - minTruncatedTextChars); - if (reducibleChars <= 0) { - continue; - } - - const requestedReduction = Math.min(reducibleChars, remainingReduction); - const targetChars = Math.max(minTruncatedTextChars, candidate.textLength - requestedReduction); - const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage); - const candidateSuffixFactory = spillMarkers?.truncationSuffix ?? suffixFactory; - const candidateTargetChars = Math.max( - targetChars, - estimateToolResultTextChars(candidateSuffixFactory(1)), + // Frozen projections shrink first; stable sorting preserves the original oldest-first order. + const recoveryCandidates = candidates + .filter((candidate) => !candidate.protectedByTrailingBatch) + .toSorted( + (left, right) => + Number(left.deferredByFreshProjection) - Number(right.deferredByFreshProjection) || + Number(right.aggregateEligible) - Number(left.aggregateEligible), ); - const truncatedMessage = truncateToolResultMessage(candidate.message, candidateTargetChars, { - minKeepChars, - suffix: candidateSuffixFactory, - }); - const newLength = getToolResultTextBudget(truncatedMessage); - const actualReduction = Math.max(0, candidate.textLength - newLength); - if (actualReduction <= 0) { - continue; - } - replacements.set(candidate.entryId, { entryId: candidate.entryId, message: truncatedMessage }); - remainingReduction -= actualReduction; - } - - for (const candidate of recoveryCandidates) { - if (remainingReduction <= 0) { - break; + // Trim all older entries before clearing any, so fresh output and spill pointers stay recoverable. + for (const clear of [false, true]) { + for (const candidate of recoveryCandidates) { + if (remainingReduction <= 0) { + break; + } + const baseMessage = replacements.get(candidate.entryId)?.message ?? candidate.message; + const baseTextLength = getToolResultTextBudget(baseMessage); + if (!clear && baseTextLength <= minTruncatedTextChars) { + continue; + } + const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage); + let message: AgentMessage; + if (clear) { + message = clearToolResultText( + candidate.message, + Math.max(0, baseTextLength - remainingReduction), + spillMarkers, + ); + } else { + const suffix = spillMarkers?.truncationSuffix ?? suffixFactory; + const targetChars = Math.max( + minTruncatedTextChars, + baseTextLength - remainingReduction, + estimateToolResultTextChars(suffix(1)), + ); + message = truncateToolResultMessage(candidate.message, targetChars, { + minKeepChars, + suffix, + }); + } + const actualReduction = Math.max(0, baseTextLength - getToolResultTextBudget(message)); + if (actualReduction <= 0 && (!clear || !spillMarkers)) { + continue; + } + replacements.set(candidate.entryId, { entryId: candidate.entryId, message }); + remainingReduction -= actualReduction; } - const baseMessage = replacements.get(candidate.entryId)?.message ?? candidate.message; - const baseTextLength = getToolResultTextBudget(baseMessage); - const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage); - const emptyMessage = clearToolResultText( - candidate.message, - Math.max(0, baseTextLength - remainingReduction), - spillMarkers, - ); - const actualReduction = Math.max(0, baseTextLength - getToolResultTextBudget(emptyMessage)); - if (actualReduction <= 0 && !spillMarkers) { - continue; - } - replacements.set(candidate.entryId, { entryId: candidate.entryId, message: emptyMessage }); - remainingReduction -= actualReduction; } return { replacements: [...replacements.values()], pressureExceeded: true }; @@ -1052,16 +987,14 @@ function buildAggregateToolResultReplacements(params: { function getTrailingToolResultEntryIds(branch: ToolResultBranchEntry[]): Set { const ids = new Set(); - let sawMessage = false; for (let index = branch.length - 1; index >= 0; index--) { const entry = branch[index]; if (entry?.type !== "message" || !entry.message) { - if (!sawMessage) { + if (ids.size === 0) { continue; } break; } - sawMessage = true; if ((entry.message as { role?: string }).role !== "toolResult") { break; } @@ -1094,7 +1027,12 @@ function clearToolResultText( if (!isToolResultTextBlock(block)) { return block; } - const replacementText = formatAggregateElisionText(remainingTextBudget, spillMarkers); + const replacementText = + [spillMarkers?.full, spillMarkers?.compact].find( + (marker): marker is string => + typeof marker === "string" && + estimateToolResultTextChars(marker) <= remainingTextBudget, + ) ?? sliceToolResultTextToBudget(AGGREGATE_ELISION_MARKER, remainingTextBudget); remainingTextBudget = Math.max( 0, remainingTextBudget - estimateToolResultTextChars(replacementText), @@ -1107,82 +1045,27 @@ function clearToolResultText( } as AgentMessage; } -function buildOversizedToolResultReplacements(params: { - branch: ToolResultBranchEntry[]; - maxChars: number; - minKeepChars?: number; - protectedEntryIds?: Set; -}): ToolResultReplacement[] { - const minKeepChars = params.minKeepChars ?? MIN_KEEP_CHARS; - const replacements: ToolResultReplacement[] = []; - - for (const entry of params.branch) { - if (entry.type !== "message" || !entry.message) { - continue; - } - const msg = entry.message; - if ((msg as { role?: string }).role !== "toolResult") { - continue; - } - if (getToolResultTextBudget(msg) <= params.maxChars) { - continue; - } - const replacementMinKeepChars = params.protectedEntryIds?.has(entry.id) - ? Math.max(minKeepChars, MIN_KEEP_CHARS) - : minKeepChars; - const spillMarkers = resolveAggregateElisionMarkers(msg); - const suffixFactory = spillMarkers?.truncationSuffix; - const maxChars = Math.max( - params.maxChars, - suffixFactory ? estimateToolResultTextChars(suffixFactory(1)) : 0, - ); - replacements.push({ - entryId: entry.id, - message: truncateToolResultMessage(msg, maxChars, { - minKeepChars: replacementMinKeepChars, - ...(suffixFactory ? { suffix: suffixFactory } : {}), - }), - }); - } - - return replacements; -} - -function calculateReplacementReduction( - branch: ToolResultBranchEntry[], - replacements: ToolResultReplacement[], -): number { - if (replacements.length === 0) { - return 0; - } - const branchById = new Map(branch.map((entry) => [entry.id, entry])); - return replacements.reduce((reduction, replacement) => { - const entry = branchById.get(replacement.entryId); - if (!entry?.message) { - return reduction; - } - return ( - reduction + - Math.max( - 0, - getToolResultTextBudget(entry.message) - getToolResultTextBudget(replacement.message), - ) - ); - }, 0); -} - function applyToolResultReplacementsToBranch( branch: ToolResultBranchEntry[], replacements: ToolResultReplacement[], -): ToolResultBranchEntry[] { +): { branch: ToolResultBranchEntry[]; reducedChars: number } { if (replacements.length === 0) { - return branch; + return { branch, reducedChars: 0 }; } const replacementsById = new Map(replacements.map(({ entryId, message }) => [entryId, message])); - return branch.map((entry) => { + let reducedChars = 0; + const nextBranch = branch.map((entry) => { const message = replacementsById.get(entry.id); - return message && entry.type === "message" ? { ...entry, message } : entry; + if (!message || entry.type !== "message" || !entry.message) { + return entry; + } + reducedChars += Math.max( + 0, + getToolResultTextBudget(entry.message) - getToolResultTextBudget(message), + ); + return { ...entry, message }; }); + return { branch: nextBranch, reducedChars }; } function buildToolResultReplacementPlan(params: { @@ -1192,6 +1075,7 @@ function buildToolResultReplacementPlan(params: { minKeepChars?: number; protectTrailingToolResults?: boolean; }): { + branch: ToolResultBranchEntry[]; replacements: ToolResultReplacement[]; oversizedReplacementCount: number; aggregateReplacementCount: number; @@ -1203,40 +1087,50 @@ function buildToolResultReplacementPlan(params: { const protectedEntryIds = params.protectTrailingToolResults ? getTrailingToolResultEntryIds(params.branch) : undefined; - const oversizedReplacements = buildOversizedToolResultReplacements({ - branch: params.branch, - maxChars: params.maxChars, - minKeepChars, - protectedEntryIds, + const oversizedReplacements = params.branch.flatMap((entry): ToolResultReplacement[] => { + const message = entry.message; + if ( + entry.type !== "message" || + message?.role !== "toolResult" || + getToolResultTextBudget(message) <= params.maxChars + ) { + return []; + } + const suffix = resolveAggregateElisionMarkers(message)?.truncationSuffix; + const maxChars = Math.max(params.maxChars, suffix ? estimateToolResultTextChars(suffix(1)) : 0); + return [ + { + entryId: entry.id, + message: truncateToolResultMessage(message, maxChars, { + minKeepChars: protectedEntryIds?.has(entry.id) + ? Math.max(minKeepChars, MIN_KEEP_CHARS) + : minKeepChars, + ...(suffix ? { suffix } : {}), + }), + }, + ]; }); - const oversizedReducibleChars = calculateReplacementReduction( - params.branch, - oversizedReplacements, - ); - const oversizedTrimmedBranch = applyToolResultReplacementsToBranch( - params.branch, - oversizedReplacements, - ); + const oversizedPhase = applyToolResultReplacementsToBranch(params.branch, oversizedReplacements); const aggregatePlan = buildAggregateToolResultReplacements({ - branch: oversizedTrimmedBranch, + branch: oversizedPhase.branch, spillSourceBranch: params.branch, aggregateBudgetChars: params.aggregateBudgetChars, minKeepChars, - protectTrailingToolResults: params.protectTrailingToolResults, + protectedEntryIds, }); - const aggregateReplacements = aggregatePlan.replacements; - const aggregateReducibleChars = calculateReplacementReduction( - oversizedTrimmedBranch, - aggregateReplacements, + const aggregatePhase = applyToolResultReplacementsToBranch( + oversizedPhase.branch, + aggregatePlan.replacements, ); return { - replacements: [...oversizedReplacements, ...aggregateReplacements], + branch: aggregatePhase.branch, + replacements: [...oversizedReplacements, ...aggregatePlan.replacements], oversizedReplacementCount: oversizedReplacements.length, - aggregateReplacementCount: aggregateReplacements.length, + aggregateReplacementCount: aggregatePlan.replacements.length, aggregatePressureExceeded: aggregatePlan.pressureExceeded, - oversizedReducibleChars, - aggregateReducibleChars, + oversizedReducibleChars: oversizedPhase.reducedChars, + aggregateReducibleChars: aggregatePhase.reducedChars, }; } @@ -1252,20 +1146,13 @@ function buildRecoveryToolResultReplacementPlan(params: { aggregateBudgetChars: number; plan: ReturnType; } { - const maxChars = Math.max( - 1, - params.maxCharsOverride ?? calculateMaxToolResultChars(params.contextWindowTokens), - ); - const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars( - params.contextWindowTokens, - maxChars, - params.aggregateMaxCharsOverride, - ); + const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets(params); const projectedBranch = params.projectionState - ? seedRecoveryBranchFromFrozenProjection({ + ? projectToolResultBranch({ branch: params.branch, projectionState: params.projectionState, - }) + frozenOnly: true, + }).branch : params.branch; const plan = buildToolResultReplacementPlan({ branch: projectedBranch, @@ -1274,9 +1161,8 @@ function buildRecoveryToolResultReplacementPlan(params: { minKeepChars: RECOVERY_MIN_KEEP_CHARS, protectTrailingToolResults: params.protectTrailingToolResults, }); - const finalBranch = applyToolResultReplacementsToBranch(projectedBranch, plan.replacements); const replacements = params.branch.flatMap((entry, index) => { - const finalEntry = finalBranch[index]; + const finalEntry = plan.branch[index]; if ( entry.type !== "message" || !entry.message || @@ -1304,16 +1190,8 @@ export function estimateToolResultReductionPotential(params: { maxCharsOverride?: number; aggregateMaxCharsOverride?: number; }): ToolResultReductionPotential { - const { messages, contextWindowTokens } = params; - const maxChars = Math.max( - 1, - params.maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens), - ); - const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars( - contextWindowTokens, - maxChars, - params.aggregateMaxCharsOverride, - ); + const { messages } = params; + const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets(params); const branch = messages.map((message, index) => ({ id: `message-${index}`, type: "message", diff --git a/src/agents/embedded-agent-subscribe.handlers.compaction.ts b/src/agents/embedded-agent-subscribe.handlers.compaction.ts index d082a36f729c..a08a470bd197 100644 --- a/src/agents/embedded-agent-subscribe.handlers.compaction.ts +++ b/src/agents/embedded-agent-subscribe.handlers.compaction.ts @@ -44,6 +44,48 @@ function compactionLogKind(reason: CompactionReason): string { return reason === "manual" ? "manual compaction" : "auto-compaction"; } +function emitCompactionAgentEvent( + ctx: EmbeddedAgentSubscribeContext, + data: { phase: "start" } | { phase: "end"; willRetry: boolean; completed: boolean }, +): void { + const event = { stream: "compaction" as const, data }; + emitAgentEvent({ runId: ctx.params.runId, ...event }); + runBestEffortCallback({ + label: "compaction agent event", + log: ctx.log, + callback: () => ctx.params.onAgentEvent?.(event), + }); +} + +function runBestEffortCompactionHook( + ctx: EmbeddedAgentSubscribeContext, + phase: "before" | "after", +): void { + const hookRunner = getGlobalHookRunner(); + const hookName = phase === "before" ? "before_compaction" : "after_compaction"; + if (!hookRunner?.hasHooks(hookName)) { + return; + } + const metrics = { + messageCount: ctx.params.session.messages?.length ?? 0, + sessionFile: ctx.params.session.sessionFile, + }; + const context = { sessionKey: ctx.params.sessionKey }; + const hook = + phase === "before" + ? hookRunner.runBeforeCompaction( + { ...metrics, messages: ctx.params.session.messages }, + context, + ) + : hookRunner.runAfterCompaction( + { ...metrics, compactedCount: ctx.getCompactionCount() }, + context, + ); + void hook.catch((err: unknown) => { + ctx.log.warn(`${hookName} hook failed: ${String(err)}`); + }); +} + /** Handles compaction start events from an embedded agent session. */ export function handleCompactionStart( ctx: EmbeddedAgentSubscribeContext, @@ -60,40 +102,11 @@ export function handleCompactionStart( reason, consoleMessage: `embedded run ${kind} start: runId=${ctx.params.runId} reason=${reason}`, }); - emitAgentEvent({ - runId: ctx.params.runId, - stream: "compaction", - data: { phase: "start" }, - }); - runBestEffortCallback({ - label: "compaction agent event", - log: ctx.log, - callback: () => - ctx.params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "start" }, - }), - }); + emitCompactionAgentEvent(ctx, { phase: "start" }); // Hooks are fire-and-forget so compaction state updates and liveness pauses // cannot be delayed by plugin work. - const hookRunner = getGlobalHookRunner(); - if (hookRunner?.hasHooks("before_compaction")) { - void hookRunner - .runBeforeCompaction( - { - messageCount: ctx.params.session.messages?.length ?? 0, - messages: ctx.params.session.messages, - sessionFile: ctx.params.session.sessionFile, - }, - { - sessionKey: ctx.params.sessionKey, - }, - ) - .catch((err: unknown) => { - ctx.log.warn(`before_compaction hook failed: ${String(err)}`); - }); - } + runBestEffortCompactionHook(ctx, "before"); } /** Handles compaction completion, retry, and incomplete events. */ @@ -107,7 +120,8 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com // trimming context, and the persisted count must reflect that successful trim. const hasResult = evt.result != null; const wasAborted = Boolean(evt.aborted); - if (hasResult && !wasAborted) { + const completed = hasResult && !wasAborted; + if (completed) { ctx.incrementCompactionCount(); const tokensAfter = typeof evt.result === "object" && evt.result @@ -130,14 +144,18 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com compactionCount: observedCompactionCount, consoleMessage: `embedded run ${kind} complete: runId=${ctx.params.runId} reason=${reason} compactionCount=${observedCompactionCount} willRetry=${willRetry}`, }); - void reconcileSessionStoreCompactionCountAfterSuccess({ - sessionKey: ctx.params.sessionKey, - agentId: ctx.params.agentId, - configStore: ctx.params.config?.session?.store, - observedCompactionCount, - }).catch((err: unknown) => { - ctx.log.warn(`late compaction count reconcile failed: ${String(err)}`); - }); + void import("./embedded-agent-subscribe.handlers.compaction.runtime.js") + .then(({ default: reconcile }) => + reconcile({ + sessionKey: ctx.params.sessionKey, + agentId: ctx.params.agentId, + configStore: ctx.params.config?.session?.store, + observedCompactionCount, + }), + ) + .catch((err: unknown) => { + ctx.log.warn(`late compaction count reconcile failed: ${String(err)}`); + }); } if (willRetry) { ctx.noteCompactionRetry(); @@ -148,9 +166,17 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com ctx.state.livenessState = "working"; } ctx.maybeResolveCompactionWait(); - clearStaleAssistantUsageOnSessionMessages(ctx); + const messages = ctx.params.session.messages; + if (Array.isArray(messages)) { + // Marker-free final compaction has no fresh boundary, so stale totals + // must be cleared before later context counters inspect assistant usage. + stripStaleAssistantUsageBeforeLatestCompaction(messages, { + mutate: true, + whenMissingCompactionSummary: "zeroAssistantUsage", + }); + } } - if (!hasResult || wasAborted) { + if (!completed) { ctx.log.info(`embedded run ${kind} incomplete`, { event: "embedded_run_compaction_end", runId: ctx.params.runId, @@ -161,65 +187,11 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com consoleMessage: `embedded run ${kind} incomplete: runId=${ctx.params.runId} reason=${reason} aborted=${wasAborted} willRetry=${willRetry}`, }); } - emitAgentEvent({ - runId: ctx.params.runId, - stream: "compaction", - data: { phase: "end", willRetry, completed: hasResult && !wasAborted }, - }); - runBestEffortCallback({ - label: "compaction agent event", - log: ctx.log, - callback: () => - ctx.params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry, completed: hasResult && !wasAborted }, - }), - }); + emitCompactionAgentEvent(ctx, { phase: "end", willRetry, completed }); // after_compaction runs only once the run will not retry, matching the visible // post-compaction session state plugin authors observe. if (!willRetry) { - const hookRunnerEnd = getGlobalHookRunner(); - if (hookRunnerEnd?.hasHooks("after_compaction")) { - void hookRunnerEnd - .runAfterCompaction( - { - messageCount: ctx.params.session.messages?.length ?? 0, - compactedCount: ctx.getCompactionCount(), - sessionFile: ctx.params.session.sessionFile, - }, - { sessionKey: ctx.params.sessionKey }, - ) - .catch((err: unknown) => { - ctx.log.warn(`after_compaction hook failed: ${String(err)}`); - }); - } + runBestEffortCompactionHook(ctx, "after"); } } - -/** Lazily reconciles persisted compaction count after a successful compaction. */ -async function reconcileSessionStoreCompactionCountAfterSuccess(params: { - sessionKey?: string; - agentId?: string; - configStore?: string; - observedCompactionCount: number; - now?: number; -}): Promise { - const { default: reconcile } = - await import("./embedded-agent-subscribe.handlers.compaction.runtime.js"); - return reconcile(params); -} - -function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedAgentSubscribeContext): void { - const messages = ctx.params.session.messages; - if (!Array.isArray(messages)) { - return; - } - // Marker-free final compaction has no fresh boundary to compare against. - // Clear all assistant usage or stale pre-compaction totals keep driving the - // context counter after cleanup. - stripStaleAssistantUsageBeforeLatestCompaction(messages, { - mutate: true, - whenMissingCompactionSummary: "zeroAssistantUsage", - }); -} diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 5a1c201100b0..0d0a1627990a 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1036,6 +1036,45 @@ describe("Invalid engine fallback", () => { ); }); + it("coalesces fallback initialization across concurrent lifecycle failures", async () => { + const defaultFactory = vi.fn(async () => new LegacyContextEngine()); + registerContextEngineForOwner("legacy", defaultFactory, "core", { + allowSameOwnerRefresh: true, + }); + const engineId = uniqueEngineId("concurrent-runtime-fail"); + const assemble = vi.fn(async () => { + await Promise.resolve(); + throw new Error("plugin context unavailable"); + }); + registerTestContextEngine(engineId, () => ({ + info: { id: engineId, name: "Concurrent Context Engine" }, + async ingest() { + return { ingested: true }; + }, + assemble, + async compact() { + return { ok: true, compacted: false }; + }, + })); + const engine = await resolveContextEngine(configWithSlot(engineId)); + const messages = [makeMockMessage("user", "first"), makeMockMessage("user", "second")]; + + const results = await Promise.all( + messages.map((message, index) => + engine.assemble({ sessionId: `session-${index}`, messages: [message] }), + ), + ); + + expect(results.map(({ messages: assembled }) => assembled)).toEqual( + messages.map((message) => [message]), + ); + expect(assemble).toHaveBeenCalledTimes(2); + expect(defaultFactory).toHaveBeenCalledTimes(1); + expect(listContextEngineQuarantines()).toEqual([ + expect.objectContaining({ engineId, operation: "assemble" }), + ]); + }); + it("exposes fallback metadata on the same engine after lifecycle quarantine", async () => { const engineId = uniqueEngineId("runtime-fail-metadata"); const assemble = vi.fn(async () => { diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index b805100b86e7..8ceba9b559aa 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -54,7 +54,8 @@ function buildCompactionResultSessionTarget(params: { const suppliedAgentId = targetAgentId ?? requestedAgentId; const suppliedSessionId = normalizeOptionalString(params.sessionId); const suppliedSessionKey = targetSessionKey ?? requestedSessionKey; - const callerAgentId = suppliedAgentId ?? parseAgentSessionKey(suppliedSessionKey)?.agentId; + const suppliedSessionKeyAgentId = parseAgentSessionKey(suppliedSessionKey)?.agentId; + const callerAgentId = suppliedAgentId ?? suppliedSessionKeyAgentId; if ( (callerAgentId && marker && marker.agentId !== callerAgentId) || (targetStorePath && marker && path.resolve(marker.storePath) !== path.resolve(targetStorePath)) @@ -64,12 +65,11 @@ function buildCompactionResultSessionTarget(params: { if (marker && suppliedSessionId && marker.sessionId !== suppliedSessionId) { throw new Error("Context-engine successor identity is inconsistent"); } - const candidateSessionKey = suppliedSessionKey; const candidateEntry = - marker && candidateSessionKey + marker && suppliedSessionKey ? loadSessionEntry({ agentId: marker.agentId, - sessionKey: candidateSessionKey, + sessionKey: suppliedSessionKey, storePath: marker.storePath, }) : undefined; @@ -86,17 +86,12 @@ function buildCompactionResultSessionTarget(params: { ) : undefined; const callerAuthorizedMarkerKey = Boolean( - candidateSessionKey && - suppliedSessionKey && - candidateSessionKey === suppliedSessionKey && - (!candidateEntry || candidateEntry.sessionId === callerSessionId), + suppliedSessionKey && (!candidateEntry || candidateEntry.sessionId === callerSessionId), ); const markerSessionKey = marker - ? callerAuthorizedMarkerKey - ? candidateSessionKey - : candidateEntry?.sessionId === marker.sessionId - ? candidateSessionKey - : (preferredMarkerSessionKey ?? (candidateEntry ? undefined : candidateSessionKey)) + ? callerAuthorizedMarkerKey || candidateEntry?.sessionId === marker.sessionId + ? suppliedSessionKey + : (preferredMarkerSessionKey ?? (candidateEntry ? undefined : suppliedSessionKey)) : undefined; if (sessionFile && !marker) { throw new Error("Legacy context-engine file successors are unsupported"); @@ -106,7 +101,7 @@ function buildCompactionResultSessionTarget(params: { } if ( marker && - candidateSessionKey && + suppliedSessionKey && ((candidateEntry && candidateEntry.sessionId !== marker.sessionId && !callerAuthorizedMarkerKey) || @@ -114,11 +109,7 @@ function buildCompactionResultSessionTarget(params: { ) { throw new Error("Legacy context-engine successor session key is inconsistent"); } - if ( - marker && - parseAgentSessionKey(candidateSessionKey)?.agentId && - parseAgentSessionKey(candidateSessionKey)?.agentId !== marker.agentId - ) { + if (marker && suppliedSessionKeyAgentId && suppliedSessionKeyAgentId !== marker.agentId) { throw new Error("Legacy context-engine successor identity is inconsistent"); } const sessionId = marker?.sessionId ?? suppliedSessionId ?? targetSessionId ?? callerSessionId; @@ -239,16 +230,7 @@ function renderMemorySystemPromptAddition( params: MemoryPromptSectionParams, prepared?: PreparedMemoryPromptSection, ): string | undefined { - const lines = buildMemoryPromptSection( - { - availableTools: params.availableTools, - citationsMode: params.citationsMode, - agentId: params.agentId, - agentSessionKey: params.agentSessionKey, - sandboxed: params.sandboxed, - }, - prepared, - ); + const lines = buildMemoryPromptSection(params, prepared); if (lines.length === 0) { return undefined; } @@ -277,12 +259,6 @@ export function buildMemorySystemPromptAddition( export async function prepareMemorySystemPromptAddition( params: MemoryPromptSectionParams, ): Promise { - const prepared = await prepareMemoryPromptSection({ - availableTools: params.availableTools, - citationsMode: params.citationsMode, - agentId: params.agentId, - agentSessionKey: params.agentSessionKey, - sandboxed: params.sandboxed, - }); + const prepared = await prepareMemoryPromptSection(params); return renderMemorySystemPromptAddition(params, prepared); } diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index db8406dc6957..acb02e2b85b4 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -1,17 +1,7 @@ // Legacy context engine wraps pre-plugin context behavior behind the pluggable interface. -import type { AgentMessage } from "../agents/runtime/index.js"; -import type { MemoryCitationsMode } from "../config/types.memory.js"; import { delegateCompactionToRuntime } from "./delegate.js"; import { CONTEXT_ENGINE_HOST_PARAMS } from "./registry.js"; -import type { - ContextEngine, - ContextEngineInfo, - AssembleResult, - CompactResult, - ContextEngineRuntimeContext, - ContextEngineSessionTarget, - IngestResult, -} from "./types.js"; +import type { AssembleResult, ContextEngine, ContextEngineInfo } from "./types.js"; /** * LegacyContextEngine wraps the existing compaction behavior behind the @@ -29,25 +19,12 @@ export class LegacyContextEngine implements ContextEngine { acceptedHostParams: [...CONTEXT_ENGINE_HOST_PARAMS], }; - async ingest(_params: { - sessionId: string; - sessionKey?: string; - message: AgentMessage; - isHeartbeat?: boolean; - }): Promise { + async ingest(_params: Parameters[0]) { // No-op: SessionManager handles message persistence in the legacy flow return { ingested: false }; } - async assemble(params: { - sessionId: string; - sessionKey?: string; - messages: AgentMessage[]; - tokenBudget?: number; - availableTools?: Set; - citationsMode?: MemoryCitationsMode; - model?: string; - }): Promise { + async assemble(params: Parameters[0]): Promise { // Pass-through: the existing sanitize -> validate -> limit -> repair pipeline // in attempt.ts handles context assembly for the legacy engine. // We just return the messages as-is with a rough token estimate. @@ -57,33 +34,12 @@ export class LegacyContextEngine implements ContextEngine { }; } - async afterTurn(_params: { - sessionId: string; - sessionKey?: string; - sessionFile: string; - messages: AgentMessage[]; - prePromptMessageCount: number; - autoCompactionSummary?: string; - isHeartbeat?: boolean; - tokenBudget?: number; - runtimeContext?: ContextEngineRuntimeContext; - }): Promise { + async afterTurn(_params: Parameters>[0]): Promise { // No-op: legacy flow persists context directly in SessionManager. } - async compact(params: { - sessionId: string; - sessionKey: string; - agentId?: string; - sessionTarget?: ContextEngineSessionTarget; - tokenBudget?: number; - force?: boolean; - currentTokenCount?: number; - compactionTarget?: "budget" | "threshold"; - customInstructions?: string; - runtimeContext?: ContextEngineRuntimeContext; - }): Promise { - return await delegateCompactionToRuntime(params); + compact(params: Parameters[0]) { + return delegateCompactionToRuntime(params); } async dispose(): Promise { diff --git a/src/context-engine/quarantine-health.ts b/src/context-engine/quarantine-health.ts index 5acd357f85d0..389956298fe4 100644 --- a/src/context-engine/quarantine-health.ts +++ b/src/context-engine/quarantine-health.ts @@ -14,12 +14,8 @@ type PersistedContextEngineRuntimeQuarantine = { failedAt: Date; }; -type PersistedContextEngineQuarantineRecord = RuntimeHealthRecordEnvelope & { - engineId: string; - owner?: string; - operation: string; - reason: string; -}; +type PersistedContextEngineQuarantineRecord = RuntimeHealthRecordEnvelope & + Omit; function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -56,10 +52,6 @@ const quarantineStore = createRuntimeHealthStore) { - return JSON.stringify([record.engineId, record.processId]); -} - export function recordPersistedContextEngineQuarantine( quarantine: PersistedContextEngineRuntimeQuarantine, ): void { @@ -72,19 +64,19 @@ export function recordPersistedContextEngineQuarantine( }; // The in-memory registry only records the first quarantine per engine, so // this is called at most once per (engine, process) and overwrite is safe. - quarantineStore.register(recordKey(record), record); + quarantineStore.register(JSON.stringify([record.engineId, record.processId]), record); } export function listPersistedContextEngineQuarantines(): PersistedContextEngineRuntimeQuarantine[] { - return quarantineStore.list().map((record) => { + return quarantineStore.list().map(({ engineId, operation, reason, owner, failedAtMs }) => { const quarantine: PersistedContextEngineRuntimeQuarantine = { - engineId: record.engineId, - operation: record.operation, - reason: record.reason, - failedAt: new Date(record.failedAtMs), + engineId, + operation, + reason, + failedAt: new Date(failedAtMs), }; - if (record.owner) { - quarantine.owner = record.owner; + if (owner) { + quarantine.owner = owner; } return quarantine; }); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index c61438e86e60..2f8df3398803 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -21,7 +21,6 @@ import { import type { BootstrapResult, ContextEngine, - ContextEngineInfo, ContextEngineMaintenanceResult, IngestBatchResult, IngestResult, @@ -50,76 +49,130 @@ const GUARDED_CONTEXT_ENGINE_METHODS = new Set( export const CONTEXT_ENGINE_HOST_PARAMS = new Set( "sessionKey prompt runtimeSettings sessionTarget runtimeContext".split(" "), ); -function wrapContextEngineWithHostParamProjection(engine: ContextEngine): ContextEngine { - const removeAfter = getPluginCompatRecord("context-engine-legacy-host-param-default").removeAfter; - const accepted = engine.info.acceptedHostParams; - const engineRecord = engine as unknown as Record; - const wrappedRecord: Record = {}; - Object.defineProperty(wrappedRecord, "info", { get: () => engine.info }); - for (const methodName of GUARDED_CONTEXT_ENGINE_METHODS) { - const method = engineRecord[methodName]; - if (typeof method !== "function") { - continue; - } - wrappedRecord[methodName] = (params: Record) => { - // Removal(2026-08-12): undeclared engines get full params. Contract: context-engine-legacy-host-param-default. - const useLegacyDefault = - removeAfter !== undefined && new Date().toISOString().slice(0, 10) <= removeAfter; - const currentAccepted = accepted ?? (useLegacyDefault ? [] : undefined); - if (!currentAccepted) { - return method.call(engine, params); - } - const projected = Object.fromEntries( - Object.entries(params).filter( - ([key]) => currentAccepted.includes(key) || !CONTEXT_ENGINE_HOST_PARAMS.has(key), - ), - ); - return method.call(engine, projected); - }; - } - if (engine.dispose) { - wrappedRecord.dispose = engine.dispose.bind(engine); - } - return Object.create(engine, Object.getOwnPropertyDescriptors(wrappedRecord)) as ContextEngine; -} - type ResolvedContextEngineMetadata = { owner: string; -}; - -type RuntimeQuarantineProxyState = { engineId: string; - getResolvedFallbackEngine: () => ContextEngine | undefined; }; -const RESOLVED_CONTEXT_ENGINE_METADATA = new WeakMap< - ContextEngine, - ResolvedContextEngineMetadata ->(); -const RUNTIME_QUARANTINE_PROXY_STATE = new WeakMap(); +const resolvedEngineMetadata = new WeakMap(); function wrapResolvedContextEngine( engine: ContextEngine, - metadata: { - owner: string; - engineId: string; + metadata: ResolvedContextEngineMetadata & { defaultEngineId?: string; factoryCtx?: ContextEngineFactoryContext; }, ): ContextEngine { - const projected = wrapContextEngineWithHostParamProjection(engine); - const wrapped = + const removeAfter = getPluginCompatRecord("context-engine-legacy-host-param-default").removeAfter; + const accepted = engine.info.acceptedHostParams; + const fallback = metadata.defaultEngineId && metadata.factoryCtx && metadata.engineId !== metadata.defaultEngineId - ? wrapContextEngineWithRuntimeQuarantine({ - engine: projected, - engineId: metadata.engineId, - owner: metadata.owner, - defaultEngineId: metadata.defaultEngineId, - factoryCtx: metadata.factoryCtx, - }) - : projected; - RESOLVED_CONTEXT_ENGINE_METADATA.set(wrapped, metadata); + ? { defaultEngineId: metadata.defaultEngineId, factoryCtx: metadata.factoryCtx } + : undefined; + let fallbackEnginePromise: Promise | undefined; + let resolvedFallbackEngine: ContextEngine | undefined; + const getFallbackEngine = fallback + ? () => + (fallbackEnginePromise ??= resolveDefaultContextEngine( + fallback.defaultEngineId, + fallback.factoryCtx, + ).then((resolved) => { + resolvedFallbackEngine = resolved; + return resolved; + })) + : undefined; + const projectParams = (params: Record) => { + // Removal(2026-08-12): undeclared engines get full params. Contract: context-engine-legacy-host-param-default. + const useLegacyDefault = + removeAfter !== undefined && new Date().toISOString().slice(0, 10) <= removeAfter; + const currentAccepted = accepted ?? (useLegacyDefault ? [] : undefined); + return currentAccepted + ? Object.fromEntries( + Object.entries(params).filter( + ([key]) => currentAccepted.includes(key) || !CONTEXT_ENGINE_HOST_PARAMS.has(key), + ), + ) + : params; + }; + + // A fresh target keeps Proxy invariants compatible with frozen engines and private getters. + const wrapped = new Proxy( + Object.create(engine, { info: { get: () => engine.info } }) as ContextEngine, + { + get(_target, property) { + if (property === "info") { + if (!fallback || !getContextEngineQuarantine(metadata.engineId)) { + return engine.info; + } + return ( + resolvedFallbackEngine?.info ?? { + id: fallback.defaultEngineId, + name: + fallback.defaultEngineId === "legacy" + ? "Legacy Context Engine" + : `${fallback.defaultEngineId} Context Engine`, + } + ); + } + + const method = Reflect.get(engine, property, engine); + if (typeof method !== "function") { + return method; + } + if (!GUARDED_CONTEXT_ENGINE_METHODS.has(property)) { + return method.bind(engine); + } + if (!fallback || !getFallbackEngine) { + return (params: Record) => method.call(engine, projectParams(params)); + } + + const methodName = property as GuardedContextEngineMethodName; + return async (methodParams: Record) => { + const abortSignal = contextEngineAbortSignal(methodParams); + if (abortSignal?.aborted) { + const reason = abortSignal.reason; + throw reason instanceof Error + ? reason + : createAbortError( + typeof reason === "string" && reason + ? reason + : "Context engine operation aborted.", + ); + } + const invokeFallback = () => + invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams }); + if (getContextEngineQuarantine(metadata.engineId)) { + // Runtime failures downgrade future guarded calls for this process. + return await invokeFallback(); + } + + try { + return await method.call(engine, projectParams(methodParams)); + } catch (error) { + if (isContextEngineAbortRejection(error, abortSignal)) { + // Abort is caller intent, not engine instability; never quarantine for it. + throw error; + } + recordContextEngineQuarantine({ + engineId: metadata.engineId, + owner: metadata.owner, + operation: methodName, + error, + defaultEngineId: fallback.defaultEngineId, + }); + if (methodName === "compact" || methodName === "prepareSubagentSpawn") { + throw error; + } + return await invokeFallback().catch(() => { + throw error; + }); + } + }; + }, + }, + ); + resolvedEngineMetadata.set(wrapped, metadata); return wrapped; } @@ -163,10 +216,6 @@ function requireContextEngineOwner(owner: string): string { return normalizedOwner; } -function formatContextEngineError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function recordContextEngineQuarantine(params: { engineId: string; owner?: string; @@ -183,7 +232,7 @@ function recordContextEngineQuarantine(params: { const quarantine: ContextEngineRuntimeQuarantine = { engineId: params.engineId, operation: params.operation, - reason: formatContextEngineError(params.error), + reason: params.error instanceof Error ? params.error.message : String(params.error), failedAt: new Date(), ...(params.owner ? { owner: params.owner } : {}), }; @@ -211,23 +260,13 @@ export function listContextEngineQuarantines(): ContextEngineRuntimeQuarantine[] ({ failedAt, ...quarantine }) => ({ ...quarantine, failedAt: new Date(failedAt) }), ); const seenEngineIds = new Set(quarantines.map((entry) => entry.engineId)); - for (const entry of listPersistedContextEngineQuarantines()) { - if (seenEngineIds.has(entry.engineId)) { - continue; - } - quarantines.push(entry); - seenEngineIds.add(entry.engineId); - } - return quarantines; + return quarantines.concat( + listPersistedContextEngineQuarantines().filter(({ engineId }) => !seenEngineIds.has(engineId)), + ); } -function clearContextEngineRuntimeQuarantine(engineId?: string): void { - const quarantinedEngines = contextEngineRegistryState.quarantinedEngines; - if (engineId === undefined) { - quarantinedEngines.clear(); - } else { - quarantinedEngines.delete(engineId); - } +function clearContextEngineRuntimeQuarantine(engineId: string): void { + contextEngineRegistryState.quarantinedEngines.delete(engineId); clearPersistedContextEngineQuarantineForProcess(engineId, process.pid); } @@ -324,7 +363,10 @@ export function clearContextEnginesForOwner(owner: string): void { export function resolveContextEngineOwnerPluginId( engine: ContextEngine | undefined | null, ): string | undefined { - const owner = engine && resolveEffectiveContextEngineMetadata(engine)?.owner; + const metadata = engine ? resolvedEngineMetadata.get(engine) : undefined; + // Quarantined work belongs to its core-owned fallback, never the disabled plugin. + const owner = + metadata && !getContextEngineQuarantine(metadata.engineId) ? metadata.owner : undefined; if (!owner?.startsWith("plugin:")) { return undefined; } @@ -332,23 +374,6 @@ export function resolveContextEngineOwnerPluginId( return pluginId || undefined; } -function resolveEffectiveContextEngineMetadata( - engine: ContextEngine, -): ResolvedContextEngineMetadata | undefined { - const quarantineState = RUNTIME_QUARANTINE_PROXY_STATE.get(engine); - if (quarantineState && getContextEngineQuarantine(quarantineState.engineId)) { - // After quarantine, metadata follows the resolved fallback so plugin-scoped operations do not - // keep attributing work to a disabled engine. - const fallbackEngine = quarantineState.getResolvedFallbackEngine(); - return ( - (fallbackEngine ? RESOLVED_CONTEXT_ENGINE_METADATA.get(fallbackEngine) : undefined) ?? { - owner: CORE_CONTEXT_ENGINE_OWNER, - } - ); - } - return RESOLVED_CONTEXT_ENGINE_METADATA.get(engine); -} - function describeResolvedContextEngineContractError( engineId: string, engine: unknown, @@ -405,14 +430,10 @@ const CONTEXT_ENGINE_FALLBACK_RESULTS = { }; function contextEngineAbortSignal(methodParams: unknown): AbortSignal | undefined { - if (!methodParams || typeof methodParams !== "object") { - return undefined; - } - const signal = (methodParams as { abortSignal?: unknown }).abortSignal; - if (signal && typeof signal === "object" && "aborted" in signal) { - return signal as AbortSignal; - } - return undefined; + const signal = (methodParams as { abortSignal?: unknown } | null | undefined)?.abortSignal; + return signal && typeof signal === "object" && "aborted" in signal + ? (signal as AbortSignal) + : undefined; } function isContextEngineAbortRejection(error: unknown, signal: AbortSignal | undefined): boolean { @@ -450,93 +471,6 @@ async function invokeFallbackContextEngineMethod(params: { return fallbackResult ? { ...fallbackResult } : undefined; } -function wrapContextEngineWithRuntimeQuarantine(params: { - engine: ContextEngine; - engineId: string; - owner: string; - defaultEngineId: string; - factoryCtx: ContextEngineFactoryContext; -}): ContextEngine { - let fallbackEnginePromise: Promise | undefined; - let resolvedFallbackEngine: ContextEngine | undefined; - const getFallbackEngine = () => { - fallbackEnginePromise ??= resolveDefaultContextEngine( - params.defaultEngineId, - params.factoryCtx, - ).then((engine) => { - resolvedFallbackEngine = engine; - return engine; - }); - return fallbackEnginePromise; - }; - const fallbackInfo = (): ContextEngineInfo => - resolvedFallbackEngine?.info ?? { - id: params.defaultEngineId, - name: - params.defaultEngineId === "legacy" - ? "Legacy Context Engine" - : `${params.defaultEngineId} Context Engine`, - }; - const isQuarantined = () => Boolean(getContextEngineQuarantine(params.engineId)); - - const proxy = new Proxy(params.engine, { - get(target, property, receiver) { - if (property === "info" && isQuarantined()) { - return fallbackInfo(); - } - const value = Reflect.get(target, property, receiver); - if (typeof value !== "function" || !GUARDED_CONTEXT_ENGINE_METHODS.has(property)) { - return typeof value === "function" ? value.bind(target) : value; - } - - const methodName = property as GuardedContextEngineMethodName; - return async (methodParams: unknown) => { - const abortSignal = contextEngineAbortSignal(methodParams); - if (abortSignal?.aborted) { - const reason = abortSignal.reason; - throw reason instanceof Error - ? reason - : createAbortError( - typeof reason === "string" && reason ? reason : "Context engine operation aborted.", - ); - } - const invokeFallback = () => - invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams }); - if (isQuarantined()) { - // Runtime failures downgrade future guarded calls for this process. - return await invokeFallback(); - } - - try { - return await (value as (methodParams: unknown) => unknown).call(target, methodParams); - } catch (error) { - if (isContextEngineAbortRejection(error, abortSignal)) { - // Abort is caller intent, not engine instability; never quarantine for it. - throw error; - } - recordContextEngineQuarantine({ - engineId: params.engineId, - owner: params.owner, - operation: methodName, - error, - defaultEngineId: params.defaultEngineId, - }); - if (methodName === "compact" || methodName === "prepareSubagentSpawn") { - throw error; - } - return await invokeFallback().catch(() => { - throw error; - }); - } - }; - }, - }); - RUNTIME_QUARANTINE_PROXY_STATE.set(proxy, { - engineId: params.engineId, - getResolvedFallbackEngine: () => resolvedFallbackEngine, - }); - return proxy; -} // --------------------------------------------------------------------------- // Resolution // --------------------------------------------------------------------------- diff --git a/src/context-engine/runtime-settings.ts b/src/context-engine/runtime-settings.ts index 715ab337c153..5c2ad151f4b6 100644 --- a/src/context-engine/runtime-settings.ts +++ b/src/context-engine/runtime-settings.ts @@ -1,3 +1,4 @@ +import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce"; import type { ContextEngineHostSupport } from "./host-compat.js"; import type { ContextEngineRuntimeReasonCode, @@ -7,7 +8,6 @@ import type { } from "./types.js"; type OptionalString = string | null | undefined; -type OptionalReason = string | null | undefined; const RUNTIME_REASON_CODES = new Set([ "provider_timeout", @@ -17,20 +17,19 @@ const RUNTIME_REASON_CODES = new Set([ "runtime_unavailable", "unknown", ]); - -function normalizeNullableString(value: OptionalString): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} +const RUNTIME_REASON_PATTERNS: Array<[ContextEngineRuntimeReasonCode, RegExp]> = [ + ["provider_timeout", /timeout/iu], + ["rate_limited", /rate|limit|429/iu], + ["context_overflow", /overflow|context|pressure/iu], + ["runtime_unavailable", /runtime/iu], + ["provider_unavailable", /provider|primary|unavailable/iu], +]; function normalizeNullableNumber(value: number | null | undefined): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function normalizeReasonCode(value: OptionalReason): ContextEngineRuntimeReasonCode | null { +function normalizeReasonCode(value: OptionalString): ContextEngineRuntimeReasonCode | null { const normalized = normalizeNullableString(value); if (!normalized) { return null; @@ -39,23 +38,7 @@ function normalizeReasonCode(value: OptionalReason): ContextEngineRuntimeReasonC return normalized as ContextEngineRuntimeReasonCode; } - const lower = normalized.toLowerCase(); - if (lower.includes("timeout")) { - return "provider_timeout"; - } - if (lower.includes("rate") || lower.includes("limit") || lower.includes("429")) { - return "rate_limited"; - } - if (lower.includes("overflow") || lower.includes("context") || lower.includes("pressure")) { - return "context_overflow"; - } - if (lower.includes("runtime")) { - return "runtime_unavailable"; - } - if (lower.includes("provider") || lower.includes("primary") || lower.includes("unavailable")) { - return "provider_unavailable"; - } - return "unknown"; + return RUNTIME_REASON_PATTERNS.find(([, pattern]) => pattern.test(normalized))?.[0] ?? "unknown"; } export function buildContextEngineRuntimeSettings(params: { @@ -68,8 +51,8 @@ export function buildContextEngineRuntimeSettings(params: { modelFamily?: OptionalString; selectedContextEngineId?: OptionalString; contextEngineSelectionSource?: ContextEngineSelectionSource; - fallbackReason?: OptionalReason; - degradedReason?: OptionalReason; + fallbackReason?: OptionalString; + degradedReason?: OptionalString; promptTokenBudget?: number | null; maxOutputTokens?: number | null; contextEngineHost: ContextEngineHostSupport; From 685a3dbe5d725fa8479bb70628de8caebd035826 Mon Sep 17 00:00:00 2001 From: zhangLei99586 Date: Sun, 2 Aug 2026 01:40:40 +0800 Subject: [PATCH 20/53] fix(proxy-capture): make path-based session cleanup atomic (#98852) --- src/proxy-capture/store.sqlite.test.ts | 61 ++++++++++++++++++++++++++ src/proxy-capture/store.sqlite.ts | 18 +++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/proxy-capture/store.sqlite.test.ts b/src/proxy-capture/store.sqlite.test.ts index 719b2a5a3a94..486ea164e548 100644 --- a/src/proxy-capture/store.sqlite.test.ts +++ b/src/proxy-capture/store.sqlite.test.ts @@ -1,6 +1,7 @@ // Proxy capture SQLite store tests cover persisted capture reads and writes. import fs from "node:fs"; import path from "node:path"; +import { constants } from "node:sqlite"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; @@ -161,6 +162,66 @@ describe("DebugProxyCaptureStore", () => { expect(lease.store.isClosed).toBe(true); }); + it.each(["deleteSessions", "purgeAll"] as const)( + "rolls back path-based %s when session deletion fails", + (operation) => { + const root = makeTempDir(cleanupDirs, "openclaw-proxy-capture-rollback-"); + const dbPath = path.join(root, "capture.sqlite"); + const blobDir = path.join(root, "blobs"); + const lease = acquireDebugProxyCaptureStore(dbPath, blobDir); + const sessionId = "path-based-rollback-session"; + + try { + lease.store.upsertSession({ + id: sessionId, + startedAt: 1, + mode: "sdk", + sourceScope: "openclaw", + sourceProcess: "plugin", + dbPath, + blobDir, + }); + const blob = lease.store.persistPayload(Buffer.from("rollback payload"), "text/plain"); + lease.store.recordEvent({ + sessionId, + ts: 2, + sourceScope: "openclaw", + sourceProcess: "plugin", + protocol: "https", + direction: "outbound", + kind: "request", + flowId: "path-based-rollback-flow", + dataBlobId: blob.blobId, + dataSha256: blob.sha256, + }); + + const cleanup = () => + operation === "deleteSessions" + ? lease.store.deleteSessions([sessionId]) + : lease.store.purgeAll(); + lease.store.db.setAuthorizer((action, table) => + action === constants.SQLITE_DELETE && table === "capture_sessions" + ? constants.SQLITE_DENY + : constants.SQLITE_OK, + ); + + expect(cleanup).toThrow(/not authorized/u); + lease.store.db.setAuthorizer(null); + expect(lease.store.listSessions()).toHaveLength(1); + expect(lease.store.getSessionEvents(sessionId)).toHaveLength(1); + expect(fs.existsSync(blob.path)).toBe(true); + + expect(cleanup()).toEqual({ sessions: 1, events: 1, blobs: 1 }); + expect(lease.store.listSessions()).toEqual([]); + expect(lease.store.getSessionEvents(sessionId)).toEqual([]); + expect(fs.existsSync(blob.path)).toBe(false); + } finally { + lease.store.db.setAuthorizer(null); + lease.release(); + } + }, + ); + it("uses rollback journaling for captures on NFS-backed volumes", () => { vi.spyOn(fs, "statfsSync").mockReturnValue({ type: 0x6969, diff --git a/src/proxy-capture/store.sqlite.ts b/src/proxy-capture/store.sqlite.ts index 074926061a5e..4f5ecbce5e1c 100644 --- a/src/proxy-capture/store.sqlite.ts +++ b/src/proxy-capture/store.sqlite.ts @@ -616,7 +616,9 @@ class DebugProxyCaptureStoreImpl { const eventCount = (this.db.prepare(`SELECT COUNT(*) AS count FROM capture_events`).get() as { count: number }) .count ?? 0; - this.db.exec(`DELETE FROM capture_events; DELETE FROM capture_sessions;`); + runSqliteImmediateTransactionSync(this.db, () => { + this.db.exec(`DELETE FROM capture_events; DELETE FROM capture_sessions;`); + }); let blobs = 0; if (fs.existsSync(this.pathBased.blobDir)) { for (const entry of fs.readdirSync(this.pathBased.blobDir)) { @@ -764,12 +766,14 @@ class DebugProxyCaptureStoreImpl { ) .get(...sessionIds) as { count: number } ).count ?? 0; - this.db - .prepare(`DELETE FROM capture_events WHERE session_id IN (${placeholders})`) - .run(...sessionIds); - this.db - .prepare(`DELETE FROM capture_sessions WHERE id IN (${placeholders})`) - .run(...sessionIds); + runSqliteImmediateTransactionSync(this.db, () => { + this.db + .prepare(`DELETE FROM capture_events WHERE session_id IN (${placeholders})`) + .run(...sessionIds); + this.db + .prepare(`DELETE FROM capture_sessions WHERE id IN (${placeholders})`) + .run(...sessionIds); + }); const candidateBlobIds = blobRows .map((row) => row.blobId?.trim()) .filter((blobId): blobId is string => Boolean(blobId)); From b7f0df0ac2e25efb7d6f83ede70a4739086237b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:40:56 -0700 Subject: [PATCH 21/53] refactor(agents): consolidate main-session recovery ownership (#117383) * refactor(agents): consolidate main-session recovery ownership * test(agents): cover recovery owner boundaries --- src/agents/agent-command-recovery-owner.ts | 36 +- src/agents/main-session-recovery-lifecycle.ts | 228 ++++---- .../main-session-recovery-owner-release.ts | 15 +- src/agents/main-session-recovery-restore.ts | 36 -- .../main-session-recovery-state.test.ts | 15 +- src/agents/main-session-recovery-state.ts | 176 +++--- .../main-session-recovery-store.test.ts | 8 +- src/agents/main-session-recovery-store.ts | 251 +++----- src/agents/main-session-recovery-types.ts | 50 +- src/agents/main-session-restart-dispatch.ts | 310 +++++----- .../main-session-restart-recovery-failure.ts | 99 ++-- .../main-session-restart-recovery-marking.ts | 204 +++---- .../main-session-restart-recovery-notice.ts | 61 +- .../main-session-restart-recovery-runtime.ts | 542 +++++++----------- .../main-session-restart-recovery-shared.ts | 5 - .../main-session-restart-recovery-store.ts | 217 +++---- .../main-session-restart-recovery.test.ts | 256 ++++----- src/agents/main-session-restart-recovery.ts | 2 - .../doctor-main-session-recovery.test.ts | 96 ++++ src/commands/doctor-main-session-recovery.ts | 18 +- .../agent-run-execution-phase.ts | 26 +- .../server-methods/agent-session-persist.ts | 11 +- 22 files changed, 1175 insertions(+), 1487 deletions(-) delete mode 100644 src/agents/main-session-recovery-restore.ts create mode 100644 src/commands/doctor-main-session-recovery.test.ts diff --git a/src/agents/agent-command-recovery-owner.ts b/src/agents/agent-command-recovery-owner.ts index 7ffd52f34f62..26f27e140c0d 100644 --- a/src/agents/agent-command-recovery-owner.ts +++ b/src/agents/agent-command-recovery-owner.ts @@ -3,16 +3,12 @@ import type { InternalSessionEntry } from "../config/sessions.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { AgentCommandOpts } from "./command/types.js"; +import { repairMainSessionRecoveryMutation } from "./main-session-recovery-lifecycle.js"; import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js"; import { - restoreAdmittedRecoveryWithRetries, - scheduleAdmittedRecoveryRestore, -} from "./main-session-recovery-restore.js"; -import { - bindMainSessionRecoveryOwnerRun, claimMainSessionRecoveryOwner, inspectMainSessionRecoveryRequired, - readMainSessionRecoveryOwner, + refreshMainSessionRecoveryOwner, releaseMainSessionRecoveryOwner, type MainSessionRecoveryOwnerLease, type MainSessionRecoveryPendingTarget, @@ -85,14 +81,11 @@ async function claimAgentCommandRecoveryOwner(params: { // session resolution so rollover or rerouting cannot execute under another row's lease. throw new Error("main-session recovery owner changed during ingress preparation; retry"); } - if (params.opts.runId) { - return await bindMainSessionRecoveryOwnerRun(transferredLease, params.opts.runId); - } - const snapshot = await readMainSessionRecoveryOwner(transferredLease); + const snapshot = await refreshMainSessionRecoveryOwner(transferredLease, params.opts.runId); if (!snapshot) { throw new Error("main-session recovery owner changed during ingress preparation; retry"); } - return { ...snapshot, lease: transferredLease }; + return snapshot; } if (params.opts.sessionEffects === "internal") { return undefined; @@ -167,17 +160,16 @@ export async function runWithAgentCommandRecoveryOwner< } catch (error) { // Gateway admission consumes the durable reservation before command // preparation. Restore it when preparation fails before a run exists. - if (params.restoreAdmittedRecovery) { - try { - pendingRecovery = await restoreAdmittedRecoveryWithRetries( - params.restoreAdmittedRecovery, - ); - } catch (restoreError) { - log.warn( - `failed to restore admitted recovery after command preparation: ${formatErrorMessage(restoreError)}`, - ); - scheduleAdmittedRecoveryRestore(params.restoreAdmittedRecovery); - } + const restoreAdmittedRecovery = params.restoreAdmittedRecovery; + if (restoreAdmittedRecovery) { + pendingRecovery = await repairMainSessionRecoveryMutation({ + mutation: restoreAdmittedRecovery, + onDeferredSuccess: scheduleMainSessionRecoveryPendingTarget, + onError: (restoreError) => + log.warn( + `failed to restore admitted recovery after command preparation: ${formatErrorMessage(restoreError)}`, + ), + }); } throw error; } diff --git a/src/agents/main-session-recovery-lifecycle.ts b/src/agents/main-session-recovery-lifecycle.ts index cd3fd3ddf70a..cd52668708d5 100644 --- a/src/agents/main-session-recovery-lifecycle.ts +++ b/src/agents/main-session-recovery-lifecycle.ts @@ -1,12 +1,13 @@ import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { mergeRestartRecoveryTerminalRunIds } from "../config/sessions/restart-recovery-state.js"; +import { retryAsync } from "../infra/retry.js"; import { buildMainSessionRecoveryClearPatch, type MainRecoveryStateFields, } from "./main-session-recovery-clear.js"; -const MAIN_RESTART_RECOVERY_WEDGED_FALLBACK_REASON = - "main-session restart recovery is tombstoned for this session"; +const MAIN_SESSION_RECOVERY_RETRY_DELAY_MS = 1_000; +const MAIN_SESSION_RECOVERY_RETRY_MAX_DELAY_MS = 30_000; type MainRecoveryLifecycleEvent = { runId?: string; @@ -14,26 +15,45 @@ type MainRecoveryLifecycleEvent = { data?: { error?: unknown; phase?: unknown; stopReason?: unknown }; }; -export function inspectMainSessionRecoveryHealth(entry: SessionEntry): - | { status: "none" } - | { status: "active" } - | { - status: "tombstoned"; - reason: string; - repair: "clear_stale_abort" | null; - } { - const state = entry.mainRestartRecovery; - if (!state) { - return { status: "none" }; +export async function retryMainSessionRecoveryMutation(mutation: () => Promise): Promise { + return await retryAsync(mutation, 3, 25); +} + +/** Retries now, then leaves an exact idempotent repair queued after transient failure. */ +export async function repairMainSessionRecoveryMutation(params: { + mutation: () => Promise; + onDeferredSuccess: (result: T) => void | Promise; + onError: (error: unknown) => void; +}): Promise { + try { + return await retryMainSessionRecoveryMutation(params.mutation); + } catch (error) { + params.onError(error); + scheduleMainSessionRecoveryMutation({ + mutation: () => retryMainSessionRecoveryMutation(params.mutation), + onSuccess: params.onDeferredSuccess, + }); + return undefined; } - if (!state.tombstone) { - return { status: "active" }; - } - return { - status: "tombstoned", - reason: state.tombstone.reason.trim() || MAIN_RESTART_RECOVERY_WEDGED_FALLBACK_REASON, - repair: entry.abortedLastRun === true ? "clear_stale_abort" : null, - }; +} + +/** Keeps an idempotent durable-state repair alive until it succeeds or restart retires it. */ +export function scheduleMainSessionRecoveryMutation(params: { + mutation: () => Promise; + onError?: (error: unknown) => void; + onSuccess: (result: T) => void | Promise; + delayMs?: number; +}): void { + const delayMs = params.delayMs ?? MAIN_SESSION_RECOVERY_RETRY_DELAY_MS; + setTimeout(() => { + void params.mutation().then(params.onSuccess, (error: unknown) => { + params.onError?.(error); + scheduleMainSessionRecoveryMutation({ + ...params, + delayMs: Math.min(delayMs * 2, MAIN_SESSION_RECOVERY_RETRY_MAX_DELAY_MS), + }); + }); + }, delayMs).unref?.(); } function lifecyclePhase(event: MainRecoveryLifecycleEvent): "start" | "end" | "error" | null { @@ -61,6 +81,47 @@ export function isMainSessionRecoveryLifecycleEvent(params: { ); } +function settleForegroundOwner( + entry: MainRecoveryStateFields, + runId: string, + lifecycleGeneration: string, + currentLifecycleGeneration: string, +) { + const state = entry.mainRestartRecovery; + const claims = state?.foregroundClaims; + const claimId = + lifecycleGeneration === currentLifecycleGeneration && + claims?.lifecycleGeneration === lifecycleGeneration + ? claims.tokens.find((token) => claims.runIdsByClaimId?.[token] === runId) + : undefined; + if (!state || !claims || !claimId) { + return { + hasCurrentOwner: + Boolean( + claims?.lifecycleGeneration === currentLifecycleGeneration && claims.tokens.length, + ) || state?.reservation?.lifecycleGeneration === currentLifecycleGeneration, + }; + } + const tokens = claims.tokens.filter((token) => token !== claimId); + const runIdsByClaimId = Object.fromEntries( + Object.entries(claims.runIdsByClaimId ?? {}).filter(([token]) => token !== claimId), + ); + const foregroundClaims = tokens.length + ? { + lifecycleGeneration: claims.lifecycleGeneration, + tokens, + ...(Object.keys(runIdsByClaimId).length ? { runIdsByClaimId } : {}), + } + : undefined; + return { + claimId, + state: { ...state, revision: state.revision + 1, foregroundClaims }, + hasCurrentOwner: + Boolean(foregroundClaims) || + state.reservation?.lifecycleGeneration === currentLifecycleGeneration, + }; +} + export function projectMainSessionRecoveryLifecycle(params: { currentLifecycleGeneration: string; entry?: @@ -73,19 +134,17 @@ export function projectMainSessionRecoveryLifecycle(params: { event: MainRecoveryLifecycleEvent; snapshotPatch: Partial; }): { action: "suppress" } | { action: "apply"; patch: Partial } { + const apply = (patch: Partial) => ({ action: "apply" as const, patch }); if (params.entry?.mainRestartRecovery?.tombstone) { // Keep the operator boundary while allowing unrelated lifecycle status to settle. return isMainSessionRecoveryLifecycleEvent(params) ? { action: "suppress" } - : { - action: "apply", - patch: { - ...params.snapshotPatch, - abortedLastRun: params.entry.abortedLastRun, - restartRecoveryRuns: params.entry.restartRecoveryRuns, - mainRestartRecovery: params.entry.mainRestartRecovery, - }, - }; + : apply({ + ...params.snapshotPatch, + abortedLastRun: params.entry.abortedLastRun, + restartRecoveryRuns: params.entry.restartRecoveryRuns, + mainRestartRecovery: params.entry.mainRestartRecovery, + }); } if (isMainSessionRecoveryLifecycleEvent(params)) { return { action: "suppress" }; @@ -113,8 +172,13 @@ export function projectMainSessionRecoveryLifecycle(params: { ) : runs; if (settlesRecovery) { + if (!matchesFence || !runId || !lifecycleGeneration) { + // No terminal snapshot may settle a recovery row it cannot identify. + return params.entry?.mainRestartRecovery || runs?.length + ? { action: "suppress" } + : apply(patch); + } if ( - matchesFence && lifecycleGeneration !== params.currentLifecycleGeneration && remaining?.some( (run) => @@ -123,88 +187,34 @@ export function projectMainSessionRecoveryLifecycle(params: { ) { // Older generations share the live owner's run id. Consume only their // fence; recording that id as terminal would also tombstone its replacement. - return { action: "apply", patch: { restartRecoveryRuns: remaining } }; + return apply({ restartRecoveryRuns: remaining }); } - const foregroundClaims = params.entry?.mainRestartRecovery?.foregroundClaims; - const foregroundOwnerClaimId = - runId && - lifecycleGeneration && - lifecycleGeneration === params.currentLifecycleGeneration && - foregroundClaims?.lifecycleGeneration === lifecycleGeneration - ? foregroundClaims.tokens.find( - (claimId) => foregroundClaims.runIdsByClaimId?.[claimId] === runId, - ) - : undefined; - const remainingForegroundClaimIds = foregroundOwnerClaimId - ? foregroundClaims!.tokens.filter((claimId) => claimId !== foregroundOwnerClaimId) - : foregroundClaims?.tokens; - const remainingForegroundRunIds = foregroundOwnerClaimId - ? Object.fromEntries( - Object.entries(foregroundClaims?.runIdsByClaimId ?? {}).filter( - ([claimId]) => claimId !== foregroundOwnerClaimId, - ), - ) - : foregroundClaims?.runIdsByClaimId; - const remainingForegroundClaims = remainingForegroundClaimIds?.length - ? { - lifecycleGeneration: foregroundClaims!.lifecycleGeneration, - tokens: remainingForegroundClaimIds, - ...(remainingForegroundRunIds && Object.keys(remainingForegroundRunIds).length > 0 - ? { runIdsByClaimId: remainingForegroundRunIds } - : {}), - } - : undefined; - const recoveryStateAfterForegroundSettlement = foregroundOwnerClaimId - ? { - ...params.entry!.mainRestartRecovery!, - revision: params.entry!.mainRestartRecovery!.revision + 1, - foregroundClaims: remainingForegroundClaims, - } - : params.entry?.mainRestartRecovery; - const hasForegroundOwners = Boolean( - remainingForegroundClaims?.lifecycleGeneration === params.currentLifecycleGeneration && - remainingForegroundClaims.tokens.length, + const foreground = settleForegroundOwner( + params.entry ?? {}, + runId, + lifecycleGeneration, + params.currentLifecycleGeneration, ); - const reservation = params.entry?.mainRestartRecovery?.reservation; - const hasCurrentReservation = - reservation?.lifecycleGeneration === params.currentLifecycleGeneration; - const hasCurrentOwner = hasForegroundOwners || hasCurrentReservation; - if (!matchesFence) { - // No terminal snapshot may settle a recovery row it cannot identify. - return params.entry?.mainRestartRecovery || runs?.length - ? { action: "suppress" } - : { action: "apply", patch }; - } - if (hasCurrentOwner) { + if (foreground.hasCurrentOwner) { // A terminal event may consume its own claim. Another owner still keeps // the aggregate live until that owner's terminal event or release. - return { - action: "apply", - patch: { - restartRecoveryRuns: remaining?.length ? remaining : undefined, - restartRecoveryTerminalRunIds: mergeRestartRecoveryTerminalRunIds( - params.entry?.restartRecoveryTerminalRunIds, - [runId], - ), - ...(foregroundOwnerClaimId - ? { mainRestartRecovery: recoveryStateAfterForegroundSettlement } - : {}), - }, - }; + return apply({ + restartRecoveryRuns: remaining?.length ? remaining : undefined, + restartRecoveryTerminalRunIds: mergeRestartRecoveryTerminalRunIds( + params.entry?.restartRecoveryTerminalRunIds, + [runId], + ), + ...(foreground.claimId ? { mainRestartRecovery: foreground.state } : {}), + }); } - if (foregroundOwnerClaimId) { + if (foreground.claimId) { // This exact foreground run completed while its release lease was still // active. Its terminal snapshot is authoritative and consumes the cycle. Object.assign(patch, buildMainSessionRecoveryClearPatch(params.entry)); - return { action: "apply", patch }; + return apply(patch); } - if ( - !hasForegroundOwners && - !hasCurrentReservation && - params.entry?.abortedLastRun === true && - (remaining?.length ?? 0) > 0 - ) { - return { action: "apply", patch: { restartRecoveryRuns: remaining } }; + if (params.entry?.abortedLastRun === true && (remaining?.length ?? 0) > 0) { + return apply({ restartRecoveryRuns: remaining }); } const recoveryDeliveryRunId = typeof params.entry?.restartRecoveryDeliveryRunId === "string" @@ -216,19 +226,19 @@ export function projectMainSessionRecoveryLifecycle(params: { patch.abortedLastRun = false; patch.restartRecoveryRuns = remaining; patch.mainRestartRecovery = params.entry?.mainRestartRecovery; - return { action: "apply", patch }; + return apply(patch); } // An admitted recovery clears the interruption flag before it runs. With // no live owner left, that exact delivery run is the durable cleanup boundary. Object.assign(patch, buildMainSessionRecoveryClearPatch(params.entry)); - return { action: "apply", patch }; + return apply(patch); } if (phase === "start" || !matchesFence || !remaining) { - return { action: "apply", patch }; + return apply(patch); } if (params.entry?.abortedLastRun === true && remaining.length > 0) { - return { action: "apply", patch: { restartRecoveryRuns: remaining } }; + return apply({ restartRecoveryRuns: remaining }); } patch.restartRecoveryRuns = remaining.length > 0 ? remaining : undefined; - return { action: "apply", patch }; + return apply(patch); } diff --git a/src/agents/main-session-recovery-owner-release.ts b/src/agents/main-session-recovery-owner-release.ts index 2963f9e2bcc2..d301936225a4 100644 --- a/src/agents/main-session-recovery-owner-release.ts +++ b/src/agents/main-session-recovery-owner-release.ts @@ -1,5 +1,3 @@ -import { getRuntimeConfig } from "../config/io.js"; -import { getGatewayRecoveryRuntime } from "../gateway/server-recovery-runtime-context.js"; import type { MainSessionRecoveryPendingTarget } from "./main-session-recovery-store.js"; /** Schedules exact-row recovery only after the caller releases its lifecycle admission. */ @@ -10,17 +8,16 @@ export function scheduleMainSessionRecoveryPendingTarget( return; } void import("./main-session-restart-recovery.js").then( - ({ scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease }) => { - scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease({ + ({ scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease: schedule }) => + schedule({ expectedSessionId: target.sessionId, getConfig: getRuntimeConfig, getGatewayRuntime: getGatewayRecoveryRuntime, sessionKey: target.sessionKey, storePath: target.storePath, - }); - }, - () => { - // Startup recovery remains the fallback if the optional recovery module cannot load. - }, + }), + () => {}, // Startup recovery remains the fallback if this optional module cannot load. ); } +import { getRuntimeConfig } from "../config/io.js"; +import { getGatewayRecoveryRuntime } from "../gateway/server-recovery-runtime-context.js"; diff --git a/src/agents/main-session-recovery-restore.ts b/src/agents/main-session-recovery-restore.ts deleted file mode 100644 index f80731ab0c7c..000000000000 --- a/src/agents/main-session-recovery-restore.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { formatErrorMessage } from "../infra/errors.js"; -import { retryAsync } from "../infra/retry.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js"; -import type { MainSessionRecoveryPendingTarget } from "./main-session-recovery-store.js"; - -const log = createSubsystemLogger("main-session-recovery"); -const RESTORE_RETRY_DELAY_MS = 1_000; -const RESTORE_RETRY_MAX_DELAY_MS = 30_000; - -export type RestoreAdmittedRecovery = () => Promise; - -export async function restoreAdmittedRecoveryWithRetries( - restore: RestoreAdmittedRecovery, -): Promise { - return await retryAsync(restore, 3, 25); -} - -export function scheduleAdmittedRecoveryRestore( - restore: RestoreAdmittedRecovery, - delayMs = RESTORE_RETRY_DELAY_MS, -): void { - // Gateway admission consumed the reservation already. Keep restoration - // alive until this exact idempotent callback repairs or rejects its fence. - setTimeout(() => { - void restoreAdmittedRecoveryWithRetries(restore).then( - (pendingRecovery) => { - scheduleMainSessionRecoveryPendingTarget(pendingRecovery); - }, - (error: unknown) => { - log.warn(`failed delayed admitted recovery restoration: ${formatErrorMessage(error)}`); - scheduleAdmittedRecoveryRestore(restore, Math.min(delayMs * 2, RESTORE_RETRY_MAX_DELAY_MS)); - }, - ); - }, delayMs).unref?.(); -} diff --git a/src/agents/main-session-recovery-state.test.ts b/src/agents/main-session-recovery-state.test.ts index 58a529f057f7..4b8ef1e8b404 100644 --- a/src/agents/main-session-recovery-state.test.ts +++ b/src/agents/main-session-recovery-state.test.ts @@ -4,10 +4,7 @@ import type { MainRestartRecoveryState, } from "../config/sessions.js"; import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js"; -import { - inspectMainSessionRecoveryHealth, - projectMainSessionRecoveryLifecycle, -} from "./main-session-recovery-lifecycle.js"; +import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lifecycle.js"; import { transitionMainSessionRecovery } from "./main-session-recovery-state.js"; const sessionKey = "agent:main:main"; @@ -701,18 +698,10 @@ describe("main session recovery state", () => { reason: view.reason, }), ).toEqual({ kind: "tombstoned" }); - expect(inspectMainSessionRecoveryHealth(entry)).toEqual({ - status: "tombstoned", - reason: view.reason, - repair: null, - }); + expect(entry.mainRestartRecovery?.tombstone?.reason).toBe(view.reason); expect(observe(entry, "generation-1")).toEqual({ status: "tombstoned" }); entry.abortedLastRun = true; - expect(inspectMainSessionRecoveryHealth(entry)).toMatchObject({ - status: "tombstoned", - repair: "clear_stale_abort", - }); expect(transitionMainSessionRecovery(entry, { kind: "doctor_repair", now: 500 })).toEqual({ kind: "doctor_repaired", }); diff --git a/src/agents/main-session-recovery-state.ts b/src/agents/main-session-recovery-state.ts index de54fc8b5a5b..95d7a0e168eb 100644 --- a/src/agents/main-session-recovery-state.ts +++ b/src/agents/main-session-recovery-state.ts @@ -17,6 +17,7 @@ import type { MainSessionRecoveryTransitionResult, MainSessionRecoveryView, } from "./main-session-recovery-types.js"; +import { MAX_RECOVERY_RETRIES } from "./main-session-restart-recovery-shared.js"; export type { MainSessionRecoveryCommand, @@ -26,13 +27,15 @@ export type { MainSessionRecoveryTransitionResult, } from "./main-session-recovery-types.js"; -const MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS = 3; - const MAIN_RESTART_RECOVERY_REMEDIATION_HINT = "inspect the failed main session and use /new or reset to start a replacement session"; -function nextRevision(state: MainRestartRecoveryState): number { - return state.revision + 1; +function updateRecoveryState( + entry: SessionEntry, + state: MainRestartRecoveryState, + patch: Omit, "revision">, +): MainRestartRecoveryState { + return (entry.mainRestartRecovery = { ...state, revision: state.revision + 1, ...patch }); } function createCycle(cycleId: string): MainRestartRecoveryState { @@ -43,18 +46,6 @@ function createCycle(cycleId: string): MainRestartRecoveryState { }; } -function observationFor(entry: SessionEntry): MainSessionRecoveryObservation | undefined { - const state = entry.mainRestartRecovery; - if (!state) { - return undefined; - } - return { - sessionId: entry.sessionId, - cycleId: state.cycleId, - revision: state.revision, - }; -} - function matchesObservation( entry: SessionEntry, observation: MainSessionRecoveryObservation, @@ -78,6 +69,17 @@ function hasCurrentForegroundClaim( ); } +function ownsForegroundClaim( + state: MainRestartRecoveryState | undefined, + claim: { cycleId: string; lifecycleGeneration: string; claimId: string }, +): boolean { + return ( + state?.cycleId === claim.cycleId && + state.foregroundClaims?.lifecycleGeneration === claim.lifecycleGeneration && + state.foregroundClaims.tokens.includes(claim.claimId) + ); +} + function validateRecoveryAdmission( entry: SessionEntry, command: { @@ -106,12 +108,8 @@ function validateRecoveryAdmission( export function normalizeMainSessionRecoveryRunFences( runs: Iterable, ): RestartRecoveryRun[] { - const ownersByRunId = new Map(); - for (const run of runs) { - ownersByRunId.set(run.runId, run); - } - return [...ownersByRunId.values()].toSorted((left, right) => - left.runId.localeCompare(right.runId), + return [...new Map([...runs].map((run) => [run.runId, run] as const)).values()].toSorted( + (left, right) => left.runId.localeCompare(right.runId), ); } @@ -124,31 +122,6 @@ function recordLifecycleFence(entry: SessionEntry, run: RestartRecoveryRun): voi ]); } -function hasLifecycleFence(entry: SessionEntry, run: RestartRecoveryRun): boolean { - return Boolean( - entry.restartRecoveryRuns?.some( - (candidate) => - candidate.runId === run.runId && candidate.lifecycleGeneration === run.lifecycleGeneration, - ), - ); -} - -function formatAttemptBudgetReason(attempts: number): string { - return ( - `main-session restart recovery blocked after ${attempts} charged automatic resume attempts; ` + - MAIN_RESTART_RECOVERY_REMEDIATION_HINT - ); -} - -export function isMainSessionRecoveryExhausted(entry: SessionEntry): boolean { - return ( - entry.status === "running" && - entry.abortedLastRun === true && - (entry.mainRestartRecovery?.chargedAttempts ?? 0) >= - MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS - ); -} - export function isMainRestartRecoveryCandidate(entry: SessionEntry, sessionKey: string): boolean { if (typeof entry.spawnDepth === "number" && entry.spawnDepth > 0) { return false; @@ -163,6 +136,18 @@ export function isMainRestartRecoveryCandidate(entry: SessionEntry, sessionKey: ); } +export function isMainSessionRecoveryPending(entry: SessionEntry, sessionKey: string): boolean { + const state = entry.mainRestartRecovery; + return ( + entry.status === "running" && + entry.abortedLastRun === true && + isMainRestartRecoveryCandidate(entry, sessionKey) && + !state?.foregroundClaims && + !state?.reservation && + !state?.tombstone + ); +} + // A healthy session can retain lifecycle fences after its final recovery owner // clears. With no active delivery or aggregate, those fences no longer own work. function hasOrphanedMainRestartRecoveryFences(entry: SessionEntry, sessionKey: string): boolean { @@ -216,18 +201,24 @@ function inspectMainSessionRecovery(params: { ) { return { status: "inactive" }; } - const observation = observationFor(entry); - if (!state || !observation) { + if (!state) { return { status: "inactive" }; } + const observation = { + sessionId: entry.sessionId, + cycleId: state.cycleId, + revision: state.revision, + }; if (state.reservation) { return { status: "blocked" }; } - if (state.chargedAttempts >= MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS) { + if (state.chargedAttempts >= MAX_RECOVERY_RETRIES) { return { status: "exhausted", observation, - reason: formatAttemptBudgetReason(state.chargedAttempts), + reason: + `main-session restart recovery blocked after ${state.chargedAttempts} charged automatic resume attempts; ` + + MAIN_RESTART_RECOVERY_REMEDIATION_HINT, }; } return { @@ -318,11 +309,7 @@ export function transitionMainSessionRecovery( Object.assign(entry, buildMainSessionRecoveryClearPatch(entry)); state = undefined; } else { - entry.mainRestartRecovery = state = { - ...state, - revision: nextRevision(state), - foregroundClaims: undefined, - }; + state = updateRecoveryState(entry, state, { foregroundClaims: undefined }); } } if ( @@ -331,11 +318,7 @@ export function transitionMainSessionRecovery( ) { // A process restart makes dispatch outcome unknowable: retain the charge, // but release the stale slot so the next bounded attempt can proceed. - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), - reservation: undefined, - }; + updateRecoveryState(entry, state, { reservation: undefined }); } return { kind: "observed", @@ -364,16 +347,14 @@ export function transitionMainSessionRecovery( if (command.attempt !== state.chargedAttempts + 1) { return { kind: "rejected", reason: "stale_revision" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { chargedAttempts: command.attempt, reservation: { runId: command.runId, attempt: command.attempt, lifecycleGeneration: command.lifecycleGeneration, }, - }; + }); entry.updatedAt = command.now; return { kind: "reserved", @@ -400,15 +381,13 @@ export function transitionMainSessionRecovery( ) { return { kind: "rejected", reason: "stale_reservation" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { chargedAttempts: command.kind === "cancel_reservation" ? Math.max(0, command.reservation.attempt - 1) : state.chargedAttempts, reservation: undefined, - }; + }); return { kind: "applied" }; } case "validate_recovery": { @@ -421,12 +400,10 @@ export function transitionMainSessionRecovery( return { kind: "rejected", reason: conflict }; } const state = entry.mainRestartRecovery!; - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { reservation: undefined, foregroundClaims: undefined, - }; + }); entry.abortedLastRun = false; recordLifecycleFence(entry, { runId: command.runId, @@ -450,10 +427,10 @@ export function transitionMainSessionRecovery( if ( !state || state.reservation || - !hasLifecycleFence(entry, { - runId: command.runId, - lifecycleGeneration: command.lifecycleGeneration, - }) + !entry.restartRecoveryRuns?.some( + (run) => + run.runId === command.runId && run.lifecycleGeneration === command.lifecycleGeneration, + ) ) { return { kind: "rejected", reason: "stale_reservation" }; } @@ -490,7 +467,7 @@ export function transitionMainSessionRecovery( if (state.tombstone) { return { kind: "rejected", reason: "already_tombstoned" }; } - if (state.chargedAttempts >= MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS) { + if (state.chargedAttempts >= MAX_RECOVERY_RETRIES) { // The final charge fences foreground work until the scheduler commits // the matching tombstone. Admitting here can race that reconciliation. return { kind: "rejected", reason: "recovery_exhausted" }; @@ -513,9 +490,7 @@ export function transitionMainSessionRecovery( runId: command.runId, }); } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { reservation: state.reservation?.lifecycleGeneration === command.lifecycleGeneration ? state.reservation @@ -525,7 +500,7 @@ export function transitionMainSessionRecovery( tokens, ...(runIdsByClaimId ? { runIdsByClaimId } : {}), }, - }; + }); return { kind: "foreground_claimed", claim: { @@ -541,47 +516,32 @@ export function transitionMainSessionRecovery( case "bind_foreground_run": { const state = entry.mainRestartRecovery; const claims = state?.foregroundClaims; - if ( - !state || - state.cycleId !== command.claim.cycleId || - claims?.lifecycleGeneration !== command.claim.lifecycleGeneration || - !claims.tokens.includes(command.claim.claimId) - ) { + if (!state || !claims || !ownsForegroundClaim(state, command.claim)) { return { kind: "no_change" }; } recordLifecycleFence(entry, { lifecycleGeneration: command.claim.lifecycleGeneration, runId: command.runId, }); - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { foregroundClaims: { ...claims, runIdsByClaimId: { ...claims.runIdsByClaimId, [command.claim.claimId]: command.runId }, }, - }; + }); return { kind: "applied" }; } case "validate_foreground": { const state = entry.mainRestartRecovery; - const claims = state?.foregroundClaims; return entry.sessionId === command.claim.sessionId && - state?.cycleId === command.claim.cycleId && - claims?.lifecycleGeneration === command.claim.lifecycleGeneration && - claims.tokens.includes(command.claim.claimId) + ownsForegroundClaim(state, command.claim) ? { kind: "foreground_validated" } : { kind: "no_change" }; } case "release_foreground": { const state = entry.mainRestartRecovery; const claims = state?.foregroundClaims; - if ( - !state || - state.cycleId !== command.claim.cycleId || - claims?.lifecycleGeneration !== command.claim.lifecycleGeneration || - !claims.tokens.includes(command.claim.claimId) - ) { + if (!state || !claims || !ownsForegroundClaim(state, command.claim)) { return { kind: "no_change" }; } const tokens = claims.tokens.filter((token) => token !== command.claim.claimId); @@ -594,9 +554,7 @@ export function transitionMainSessionRecovery( Object.assign(entry, buildMainSessionRecoveryClearPatch(entry)); return { kind: "applied" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { foregroundClaims: tokens.length > 0 ? { @@ -605,7 +563,7 @@ export function transitionMainSessionRecovery( ...(Object.keys(runIdsByClaimId).length > 0 ? { runIdsByClaimId } : {}), } : undefined, - }; + }); return { kind: "applied" }; } case "tombstone": { @@ -620,13 +578,11 @@ export function transitionMainSessionRecovery( if (state.tombstone) { return { kind: "rejected", reason: "already_tombstoned" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { tombstone: { reason: command.reason, }, - }; + }); entry.abortedLastRun = false; entry.status = "failed"; entry.endedAt = command.now; diff --git a/src/agents/main-session-recovery-store.test.ts b/src/agents/main-session-recovery-store.test.ts index ef8b7320c38f..c4eeb72f3c91 100644 --- a/src/agents/main-session-recovery-store.test.ts +++ b/src/agents/main-session-recovery-store.test.ts @@ -15,7 +15,7 @@ import { claimMainSessionRecoveryOwner, commitMainSessionRecovery, inspectMainSessionRecoveryRequired, - readMainSessionRecoveryOwner, + refreshMainSessionRecoveryOwner, releaseMainSessionRecoveryOwner, } from "./main-session-recovery-store.js"; @@ -516,9 +516,9 @@ describe("main session recovery store", () => { throw new Error("expected foreground owner claim"); } - await expect(readMainSessionRecoveryOwner(claim.lease)).resolves.toBeDefined(); + await expect(refreshMainSessionRecoveryOwner(claim.lease)).resolves.toBeDefined(); await releaseMainSessionRecoveryOwner(claim.lease); - await expect(readMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); + await expect(refreshMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); }); it("returns a retry target only when the final foreground owner releases", async () => { @@ -735,6 +735,6 @@ describe("main session recovery store", () => { } rotateAgentEventLifecycleGeneration(); - await expect(readMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); + await expect(refreshMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); }); }); diff --git a/src/agents/main-session-recovery-store.ts b/src/agents/main-session-recovery-store.ts index a5e9c0e6dcd6..350463345982 100644 --- a/src/agents/main-session-recovery-store.ts +++ b/src/agents/main-session-recovery-store.ts @@ -2,9 +2,13 @@ import { randomUUID } from "node:crypto"; import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { applySessionEntryReplacements } from "../config/sessions/session-accessor.js"; import { getAgentEventLifecycleGeneration } from "../infra/agent-events.js"; -import { retryAsync } from "../infra/retry.js"; +import { + retryMainSessionRecoveryMutation, + scheduleMainSessionRecoveryMutation, +} from "./main-session-recovery-lifecycle.js"; import { isMainRestartRecoveryCandidate, + isMainSessionRecoveryPending, transitionMainSessionRecovery, type MainSessionRecoveryCommand, type MainSessionRecoveryOwnerClaim, @@ -17,9 +21,6 @@ type MainSessionRecoveryStoreTarget = { storePath: string; }; -const OWNER_RELEASE_RETRY_DELAY_MS = 1_000; -const OWNER_RELEASE_RETRY_MAX_DELAY_MS = 30_000; - export type MainSessionRecoveryOwnerLease = MainSessionRecoveryOwnerClaim & MainSessionRecoveryStoreTarget; @@ -33,30 +34,6 @@ export type MainSessionRecoveryPendingTarget = MainSessionRecoveryStoreTarget & sessionId: string; }; -type MainSessionRecoveryOwnerClaimResult = - | { - kind: "claimed"; - lease: MainSessionRecoveryOwnerLease; - entry: SessionEntry; - sessionKey: string; - } - | { kind: "invalidated"; reason: string } - | { kind: "not_required" }; - -type MainSessionRecoveryInspectionResult = - | { kind: "invalidated"; reason: string } - | { kind: "not_required" } - | { kind: "required" }; - -function transitionChanged(result: MainSessionRecoveryTransitionResult): boolean { - return ( - result.kind !== "foreground_validated" && - result.kind !== "no_change" && - result.kind !== "observed" && - result.kind !== "rejected" - ); -} - function matchesReservation(entry: SessionEntry, reservation: MainSessionRecoveryReservation) { const state = entry.mainRestartRecovery; return ( @@ -67,45 +44,13 @@ function matchesReservation(entry: SessionEntry, reservation: MainSessionRecover ); } -function matchesRecoveryAdmission( - entry: SessionEntry, - command: Extract, -): boolean { - const reservation = entry.mainRestartRecovery?.reservation; - return ( - entry.sessionId === command.sessionId && - reservation?.runId === command.runId && - reservation.lifecycleGeneration === command.lifecycleGeneration - ); -} - -function matchesOwnerClaim(entry: SessionEntry, claim: MainSessionRecoveryOwnerClaim): boolean { - const state = entry.mainRestartRecovery; - return ( - state?.cycleId === claim.cycleId && - state.foregroundClaims?.lifecycleGeneration === claim.lifecycleGeneration && - state.foregroundClaims.tokens.includes(claim.claimId) - ); -} - function currentGenerationRequiredBy(command: MainSessionRecoveryCommand): string | undefined { // Generation gates new decisions. Exact reservation/token cleanup must remain // valid after a restart so the old owner cannot leak its slot or claim. - switch (command.kind) { - case "admit_recovery": - case "claim_foreground": - case "inspect": - case "mark_admitted_recovery_interrupted": - case "observe": - case "prepare_attempt": - case "validate_recovery": - return command.lifecycleGeneration; - case "validate_foreground": - case "bind_foreground_run": - return command.claim.lifecycleGeneration; - default: - return undefined; + if (command.kind === "validate_foreground" || command.kind === "bind_foreground_run") { + return command.claim.lifecycleGeneration; } + return "lifecycleGeneration" in command ? command.lifecycleGeneration : undefined; } export async function commitMainSessionRecovery(params: { @@ -116,26 +61,21 @@ export async function commitMainSessionRecovery(params: { shouldContinue?: () => boolean; target: MainSessionRecoveryStoreTarget; }): Promise { - const cancellation = - params.command.kind === "cancel_reservation" ? params.command.reservation : undefined; - const abandonment = - params.command.kind === "abandon_reservation" ? params.command.reservation : undefined; + const reservationCleanup = + params.command.kind === "cancel_reservation" || params.command.kind === "abandon_reservation" + ? params.command.reservation + : undefined; const recoveryAdmission = params.command.kind === "admit_recovery" || params.command.kind === "validate_recovery" ? params.command : undefined; const ownerClaim = params.command.kind === "claim_foreground" ? params.command : undefined; - const ownerValidation = - params.command.kind === "validate_foreground" ? params.command.claim : undefined; - const ownerRelease = - params.command.kind === "release_foreground" ? params.command.claim : undefined; - const reservationCleanup = cancellation ?? abandonment; + const exactOwnerClaim = + params.command.kind === "validate_foreground" || params.command.kind === "release_foreground" + ? params.command.claim + : undefined; const scansAliases = Boolean( - params.scanAliases || - reservationCleanup || - recoveryAdmission || - ownerValidation || - ownerRelease, + params.scanAliases || reservationCleanup || recoveryAdmission || exactOwnerClaim, ); return await applySessionEntryReplacements({ requireWriteSuccess: params.requireWriteSuccess, @@ -172,11 +112,24 @@ export async function commitMainSessionRecovery(params: { // Canonical session-key migration may happen between reservation and // Gateway admission; the reservation identity remains authoritative. candidate = - entries.find(({ entry }) => matchesRecoveryAdmission(entry, recoveryAdmission)) ?? - selected; - } else if (ownerValidation || ownerRelease) { - const exactClaim = ownerValidation ?? ownerRelease!; - candidate = entries.find(({ entry }) => matchesOwnerClaim(entry, exactClaim)) ?? selected; + entries.find(({ entry }) => { + const reservation = (entry as SessionEntry).mainRestartRecovery?.reservation; + return ( + entry.sessionId === recoveryAdmission.sessionId && + reservation?.runId === recoveryAdmission.runId && + reservation.lifecycleGeneration === recoveryAdmission.lifecycleGeneration + ); + }) ?? selected; + } else if (exactOwnerClaim) { + candidate = + entries.find(({ entry }) => { + const state = (entry as SessionEntry).mainRestartRecovery; + return ( + state?.cycleId === exactOwnerClaim.cycleId && + state.foregroundClaims?.lifecycleGeneration === exactOwnerClaim.lifecycleGeneration && + state.foregroundClaims.tokens.includes(exactOwnerClaim.claimId) + ); + }) ?? selected; } else if (ownerClaim && (!selected || selected.entry.sessionId !== ownerClaim.sessionId)) { candidate = entries.find(({ entry }) => entry.sessionId === ownerClaim.sessionId); } else if (params.scanAliases && params.expectedSessionId) { @@ -209,7 +162,11 @@ export async function commitMainSessionRecovery(params: { } const transition = transitionMainSessionRecovery(entry, command); const changed = - transitionChanged(transition) || previousRecoveryState !== entry.mainRestartRecovery; + previousRecoveryState !== entry.mainRestartRecovery || + (transition.kind !== "foreground_validated" && + transition.kind !== "no_change" && + transition.kind !== "observed" && + transition.kind !== "rejected"); return { result: { entry, sessionKey: candidate.sessionKey, transition }, ...(changed ? { replacements: [{ sessionKey: candidate.sessionKey, entry }] } : {}), @@ -218,16 +175,28 @@ export async function commitMainSessionRecovery(params: { }); } -export async function readMainSessionRecoveryOwner( +export async function refreshMainSessionRecoveryOwner( lease: MainSessionRecoveryOwnerLease, -): Promise<{ entry: SessionEntry; sessionKey: string } | undefined> { + runId?: string, +): Promise< + { lease: MainSessionRecoveryOwnerLease; entry: SessionEntry; sessionKey: string } | undefined +> { const result = await commitMainSessionRecovery({ - command: { kind: "validate_foreground", claim: lease }, + command: runId + ? { kind: "bind_foreground_run", claim: lease, runId } + : { kind: "validate_foreground", claim: lease }, requireWriteSuccess: true, target: lease, }); - return result.transition.kind === "foreground_validated" && result.entry && result.sessionKey - ? { entry: result.entry, sessionKey: result.sessionKey } + const accepted = runId + ? result.transition.kind === "applied" + : result.transition.kind === "foreground_validated"; + return accepted && result.entry && result.sessionKey + ? { + lease: runId ? { ...lease, runId } : lease, + entry: result.entry, + sessionKey: result.sessionKey, + } : undefined; } @@ -238,7 +207,7 @@ export async function claimMainSessionRecoveryOwner(params: { sessionId: string; runId?: string; target: MainSessionRecoveryStoreTarget; -}): Promise { +}) { const command = { kind: "claim_foreground" as const, cycleId: randomUUID(), @@ -263,70 +232,44 @@ export async function claimMainSessionRecoveryOwner(params: { } if (claim.transition.kind === "foreground_claimed") { if (!claim.entry || !claim.sessionKey) { - return { kind: "invalidated", reason: "state_changed" }; + return { kind: "invalidated", reason: "state_changed" } as const; } return { kind: "claimed", lease: { ...claim.transition.claim, storePath: params.target.storePath }, entry: claim.entry, sessionKey: claim.sessionKey, - }; + } as const; } if (claim.transition.kind === "rejected" && claim.transition.reason === "stale_generation") { - return { kind: "invalidated", reason: claim.transition.reason }; + return { kind: "invalidated", reason: claim.transition.reason } as const; } if (!claim.entry && (params.allowMissingSession || params.replacementSessionId)) { // A fresh explicit session has no predecessor. An automatic rollover can // also lose its predecessor before admission. Either way, no row remains to fence. - return { kind: "not_required" }; + return { kind: "not_required" } as const; } - if ( - params.replacementSessionId && - claim.entry?.sessionId === params.replacementSessionId && + const healthyExpectedSession = + claim.entry && claim.entry.abortedLastRun !== true && claim.entry.restartRecoveryRuns === undefined && - claim.entry.mainRestartRecovery === undefined - ) { - return { kind: "not_required" }; - } + claim.entry.mainRestartRecovery === undefined && + (claim.entry.sessionId === params.sessionId || + claim.entry.sessionId === params.replacementSessionId); if ( claim.entry?.sessionId === params.sessionId && claim.sessionKey && !isMainRestartRecoveryCandidate(claim.entry, claim.sessionKey) ) { - return { kind: "not_required" }; + return { kind: "not_required" } as const; } - if ( - claim.entry?.sessionId === params.sessionId && - claim.entry.abortedLastRun !== true && - claim.entry.restartRecoveryRuns === undefined && - claim.entry.mainRestartRecovery === undefined - ) { + if (healthyExpectedSession) { // A healthy completion may clear recovery between the caller's read and this // transaction. Only that fully clean same-session state can proceed unclaimed. - return { kind: "not_required" }; + return { kind: "not_required" } as const; } const reason = claim.transition.kind === "rejected" ? claim.transition.reason : "state_changed"; - return { kind: "invalidated", reason }; -} - -export async function bindMainSessionRecoveryOwnerRun( - lease: MainSessionRecoveryOwnerLease, - runId: string, -): Promise<{ - lease: MainSessionRecoveryOwnerLease; - entry: SessionEntry; - sessionKey: string; -}> { - const result = await commitMainSessionRecovery({ - command: { kind: "bind_foreground_run", claim: lease, runId }, - requireWriteSuccess: true, - target: lease, - }); - if (result.transition.kind !== "applied" || !result.entry || !result.sessionKey) { - throw new Error("main-session recovery owner changed before run binding"); - } - return { lease: { ...lease, runId }, entry: result.entry, sessionKey: result.sessionKey }; + return { kind: "invalidated", reason } as const; } export async function inspectMainSessionRecoveryRequired(params: { @@ -334,7 +277,7 @@ export async function inspectMainSessionRecoveryRequired(params: { expectedSessionId: string; lifecycleGeneration: string; target: MainSessionRecoveryStoreTarget; -}): Promise { +}) { const command = { kind: "inspect" as const, lifecycleGeneration: params.lifecycleGeneration, @@ -376,59 +319,39 @@ async function releaseMainSessionRecoveryOwnerWithRetries( ): Promise { // A leaked current-generation token blocks automatic recovery until restart. // Token-scoped release is idempotent, so transient writer failures are safe to retry. - const released = await retryAsync( - async () => - await commitMainSessionRecovery({ - command: { kind: "release_foreground", claim: lease }, - requireWriteSuccess: true, - target: lease, - }), - 3, - 25, + const released = await retryMainSessionRecoveryMutation(async () => + commitMainSessionRecovery({ + command: { kind: "release_foreground", claim: lease }, + requireWriteSuccess: true, + target: lease, + }), ); const { entry, sessionKey } = released; - const state = entry?.mainRestartRecovery; if ( (released.transition.kind !== "applied" && released.transition.kind !== "no_change") || !entry || !sessionKey || entry.sessionId !== lease.sessionId || - entry.status !== "running" || - entry.abortedLastRun !== true || - !isMainRestartRecoveryCandidate(entry, sessionKey) || - state?.foregroundClaims || - state?.reservation || - state?.tombstone + !isMainSessionRecoveryPending(entry, sessionKey) ) { return undefined; } return { sessionId: entry.sessionId, sessionKey, storePath: lease.storePath }; } -function scheduleMainSessionRecoveryOwnerRelease( - lease: MainSessionRecoveryOwnerLease, - delayMs = OWNER_RELEASE_RETRY_DELAY_MS, -): void { +function scheduleMainSessionRecoveryOwnerRelease(lease: MainSessionRecoveryOwnerLease): void { // A token is process-owned but durably blocks recovery. Keep exact-token // cleanup alive through transient writer outages until release or restart. - setTimeout(() => { - void releaseMainSessionRecoveryOwnerWithRetries(lease).then( - async (pending) => { - if (!pending) { - return; - } + scheduleMainSessionRecoveryMutation({ + mutation: () => releaseMainSessionRecoveryOwnerWithRetries(lease), + onSuccess: async (pending) => { + if (pending) { const { scheduleMainSessionRecoveryPendingTarget } = await import("./main-session-recovery-owner-release.js"); scheduleMainSessionRecoveryPendingTarget(pending); - }, - () => { - scheduleMainSessionRecoveryOwnerRelease( - lease, - Math.min(delayMs * 2, OWNER_RELEASE_RETRY_MAX_DELAY_MS), - ); - }, - ); - }, delayMs).unref?.(); + } + }, + }); } export async function releaseMainSessionRecoveryOwner( diff --git a/src/agents/main-session-recovery-types.ts b/src/agents/main-session-recovery-types.ts index 3cbf089a113f..7a86dd6f52e1 100644 --- a/src/agents/main-session-recovery-types.ts +++ b/src/agents/main-session-recovery-types.ts @@ -53,6 +53,12 @@ export type MainSessionRecoveryConflict = | "stale_reservation" | "stale_revision"; +type RecoveryRunOwner = { + lifecycleGeneration: string; + runId: string; + sessionId: string; +}; + export type MainSessionRecoveryCommand = | { kind: "mark_interrupted"; @@ -80,28 +86,15 @@ export type MainSessionRecoveryCommand = observation: MainSessionRecoveryObservation; runId: string; } - | { kind: "cancel_reservation"; reservation: MainSessionRecoveryReservation } - | { kind: "abandon_reservation"; reservation: MainSessionRecoveryReservation } | { - kind: "validate_recovery"; - lifecycleGeneration: string; - runId: string; - sessionId: string; + kind: "cancel_reservation" | "abandon_reservation"; + reservation: MainSessionRecoveryReservation; } - | { - kind: "admit_recovery"; - lifecycleGeneration: string; + | ({ kind: "validate_recovery" } & RecoveryRunOwner) + | ({ + kind: "admit_recovery" | "mark_admitted_recovery_interrupted"; now: number; - runId: string; - sessionId: string; - } - | { - kind: "mark_admitted_recovery_interrupted"; - lifecycleGeneration: string; - now: number; - runId: string; - sessionId: string; - } + } & RecoveryRunOwner) | { kind: "claim_foreground"; cycleId: string; @@ -129,15 +122,18 @@ export type MainSessionRecoveryCommand = | { kind: "clear" }; export type MainSessionRecoveryTransitionResult = - | { kind: "admitted_recovery" } - | { kind: "applied" } - | { kind: "doctor_repaired" } + | { + kind: + | "admitted_recovery" + | "applied" + | "doctor_repaired" + | "foreground_validated" + | "no_change" + | "recovery_validated" + | "tombstoned"; + } | { kind: "failed"; noticeEntry: SessionEntry } | { kind: "foreground_claimed"; claim: MainSessionRecoveryOwnerClaim } - | { kind: "foreground_validated" } - | { kind: "no_change" } | { kind: "observed"; view: MainSessionRecoveryView } | { kind: "rejected"; reason: MainSessionRecoveryConflict } - | { kind: "recovery_validated" } - | { kind: "reserved"; reservation: MainSessionRecoveryReservation } - | { kind: "tombstoned" }; + | { kind: "reserved"; reservation: MainSessionRecoveryReservation }; diff --git a/src/agents/main-session-restart-dispatch.ts b/src/agents/main-session-restart-dispatch.ts index 35569ca45bb5..a9ef1d008672 100644 --- a/src/agents/main-session-restart-dispatch.ts +++ b/src/agents/main-session-restart-dispatch.ts @@ -13,7 +13,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isTrustedMessageActionTurnIngress } from "../gateway/message-action-turn-capability.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; import { getAgentEventLifecycleGeneration } from "../infra/agent-events.js"; -import { retryAsync } from "../infra/retry.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { findRestartRecoveryUnsafeReplyHook } from "../plugins/restart-recovery-hook-safety.js"; import { CommandLane } from "../process/lanes.js"; @@ -28,14 +27,14 @@ import { import { isDeliverableMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js"; +import { + repairMainSessionRecoveryMutation, + retryMainSessionRecoveryMutation, + scheduleMainSessionRecoveryMutation, +} from "./main-session-recovery-lifecycle.js"; import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js"; import { - restoreAdmittedRecoveryWithRetries, - scheduleAdmittedRecoveryRestore, - type RestoreAdmittedRecovery, -} from "./main-session-recovery-restore.js"; -import { - isMainRestartRecoveryCandidate, + isMainSessionRecoveryPending, type MainSessionRecoveryObservation, type MainSessionRecoveryReservation, } from "./main-session-recovery-state.js"; @@ -43,8 +42,6 @@ import { commitMainSessionRecovery } from "./main-session-recovery-store.js"; import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; const log = createSubsystemLogger("main-session-restart-recovery"); -const RESERVATION_ROLLBACK_RETRY_DELAY_MS = 1_000; -const RESERVATION_ROLLBACK_RETRY_MAX_DELAY_MS = 30_000; const RESTART_RECOVERY_RESUME_MESSAGE = "[System] Your previous turn was interrupted by a gateway restart while " + "OpenClaw was waiting on tool/model work. Continue from the existing " + @@ -282,6 +279,56 @@ function isExactRestartRecoveryDispatchAdmission(params: { ); } +async function settleAcceptedRestartRecovery(params: { + expectedRecoveryRunId: string; + expectedRecoverySourceRunId?: string; + expectedSessionId: string; + lifecycleGeneration: string; + reservation?: MainSessionRecoveryReservation; + sessionKey: string; + sessionKeys: readonly string[]; + shouldContinue?: () => boolean; + storePath: string; + terminalStatus?: RestartRecoveryTerminalStatus; +}): Promise { + const admission = await commitMainSessionRecovery({ + command: { + kind: "admit_recovery", + lifecycleGeneration: params.lifecycleGeneration, + now: Date.now(), + runId: params.expectedRecoveryRunId, + sessionId: params.expectedSessionId, + }, + shouldContinue: params.shouldContinue, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, + }); + if ( + admission.transition.kind !== "admitted_recovery" && + !isExactRestartRecoveryDispatchAdmission({ + admission, + lifecycleGeneration: params.lifecycleGeneration, + recoveryRunId: params.expectedRecoveryRunId, + sessionId: params.expectedSessionId, + terminalStatus: params.terminalStatus, + }) + ) { + return false; + } + if (params.shouldContinue?.() === false) { + return true; + } + if (params.reservation) { + await commitMainSessionRecovery({ + command: { kind: "abandon_reservation", reservation: params.reservation }, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, + }); + } + if (params.shouldContinue?.() !== false) { + await settleRestartRecoveryDispatch(params); + } + return true; +} + type MainSessionResumeResult = "resumed" | "skipped" | "failed"; async function rollbackRestartRecoveryReservation(params: { @@ -290,57 +337,41 @@ async function rollbackRestartRecoveryReservation(params: { sessionKey: string; storePath: string; }) { - return await retryAsync( - async () => - await commitMainSessionRecovery({ - command: { kind: params.kind, reservation: params.reservation }, - requireWriteSuccess: true, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }), - 3, - 25, + return await retryMainSessionRecoveryMutation(async () => + commitMainSessionRecovery({ + command: { kind: params.kind, reservation: params.reservation }, + requireWriteSuccess: true, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, + }), ); } function scheduleRestartRecoveryReservationRollback( params: Parameters[0], - delayMs = RESERVATION_ROLLBACK_RETRY_DELAY_MS, ): void { // Keep the exact reservation token alive after transient store outages. // A Gateway restart safely retires the timer and its stale-generation slot. - setTimeout(() => { - void rollbackRestartRecoveryReservation(params).then( - ({ entry, sessionKey }) => { - const state = entry?.mainRestartRecovery; - if ( - entry?.sessionId === params.reservation.sessionId && - sessionKey && - entry.status === "running" && - entry.abortedLastRun === true && - isMainRestartRecoveryCandidate(entry, sessionKey) && - state && - !state.foregroundClaims && - !state.reservation && - !state.tombstone - ) { - scheduleMainSessionRecoveryPendingTarget({ - sessionId: entry.sessionId, - sessionKey, - storePath: params.storePath, - }); - } - }, - (error: unknown) => { - log.warn( - `failed delayed restart recovery reservation rollback ${params.sessionKey}: ${String(error)}`, - ); - scheduleRestartRecoveryReservationRollback( - params, - Math.min(delayMs * 2, RESERVATION_ROLLBACK_RETRY_MAX_DELAY_MS), - ); - }, - ); - }, delayMs).unref?.(); + scheduleMainSessionRecoveryMutation({ + mutation: () => rollbackRestartRecoveryReservation(params), + onError: (error) => { + log.warn( + `failed delayed restart recovery reservation rollback ${params.sessionKey}: ${String(error)}`, + ); + }, + onSuccess: ({ entry, sessionKey }) => { + if ( + entry?.sessionId === params.reservation.sessionId && + sessionKey && + isMainSessionRecoveryPending(entry, sessionKey) + ) { + scheduleMainSessionRecoveryPendingTarget({ + sessionId: entry.sessionId, + sessionKey, + storePath: params.storePath, + }); + } + }, + }); } export async function resumeMainSession(params: { @@ -387,6 +418,20 @@ export async function resumeMainSession(params: { const recoverySessionKeys = Array.from(new Set([dispatchSessionKey, params.sessionKey])); let reservation: MainSessionRecoveryReservation | undefined; let dispatchStarted = false; + const rollbackReservation = async (kind: "abandon_reservation" | "cancel_reservation") => { + if (!reservation) { + return undefined; + } + const current = reservation; + const result = await rollbackRestartRecoveryReservation({ + kind, + reservation: current, + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + reservation = undefined; + return { current, result }; + }; try { const reserved = await commitMainSessionRecovery({ command: { @@ -406,13 +451,7 @@ export async function resumeMainSession(params: { } reservation = reserved.transition.reservation; if (params.shouldContinue?.() === false) { - await rollbackRestartRecoveryReservation({ - kind: "cancel_reservation", - reservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }); - reservation = undefined; + await rollbackReservation("cancel_reservation"); return "skipped"; } // Persist one stable RPC id before dispatch. A transport rejection is @@ -448,17 +487,11 @@ export async function resumeMainSession(params: { }, }); if (!recoveryStatePrepared) { - const rollback = await rollbackRestartRecoveryReservation({ - kind: "cancel_reservation", - reservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }); - reservation = undefined; + const rollback = await rollbackReservation("cancel_reservation"); if (params.shouldContinue?.() === false) { return "skipped"; } - const current = rollback.entry; + const current = rollback?.result.entry; return current?.sessionId === params.entry.sessionId && current.status === "running" && current.abortedLastRun === true && @@ -502,13 +535,7 @@ export async function resumeMainSession(params: { } } if (params.shouldContinue?.() === false) { - await rollbackRestartRecoveryReservation({ - kind: "cancel_reservation", - reservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }); - reservation = undefined; + await rollbackReservation("cancel_reservation"); return "skipped"; } if (params.forceRestartSafeTools) { @@ -537,44 +564,24 @@ export async function resumeMainSession(params: { if (params.shouldContinue?.() === false) { return "skipped"; } - const admission = await commitMainSessionRecovery({ - command: { - kind: "admit_recovery", - lifecycleGeneration, - now: Date.now(), - runId: recoveryRunId, - sessionId: params.entry.sessionId, - }, - shouldContinue: params.shouldContinue, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); if ( - admission.transition.kind !== "admitted_recovery" && - !isExactRestartRecoveryDispatchAdmission({ - admission, + !(await settleAcceptedRestartRecovery({ + expectedRecoveryRunId: recoveryRunId, + expectedRecoverySourceRunId: sourceRunId, + expectedSessionId: params.entry.sessionId, lifecycleGeneration, - recoveryRunId, - sessionId: params.entry.sessionId, + sessionKey: params.sessionKey, + sessionKeys: recoverySessionKeys, + shouldContinue: params.shouldContinue, + storePath: params.storePath, terminalStatus, - }) + })) ) { throw new Error(`restart recovery admission changed before settlement: ${params.sessionKey}`); } if (params.shouldContinue?.() === false) { return "skipped"; } - await settleRestartRecoveryDispatch({ - expectedRecoveryRunId: recoveryRunId, - expectedRecoverySourceRunId: sourceRunId, - expectedSessionId: params.entry.sessionId, - sessionKeys: recoverySessionKeys, - shouldContinue: params.shouldContinue, - storePath: params.storePath, - terminalStatus, - }); - if (params.shouldContinue?.() === false) { - return "skipped"; - } log.info( `resumed interrupted main session: ${params.sessionKey}${ sanitizedPendingText ? " (with pending payload)" : "" @@ -590,52 +597,23 @@ export async function resumeMainSession(params: { params.gatewayRuntime, ); if (terminalStatus && params.shouldContinue?.() !== false) { - const admission = await commitMainSessionRecovery({ - command: { - kind: "admit_recovery", - lifecycleGeneration, - now: Date.now(), - runId: recoveryRunId, - sessionId: params.entry.sessionId, - }, - shouldContinue: params.shouldContinue, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); - const exactRunAlreadyAdmitted = isExactRestartRecoveryDispatchAdmission({ - admission, + const settled = await settleAcceptedRestartRecovery({ + expectedRecoveryRunId: recoveryRunId, + expectedRecoverySourceRunId: sourceRunId, + expectedSessionId: params.entry.sessionId, lifecycleGeneration, - recoveryRunId, - sessionId: params.entry.sessionId, + reservation, + sessionKey: params.sessionKey, + sessionKeys: recoverySessionKeys, + shouldContinue: params.shouldContinue, + storePath: params.storePath, terminalStatus, }); - if (admission.transition.kind !== "admitted_recovery" && !exactRunAlreadyAdmitted) { - if (params.shouldContinue?.() !== false) { - log.warn( - `restart recovery admission changed before settlement: ${params.sessionKey}`, - ); - } + if (!settled) { + log.warn(`restart recovery admission changed before settlement: ${params.sessionKey}`); } else if (params.shouldContinue?.() !== false) { - if (reservation) { - await commitMainSessionRecovery({ - command: { kind: "abandon_reservation", reservation }, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); - } - if (params.shouldContinue?.() !== false) { - await settleRestartRecoveryDispatch({ - expectedRecoveryRunId: recoveryRunId, - expectedRecoverySourceRunId: sourceRunId, - expectedSessionId: params.entry.sessionId, - sessionKeys: recoverySessionKeys, - shouldContinue: params.shouldContinue, - storePath: params.storePath, - terminalStatus, - }); - if (params.shouldContinue?.() !== false) { - log.info(`settled completed restart recovery for ${params.sessionKey}`); - return "resumed"; - } - } + log.info(`settled completed restart recovery for ${params.sessionKey}`); + return "resumed"; } } } @@ -644,7 +622,7 @@ export async function resumeMainSession(params: { log.warn( `failed to settle ambiguous restart recovery ${params.sessionKey}: ${String(settlementError)}`, ); - const restoreAdmittedRecovery: RestoreAdmittedRecovery = async () => { + const restoreAdmittedRecovery = async () => { if (params.shouldContinue?.() === false) { return undefined; } @@ -671,36 +649,32 @@ export async function resumeMainSession(params: { } : undefined; }; - try { - const restored = await restoreAdmittedRecoveryWithRetries(restoreAdmittedRecovery); - if (params.shouldContinue?.() !== false) { - scheduleMainSessionRecoveryPendingTarget(restored); - } - } catch (restoreError) { - if (params.shouldContinue?.() !== false) { - log.warn( - `failed to restore ambiguous restart recovery ${params.sessionKey}: ${String(restoreError)}`, - ); - scheduleAdmittedRecoveryRestore(restoreAdmittedRecovery); - } + const restored = await repairMainSessionRecoveryMutation({ + mutation: restoreAdmittedRecovery, + onDeferredSuccess: scheduleMainSessionRecoveryPendingTarget, + onError: (restoreError) => { + if (params.shouldContinue?.() !== false) { + log.warn( + `failed to restore ambiguous restart recovery ${params.sessionKey}: ${String(restoreError)}`, + ); + } + }, + }); + if (params.shouldContinue?.() !== false) { + scheduleMainSessionRecoveryPendingTarget(restored); } } } if (reservation) { - const rollbackReservation = reservation; - await rollbackRestartRecoveryReservation({ - kind: dispatchStarted && !explicitlyRejected ? "abandon_reservation" : "cancel_reservation", - reservation: rollbackReservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }).catch((rollbackError: unknown) => { + const rollbackKind = + dispatchStarted && !explicitlyRejected ? "abandon_reservation" : "cancel_reservation"; + await rollbackReservation(rollbackKind).catch((rollbackError: unknown) => { log.warn( `failed to roll back interrupted main session recovery attempt ${params.sessionKey}: ${String(rollbackError)}`, ); scheduleRestartRecoveryReservationRollback({ - kind: - dispatchStarted && !explicitlyRejected ? "abandon_reservation" : "cancel_reservation", - reservation: rollbackReservation, + kind: rollbackKind, + reservation: reservation!, sessionKey: params.sessionKey, storePath: params.storePath, }); diff --git a/src/agents/main-session-restart-recovery-failure.ts b/src/agents/main-session-restart-recovery-failure.ts index 90e56d2fcb6f..95db9104f29b 100644 --- a/src/agents/main-session-restart-recovery-failure.ts +++ b/src/agents/main-session-restart-recovery-failure.ts @@ -1,18 +1,20 @@ import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; -import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { - isMainSessionRecoveryExhausted, - type MainSessionRecoveryObservation, -} from "./main-session-recovery-state.js"; +import type { MainSessionRecoveryObservation } from "./main-session-recovery-state.js"; import { commitMainSessionRecovery } from "./main-session-recovery-store.js"; -import { buildUnresumableSessionNoticeIdempotencyKey } from "./main-session-restart-claim.js"; import { resolveRestartRecoveryDeliveryContext } from "./main-session-restart-dispatch.js"; -import { sendUnresumableSessionNotice } from "./main-session-restart-recovery-notice.js"; -import { buildRestartRecoveryExpectedState, log } from "./main-session-restart-recovery-shared.js"; +import { + sendUnresumableSessionNotice, + writeUnresumableSessionNotice, +} from "./main-session-restart-recovery-notice.js"; +import { + buildRestartRecoveryExpectedState, + log, + MAX_RECOVERY_RETRIES, +} from "./main-session-restart-recovery-shared.js"; const TOMBSTONED_SESSION_NOTICE = "I couldn't recover this session after repeated gateway restarts. " + @@ -62,10 +64,33 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { let entry = params.entry; let observation = params.observation; for (let attempt = 0; attempt < 3; attempt += 1) { + const recoveryState = entry.mainRestartRecovery; + if ( + !recoveryState || + recoveryState.cycleId !== observation.cycleId || + recoveryState.revision !== observation.revision + ) { + return "skipped"; + } + const now = Date.now(); const notice = await writeUnresumableSessionNotice({ - ...params, + agentId: resolveAgentIdFromSessionKey(params.sessionKey), entry, - observation, + expectedSessionState: buildRestartRecoveryExpectedState(entry, observation), + sessionKey: params.sessionKey, + sessionLifecyclePatch: { + abortedLastRun: false, + endedAt: now, + mainRestartRecovery: { + ...recoveryState, + revision: recoveryState.revision + 1, + tombstone: { reason: params.reason }, + }, + runtimeMs: Math.max(0, now - (entry.startedAt ?? now)), + status: "failed", + updatedAt: now, + }, + storePath: params.storePath, text: TOMBSTONED_SESSION_NOTICE, }); if (notice === "written") { @@ -85,7 +110,9 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { current.sessionId !== params.entry.sessionId || state?.cycleId !== params.observation.cycleId || state.tombstone || - !isMainSessionRecoveryExhausted(current) + current.status !== "running" || + current.abortedLastRun !== true || + state.chargedAttempts < MAX_RECOVERY_RETRIES ) { return "skipped"; } @@ -112,53 +139,3 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { }); return "tombstoned"; } - -async function writeUnresumableSessionNotice(params: { - entry: SessionEntry; - observation: MainSessionRecoveryObservation; - reason: string; - sessionKey: string; - storePath: string; - text: string; -}): Promise<"failed" | "stale" | "written"> { - const recoveryState = params.entry.mainRestartRecovery; - if ( - !recoveryState || - recoveryState.cycleId !== params.observation.cycleId || - recoveryState.revision !== params.observation.revision - ) { - return "stale"; - } - const now = Date.now(); - const result = await appendAssistantMessageToSessionTranscript({ - agentId: resolveAgentIdFromSessionKey(params.sessionKey), - sessionKey: params.sessionKey, - expectedSessionId: params.entry.sessionId, - expectedSessionState: buildRestartRecoveryExpectedState(params.entry, params.observation), - sessionLifecyclePatch: { - abortedLastRun: false, - endedAt: now, - mainRestartRecovery: { - ...recoveryState, - revision: recoveryState.revision + 1, - tombstone: { reason: params.reason }, - }, - runtimeMs: Math.max(0, now - (params.entry.startedAt ?? now)), - status: "failed", - updatedAt: now, - }, - storePath: params.storePath, - text: params.text, - idempotencyKey: buildUnresumableSessionNoticeIdempotencyKey(params.entry), - }).catch((error: unknown) => ({ ok: false as const, reason: String(error) })); - if (!result.ok) { - log.warn( - `failed to write interrupted main session notice ${params.sessionKey}: ${result.reason}`, - ); - } - return result.ok - ? "written" - : "code" in result && result.code === "session-rebound" - ? "stale" - : "failed"; -} diff --git a/src/agents/main-session-restart-recovery-marking.ts b/src/agents/main-session-restart-recovery-marking.ts index ea5e53eefd9f..50188ee62355 100644 --- a/src/agents/main-session-restart-recovery-marking.ts +++ b/src/agents/main-session-restart-recovery-marking.ts @@ -18,6 +18,7 @@ import { listActiveEmbeddedRunSessionKeys, } from "./embedded-agent-runner/run-state.js"; import { + isMainRestartRecoveryCandidate, normalizeMainSessionRecoveryRunFences, transitionMainSessionRecovery, } from "./main-session-recovery-state.js"; @@ -27,10 +28,50 @@ import { normalizeFiniteTimestamp, normalizeStringSet, resolveRestartRecoveryStorePaths, - shouldSkipMainRecovery, } from "./main-session-restart-recovery-shared.js"; import { resolveAgentSessionDirs } from "./session-dirs.js"; +async function markRecoveryStore(params: { + storePath: string; + statuses?: Array>; + plan: ( + entry: SessionEntry, + sessionKey: string, + ) => { replaceRuns?: boolean; resetRuntime?: boolean; runs?: RestartRecoveryRun[] } | undefined; +}) { + return await applySessionEntryReplacements<{ marked: number; skipped: number }>({ + storePath: params.storePath, + statuses: params.statuses, + requireWriteSuccess: true, + update: (entries) => { + const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = []; + const counts = { marked: 0, skipped: 0 }; + for (const { sessionKey, entry } of entries) { + const plan = params.plan(entry, sessionKey); + if (!plan) { + continue; + } + if (!isMainRestartRecoveryCandidate(entry, sessionKey)) { + counts.skipped++; + continue; + } + if (plan.replaceRuns) { + entry.restartRecoveryRuns = plan.runs; + } + transitionMainSessionRecovery(entry, { + kind: "mark_interrupted", + cycleId: randomUUID(), + now: Date.now(), + ...plan, + }); + replacements.push({ sessionKey, entry }); + counts.marked++; + } + return { result: counts, replacements }; + }, + }); +} + export async function markRestartAbortedMainSessions(params: { cfg?: OpenClawConfig; additionalCfgs?: Iterable; @@ -114,67 +155,49 @@ export async function markRestartAbortedMainSessions(params: { } for (const storePath of storePaths) { - const storeResult = await applySessionEntryReplacements({ + const storeResult = await markRecoveryStore({ storePath, - requireWriteSuccess: true, - update: (entries) => { - const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = []; - const counts = { marked: 0, skipped: 0 }; - for (const { sessionKey, entry } of entries) { - const registeredActiveRuns = listAgentRunsForSession({ - sessionKey, - sessionId: entry.sessionId, - }); - const matchingActiveRuns = activeRuns.filter( - (run) => - (run.sessionId ? run.sessionId === entry.sessionId : run.sessionKey === sessionKey) && - (entry.status === "running" || - run.observedAt === undefined || - normalizeFiniteTimestamp(entry.updatedAt) === undefined || - (entry.updatedAt < run.observedAt && - run.lifecycleGeneration !== currentLifecycleGeneration)) && - params.isActiveRun?.(run) !== false, - ); - if ( - entry.status !== "running" && - matchingActiveRuns.length === 0 && - registeredActiveRuns.length === 0 - ) { - continue; - } - const matches = - typeof entry.sessionId === "string" && sessionIds.has(entry.sessionId) - ? true - : !preferSessionIdMatch && sessionKeys.has(sessionKey); - if (!matches) { - continue; - } - if (shouldSkipMainRecovery(entry, sessionKey)) { - counts.skipped++; - continue; - } - const wasRunning = entry.status === "running"; - entry.restartRecoveryRuns = normalizeMainSessionRecoveryRunFences([ - ...(entry.restartRecoveryRuns ?? []).filter( - (run) => run.lifecycleGeneration === currentLifecycleGeneration, - ), - ...registeredActiveRuns, - ...matchingActiveRuns.map(({ runId, lifecycleGeneration }) => ({ - runId, - lifecycleGeneration, - })), - ]); - transitionMainSessionRecovery(entry, { - kind: "mark_interrupted", - cycleId: randomUUID(), - now: Date.now(), - resetRuntime: !wasRunning, - runs: entry.restartRecoveryRuns, - }); - replacements.push({ sessionKey, entry }); - counts.marked++; + plan: (entry, sessionKey) => { + const registeredActiveRuns = listAgentRunsForSession({ + sessionKey, + sessionId: entry.sessionId, + }); + const matchingActiveRuns = activeRuns.filter( + (run) => + (run.sessionId ? run.sessionId === entry.sessionId : run.sessionKey === sessionKey) && + (entry.status === "running" || + run.observedAt === undefined || + normalizeFiniteTimestamp(entry.updatedAt) === undefined || + (entry.updatedAt < run.observedAt && + run.lifecycleGeneration !== currentLifecycleGeneration)) && + params.isActiveRun?.(run) !== false, + ); + if ( + entry.status !== "running" && + matchingActiveRuns.length === 0 && + registeredActiveRuns.length === 0 + ) { + return undefined; } - return { result: counts, replacements }; + const matches = + typeof entry.sessionId === "string" && sessionIds.has(entry.sessionId) + ? true + : !preferSessionIdMatch && sessionKeys.has(sessionKey); + if (!matches) { + return undefined; + } + const wasRunning = entry.status === "running"; + const runs = normalizeMainSessionRecoveryRunFences([ + ...(entry.restartRecoveryRuns ?? []).filter( + (run) => run.lifecycleGeneration === currentLifecycleGeneration, + ), + ...registeredActiveRuns, + ...matchingActiveRuns.map(({ runId, lifecycleGeneration }) => ({ + runId, + lifecycleGeneration, + })), + ]); + return { replaceRuns: true, resetRuntime: !wasRunning, runs }; }, }); result.marked += storeResult.marked; @@ -215,47 +238,32 @@ export async function markStartupOrphanedMainSessionsForRecovery(params: { providedActiveSessionKeys ?? normalizeStringSet(listActiveEmbeddedRunSessionKeys()); for (const storePath of await resolveRestartRecoveryStorePaths(params)) { - const storeResult = await applySessionEntryReplacements({ + const storeResult = await markRecoveryStore({ storePath, statuses: ["running"], - update: (entries) => { - const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = []; - const counts = { marked: 0, skipped: 0 }; - for (const { sessionKey, entry } of entries) { - if (entry.status !== "running" || entry.abortedLastRun === true) { - continue; - } - if (shouldSkipMainRecovery(entry, sessionKey)) { - counts.skipped++; - continue; - } - const updatedAt = normalizeFiniteTimestamp(entry.updatedAt); - if ( - updatedBeforeMs !== undefined && - updatedAt !== undefined && - updatedAt > updatedBeforeMs - ) { - continue; - } - if ( - hasCurrentProcessOwner({ - activeSessionIds: resolveActiveSessionIds(), - activeSessionKeys: resolveActiveSessionKeys(), - entry, - sessionKey, - }) - ) { - continue; - } - transitionMainSessionRecovery(entry, { - kind: "mark_interrupted", - cycleId: randomUUID(), - now: Date.now(), - }); - replacements.push({ sessionKey, entry }); - counts.marked++; + plan: (entry, sessionKey) => { + if (entry.status !== "running" || entry.abortedLastRun === true) { + return undefined; } - return { result: counts, replacements }; + const updatedAt = normalizeFiniteTimestamp(entry.updatedAt); + if ( + updatedBeforeMs !== undefined && + updatedAt !== undefined && + updatedAt > updatedBeforeMs + ) { + return undefined; + } + if ( + hasCurrentProcessOwner({ + activeSessionIds: resolveActiveSessionIds(), + activeSessionKeys: resolveActiveSessionKeys(), + entry, + sessionKey, + }) + ) { + return undefined; + } + return {}; }, }); result.marked += storeResult.marked; diff --git a/src/agents/main-session-restart-recovery-notice.ts b/src/agents/main-session-restart-recovery-notice.ts index 05a4dc8f4c64..e2f3d537e2c7 100644 --- a/src/agents/main-session-restart-recovery-notice.ts +++ b/src/agents/main-session-restart-recovery-notice.ts @@ -1,5 +1,9 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; +import type { + SessionTranscriptTurnExpectedState, + SessionTranscriptTurnLifecyclePatch, +} from "../config/sessions/session-accessor.js"; import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; @@ -16,28 +20,6 @@ import { UNRESUMABLE_SESSION_NOTICE, } from "./main-session-restart-recovery-shared.js"; -async function markSessionFailed(params: { - observation: MainSessionRecoveryObservation; - storePath: string; - sessionKey: string; - reason: string; -}): Promise { - const marked = await commitMainSessionRecovery({ - command: { - kind: "fail_recovery", - now: Date.now(), - observation: params.observation, - }, - requireWriteSuccess: true, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); - if (marked.transition.kind === "failed") { - log.warn(`marked interrupted main session failed: ${params.sessionKey} (${params.reason})`); - return true; - } - return false; -} - export async function sendUnresumableSessionNotice(params: { deliveryContext: DeliveryContext; entry: SessionEntry; @@ -79,19 +61,24 @@ export async function sendUnresumableSessionNotice(params: { } } -async function writeUnresumableSessionNotice(params: { +export async function writeUnresumableSessionNotice(params: { agentId: string; entry: SessionEntry; sessionKey: string; storePath: string; -}): Promise { + expectedSessionState?: SessionTranscriptTurnExpectedState; + sessionLifecyclePatch?: SessionTranscriptTurnLifecyclePatch; + text?: string; +}): Promise<"failed" | "stale" | "written"> { const result = await appendAssistantMessageToSessionTranscript({ agentId: params.agentId, sessionKey: params.sessionKey, expectedSessionId: params.entry.sessionId, - expectedSessionState: buildRestartRecoveryExpectedState(params.entry), + expectedSessionState: + params.expectedSessionState ?? buildRestartRecoveryExpectedState(params.entry), + sessionLifecyclePatch: params.sessionLifecyclePatch, storePath: params.storePath, - text: UNRESUMABLE_SESSION_NOTICE, + text: params.text ?? UNRESUMABLE_SESSION_NOTICE, idempotencyKey: buildUnresumableSessionNoticeIdempotencyKey(params.entry), }).catch((error: unknown) => ({ ok: false as const, reason: String(error) })); if (!result.ok) { @@ -99,7 +86,11 @@ async function writeUnresumableSessionNotice(params: { `failed to write interrupted main session notice ${params.sessionKey}: ${result.reason}`, ); } - return result.ok; + return result.ok + ? "written" + : "code" in result && result.code === "session-rebound" + ? "stale" + : "failed"; } export async function failUnresumableMainSession(params: { @@ -119,7 +110,7 @@ export async function failUnresumableMainSession(params: { }); if ( !deliveryContext && - !(await writeUnresumableSessionNotice({ + (await writeUnresumableSessionNotice({ agentId: resolveAgentIdFromSessionKey( params.sessionKey, params.cfg ? resolveDefaultAgentId(params.cfg) : undefined, @@ -127,20 +118,20 @@ export async function failUnresumableMainSession(params: { entry: params.entry, sessionKey: params.sessionKey, storePath: params.storePath, - })) + })) !== "written" ) { // Keep ownership for another recovery attempt until its terminal notice is durable. return "failed"; } - const marked = await markSessionFailed({ - observation: params.observation, - storePath: params.storePath, - sessionKey: params.sessionKey, - reason: params.reason, + const marked = await commitMainSessionRecovery({ + command: { kind: "fail_recovery", now: Date.now(), observation: params.observation }, + requireWriteSuccess: true, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, }); - if (!marked) { + if (marked.transition.kind !== "failed") { return "skipped"; } + log.warn(`marked interrupted main session failed: ${params.sessionKey} (${params.reason})`); if (deliveryContext) { await sendUnresumableSessionNotice({ deliveryContext, diff --git a/src/agents/main-session-restart-recovery-runtime.ts b/src/agents/main-session-restart-recovery-runtime.ts index da4e74f13b5e..b319db9d2bf1 100644 --- a/src/agents/main-session-restart-recovery-runtime.ts +++ b/src/agents/main-session-restart-recovery-runtime.ts @@ -4,6 +4,7 @@ import { getAgentEventLifecycleGeneration, isAgentEventLifecycleGenerationCurrent, } from "../infra/agent-events.js"; +import { sleepWithAbort } from "../infra/backoff.js"; import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js"; import { beginSessionWorkAdmission, @@ -28,7 +29,44 @@ import { recoverStore, } from "./main-session-restart-recovery-store.js"; -async function recoverRestartAbortedMainSessionsWithOptions(params: { +type RecoveryCounts = { recovered: number; failed: number; skipped: number }; + +async function runRecoveryRetries(params: { + initialDelayMs: number; + maxRetries: number; + retryDelayMs?: number; + shouldContinue: () => boolean; + signal?: AbortSignal; + attempt: (finalAttempt: boolean) => Promise; + onError: (error: unknown, finalAttempt: boolean) => void | Promise; +}): Promise { + let delayMs = params.initialDelayMs; + for (let attempt = 1; attempt <= params.maxRetries && params.shouldContinue(); attempt += 1) { + const finalAttempt = attempt === params.maxRetries; + try { + if (delayMs > 0) { + await sleepWithAbort(delayMs, params.signal, { ref: false }); + } + if (!params.shouldContinue() || (await params.attempt(finalAttempt))) { + return; + } + } catch (error) { + if (!params.shouldContinue()) { + return; + } + await params.onError(error, finalAttempt); + if (finalAttempt) { + return; + } + } + delayMs = + delayMs > 0 + ? delayMs * RETRY_BACKOFF_MULTIPLIER + : (params.retryDelayMs ?? DEFAULT_RECOVERY_DELAY_MS); + } +} + +export async function recoverRestartAbortedMainSessions(params: { cfg?: OpenClawConfig; onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void; stateDir?: string; @@ -38,7 +76,7 @@ async function recoverRestartAbortedMainSessionsWithOptions(params: { lifecycleGeneration?: string; shouldContinue?: () => boolean; gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { +}): Promise { const result = { recovered: 0, failed: 0, skipped: 0 }; const resumedSessionKeys = params.resumedSessionKeys ?? new Set(); @@ -70,117 +108,75 @@ async function recoverRestartAbortedMainSessionsWithOptions(params: { return result; } -export async function recoverRestartAbortedMainSessions(params: { - cfg?: OpenClawConfig; - stateDir?: string; - resumedSessionKeys?: Set; - activeSessionIds?: Iterable; - activeSessionKeys?: Iterable; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - return await recoverRestartAbortedMainSessionsWithOptions(params); -} - /** Retries one exact durable Control UI row from its owning per-agent SQLite store. */ export async function retryRestartAbortedMainSessionRecovery(params: { canonicalSessionKey?: string; cfg?: OpenClawConfig; - expectedRecoveryRunId: string; - expectedRecoverySourceRunId: string; + expectedRecoveryRunId?: string; + expectedRecoverySourceRunId?: string; expectedSessionId: string; sessionKey: string; storePath: string; gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - const expectedClaim: ExpectedRestartRecoveryClaim = { +}): Promise { + const expected = { canonicalSessionKey: params.canonicalSessionKey, - recoveryRunId: params.expectedRecoveryRunId, - recoverySourceRunId: params.expectedRecoverySourceRunId, sessionId: params.expectedSessionId, sessionKey: params.sessionKey, }; - if (!loadExpectedRestartRecoveryClaim({ expected: expectedClaim, storePath: params.storePath })) { - return { recovered: 0, failed: 0, skipped: 0 }; - } - const assertClaimCurrent = () => { - if ( - !loadExpectedRestartRecoveryClaim({ expected: expectedClaim, storePath: params.storePath }) - ) { - throw new Error("restart recovery session ownership changed before dispatch"); - } - }; - // Keep lifecycle replacement behind the accepted recovery dispatch. The agent - // RPC atomically adopts this lease, so no second admission can deadlock behind - // a mutation that already sees the accepted browser turn as active work. - const admission = await beginSessionWorkAdmission({ - scope: params.storePath, - identities: [params.sessionKey, params.canonicalSessionKey, params.expectedSessionId], - assertAllowed: assertClaimCurrent, - revalidateAllowed: assertClaimCurrent, + const expectedClaim: ExpectedRestartRecoveryClaim | undefined = + params.expectedRecoveryRunId && params.expectedRecoverySourceRunId + ? { + ...expected, + recoveryRunId: params.expectedRecoveryRunId, + recoverySourceRunId: params.expectedRecoverySourceRunId, + } + : undefined; + return await recoverExpectedRestartRecovery({ + ...params, + ...(expectedClaim ? { expectedClaim } : { expectedTarget: expected }), }); - const handoffId = admission.createHandoff(); - try { - return await admission.run( - async () => - await recoverStore({ - cfg: params.cfg, - storePath: params.storePath, - resumedSessionKeys: new Set(), - expectedClaim, - sessionWorkAdmissionHandoffId: handoffId, - gatewayRuntime: params.gatewayRuntime, - }), - ); - } finally { - cancelSessionWorkAdmissionHandoff(handoffId); - admission.release(); - } } -/** Reconciles one interrupted row after its final foreground owner releases. */ -export async function retryRestartAbortedMainSessionRecoveryAfterOwnerRelease(params: { +async function recoverExpectedRestartRecovery(params: { cfg?: OpenClawConfig; - expectedSessionId: string; - sessionKey: string; - storePath: string; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - return await recoverExpectedRestartRecoveryTarget(params); -} - -async function recoverExpectedRestartRecoveryTarget(params: { - canonicalSessionKey?: string; - cfg?: OpenClawConfig; - expectedSessionId: string; + expectedClaim?: ExpectedRestartRecoveryClaim; + expectedTarget?: ExpectedRestartRecoveryTarget; lifecycleGeneration?: string; observationOnly?: boolean; sessionKey: string; shouldContinue?: () => boolean; storePath: string; gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - const expectedTarget: ExpectedRestartRecoveryTarget = { - canonicalSessionKey: params.canonicalSessionKey, - sessionId: params.expectedSessionId, - sessionKey: params.sessionKey, - }; - const assertTargetCurrent = () => { - if ( - !loadExpectedRestartRecoveryTarget({ expected: expectedTarget, storePath: params.storePath }) - ) { - throw new Error("restart recovery session ownership changed before owner-release retry"); - } - }; - if ( - !loadExpectedRestartRecoveryTarget({ expected: expectedTarget, storePath: params.storePath }) - ) { +}): Promise { + const loadExpected = () => + params.expectedClaim + ? loadExpectedRestartRecoveryClaim({ + expected: params.expectedClaim, + storePath: params.storePath, + }) + : params.expectedTarget + ? loadExpectedRestartRecoveryTarget({ + expected: params.expectedTarget, + storePath: params.storePath, + }) + : undefined; + if (!loadExpected()) { return { recovered: 0, failed: 0, skipped: 0 }; } + const assertExpectedCurrent = () => { + if (!loadExpected()) { + throw new Error("restart recovery session ownership changed before dispatch"); + } + }; + const expectedSessionId = (params.expectedClaim ?? params.expectedTarget)!.sessionId; + // Keep lifecycle replacement behind accepted recovery dispatch. The RPC + // adopts this lease, so another admission cannot deadlock behind its active work. const admission = await beginSessionWorkAdmission({ scope: params.storePath, - identities: [params.sessionKey, params.expectedSessionId], - assertAllowed: assertTargetCurrent, - revalidateAllowed: assertTargetCurrent, + identities: [params.sessionKey, params.expectedClaim?.canonicalSessionKey, expectedSessionId], + assertAllowed: assertExpectedCurrent, + revalidateAllowed: assertExpectedCurrent, }); const handoffId = admission.createHandoff(); try { @@ -191,7 +187,8 @@ async function recoverExpectedRestartRecoveryTarget(params: { observationOnly: params.observationOnly, storePath: params.storePath, resumedSessionKeys: new Set(), - expectedTarget, + expectedClaim: params.expectedClaim, + expectedTarget: params.expectedTarget, sessionWorkAdmissionHandoffId: handoffId, lifecycleGeneration: params.lifecycleGeneration, shouldContinue: params.shouldContinue, @@ -206,125 +203,61 @@ async function recoverExpectedRestartRecoveryTarget(params: { export function scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease(params: { delayMs?: number; - expectedSessionId: string; getConfig: () => OpenClawConfig; getGatewayRuntime: () => GatewayRecoveryRuntime | undefined; maxRetries?: number; + expectedSessionId: string; sessionKey: string; storePath: string; }): void { - const retryDelayMs = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; - const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES; - const scheduleAttempt = (attempt: number, delayMs: number) => { - const run = () => { - void runWithGatewayIndependentRootWorkAdmission(async () => { - const gatewayRuntime = params.getGatewayRuntime(); - if (!gatewayRuntime) { - throw new Error("Gateway recovery runtime is unavailable"); - } - return await retryRestartAbortedMainSessionRecoveryAfterOwnerRelease({ - cfg: params.getConfig(), - expectedSessionId: params.expectedSessionId, + const recover = () => + runWithGatewayIndependentRootWorkAdmission(async () => { + const gatewayRuntime = params.getGatewayRuntime(); + if (!gatewayRuntime) { + throw new Error("Gateway recovery runtime is unavailable"); + } + return await retryRestartAbortedMainSessionRecovery({ + cfg: params.getConfig(), + expectedSessionId: params.expectedSessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + gatewayRuntime, + }); + }); + void runRecoveryRetries({ + initialDelayMs: 0, + maxRetries: params.maxRetries ?? MAX_RECOVERY_RETRIES, + retryDelayMs: params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS, + shouldContinue: () => true, + attempt: async (finalAttempt) => { + const result = await recover(); + const stillPending = loadExpectedRestartRecoveryTarget({ + expected: { + sessionId: params.expectedSessionId, sessionKey: params.sessionKey, - storePath: params.storePath, - gatewayRuntime, - }); - }) - .then((result) => { - const stillPending = loadExpectedRestartRecoveryTarget({ - expected: { - sessionId: params.expectedSessionId, - sessionKey: params.sessionKey, - }, - storePath: params.storePath, - }); - if ( - (result.failed > 0 || (result.recovered === 0 && stillPending)) && - attempt < maxRetries - ) { - scheduleAttempt(attempt + 1, retryDelayMs * 2 ** (attempt - 1)); - } else if ( - attempt === maxRetries && - stillPending?.mainRestartRecovery?.chargedAttempts === MAX_RECOVERY_RETRIES && - !stillPending.mainRestartRecovery.reservation - ) { - // The last ambiguous dispatch consumed the final durable charge. - // One exact observation tombstones exhaustion without dispatching again. - scheduleAttempt(attempt + 1, 0); - } - }) - .catch((error: unknown) => { - if (attempt < maxRetries) { - scheduleAttempt(attempt + 1, retryDelayMs * 2 ** (attempt - 1)); - } else { - log.warn(`main-session owner-release recovery failed: ${String(error)}`); - } - }); - }; - if (delayMs <= 0) { - run(); - } else { - setTimeout(run, delayMs).unref?.(); - } - }; - scheduleAttempt(1, 0); -} - -async function recoverStartupOrphanedMainSessionsWithOptions(params: { - cfg?: OpenClawConfig; - stateDir?: string; - activeSessionIds?: Iterable; - activeSessionKeys?: Iterable; - updatedBeforeMs?: number; - resumedSessionKeys?: Set; - onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void; - lifecycleGeneration?: string; - shouldContinue?: () => boolean; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ marked: number; recovered: number; failed: number; skipped: number }> { - if (params.shouldContinue?.() === false) { - return { marked: 0, recovered: 0, failed: 0, skipped: 0 }; - } - const startupRecoveryCutoffMs = params.updatedBeforeMs ?? Date.now(); - const marked = await markStartupOrphanedMainSessionsForRecovery({ - cfg: params.cfg, - stateDir: params.stateDir, - activeSessionIds: params.activeSessionIds, - activeSessionKeys: params.activeSessionKeys, - updatedBeforeMs: startupRecoveryCutoffMs, + }, + storePath: params.storePath, + }); + if (result.failed === 0 && (result.recovered > 0 || !stillPending)) { + return true; + } + if ( + finalAttempt && + stillPending?.mainRestartRecovery?.chargedAttempts === MAX_RECOVERY_RETRIES && + !stillPending.mainRestartRecovery.reservation + ) { + // The last ambiguous dispatch consumed the final durable charge. One + // exact observation tombstones exhaustion without dispatching again. + await recover(); + } + return false; + }, + onError: (error, finalAttempt) => { + if (finalAttempt) { + log.warn(`main-session owner-release recovery failed: ${String(error)}`); + } + }, }); - if (params.shouldContinue?.() === false) { - return { marked: marked.marked, recovered: 0, failed: 0, skipped: marked.skipped }; - } - const recovered = await recoverRestartAbortedMainSessionsWithOptions({ - cfg: params.cfg, - onExhaustedTarget: params.onExhaustedTarget, - stateDir: params.stateDir, - resumedSessionKeys: params.resumedSessionKeys, - activeSessionIds: params.activeSessionIds, - activeSessionKeys: params.activeSessionKeys, - lifecycleGeneration: params.lifecycleGeneration, - shouldContinue: params.shouldContinue, - gatewayRuntime: params.gatewayRuntime, - }); - return { - marked: marked.marked, - recovered: recovered.recovered, - failed: recovered.failed, - skipped: marked.skipped + recovered.skipped, - }; -} - -export async function recoverStartupOrphanedMainSessions(params: { - cfg?: OpenClawConfig; - stateDir?: string; - activeSessionIds?: Iterable; - activeSessionKeys?: Iterable; - updatedBeforeMs?: number; - resumedSessionKeys?: Set; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ marked: number; recovered: number; failed: number; skipped: number }> { - return await recoverStartupOrphanedMainSessionsWithOptions(params); } export function scheduleRestartAbortedMainSessionRecovery(params: { @@ -336,158 +269,109 @@ export function scheduleRestartAbortedMainSessionRecovery(params: { waitForStart?: () => Promise; gatewayRuntime: GatewayRecoveryRuntime; }): { stop: () => Promise } { - const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; - const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES; const resumedSessionKeys = new Set(); const lifecycleGeneration = getAgentEventLifecycleGeneration(); + const abortController = new AbortController(); let stopped = false; - let timer: ReturnType | undefined; - let queuedAttempt: Promise | undefined; - let activeAttempt: Promise | undefined; - let cancelStartWait: (() => void) | undefined; - const startWaitCancelled = new Promise((resolve) => { - cancelStartWait = resolve; - }); const shouldContinue = () => !stopped && params.shouldContinue?.() !== false && isAgentEventLifecycleGenerationCurrent(lifecycleGeneration); - // Capture the cutoff at registration, before any startup gate can release new - // work. Sessions created by this gateway must never become recovery candidates. const startupRecoveryCutoffMs = Date.now(); - - const runRecoveryAttempt = (attempt: number, delay: number) => { - if (!shouldContinue()) { - return; - } - const exhaustedTargets = new Map(); - const reconcileExhaustedTargets = async () => { - if (!shouldContinue()) { - return; - } - const outcomes = await Promise.allSettled( - [...exhaustedTargets.values()].map((target) => - runWithGatewayIndependentRootWorkAdmission( - async () => - await recoverExpectedRestartRecoveryTarget({ - canonicalSessionKey: target.canonicalSessionKey, - cfg: params.cfg, - expectedSessionId: target.sessionId, - lifecycleGeneration, - observationOnly: true, - sessionKey: target.sessionKey, - shouldContinue, - storePath: target.storePath, - gatewayRuntime: params.gatewayRuntime, - }), - ), - ), - ); - for (const outcome of outcomes) { - if (outcome.status === "rejected") { - log.warn(`main-session exhaustion reconciliation failed: ${String(outcome.reason)}`); - } - } - }; - // Delayed retries outlive startup; each attempt must independently block - // host suspension while it reads and rewrites recovery session state. - const pendingAttempt = runWithGatewayIndependentRootWorkAdmission( - async () => - await recoverStartupOrphanedMainSessionsWithOptions({ + let startupMarked = false; + const runRecoveryAttempt = async ( + exhaustedTargets: Map, + ): Promise => { + return await runWithGatewayIndependentRootWorkAdmission(async () => { + if (!startupMarked) { + await markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg, - onExhaustedTarget: (target) => { - exhaustedTargets.set(`${target.storePath}\u0000${target.sessionKey}`, target); - }, stateDir: params.stateDir, - resumedSessionKeys, updatedBeforeMs: startupRecoveryCutoffMs, - lifecycleGeneration, - shouldContinue, - gatewayRuntime: params.gatewayRuntime, - }), - ) - .then(async (result) => { - if (!shouldContinue()) { - return; - } - if (result.failed > 0 && attempt < maxRetries) { - const retryDelay = - delay > 0 ? delay * RETRY_BACKOFF_MULTIPLIER : DEFAULT_RECOVERY_DELAY_MS; - scheduleAttempt(attempt + 1, retryDelay); - } else if (result.failed > 0 && attempt === maxRetries && exhaustedTargets.size > 0) { - // Reconcile only exact rows whose final dispatch retained its durable charge. - await reconcileExhaustedTargets(); - } - }) - .catch(async (err: unknown) => { - if (!shouldContinue()) { - return; - } - if (attempt < maxRetries) { - log.warn(`main-session restart recovery failed: ${String(err)}`); - const retryDelay = - delay > 0 ? delay * RETRY_BACKOFF_MULTIPLIER : DEFAULT_RECOVERY_DELAY_MS; - scheduleAttempt(attempt + 1, retryDelay); - } else { - log.warn(`main-session restart recovery gave up: ${String(err)}`); - await reconcileExhaustedTargets(); - } + }); + startupMarked = true; + } + return await recoverRestartAbortedMainSessions({ + cfg: params.cfg, + onExhaustedTarget: (target) => { + exhaustedTargets.set(`${target.storePath}\u0000${target.sessionKey}`, target); + }, + stateDir: params.stateDir, + resumedSessionKeys, + lifecycleGeneration, + shouldContinue, + gatewayRuntime: params.gatewayRuntime, }); - const trackedAttempt = pendingAttempt.finally(() => { - if (activeAttempt === trackedAttempt) { - activeAttempt = undefined; - } }); - activeAttempt = trackedAttempt; }; - - const queueRecoveryAttempt = (attempt: number, delay: number) => { - const pendingStart = Promise.resolve().then(async () => { - if (attempt === 1 && params.waitForStart) { - // Shutdown must cancel an unresolved startup gate so failed startup and - // same-port replacement cannot hang while joining this lifetime owner. - await Promise.race([params.waitForStart(), startWaitCancelled]); + const reconcileExhaustedTargets = async (targets: Iterable) => { + const outcomes = await Promise.allSettled( + [...targets].map((target) => + runWithGatewayIndependentRootWorkAdmission(async () => + recoverExpectedRestartRecovery({ + cfg: params.cfg, + expectedTarget: { + canonicalSessionKey: target.canonicalSessionKey, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + }, + lifecycleGeneration, + observationOnly: true, + sessionKey: target.sessionKey, + shouldContinue, + storePath: target.storePath, + gatewayRuntime: params.gatewayRuntime, + }), + ), + ), + ); + for (const outcome of outcomes) { + if (outcome.status === "rejected") { + log.warn(`main-session exhaustion reconciliation failed: ${String(outcome.reason)}`); } - if (shouldContinue()) { - runRecoveryAttempt(attempt, delay); - } - }); - const trackedStart = pendingStart.finally(() => { - if (queuedAttempt === trackedStart) { - queuedAttempt = undefined; - } - }); - queuedAttempt = trackedStart; - }; - - const scheduleAttempt = (attempt: number, delay: number) => { - if (!shouldContinue()) { - return; } - if (delay <= 0) { - queueRecoveryAttempt(attempt, delay); - return; - } - timer = setTimeout(() => { - timer = undefined; - queueRecoveryAttempt(attempt, delay); - }, delay); - timer.unref?.(); }; - - scheduleAttempt(1, initialDelay); + const cancelled = new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + let exhaustedTargets = new Map(); + const run = Promise.resolve().then(async () => { + if (params.waitForStart) { + await Promise.race([params.waitForStart(), cancelled]); + } + await runRecoveryRetries({ + initialDelayMs: params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS, + maxRetries: Math.max(1, params.maxRetries ?? MAX_RECOVERY_RETRIES), + shouldContinue, + signal: abortController.signal, + attempt: async (finalAttempt) => { + exhaustedTargets = new Map(); + const result = await runRecoveryAttempt(exhaustedTargets); + if (result.failed === 0) { + return true; + } + if (finalAttempt && exhaustedTargets.size > 0) { + await reconcileExhaustedTargets(exhaustedTargets.values()); + } + return false; + }, + onError: async (err, finalAttempt) => { + if (finalAttempt) { + log.warn(`main-session restart recovery gave up: ${String(err)}`); + await reconcileExhaustedTargets(exhaustedTargets.values()); + } else { + log.warn(`main-session restart recovery failed: ${String(err)}`); + } + }, + }); + }); return { stop: async () => { // Restart recovery belongs to its startup generation; stale timers must // never claim a session after that gateway begins draining. stopped = true; - cancelStartWait?.(); - if (timer) { - clearTimeout(timer); - timer = undefined; - } - await queuedAttempt; - await activeAttempt; + abortController.abort(); + await run; }, }; } diff --git a/src/agents/main-session-restart-recovery-shared.ts b/src/agents/main-session-restart-recovery-shared.ts index ac5a7a1876ed..dbc97333160a 100644 --- a/src/agents/main-session-restart-recovery-shared.ts +++ b/src/agents/main-session-restart-recovery-shared.ts @@ -10,7 +10,6 @@ import { } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { isMainRestartRecoveryCandidate } from "./main-session-recovery-state.js"; import { resolveAgentSessionDirs } from "./session-dirs.js"; export const log = createSubsystemLogger("main-session-restart-recovery"); @@ -56,10 +55,6 @@ export function buildRestartRecoveryExpectedState( }; } -export function shouldSkipMainRecovery(entry: SessionEntry, sessionKey: string): boolean { - return !isMainRestartRecoveryCandidate(entry, sessionKey); -} - export function normalizeStringSet(values: Iterable | undefined): Set { const normalized = new Set(); for (const value of values ?? []) { diff --git a/src/agents/main-session-restart-recovery-store.ts b/src/agents/main-session-restart-recovery-store.ts index 45ab917e7746..c524591c5c6b 100644 --- a/src/agents/main-session-restart-recovery-store.ts +++ b/src/agents/main-session-restart-recovery-store.ts @@ -51,7 +51,6 @@ import { log, MAX_RECOVERY_RETRIES, normalizeStringSet, - shouldSkipMainRecovery, } from "./main-session-restart-recovery-shared.js"; export function loadExpectedRestartRecoveryTarget(params: { @@ -112,6 +111,13 @@ export async function recoverStore(params: { }): Promise<{ recovered: number; failed: number; skipped: number }> { const result = { recovered: 0, failed: 0, skipped: 0 }; const shouldContinue = () => params.shouldContinue?.() !== false; + const stopped = () => { + if (shouldContinue()) { + return false; + } + result.skipped++; + return true; + }; const resumeIfCurrent = async (resumeParams: Parameters[0]) => { if (!shouldContinue()) { return "skipped" as const; @@ -158,8 +164,7 @@ export async function recoverStore(params: { for (const { sessionKey, entry: loadedEntry } of entries.toSorted((a, b) => a.sessionKey.localeCompare(b.sessionKey), )) { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } let entry = loadedEntry; @@ -170,7 +175,7 @@ export async function recoverStore(params: { if (!entry || entry.status !== "running" || entry.abortedLastRun !== true) { continue; } - if (shouldSkipMainRecovery(entry, sessionKey)) { + if (!isMainRestartRecoveryCandidate(entry, sessionKey)) { result.skipped++; continue; } @@ -208,8 +213,7 @@ export async function recoverStore(params: { continue; } - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const observed = await commitMainSessionRecovery({ @@ -227,8 +231,7 @@ export async function recoverStore(params: { result.skipped++; continue; } - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } entry = observed.entry; @@ -242,8 +245,7 @@ export async function recoverStore(params: { continue; } if (recoveryView.status === "exhausted") { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const tombstone = await tombstoneMainRestartRecoveryWithNotice({ @@ -291,54 +293,16 @@ export async function recoverStore(params: { } } }; - - if ( - requiresRestartRecoveryMessageActionAuthority(entry) && - !hasRestartRecoveryMessageActionAuthority(entry) - ) { - if (!shouldContinue()) { - result.skipped++; - return result; - } - const disposition = await failUnresumableMainSession({ - cfg: params.cfg, - entry, - gatewayRuntime: params.gatewayRuntime, - observation: recoveryView.observation, - reason: "message-tool-only recovery authority is unavailable", - sessionKey, - storePath: params.storePath, - }); - result[disposition]++; - continue; - } - - const expectedRecoverySourceRunId = normalizeOptionalString( - entry.restartRecoveryDeliverySourceRunId, - ); - let resumeBlockReason: string | undefined; - let resumeSafetyResolved = false; - const failBlockedResume = async (): Promise => { - if (!resumeSafetyResolved) { - resumeSafetyResolved = true; - resumeBlockReason = resolveRestartRecoveryResumeBlockReason({ - cfg: params.cfg, - entry, - sessionKey, - }); - } - if (!resumeBlockReason) { + const failCurrent = async (reason: string) => { + if (stopped()) { return false; } - if (!shouldContinue()) { - return true; - } const disposition = await failUnresumableMainSession({ cfg: params.cfg, entry, gatewayRuntime: params.gatewayRuntime, observation: recoveryView.observation, - reason: resumeBlockReason, + reason, sessionKey, storePath: params.storePath, }); @@ -346,27 +310,67 @@ export async function recoverStore(params: { return true; }; + if ( + requiresRestartRecoveryMessageActionAuthority(entry) && + !hasRestartRecoveryMessageActionAuthority(entry) + ) { + if (!(await failCurrent("message-tool-only recovery authority is unavailable"))) { + return result; + } + continue; + } + + const expectedRecoverySourceRunId = normalizeOptionalString( + entry.restartRecoveryDeliverySourceRunId, + ); + const failBlockedResume = async (): Promise => { + const resumeBlockReason = resolveRestartRecoveryResumeBlockReason({ + cfg: params.cfg, + entry, + sessionKey, + }); + if (!resumeBlockReason) { + return false; + } + if (!shouldContinue()) { + return true; + } + await failCurrent(resumeBlockReason); + return true; + }; + const resumeCurrent = async ( + options: Pick< + Parameters[0], + "forceCodeModeTools" | "forceRestartSafeTools" | "pendingFinalDeliveryText" + > = {}, + ) => { + if (await failBlockedResume()) { + return; + } + recordResumeResult( + await resumeIfCurrent({ + canonicalSessionKey: dispatchSessionKey, + cfg: params.cfg, + entry, + observation: recoveryView.observation, + recoveryAttempt: recoveryView.nextAttempt, + storePath: params.storePath, + sessionKey, + sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, + gatewayRuntime: params.gatewayRuntime, + ...options, + }), + ); + }; + if ( entry.pendingFinalDelivery?.kind === "replayable" && entry.restartRecoveryForceSafeTools === true ) { - if (await failBlockedResume()) { - continue; - } - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ pendingFinalDeliveryText: entry.pendingFinalDelivery.text, forceRestartSafeTools: true, - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); continue; } @@ -387,30 +391,16 @@ export async function recoverStore(params: { }, ); } catch (err) { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } if (entry.pendingFinalDelivery?.kind === "replayable") { - if (await failBlockedResume()) { - continue; - } log.warn( `transcript unavailable for ${sessionKey}; resuming its durable pending final delivery`, ); - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ pendingFinalDeliveryText: entry.pendingFinalDelivery.text, - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); continue; } log.warn(`failed to read transcript for ${sessionKey}: ${String(err)}`); @@ -418,28 +408,14 @@ export async function recoverStore(params: { continue; } - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } if (entry.pendingFinalDelivery?.kind === "replayable") { - if (await failBlockedResume()) { - continue; - } - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ pendingFinalDeliveryText: entry.pendingFinalDelivery.text, forceRestartSafeTools: hasReplaySafeCodeModeCheckpointInCurrentTurn(messages), - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); continue; } @@ -453,8 +429,7 @@ export async function recoverStore(params: { ? "transcript" : undefined; if (completionSource) { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const reconciliation = await reconcileInterruptedCompletionReport({ @@ -486,8 +461,7 @@ export async function recoverStore(params: { entry.restartRecoveryDeliveryToolCallId, ); if (resumePolicy.action === "complete") { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const completion = await markSessionCompletedAfterRecoveryCheckpoint({ @@ -510,59 +484,24 @@ export async function recoverStore(params: { } else if (completion.outcome === "changed") { result.skipped++; } else { - if (!shouldContinue()) { - result.skipped++; + if (!(await failCurrent(completion.reason))) { return result; } - const disposition = await failUnresumableMainSession({ - cfg: params.cfg, - entry, - gatewayRuntime: params.gatewayRuntime, - observation: recoveryView.observation, - reason: completion.reason, - sessionKey, - storePath: params.storePath, - }); - result[disposition]++; } continue; } if (resumePolicy.action === "fail") { - if (!shouldContinue()) { - result.skipped++; + if (!(await failCurrent(resumePolicy.reason))) { return result; } - const disposition = await failUnresumableMainSession({ - cfg: params.cfg, - entry, - gatewayRuntime: params.gatewayRuntime, - observation: recoveryView.observation, - reason: resumePolicy.reason, - sessionKey, - storePath: params.storePath, - }); - result[disposition]++; continue; } - if (await failBlockedResume()) { - continue; - } - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ forceRestartSafeTools: entry.restartRecoveryForceSafeTools === true || resumePolicy.forceRestartSafeTools, forceCodeModeTools: resumePolicy.forceCodeModeTools === true, - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); } return result; diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 5ad066195b0f..b4059581146c 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js"; import { markInboundContextLabel } from "../auto-reply/reply/inbound-context-marker.js"; -import { createReplyOperation } from "../auto-reply/reply/reply-run-registry.js"; import type { ChannelOutboundAdapter } from "../channels/plugins/types.public.js"; import type { CliDeps } from "../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -56,8 +55,6 @@ import { deliverAgentCommandResult } from "./command/delivery.js"; import { setActiveEmbeddedRunLifecycleGeneration } from "./embedded-agent-runner/run-state.js"; import { clearActiveEmbeddedRun, - queueEmbeddedAgentMessageWithOutcomeAsync, - resolveActiveEmbeddedRunHandleSessionId, setActiveEmbeddedRun, type EmbeddedAgentQueueHandle, } from "./embedded-agent-runner/runs.js"; @@ -70,10 +67,8 @@ import { claimMainSessionRecoveryOwner } from "./main-session-recovery-store.js" import { markRestartAbortedMainSessions, markStartupOrphanedMainSessionsForRecovery, - recoverStartupOrphanedMainSessions as recoverStartupOrphanedMainSessionsBase, recoverRestartAbortedMainSessions as recoverRestartAbortedMainSessionsBase, retryRestartAbortedMainSessionRecovery as retryRestartAbortedMainSessionRecoveryBase, - retryRestartAbortedMainSessionRecoveryAfterOwnerRelease as retryRestartAbortedMainSessionRecoveryAfterOwnerReleaseBase, scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease, scheduleRestartAbortedMainSessionRecovery as scheduleRestartAbortedMainSessionRecoveryBase, } from "./main-session-restart-recovery.js"; @@ -119,21 +114,11 @@ type RecoveryParams = Omit[0]>, ) => recoverRestartAbortedMainSessionsBase({ gatewayRuntime: mockRecoveryRuntime, ...params }); -const recoverStartupOrphanedMainSessions = ( - params: RecoveryParams[0]>, -) => recoverStartupOrphanedMainSessionsBase({ gatewayRuntime: mockRecoveryRuntime, ...params }); const retryRestartAbortedMainSessionRecovery = ( params: RecoveryParams[0]>, ) => retryRestartAbortedMainSessionRecoveryBase({ gatewayRuntime: mockRecoveryRuntime, ...params }); -const retryRestartAbortedMainSessionRecoveryAfterOwnerRelease = ( - params: RecoveryParams< - Parameters[0] - >, -) => - retryRestartAbortedMainSessionRecoveryAfterOwnerReleaseBase({ - gatewayRuntime: mockRecoveryRuntime, - ...params, - }); +const retryRestartAbortedMainSessionRecoveryAfterOwnerRelease = + retryRestartAbortedMainSessionRecovery; const scheduleRestartAbortedMainSessionRecovery = ( params: RecoveryParams[0]>, ) => @@ -2078,7 +2063,16 @@ describe("main-session-restart-recovery", () => { updatedAt: cutoff - 10_000, status: "running", }, + "agent:main:control": { + sessionId: "control-session", + updatedAt: cutoff - 10_000, + status: "running", + }, }); + await writeTranscript(sessionsDir, "control-session", [ + { role: "user", content: "resume the control session" }, + { role: "toolResult", content: "done" }, + ]); const createHandle = (runId: string): EmbeddedAgentQueueHandle => ({ kind: "embedded", @@ -2102,138 +2096,108 @@ describe("main-session-restart-recovery", () => { setActiveEmbeddedRun(sessionId, staleHandle, sessionKey); } + const recovery = scheduleRestartAbortedMainSessionRecovery({ + cfg: {}, + delayMs: 0, + stateDir: tmpDir, + }); try { - await expect( - recoverStartupOrphanedMainSessions({ stateDir: tmpDir, updatedBeforeMs: cutoff }), - ).resolves.toEqual({ marked: 0, recovered: 0, failed: 0, skipped: 0 }); - expect(callGateway).not.toHaveBeenCalled(); - expect( - loadSessionEntry({ - sessionKey, - storePath: path.join(sessionsDir, "sessions.json"), - }), - ).toMatchObject({ status: "running" }); + await waitForFast(() => + expect( + loadSessionEntry({ + sessionKey: "agent:main:control", + storePath: path.join(sessionsDir, "sessions.json"), + }), + ).toMatchObject({ abortedLastRun: false }), + ); + await recovery.stop(); + + expect(callGateway).toHaveBeenCalledOnce(); + const activeEntry = loadSessionEntry({ + sessionKey, + storePath: path.join(sessionsDir, "sessions.json"), + }); + expect(activeEntry).toMatchObject({ status: "running" }); + expect(activeEntry?.abortedLastRun).toBeUndefined(); } finally { + await recovery.stop(); clearActiveEmbeddedRun(sessionId, currentHandle, sessionKey); clearActiveEmbeddedRun(sessionId, staleHandle, sessionKey); } }); - it("reconciles only prior-lifecycle running sessions after an in-process restart", async () => { + it("cancels a stale startup owner after a mid-scan lifecycle rotation", async () => { const sessionsDir = await makeSessionsDir(); - const cutoff = Date.now(); - const abandonedKey = "agent:main:abandoned-client"; - const liveKey = "agent:main:live-client"; + const storePath = path.join(sessionsDir, "sessions.json"); + const sessionKey = "agent:main:generation-race"; + const sessionId = "generation-race-session"; await writeStore(sessionsDir, { - [abandonedKey]: { - sessionId: "abandoned-session", - updatedAt: cutoff - 10_000, - status: "running", - }, - [liveKey]: { - sessionId: "live-session", - updatedAt: cutoff - 10_000, + [sessionKey]: { + sessionId, + updatedAt: Date.now() - 10_000, status: "running", }, }); - await writeTranscript(sessionsDir, "abandoned-session", [ - { role: "system", content: "the client disappeared before the turn became resumable" }, - ]); - const createHandle = ( - runId: string, - queueMessage: EmbeddedAgentQueueHandle["queueMessage"] = async () => {}, - abort: EmbeddedAgentQueueHandle["abort"] = () => {}, - ): EmbeddedAgentQueueHandle => ({ + const originalApply = sessionAccessor.applySessionEntryReplacements; + const markerEntered = createDeferred(); + const releaseMarker = createDeferred(); + let pausedMarker = false; + const replacementSpy = vi + .spyOn(sessionAccessor, "applySessionEntryReplacements") + .mockImplementation(async (params) => { + if (params.requireWriteSuccess === true && !pausedMarker) { + pausedMarker = true; + markerEntered.resolve(); + await releaseMarker.promise; + } + return await originalApply(params); + }); + const recovery = scheduleRestartAbortedMainSessionRecovery({ + cfg: {}, + delayMs: 0, + stateDir: tmpDir, + }); + await markerEntered.promise; + expect(getActiveGatewayRootWorkCount()).toBe(1); + + // Rotate while the production scheduler owns the startup scan. Its marker + // must re-read the replacement generation's owner before writing, while + // stop waits for that in-flight root-work admission to leave cleanly. + rotateAgentEventLifecycleGeneration(); + const liveAbort = vi.fn(); + const liveHandle: EmbeddedAgentQueueHandle = { kind: "embedded", - runId, - queueMessage, + runId: "live-run", + queueMessage: async () => {}, isStreaming: () => true, isCompacting: () => false, - abort, + abort: liveAbort, + }; + setActiveEmbeddedRun(sessionId, liveHandle, sessionKey); + let stopSettled = false; + const stopping = recovery.stop().then(() => { + stopSettled = true; }); - const abandonedReply = createReplyOperation({ - sessionKey: abandonedKey, - sessionId: "abandoned-session", - resetTriggered: false, - }); - const abandonedReplyQueue = vi.fn(async () => {}); - const abandonedReplyCancel = vi.fn(); - abandonedReply.setPhase("running"); - abandonedReply.attachBackend({ - kind: "embedded", - cancel: abandonedReplyCancel, - isStreaming: () => true, - queueMessage: abandonedReplyQueue, - }); - const abandonedEmbeddedQueue = vi.fn(async () => {}); - const abandonedEmbeddedAbort = vi.fn(); - const abandonedHandle = createHandle( - "abandoned-run", - abandonedEmbeddedQueue, - abandonedEmbeddedAbort, - ); - setActiveEmbeddedRun("abandoned-session", abandonedHandle, abandonedKey); - - const firstRecovery = recoverStartupOrphanedMainSessions({ - stateDir: tmpDir, - updatedBeforeMs: cutoff, - }); - // Advance ownership while the async store discovery above is pending. The - // older scan must drop the stale owner without overlooking this new live one. - rotateAgentEventLifecycleGeneration(); - setActiveEmbeddedRun("abandoned-session", abandonedHandle, abandonedKey); - - await expect( - queueEmbeddedAgentMessageWithOutcomeAsync("abandoned-session", "do not route stale"), - ).resolves.toMatchObject({ queued: false, reason: "no_active_run" }); - expect(abandonedEmbeddedQueue).not.toHaveBeenCalled(); - expect(abandonedEmbeddedAbort).toHaveBeenCalledWith("restart"); - expect(abandonedReplyQueue).not.toHaveBeenCalled(); - expect(abandonedReplyCancel).toHaveBeenCalledWith("restart"); - expect(resolveActiveEmbeddedRunHandleSessionId(abandonedKey)).toBeUndefined(); - - const liveReply = createReplyOperation({ - sessionKey: liveKey, - sessionId: "live-session", - resetTriggered: false, - }); - const liveAbort = vi.fn(); - const liveHandle = createHandle("live-run", undefined, liveAbort); - setActiveEmbeddedRun("live-session", liveHandle, liveKey); try { - const first = await firstRecovery; + await Promise.resolve(); + expect(stopSettled).toBe(false); + + releaseMarker.resolve(); + await stopping; - expect(first).toEqual({ marked: 1, recovered: 0, failed: 1, skipped: 0 }); expect(callGateway).not.toHaveBeenCalled(); - expect( - loadSessionEntry({ - sessionKey: abandonedKey, - storePath: path.join(sessionsDir, "sessions.json"), - }), - ).toMatchObject({ - status: "failed", - abortedLastRun: true, - }); - expect( - loadSessionEntry({ - sessionKey: liveKey, - storePath: path.join(sessionsDir, "sessions.json"), - }), - ).toMatchObject({ - status: "running", - }); + const entry = loadSessionEntry({ sessionKey, storePath }); + expect(entry).toMatchObject({ status: "running" }); + expect(entry?.abortedLastRun).toBeUndefined(); + expect(entry?.mainRestartRecovery).toBeUndefined(); expect(liveAbort).not.toHaveBeenCalled(); - expect(liveReply.abortSignal.aborted).toBe(false); - - await expect( - recoverStartupOrphanedMainSessions({ stateDir: tmpDir, updatedBeforeMs: cutoff }), - ).resolves.toEqual({ marked: 0, recovered: 0, failed: 0, skipped: 0 }); + expect(getActiveGatewayRootWorkCount()).toBe(0); } finally { - clearActiveEmbeddedRun("abandoned-session", abandonedHandle, abandonedKey); - clearActiveEmbeddedRun("live-session", liveHandle, liveKey); - abandonedReply.complete(); - liveReply.complete(); + releaseMarker.resolve(); + await stopping; + replacementSpy.mockRestore(); + clearActiveEmbeddedRun(sessionId, liveHandle, sessionKey); } }); @@ -2265,16 +2229,22 @@ describe("main-session-restart-recovery", () => { { role: "toolResult", content: "custom result" }, ]); - const result = await recoverStartupOrphanedMainSessions({ + const recovery = scheduleRestartAbortedMainSessionRecovery({ cfg: { session: { store: customStorePath } }, + delayMs: 0, stateDir: tmpDir, - updatedBeforeMs: cutoff, }); + try { + await waitForFast(() => + expect( + loadSessionEntry({ sessionKey: "agent:main:main", storePath: customStorePath }), + ).toMatchObject({ abortedLastRun: false }), + ); + await recovery.stop(); + } finally { + await recovery.stop(); + } - expect(result).toMatchObject({ marked: 2, recovered: 1, failed: 0 }); - // Discovery can revisit the non-routable default store through a canonical path alias. - expect(result.skipped).toBeGreaterThanOrEqual(1); - expect(result.skipped).toBeLessThanOrEqual(2); expect(callGateway).toHaveBeenCalledOnce(); const defaultStore = readStore(path.join(defaultSessionsDir, "sessions.json")); const customStore = readStore(customStorePath); @@ -2660,12 +2630,32 @@ describe("main-session-restart-recovery", () => { await retryScheduled.promise; expect(countAgentDispatches()).toBe(1); + await writeStore(sessionsDir, { + "agent:main:late-startup-row": { + sessionId: "late-startup-session", + updatedAt: 1, + status: "running", + }, + }); + await writeTranscript(sessionsDir, "late-startup-session", [ + { role: "user", content: "this row appeared after startup marking" }, + { role: "toolResult", content: "done" }, + ]); + await vi.advanceTimersByTimeAsync(4_999); expect(countAgentDispatches()).toBe(1); await vi.advanceTimersByTimeAsync(1); await secondDispatch.promise; + await recovery.stop(); + expect(countAgentDispatches()).toBe(2); + const lateEntry = loadSessionEntry({ + sessionKey: "agent:main:late-startup-row", + storePath: path.join(sessionsDir, "sessions.json"), + }); + expect(lateEntry).toMatchObject({ status: "running" }); + expect(lateEntry?.abortedLastRun).toBeUndefined(); } finally { await recovery?.stop(); setTimeoutSpy.mockRestore(); diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index ddcfedad416f..eaadd438431e 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -8,9 +8,7 @@ export { } from "./main-session-restart-recovery-marking.js"; export { recoverRestartAbortedMainSessions, - recoverStartupOrphanedMainSessions, retryRestartAbortedMainSessionRecovery, - retryRestartAbortedMainSessionRecoveryAfterOwnerRelease, scheduleRestartAbortedMainSessionRecovery, scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease, } from "./main-session-restart-recovery-runtime.js"; diff --git a/src/commands/doctor-main-session-recovery.test.ts b/src/commands/doctor-main-session-recovery.test.ts new file mode 100644 index 000000000000..d62db7758c77 --- /dev/null +++ b/src/commands/doctor-main-session-recovery.test.ts @@ -0,0 +1,96 @@ +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { InternalSessionEntry } from "../config/sessions.js"; +import { loadSessionEntry, upsertSessionEntry } from "../config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { noteMainSessionRecoveryIntegrity } from "./doctor-main-session-recovery.js"; + +const agentId = "main"; +const sessionKey = "agent:main:wedged-main"; +const reason = "restart recovery exhausted after 3 attempts"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function countLabel(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +describe("doctor main-session recovery integrity", () => { + let storePath = ""; + + beforeEach(() => { + storePath = path.join(tempDirs.make("openclaw-doctor-main-recovery-"), "sessions.json"); + }); + + afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + }); + + async function writeTombstone(abortedLastRun: boolean): Promise { + await upsertSessionEntry({ agentId, sessionKey, storePath }, { + sessionId: "session-wedged-main", + updatedAt: abortedLastRun ? 0 : 1, + status: "failed", + abortedLastRun, + mainRestartRecovery: { + cycleId: "cycle-wedged-main", + revision: 4, + chargedAttempts: 3, + tombstone: { reason }, + }, + } as InternalSessionEntry); + } + + it("warns about a tombstone without offering stale repair", async () => { + await writeTombstone(false); + const warnings: string[] = []; + const changes: string[] = []; + const confirmRepair = vi.fn(async () => false); + + await noteMainSessionRecoveryIntegrity({ + agentId, + storePath, + warnings, + changes, + confirmRepair, + countLabel, + }); + + expect(warnings.join("\n")).toContain("automatic restart recovery tombstoned"); + expect(warnings.join("\n")).toContain(sessionKey); + expect(warnings.join("\n")).toContain(reason); + expect(confirmRepair).not.toHaveBeenCalled(); + expect(changes).toEqual([]); + expect(loadSessionEntry({ sessionKey, storePath })?.abortedLastRun).toBe(false); + }); + + it("clears a stale aborted flag while preserving the tombstone", async () => { + await writeTombstone(true); + const warnings: string[] = []; + const changes: string[] = []; + const confirmRepair = vi.fn(async () => true); + + await noteMainSessionRecoveryIntegrity({ + agentId, + storePath, + warnings, + changes, + confirmRepair, + countLabel, + }); + + expect(confirmRepair).toHaveBeenCalledWith({ + message: "Clear stale aborted recovery flags for 1 wedged main session?", + initialValue: true, + }); + const persisted = loadSessionEntry({ sessionKey, storePath }) as + | InternalSessionEntry + | undefined; + expect(persisted?.abortedLastRun).toBe(false); + expect(persisted?.updatedAt).toBeGreaterThan(0); + expect(persisted?.mainRestartRecovery?.tombstone?.reason).toBe(reason); + expect(changes).toEqual([ + "- Cleared aborted restart-recovery flags for 1 wedged main session.", + ]); + }); +}); diff --git a/src/commands/doctor-main-session-recovery.ts b/src/commands/doctor-main-session-recovery.ts index c81e55ad765b..524d8d907e88 100644 --- a/src/commands/doctor-main-session-recovery.ts +++ b/src/commands/doctor-main-session-recovery.ts @@ -1,6 +1,6 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { inspectMainSessionRecoveryHealth } from "../agents/main-session-recovery-lifecycle.js"; import { transitionMainSessionRecovery } from "../agents/main-session-recovery-state.js"; +import type { InternalSessionEntry } from "../config/sessions.js"; import { applySessionEntryReplacements, listSessionEntries, @@ -20,8 +20,20 @@ export async function noteMainSessionRecoveryIntegrity( ): Promise { const entries = listSessionEntries({ agentId: params.agentId, storePath: params.storePath }); const wedged = entries.flatMap(({ entry, sessionKey }) => { - const health = inspectMainSessionRecoveryHealth(entry); - return health.status === "tombstoned" ? [{ key: sessionKey, health }] : []; + const tombstone = (entry as InternalSessionEntry).mainRestartRecovery?.tombstone; + return tombstone + ? [ + { + key: sessionKey, + health: { + reason: + tombstone.reason.trim() || + "main-session restart recovery is tombstoned for this session", + repair: entry.abortedLastRun === true ? "clear_stale_abort" : null, + }, + }, + ] + : []; }); if (wedged.length === 0) { return entries.length; diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index 21c5f21e29c4..e0a1a715bffc 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -12,11 +12,8 @@ import { type ExecApprovalContinuationPromptRange, } from "../../agents/bash-tools.exec-approval-output.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; +import { repairMainSessionRecoveryMutation } from "../../agents/main-session-recovery-lifecycle.js"; import { scheduleMainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery-owner-release.js"; -import { - restoreAdmittedRecoveryWithRetries, - scheduleAdmittedRecoveryRestore, -} from "../../agents/main-session-recovery-restore.js"; import { releaseMainSessionRecoveryOwner, type MainSessionRecoveryPendingTarget, @@ -510,17 +507,16 @@ export function startAgentRunExecution(params: { claimId: execApprovalFollowupHandoffClaimId, }); try { - if (prepared.restoreAdmittedRestartRecoveryInterrupted) { - try { - pendingRecovery ??= await restoreAdmittedRecoveryWithRetries( - prepared.restoreAdmittedRestartRecoveryInterrupted, - ); - } catch (err) { - params.context.logGateway.warn( - `failed to restore undispatched restart recovery: ${formatForLog(err)}`, - ); - scheduleAdmittedRecoveryRestore(prepared.restoreAdmittedRestartRecoveryInterrupted); - } + const restoreAdmittedRecovery = prepared.restoreAdmittedRestartRecoveryInterrupted; + if (restoreAdmittedRecovery) { + pendingRecovery ??= await repairMainSessionRecoveryMutation({ + mutation: restoreAdmittedRecovery, + onDeferredSuccess: scheduleMainSessionRecoveryPendingTarget, + onError: (err) => + params.context.logGateway.warn( + `failed to restore undispatched restart recovery: ${formatForLog(err)}`, + ), + }); } } finally { try { diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts index 08221cada6d5..223c32677d2a 100644 --- a/src/gateway/server-methods/agent-session-persist.ts +++ b/src/gateway/server-methods/agent-session-persist.ts @@ -1,11 +1,9 @@ import { randomUUID } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { - isMainSessionRecoveryExhausted, - transitionMainSessionRecovery, -} from "../../agents/main-session-recovery-state.js"; +import { transitionMainSessionRecovery } from "../../agents/main-session-recovery-state.js"; import type { MainSessionRecoveryOwnerLease } from "../../agents/main-session-recovery-store.js"; +import { MAX_RECOVERY_RETRIES } from "../../agents/main-session-restart-recovery-shared.js"; import { mergeSessionEntry, resolveSessionLifecycleTimestamps, @@ -188,7 +186,10 @@ export async function persistAgentSessionPhase(params: { !params.isRestartRecoveryResumeRun && internalFreshEntry && (internalFreshEntry.mainRestartRecovery?.tombstone || - isMainSessionRecoveryExhausted(internalFreshEntry)) + (internalFreshEntry.status === "running" && + internalFreshEntry.abortedLastRun === true && + (internalFreshEntry.mainRestartRecovery?.chargedAttempts ?? 0) >= + MAX_RECOVERY_RETRIES)) ) { restartRecoveryReservationConflict = `Session "${params.canonicalSessionKey}" is quarantined after restart recovery ` + From 926afb66e9c4782d644194f9704610d2540a7024 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:48:37 -0700 Subject: [PATCH 22/53] refactor(memory): unify authoritative dreaming state and presentation (#117538) --- config/max-lines-baseline.txt | 1 - ui/src/pages/agents/memory/dreaming.test.ts | 191 ++-- ui/src/pages/agents/memory/dreaming.ts | 868 +++--------------- .../pages/agents/memory/memory-panel.test.ts | 248 +++++ ui/src/pages/agents/memory/memory-panel.ts | 27 +- ui/src/pages/agents/memory/view.test.ts | 5 - ui/src/pages/agents/memory/view.ts | 775 ++++++---------- 7 files changed, 788 insertions(+), 1327 deletions(-) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6b9c6cd6c6b3..e582f0f34d97 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -975,7 +975,6 @@ ui/src/lib/skills/index.test.ts ui/src/lib/workboard/index.test.ts ui/src/pages/agents/agents-page.ts ui/src/pages/agents/memory/dreaming.test.ts -ui/src/pages/agents/memory/dreaming.ts ui/src/pages/agents/memory/view.ts ui/src/pages/agents/panels-tools-skills.ts ui/src/pages/chat/chat-command-executor.test.ts diff --git a/ui/src/pages/agents/memory/dreaming.test.ts b/ui/src/pages/agents/memory/dreaming.test.ts index 345a368635e1..1623d8910134 100644 --- a/ui/src/pages/agents/memory/dreaming.test.ts +++ b/ui/src/pages/agents/memory/dreaming.test.ts @@ -121,10 +121,40 @@ function getConfigPatchRawPayload(config: DreamingConfigCapability): Record ({ + sourceType: "chatgpt" as const, + totalItems: 0, + totalClusters: 0, + clusters: [], + }), + }, + { + label: "overview", + key: "wikiOverview", + method: "wiki.overview", + load: loadWikiOverview, + payload: () => ({ + totalItems: 0, + totalPages: 0, + pageCounts: { source: 0, synthesis: 0, report: 0, entity: 0, concept: 0 }, + totalClaims: 0, + totalQuestions: 0, + totalContradictions: 0, + clusters: [], + }), + }, +] as const; + describe("dreaming controller", () => { - it("loads and normalizes dreaming status from doctor.memory.status", async () => { + it("retains the authoritative dreaming status from doctor.memory.status", async () => { const { state, request } = createState(); - request.mockResolvedValue({ + const payload = { dreaming: { enabled: true, timezone: "America/Los_Angeles", @@ -223,12 +253,14 @@ describe("dreaming controller", () => { }, }, }, - }); + }; + request.mockResolvedValue(payload); await loadDreamingStatus(state); expect(request).toHaveBeenCalledWith("doctor.memory.status", {}); const status = state.dreamingStatus; + expect(status).toBe(payload.dreaming); expect(status?.enabled).toBe(true); expect(status?.shortTermCount).toBe(8); expect(status?.groundedSignalCount).toBe(5); @@ -340,35 +372,74 @@ describe("dreaming controller", () => { expect(state.dreamingStatusError).toBeNull(); }); - it("preserves unknown phase state when status omits phase metadata", async () => { - const { state, request } = createState(); - request.mockResolvedValue({ - dreaming: { - enabled: true, - shortTermCount: 1, - recallSignalCount: 0, - dailySignalCount: 0, - groundedSignalCount: 0, - totalSignalCount: 1, - phaseSignalCount: 0, - lightPhaseHitCount: 0, - remPhaseHitCount: 0, - promotedTotal: 0, - promotedToday: 0, - shortTermEntries: [], - signalEntries: [], - promotedEntries: [], - }, - }); + it.each(wikiResources)( + "keeps the newest $label response across an A-to-B-to-A agent switch", + async ({ key, method, load, payload }) => { + const { state, request } = createState(); + const firstAgentA = createDeferred(); + const agentB = createDeferred(); + const secondAgentA = createDeferred(); + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: [method] }, + }; + request + .mockImplementationOnce(async () => firstAgentA.promise) + .mockImplementationOnce(async () => agentB.promise) + .mockImplementationOnce(async () => secondAgentA.promise); - await loadDreamingStatus(state); + state.selectedAgentId = "agent-a"; + const staleA = load(state); + state.selectedAgentId = "agent-b"; + const staleB = load(state); + state.selectedAgentId = "agent-a"; + const latest = load(state); + const latestPayload = payload(); - expect(state.dreamingStatus?.enabled).toBe(true); - expect(state.dreamingStatus?.phases).toBeUndefined(); - expect(state.dreamingStatusError).toBeNull(); - }); + secondAgentA.resolve(latestPayload); + await latest; + expect(state[key]).toBe(latestPayload); - it("loads and normalizes wiki import insights", async () => { + firstAgentA.resolve(payload()); + agentB.resolve(payload()); + await Promise.all([staleA, staleB]); + + expect(state[key]).toBe(latestPayload); + expect(state.resourceRequests[key]).toBeUndefined(); + expect(request).toHaveBeenCalledTimes(3); + }, + ); + + it.each(wikiResources)( + "invalidates an in-flight $label request when its gateway capability disappears", + async ({ key, method, load, payload }) => { + const { state, request } = createState(); + const deferred = createDeferred(); + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: [method] }, + }; + request.mockImplementationOnce(async () => deferred.promise); + + const stale = load(state); + state.hello = { ...state.hello, features: { methods: [] } }; + await load(state); + expect(state[key]).toBeNull(); + + deferred.resolve(payload()); + await stale; + + expect(state[key]).toBeNull(); + expect(state.resourceRequests[key]).toBeUndefined(); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it("loads authoritative wiki import insights", async () => { const { state, request } = createState(); state.hello = { type: "hello-ok", @@ -708,56 +779,6 @@ describe("dreaming controller", () => { expect(state.wikiOverviewError).toBeNull(); }); - it("derives legacy wiki wiki overview page counts from clusters", async () => { - const { state, request } = createState(); - state.hello = { - type: "hello-ok", - protocol: 4, - auth: { role: "operator", scopes: [] }, - features: { methods: ["wiki.overview"] }, - }; - state.configSnapshot = { - hash: "hash-1", - config: { - plugins: { - entries: { - "memory-wiki": { - enabled: true, - }, - }, - }, - }, - }; - request.mockResolvedValue({ - totalItems: 1, - totalClaims: 2, - totalQuestions: 1, - totalContradictions: 0, - clusters: [ - { - key: "synthesis", - label: "Syntheses", - itemCount: 1, - claimCount: 2, - questionCount: 1, - contradictionCount: 0, - items: [], - }, - ], - }); - - await loadWikiOverview(state); - - expect(state.wikiOverview?.totalPages).toBe(1); - expect(state.wikiOverview?.pageCounts).toEqual({ - synthesis: 1, - entity: 0, - concept: 0, - source: 0, - report: 0, - }); - }); - it("falls back to config gating for wiki wiki overview when methods are not advertised", async () => { const { state, request } = createState(); state.configSnapshot = { @@ -773,8 +794,10 @@ describe("dreaming controller", () => { }, }; request.mockResolvedValue({ - totalItems: 1, - totalClaims: 2, + totalItems: 0, + totalPages: 0, + pageCounts: { synthesis: 0, entity: 0, concept: 0, source: 0, report: 0 }, + totalClaims: 0, totalQuestions: 0, totalContradictions: 0, clusters: [], @@ -783,8 +806,8 @@ describe("dreaming controller", () => { await loadWikiOverview(state); expect(request).toHaveBeenCalledWith("wiki.overview", {}); - expect(state.wikiOverview?.totalItems).toBe(1); - expect(state.wikiOverview?.totalPages).toBe(1); + expect(state.wikiOverview?.totalItems).toBe(0); + expect(state.wikiOverview?.totalPages).toBe(0); expect(state.wikiOverview?.pageCounts).toEqual({ synthesis: 0, entity: 0, @@ -792,7 +815,7 @@ describe("dreaming controller", () => { source: 0, report: 0, }); - expect(state.wikiOverview?.totalClaims).toBe(2); + expect(state.wikiOverview?.totalClaims).toBe(0); expect(state.wikiOverviewError).toBeNull(); expect(state.wikiOverviewLoading).toBe(false); }); diff --git a/ui/src/pages/agents/memory/dreaming.ts b/ui/src/pages/agents/memory/dreaming.ts index 7aad0d23bd5c..1ae2c6ff852f 100644 --- a/ui/src/pages/agents/memory/dreaming.ts +++ b/ui/src/pages/agents/memory/dreaming.ts @@ -1,4 +1,9 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import type { + DoctorMemoryDreamActionPayload, + DoctorMemoryDreamDiaryPayload, + DoctorMemoryStatusPayload, +} from "../../../../../src/gateway/server-methods/doctor.ts"; import { defaultSlotIdForKey, resolveSlotSelection } from "../../../../../src/plugins/slots.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.ts"; import type { ConfigSnapshot } from "../../../api/types.ts"; @@ -8,83 +13,10 @@ import type { RuntimeConfigCapability } from "../../../lib/config/index.ts"; import { isGatewayMethodAdvertised } from "../../../lib/gateway-methods.ts"; import { isPluginEnabledInConfigSnapshot } from "../../../lib/plugin-activation.ts"; -const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md"; const MEMORY_WIKI_PLUGIN_ID = "memory-wiki"; -type DreamingPhaseStatusBase = { - enabled: boolean; - cron: string; - managedCronPresent: boolean; - nextRunAtMs?: number; -}; - -type LightDreamingStatus = DreamingPhaseStatusBase & { - lookbackDays: number; - limit: number; -}; - -type DeepDreamingStatus = DreamingPhaseStatusBase & { - limit: number; - minScore: number; - minRecallCount: number; - minUniqueQueries: number; - recencyHalfLifeDays: number; - maxAgeDays?: number; - maxPromotedSnippetTokens?: number; -}; - -type RemDreamingStatus = DreamingPhaseStatusBase & { - lookbackDays: number; - limit: number; - minPatternStrength: number; -}; - -export type DreamingEntry = { - key: string; - path: string; - startLine: number; - endLine: number; - snippet: string; - recallCount: number; - dailyCount: number; - groundedCount: number; - totalSignalCount: number; - lightHits: number; - remHits: number; - phaseHitCount: number; - promotedAt?: string; - lastRecalledAt?: string; -}; - -type DreamingStatus = { - enabled: boolean; - timezone?: string; - verboseLogging: boolean; - storageMode: "inline" | "separate" | "both"; - separateReports: boolean; - shortTermCount: number; - recallSignalCount: number; - dailySignalCount: number; - groundedSignalCount: number; - totalSignalCount: number; - phaseSignalCount: number; - lightPhaseHitCount: number; - remPhaseHitCount: number; - promotedTotal: number; - promotedToday: number; - storePath?: string; - phaseSignalPath?: string; - storeError?: string; - phaseSignalError?: string; - shortTermEntries: DreamingEntry[]; - signalEntries: DreamingEntry[]; - promotedEntries: DreamingEntry[]; - phases?: { - light: LightDreamingStatus; - deep: DeepDreamingStatus; - rem: RemDreamingStatus; - }; -}; +type DreamingStatus = NonNullable; +export type DreamingEntry = DreamingStatus["shortTermEntries"][number]; type WikiImportInsightItem = { pagePath: string; @@ -166,48 +98,8 @@ export type WikiOverview = { clusters: WikiOverviewCluster[]; }; -type DoctorMemoryStatusPayload = { - dreaming?: unknown; -}; - -type DoctorMemoryDreamDiaryPayload = { - found?: unknown; - path?: unknown; - content?: unknown; -}; - -type DoctorMemoryDreamActionPayload = { - action?: unknown; - removedEntries?: unknown; - dedupedEntries?: unknown; - keptEntries?: unknown; - written?: unknown; - replaced?: unknown; - removedShortTermEntries?: unknown; - changed?: unknown; - archiveDir?: unknown; - archivedSessionCorpus?: unknown; - archivedSessionIngestion?: unknown; - archivedDreamsDiary?: unknown; - warnings?: unknown; -}; - -type WikiImportInsightsPayload = { - sourceType?: unknown; - totalItems?: unknown; - totalClusters?: unknown; - clusters?: unknown; -}; - -type WikiOverviewPayload = { - totalItems?: unknown; - totalPages?: unknown; - pageCounts?: unknown; - totalClaims?: unknown; - totalQuestions?: unknown; - totalContradictions?: unknown; - clusters?: unknown; -}; +type DreamingResourceKey = "dreamingStatus" | "dreamDiary" | "wikiImportInsights" | "wikiOverview"; +type DreamingResourceRequest = { agentId: string | null }; export type DreamingState = { client: GatewayBrowserClient | null; @@ -216,17 +108,12 @@ export type DreamingState = { configSnapshot: ConfigSnapshot | null; applySessionKey: string; selectedAgentId: string | null; - dreamingStatusRequestAgentId?: string | null; - dreamingStatusRequestGeneration?: number; - dreamingStatusActiveRequestGeneration?: number | null; + resourceRequests: Partial>; dreamingStatusAgentId?: string | null; dreamingStatusLoading: boolean; dreamingStatusError: string | null; dreamingStatus: DreamingStatus | null; dreamingModeSaving: boolean; - dreamDiaryRequestAgentId?: string | null; - dreamDiaryRequestGeneration?: number; - dreamDiaryActiveRequestGeneration?: number | null; dreamDiaryAgentId?: string | null; dreamDiaryLoading: boolean; dreamDiaryActionLoading: boolean; @@ -235,18 +122,10 @@ export type DreamingState = { dreamDiaryError: string | null; dreamDiaryPath: string | null; dreamDiaryContent: string | null; - // Agent switches can overlap RPCs; generations keep an old A -> B -> A response - // from replacing the current agent's wiki data. - wikiImportInsightsRequestAgentId?: string | null; - wikiImportInsightsRequestGeneration?: number; - wikiImportInsightsActiveRequestGeneration?: number | null; wikiImportInsightsAgentId?: string | null; wikiImportInsightsLoading: boolean; wikiImportInsightsError: string | null; wikiImportInsights: WikiImportInsights | null; - wikiOverviewRequestAgentId?: string | null; - wikiOverviewRequestGeneration?: number; - wikiOverviewActiveRequestGeneration?: number | null; wikiOverviewAgentId?: string | null; wikiOverviewLoading: boolean; wikiOverviewError: string | null; @@ -269,6 +148,7 @@ export function createDreamingState( configSnapshot: initial.configSnapshot ?? null, applySessionKey: initial.applySessionKey ?? "main", selectedAgentId: initial.selectedAgentId ?? null, + resourceRequests: {}, dreamingStatusLoading: false, dreamingStatusError: null, dreamingStatus: null, @@ -415,47 +295,6 @@ function buildSelectedAgentPayload( return buildSelectedAgentPayloadForAgentId(resolveSelectedAgentId(state)); } -function normalizeBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function normalizeFiniteInt(value: unknown, fallback = 0): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - return Math.max(0, Math.floor(value)); -} - -function normalizeFiniteScore(value: unknown, fallback = 0): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - return Math.max(0, Math.min(1, value)); -} - -function normalizeStorageMode(value: unknown): DreamingStatus["storageMode"] { - const normalized = normalizeTrimmedString(value)?.toLowerCase(); - if (normalized === "inline" || normalized === "separate" || normalized === "both") { - return normalized; - } - return "inline"; -} - -function normalizeNextRun(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function normalizePhaseStatusBase(record: Record | null): DreamingPhaseStatusBase { - return { - enabled: normalizeBoolean(record?.enabled, false), - cron: normalizeTrimmedString(record?.cron) ?? "", - managedCronPresent: normalizeBoolean(record?.managedCronPresent, false), - ...(normalizeNextRun(record?.nextRunAtMs) !== undefined - ? { nextRunAtMs: normalizeNextRun(record?.nextRunAtMs) } - : {}), - }; -} - export function resolveConfiguredDreaming(configValue: Record | null): { pluginId: string; enabled: boolean; @@ -474,608 +313,142 @@ export function resolveConfiguredDreaming(configValue: Record | const overridden = typeof dreaming?.enabled === "boolean"; return { pluginId, - enabled: slotSelection.kind === "off" ? false : normalizeBoolean(dreaming?.enabled, true), + enabled: slotSelection.kind !== "off" && dreaming?.enabled !== false, overridden, engineOff: slotSelection.kind === "off", }; } -function normalizeDreamingEntry(raw: unknown): DreamingEntry | null { - const record = asRecord(raw); - const key = normalizeTrimmedString(record?.key); - const path = normalizeTrimmedString(record?.path); - const snippet = normalizeTrimmedString(record?.snippet); - if (!key || !path || !snippet) { - return null; - } - const promotedAt = normalizeTrimmedString(record?.promotedAt); - const lastRecalledAt = normalizeTrimmedString(record?.lastRecalledAt); - return { - key, - path, - startLine: Math.max(1, normalizeFiniteInt(record?.startLine, 1)), - endLine: Math.max(1, normalizeFiniteInt(record?.endLine, 1)), - snippet, - recallCount: normalizeFiniteInt(record?.recallCount, 0), - dailyCount: normalizeFiniteInt(record?.dailyCount, 0), - groundedCount: normalizeFiniteInt(record?.groundedCount, 0), - totalSignalCount: normalizeFiniteInt(record?.totalSignalCount, 0), - lightHits: normalizeFiniteInt(record?.lightHits, 0), - remHits: normalizeFiniteInt(record?.remHits, 0), - phaseHitCount: normalizeFiniteInt(record?.phaseHitCount, 0), - ...(promotedAt ? { promotedAt } : {}), - ...(lastRecalledAt ? { lastRecalledAt } : {}), - }; -} +type DreamingResourcePayloads = { + dreamingStatus: DoctorMemoryStatusPayload; + dreamDiary: DoctorMemoryDreamDiaryPayload; + wikiImportInsights: WikiImportInsights; + wikiOverview: WikiOverview; +}; -function normalizeDreamingEntries(raw: unknown): DreamingEntry[] { - if (!Array.isArray(raw)) { - return []; - } - return raw - .map((entry) => normalizeDreamingEntry(entry)) - .filter((entry): entry is DreamingEntry => entry !== null); -} +type DreamingResourceSpec = { + method: string; + clear: (state: DreamingState) => void; + apply: (state: DreamingState, payload: DreamingResourcePayloads[Key]) => void; +}; -function normalizeStringArray(raw: unknown): string[] { - if (!Array.isArray(raw)) { - return []; - } - return raw.filter( - (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, - ); -} +const DREAMING_RESOURCE_SPECS: { + [Key in DreamingResourceKey]: DreamingResourceSpec; +} = { + dreamingStatus: { + method: "doctor.memory.status", + clear: (state) => { + state.dreamingStatus = null; + }, + apply: (state, payload) => { + state.dreamingStatus = payload.dreaming ?? null; + }, + }, + dreamDiary: { + method: "doctor.memory.dreamDiary", + clear: (state) => { + state.dreamDiaryPath = null; + state.dreamDiaryContent = null; + }, + apply: (state, payload) => { + state.dreamDiaryPath = payload.path; + state.dreamDiaryContent = payload.found ? (payload.content ?? "") : null; + }, + }, + wikiImportInsights: { + method: "wiki.importInsights", + clear: (state) => { + state.wikiImportInsights = null; + }, + apply: (state, payload) => { + state.wikiImportInsights = payload; + }, + }, + wikiOverview: { + method: "wiki.overview", + clear: (state) => { + state.wikiOverview = null; + }, + apply: (state, payload) => { + state.wikiOverview = payload; + }, + }, +}; -function normalizeWikiImportInsightItem(raw: unknown): WikiImportInsightItem | null { - const record = asRecord(raw); - const pagePath = normalizeTrimmedString(record?.pagePath); - const title = normalizeTrimmedString(record?.title); - const riskLevel = normalizeTrimmedString(record?.riskLevel); - const topicKey = normalizeTrimmedString(record?.topicKey); - const topicLabel = normalizeTrimmedString(record?.topicLabel); - const digestStatus = normalizeTrimmedString(record?.digestStatus); - const summary = normalizeTrimmedString(record?.summary); +async function loadDreamingResource( + state: DreamingState, + key: Key, + spec: DreamingResourceSpec = DREAMING_RESOURCE_SPECS[key], +): Promise { + const client = state.client; + if (!client || !state.connected) { + return; + } + + const agentId = resolveSelectedAgentId(state); + const loadingKey = `${key}Loading` as const; + const errorKey = `${key}Error` as const; + const agentKey = `${key}AgentId` as const; + if (state[agentKey] !== agentId) { + spec.clear(state); + } if ( - !pagePath || - !title || - !topicKey || - !topicLabel || - !summary || - (riskLevel !== "low" && - riskLevel !== "medium" && - riskLevel !== "high" && - riskLevel !== "unknown") || - (digestStatus !== "available" && digestStatus !== "withheld") + (key === "wikiImportInsights" || key === "wikiOverview") && + !canCallMemoryWikiMethod(state, spec.method) ) { - return null; + delete state.resourceRequests[key]; + state[loadingKey] = false; + state[errorKey] = null; + spec.clear(state); + return; } - return { - pagePath, - title, - riskLevel, - riskReasons: normalizeStringArray(record?.riskReasons), - labels: normalizeStringArray(record?.labels), - topicKey, - topicLabel, - digestStatus, - activeBranchMessages: normalizeFiniteInt(record?.activeBranchMessages, 0), - userMessageCount: normalizeFiniteInt(record?.userMessageCount, 0), - assistantMessageCount: normalizeFiniteInt(record?.assistantMessageCount, 0), - ...(normalizeTrimmedString(record?.firstUserLine) - ? { firstUserLine: normalizeTrimmedString(record?.firstUserLine) } - : {}), - ...(normalizeTrimmedString(record?.lastUserLine) - ? { lastUserLine: normalizeTrimmedString(record?.lastUserLine) } - : {}), - ...(normalizeTrimmedString(record?.assistantOpener) - ? { assistantOpener: normalizeTrimmedString(record?.assistantOpener) } - : {}), - summary, - candidateSignals: normalizeStringArray(record?.candidateSignals), - correctionSignals: normalizeStringArray(record?.correctionSignals), - preferenceSignals: normalizeStringArray(record?.preferenceSignals), - ...(normalizeTrimmedString(record?.createdAt) - ? { createdAt: normalizeTrimmedString(record?.createdAt) } - : {}), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - }; -} -function normalizeWikiImportInsightCluster(raw: unknown): WikiImportInsightCluster | null { - const record = asRecord(raw); - const key = normalizeTrimmedString(record?.key); - const label = normalizeTrimmedString(record?.label); - if (!key || !label) { - return null; + const active = state.resourceRequests[key]; + if (active?.agentId === agentId && state[loadingKey]) { + return; } - const items = Array.isArray(record?.items) - ? record.items - .map((entry) => normalizeWikiImportInsightItem(entry)) - .filter((entry): entry is WikiImportInsightItem => entry !== null) - : []; - return { - key, - label, - itemCount: normalizeFiniteInt(record?.itemCount, items.length), - highRiskCount: normalizeFiniteInt( - record?.highRiskCount, - items.filter((entry) => entry.riskLevel === "high").length, - ), - withheldCount: normalizeFiniteInt( - record?.withheldCount, - items.filter((entry) => entry.digestStatus === "withheld").length, - ), - preferenceSignalCount: normalizeFiniteInt( - record?.preferenceSignalCount, - items.reduce((sum, entry) => sum + entry.preferenceSignals.length, 0), - ), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - items, - }; -} -function normalizeWikiImportInsights(raw: unknown): WikiImportInsights { - const record = asRecord(raw); - const clusters = Array.isArray(record?.clusters) - ? record.clusters - .map((entry) => normalizeWikiImportInsightCluster(entry)) - .filter((entry): entry is WikiImportInsightCluster => entry !== null) - : []; - return { - sourceType: record?.sourceType === "chatgpt" ? "chatgpt" : "chatgpt", - totalItems: normalizeFiniteInt( - record?.totalItems, - clusters.reduce((sum, cluster) => sum + cluster.itemCount, 0), - ), - totalClusters: normalizeFiniteInt(record?.totalClusters, clusters.length), - clusters, - }; -} - -function normalizeWikiPageKind(value: unknown): WikiOverviewItem["kind"] | undefined { - return value === "entity" || - value === "concept" || - value === "source" || - value === "synthesis" || - value === "report" - ? value - : undefined; -} - -function createEmptyWikiOverviewPageCounts(): WikiOverviewPageCounts { - return { - synthesis: 0, - entity: 0, - concept: 0, - source: 0, - report: 0, - }; -} - -function normalizeWikiOverviewPageCounts( - raw: unknown, - fallback: WikiOverviewPageCounts, -): WikiOverviewPageCounts { - const record = asRecord(raw); - return { - synthesis: normalizeFiniteInt(record?.synthesis, fallback.synthesis), - entity: normalizeFiniteInt(record?.entity, fallback.entity), - concept: normalizeFiniteInt(record?.concept, fallback.concept), - source: normalizeFiniteInt(record?.source, fallback.source), - report: normalizeFiniteInt(record?.report, fallback.report), - }; -} - -function sumWikiOverviewPageCounts(pageCounts: WikiOverviewPageCounts): number { - return ( - pageCounts.synthesis + - pageCounts.entity + - pageCounts.concept + - pageCounts.source + - pageCounts.report - ); -} - -function normalizeWikiOverviewItem(raw: unknown): WikiOverviewItem | null { - const record = asRecord(raw); - const pagePath = normalizeTrimmedString(record?.pagePath); - const title = normalizeTrimmedString(record?.title); - const kind = normalizeWikiPageKind(record?.kind); - if (!pagePath || !title || !kind) { - return null; + // Request identity, not agent identity, rejects stale A -> B -> A completions. + const request: DreamingResourceRequest = { agentId }; + state.resourceRequests[key] = request; + state[loadingKey] = true; + state[errorKey] = null; + try { + const payload = await client.request( + spec.method, + buildSelectedAgentPayloadForAgentId(agentId), + ); + if (state.resourceRequests[key] !== request || resolveSelectedAgentId(state) !== agentId) { + return; + } + spec.apply(state, payload); + state[agentKey] = agentId; + } catch (error) { + if (state.resourceRequests[key] === request && resolveSelectedAgentId(state) === agentId) { + state[errorKey] = String(error); + } + } finally { + if (state.resourceRequests[key] === request) { + delete state.resourceRequests[key]; + state[loadingKey] = false; + } } - return { - pagePath, - title, - kind, - ...(normalizeTrimmedString(record?.id) ? { id: normalizeTrimmedString(record?.id) } : {}), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - ...(normalizeTrimmedString(record?.sourceType) - ? { sourceType: normalizeTrimmedString(record?.sourceType) } - : {}), - claimCount: normalizeFiniteInt(record?.claimCount, 0), - questionCount: normalizeFiniteInt(record?.questionCount, 0), - contradictionCount: normalizeFiniteInt(record?.contradictionCount, 0), - claims: normalizeStringArray(record?.claims), - questions: normalizeStringArray(record?.questions), - contradictions: normalizeStringArray(record?.contradictions), - ...(normalizeTrimmedString(record?.snippet) - ? { snippet: normalizeTrimmedString(record?.snippet) } - : {}), - }; -} - -function normalizeWikiOverviewCluster(raw: unknown): WikiOverviewCluster | null { - const record = asRecord(raw); - const key = normalizeWikiPageKind(record?.key); - const label = normalizeTrimmedString(record?.label); - if (!key || !label) { - return null; - } - const items = Array.isArray(record?.items) - ? record.items - .map((entry) => normalizeWikiOverviewItem(entry)) - .filter((entry): entry is WikiOverviewItem => entry !== null) - : []; - return { - key, - label, - itemCount: normalizeFiniteInt(record?.itemCount, items.length), - claimCount: normalizeFiniteInt( - record?.claimCount, - items.reduce((sum, item) => sum + item.claimCount, 0), - ), - questionCount: normalizeFiniteInt( - record?.questionCount, - items.reduce((sum, item) => sum + item.questionCount, 0), - ), - contradictionCount: normalizeFiniteInt( - record?.contradictionCount, - items.reduce((sum, item) => sum + item.contradictionCount, 0), - ), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - items, - }; -} - -function normalizeWikiOverview(raw: unknown): WikiOverview { - const record = asRecord(raw); - const clusters = Array.isArray(record?.clusters) - ? record.clusters - .map((entry) => normalizeWikiOverviewCluster(entry)) - .filter((entry): entry is WikiOverviewCluster => entry !== null) - : []; - const totalItems = normalizeFiniteInt( - record?.totalItems, - clusters.reduce((sum, cluster) => sum + cluster.itemCount, 0), - ); - const fallbackPageCounts = createEmptyWikiOverviewPageCounts(); - for (const cluster of clusters) { - fallbackPageCounts[cluster.key] += cluster.itemCount; - } - const pageCounts = normalizeWikiOverviewPageCounts(record?.pageCounts, fallbackPageCounts); - const fallbackTotalPages = sumWikiOverviewPageCounts(pageCounts) || totalItems; - return { - totalItems, - totalPages: normalizeFiniteInt(record?.totalPages, fallbackTotalPages), - pageCounts, - totalClaims: normalizeFiniteInt( - record?.totalClaims, - clusters.reduce((sum, cluster) => sum + cluster.claimCount, 0), - ), - totalQuestions: normalizeFiniteInt( - record?.totalQuestions, - clusters.reduce((sum, cluster) => sum + cluster.questionCount, 0), - ), - totalContradictions: normalizeFiniteInt( - record?.totalContradictions, - clusters.reduce((sum, cluster) => sum + cluster.contradictionCount, 0), - ), - clusters, - }; -} - -function normalizeDreamingStatus(raw: unknown): DreamingStatus | null { - const record = asRecord(raw); - if (!record) { - return null; - } - const phasesRecord = asRecord(record.phases); - const lightRecord = asRecord(phasesRecord?.light); - const deepRecord = asRecord(phasesRecord?.deep); - const remRecord = asRecord(phasesRecord?.rem); - const phases = - lightRecord && deepRecord && remRecord - ? { - light: { - ...normalizePhaseStatusBase(lightRecord), - lookbackDays: normalizeFiniteInt(lightRecord.lookbackDays, 0), - limit: normalizeFiniteInt(lightRecord.limit, 0), - }, - deep: { - ...normalizePhaseStatusBase(deepRecord), - limit: normalizeFiniteInt(deepRecord.limit, 0), - minScore: normalizeFiniteScore(deepRecord.minScore, 0), - minRecallCount: normalizeFiniteInt(deepRecord.minRecallCount, 0), - minUniqueQueries: normalizeFiniteInt(deepRecord.minUniqueQueries, 0), - recencyHalfLifeDays: normalizeFiniteInt(deepRecord.recencyHalfLifeDays, 0), - ...(typeof deepRecord.maxAgeDays === "number" && Number.isFinite(deepRecord.maxAgeDays) - ? { maxAgeDays: normalizeFiniteInt(deepRecord.maxAgeDays, 0) } - : {}), - ...(typeof deepRecord.maxPromotedSnippetTokens === "number" && - Number.isFinite(deepRecord.maxPromotedSnippetTokens) - ? { - maxPromotedSnippetTokens: normalizeFiniteInt( - deepRecord.maxPromotedSnippetTokens, - 0, - ), - } - : {}), - }, - rem: { - ...normalizePhaseStatusBase(remRecord), - lookbackDays: normalizeFiniteInt(remRecord.lookbackDays, 0), - limit: normalizeFiniteInt(remRecord.limit, 0), - minPatternStrength: normalizeFiniteScore(remRecord.minPatternStrength, 0), - }, - } - : undefined; - const timezone = normalizeTrimmedString(record.timezone); - const storePath = normalizeTrimmedString(record.storePath); - const phaseSignalPath = normalizeTrimmedString(record.phaseSignalPath); - const storeError = normalizeTrimmedString(record.storeError); - const phaseSignalError = normalizeTrimmedString(record.phaseSignalError); - - return { - enabled: normalizeBoolean(record.enabled, false), - ...(timezone ? { timezone } : {}), - verboseLogging: normalizeBoolean(record.verboseLogging, false), - storageMode: normalizeStorageMode(record.storageMode), - separateReports: normalizeBoolean(record.separateReports, false), - shortTermCount: normalizeFiniteInt(record.shortTermCount, 0), - recallSignalCount: normalizeFiniteInt(record.recallSignalCount, 0), - dailySignalCount: normalizeFiniteInt(record.dailySignalCount, 0), - groundedSignalCount: normalizeFiniteInt(record.groundedSignalCount, 0), - totalSignalCount: normalizeFiniteInt(record.totalSignalCount, 0), - phaseSignalCount: normalizeFiniteInt(record.phaseSignalCount, 0), - lightPhaseHitCount: normalizeFiniteInt(record.lightPhaseHitCount, 0), - remPhaseHitCount: normalizeFiniteInt(record.remPhaseHitCount, 0), - promotedTotal: normalizeFiniteInt(record.promotedTotal, 0), - promotedToday: normalizeFiniteInt(record.promotedToday, 0), - ...(storePath ? { storePath } : {}), - ...(phaseSignalPath ? { phaseSignalPath } : {}), - ...(storeError ? { storeError } : {}), - ...(phaseSignalError ? { phaseSignalError } : {}), - shortTermEntries: normalizeDreamingEntries(record.shortTermEntries), - signalEntries: normalizeDreamingEntries(record.signalEntries), - promotedEntries: normalizeDreamingEntries(record.promotedEntries), - ...(phases ? { phases } : {}), - }; } export async function loadDreamingStatus(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.dreamingStatusLoading && state.dreamingStatusRequestAgentId === agentId) { - return; - } - if (state.dreamingStatusAgentId !== agentId) { - state.dreamingStatus = null; - } - const requestGeneration = (state.dreamingStatusRequestGeneration ?? 0) + 1; - state.dreamingStatusRequestGeneration = requestGeneration; - state.dreamingStatusActiveRequestGeneration = requestGeneration; - state.dreamingStatusRequestAgentId = agentId; - state.dreamingStatusLoading = true; - state.dreamingStatusError = null; - try { - const payload = await state.client.request( - "doctor.memory.status", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.dreamingStatusActiveRequestGeneration !== requestGeneration || - state.dreamingStatusRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - state.dreamingStatus = normalizeDreamingStatus(payload?.dreaming); - state.dreamingStatusAgentId = agentId; - } catch (err) { - if ( - state.dreamingStatusActiveRequestGeneration === requestGeneration && - state.dreamingStatusRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.dreamingStatusError = String(err); - } - } finally { - if (state.dreamingStatusActiveRequestGeneration === requestGeneration) { - state.dreamingStatusLoading = false; - state.dreamingStatusRequestAgentId = null; - state.dreamingStatusActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "dreamingStatus"); } export async function loadDreamDiary(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.dreamDiaryLoading && state.dreamDiaryRequestAgentId === agentId) { - return; - } - if (state.dreamDiaryAgentId !== agentId) { - state.dreamDiaryPath = null; - state.dreamDiaryContent = null; - } - const requestGeneration = (state.dreamDiaryRequestGeneration ?? 0) + 1; - state.dreamDiaryRequestGeneration = requestGeneration; - state.dreamDiaryActiveRequestGeneration = requestGeneration; - state.dreamDiaryRequestAgentId = agentId; - state.dreamDiaryLoading = true; - state.dreamDiaryError = null; - try { - const payload = await state.client.request( - "doctor.memory.dreamDiary", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.dreamDiaryActiveRequestGeneration !== requestGeneration || - state.dreamDiaryRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - const path = normalizeTrimmedString(payload?.path) ?? DEFAULT_DREAM_DIARY_PATH; - const found = payload?.found === true; - if (found) { - state.dreamDiaryPath = path; - state.dreamDiaryContent = typeof payload?.content === "string" ? payload.content : ""; - } else { - state.dreamDiaryPath = path; - state.dreamDiaryContent = null; - } - state.dreamDiaryAgentId = agentId; - } catch (err) { - if ( - state.dreamDiaryActiveRequestGeneration === requestGeneration && - state.dreamDiaryRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.dreamDiaryError = String(err); - } - } finally { - if (state.dreamDiaryActiveRequestGeneration === requestGeneration) { - state.dreamDiaryLoading = false; - state.dreamDiaryRequestAgentId = null; - state.dreamDiaryActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "dreamDiary"); } export async function loadWikiImportInsights(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.wikiImportInsightsLoading && state.wikiImportInsightsRequestAgentId === agentId) { - return; - } - if (state.wikiImportInsightsAgentId !== agentId) { - state.wikiImportInsights = null; - } - if (!canCallMemoryWikiMethod(state, "wiki.importInsights")) { - state.wikiImportInsightsActiveRequestGeneration = null; - state.wikiImportInsightsRequestAgentId = null; - state.wikiImportInsightsLoading = false; - state.wikiImportInsights = null; - state.wikiImportInsightsError = null; - return; - } - const requestGeneration = (state.wikiImportInsightsRequestGeneration ?? 0) + 1; - state.wikiImportInsightsRequestGeneration = requestGeneration; - state.wikiImportInsightsActiveRequestGeneration = requestGeneration; - state.wikiImportInsightsRequestAgentId = agentId; - state.wikiImportInsightsLoading = true; - state.wikiImportInsightsError = null; - try { - const payload = await state.client.request( - "wiki.importInsights", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.wikiImportInsightsActiveRequestGeneration !== requestGeneration || - state.wikiImportInsightsRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - state.wikiImportInsights = normalizeWikiImportInsights(payload); - state.wikiImportInsightsAgentId = agentId; - } catch (err) { - if ( - state.wikiImportInsightsActiveRequestGeneration === requestGeneration && - state.wikiImportInsightsRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.wikiImportInsightsError = String(err); - } - } finally { - if (state.wikiImportInsightsActiveRequestGeneration === requestGeneration) { - state.wikiImportInsightsLoading = false; - state.wikiImportInsightsRequestAgentId = null; - state.wikiImportInsightsActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "wikiImportInsights"); } export async function loadWikiOverview(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.wikiOverviewLoading && state.wikiOverviewRequestAgentId === agentId) { - return; - } - if (state.wikiOverviewAgentId !== agentId) { - state.wikiOverview = null; - } - if (!canCallMemoryWikiMethod(state, "wiki.overview")) { - state.wikiOverviewActiveRequestGeneration = null; - state.wikiOverviewRequestAgentId = null; - state.wikiOverviewLoading = false; - state.wikiOverview = null; - state.wikiOverviewError = null; - return; - } - const requestGeneration = (state.wikiOverviewRequestGeneration ?? 0) + 1; - state.wikiOverviewRequestGeneration = requestGeneration; - state.wikiOverviewActiveRequestGeneration = requestGeneration; - state.wikiOverviewRequestAgentId = agentId; - state.wikiOverviewLoading = true; - state.wikiOverviewError = null; - try { - const payload = await state.client.request( - "wiki.overview", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.wikiOverviewActiveRequestGeneration !== requestGeneration || - state.wikiOverviewRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - state.wikiOverview = normalizeWikiOverview(payload); - state.wikiOverviewAgentId = agentId; - } catch (err) { - if ( - state.wikiOverviewActiveRequestGeneration === requestGeneration && - state.wikiOverviewRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.wikiOverviewError = String(err); - } - } finally { - if (state.wikiOverviewActiveRequestGeneration === requestGeneration) { - state.wikiOverviewLoading = false; - state.wikiOverviewRequestAgentId = null; - state.wikiOverviewActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "wikiOverview"); } async function runDreamDiaryAction( @@ -1309,4 +682,3 @@ export async function updateDreamingEnabled( } return ok; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/pages/agents/memory/memory-panel.test.ts b/ui/src/pages/agents/memory/memory-panel.test.ts index 3958ccb77020..ec997ee27d20 100644 --- a/ui/src/pages/agents/memory/memory-panel.test.ts +++ b/ui/src/pages/agents/memory/memory-panel.test.ts @@ -487,3 +487,251 @@ describe("AgentMemoryPanel gateway lifecycle", () => { expect(page.viewState.wikiPreviewContent).toBe(""); }); }); + +describe.runIf(process.env.OPENCLAW_UI_MEMORY_CHROMIUM_E2E === "1")( + "agent memory real Chromium owner proof", + () => { + let browser: import("playwright").Browser; + let server: import("../../../test-helpers/control-ui-e2e.ts").ControlUiE2eServer; + let e2e: typeof import("../../../test-helpers/control-ui-e2e.ts"); + + beforeAll(async () => { + const { chromium } = await import("playwright"); + e2e = await import("../../../test-helpers/control-ui-e2e.ts"); + const executablePath = e2e.resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); + if (!e2e.canRunPlaywrightChromium(executablePath)) { + throw new Error(`Real Chromium required but unavailable: ${executablePath}`); + } + server = await e2e.startControlUiE2eServer(); + browser = await chromium.launch({ executablePath, headless: true }); + }, 90_000); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("preserves both routes, agent ownership, wiki gating, and reconnects", async () => { + const status = (agentId: string, promotedToday: number) => ({ + agentId, + provider: "builtin", + embedding: { ok: true, checked: true }, + dreaming: { + enabled: true, + verboseLogging: false, + storageMode: "inline" as const, + separateReports: false, + shortTermCount: promotedToday, + recallSignalCount: 0, + dailySignalCount: 0, + groundedSignalCount: 0, + totalSignalCount: 0, + phaseSignalCount: 0, + lightPhaseHitCount: 0, + remPhaseHitCount: 0, + promotedTotal: promotedToday, + promotedToday, + shortTermEntries: [], + signalEntries: [], + promotedEntries: [], + phases: { + light: { + enabled: true, + cron: "0 * * * *", + managedCronPresent: true, + lookbackDays: 2, + limit: 10, + }, + deep: { + enabled: true, + cron: "0 3 * * *", + managedCronPresent: true, + limit: 10, + minScore: 0.8, + minRecallCount: 2, + minUniqueQueries: 2, + recencyHalfLifeDays: 14, + }, + rem: { + enabled: false, + cron: "0 5 * * 0", + managedCronPresent: false, + lookbackDays: 7, + limit: 10, + minPatternStrength: 0.75, + }, + }, + }, + }); + const diary = (agentId: string) => ({ + agentId, + found: true, + path: "DREAMS.md", + content: `# Dream Diary\n\n*April 5, 2026, 3:00 AM*\n\n${agentId} owns this dream.`, + }); + const config = { + agents: { entries: { main: { default: true }, support: {} } }, + plugins: { + entries: { + "memory-core": { enabled: true, config: { dreaming: { enabled: true } } }, + }, + }, + }; + const roster = { + agents: [ + { id: "main", name: "Main" }, + { id: "support", name: "Support" }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }; + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { width: 1440, height: 900 }, + }); + const page = await context.newPage(); + const gateway = await e2e.installMockGateway(page, { + featureMethods: [ + "chat.metadata", + "chat.startup", + "doctor.memory.status", + "doctor.memory.dreamDiary", + ], + methodResponses: { + "agents.list": roster, + "config.get": { + config, + sourceConfig: config, + runtimeConfig: config, + hash: "memory-proof-1", + issues: [], + raw: JSON.stringify(config), + valid: true, + }, + "plugins.list": { + plugins: [ + { + id: "memory-core", + name: "OpenClaw Memory", + installed: true, + enabled: true, + state: "enabled", + kind: ["memory"], + }, + ], + diagnostics: [], + mutationAllowed: true, + }, + "doctor.memory.status": { + cases: [ + { match: { agentId: "main" }, response: status("main", 11) }, + { match: { agentId: "support" }, response: status("support", 22) }, + ], + }, + "doctor.memory.dreamDiary": { + cases: [ + { match: { agentId: "main" }, response: diary("main") }, + { match: { agentId: "support" }, response: diary("support") }, + ], + }, + }, + }); + const detail = () => page.locator("openclaw-agent-memory-panel .dreams__status-detail"); + const requestCount = () => + gateway.getRequests("doctor.memory.status").then((requests) => requests.length); + const chooseAgent = async (name: string) => { + const picker = page.locator(".memory-page .agent-scope-control openclaw-agent-select"); + await picker.locator(".agent-select__trigger").click(); + await picker + .locator("wa-dropdown-item[data-agent-option]") + .filter({ hasText: name }) + .evaluate((item) => (item as HTMLElement).click()); + }; + + try { + expect((await page.goto(`${server.baseUrl}settings/agents/main/memory`))?.status()).toBe( + 200, + ); + await e2e.waitForControlUiRoute(page, { + routeId: "agents", + pathname: "/settings/agents/main/memory", + }); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("11 promoted"); + expect(await gateway.getRequests("wiki.importInsights")).toHaveLength(0); + expect(await gateway.getRequests("wiki.overview")).toHaveLength(0); + + const beforeFirstMain = await requestCount(); + await gateway.deferNext("doctor.memory.status"); + await page.evaluate(() => { + history.pushState(null, "", "/settings/memory/dreams"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + await e2e.waitForControlUiRoute(page, { + routeId: "memory", + pathname: "/settings/memory/dreams", + }); + await expect.poll(requestCount, { timeout: 15_000 }).toBeGreaterThan(beforeFirstMain); + + const beforeSupport = await requestCount(); + await gateway.deferNext("doctor.memory.status"); + await chooseAgent("Support"); + await expect.poll(requestCount, { timeout: 15_000 }).toBeGreaterThan(beforeSupport); + await gateway.setMethodResponse("doctor.memory.status", { + cases: [ + { match: { agentId: "main" }, response: status("main", 33) }, + { match: { agentId: "support" }, response: status("support", 22) }, + ], + }); + await chooseAgent("Main"); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("33 promoted"); + await gateway.resolveDeferred("doctor.memory.status", status("main", 11)); + await gateway.resolveDeferred("doctor.memory.status", status("support", 22)); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("33 promoted"); + expect(await gateway.getRequests("wiki.importInsights")).toHaveLength(0); + expect(await gateway.getRequests("wiki.overview")).toHaveLength(0); + + await gateway.setMethodResponse("doctor.memory.status", status("main", 44)); + const beforeReconnect = await requestCount(); + const socketCount = await gateway.getSocketCount(); + await gateway.closeLatest(1001, "proxy idle timeout"); + await gateway.setOnline(false); + await expect + .poll( + () => + page.evaluate( + () => + ( + document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { context: { gateway: { snapshot: { phase: string } } } }; + } + ).runtime?.context.gateway.snapshot.phase, + ), + { timeout: 15_000 }, + ) + .toBe("reconnecting"); + await expect + .poll(() => gateway.getSocketCount(), { timeout: 15_000 }) + .toBeGreaterThan(socketCount); + await gateway.setOnline(true); + await expect.poll(requestCount, { timeout: 15_000 }).toBeGreaterThan(beforeReconnect); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("44 promoted"); + await e2e.waitForControlUiRoute(page, { + routeId: "memory", + pathname: "/settings/memory/dreams", + }); + } finally { + await context.close(); + } + }, 120_000); + }, +); diff --git a/ui/src/pages/agents/memory/memory-panel.ts b/ui/src/pages/agents/memory/memory-panel.ts index 78f244f73095..ac316cd3c695 100644 --- a/ui/src/pages/agents/memory/memory-panel.ts +++ b/ui/src/pages/agents/memory/memory-panel.ts @@ -31,7 +31,12 @@ import { type DreamingState, } from "./dreaming.ts"; import { renderDreamingToggleConfirmation } from "./toggle-confirmation.ts"; -import { createDreamingViewState, renderDreaming, type DreamingViewState } from "./view.ts"; +import { + createDreamingViewState, + renderDreaming, + resetWikiPreview, + type DreamingViewState, +} from "./view.ts"; type WikiPagePreview = { title: string; @@ -187,25 +192,12 @@ class AgentMemoryPanel extends OpenClawLightDomElement { } private resetTransientState() { - this.resetWikiPreview(); + resetWikiPreview(this.viewState); this.toggleConfirmOpen = false; this.toggleConfirmLoading = false; this.pendingEnabled = null; } - private resetWikiPreview() { - this.viewState.wikiPreviewRequestId += 1; - this.viewState.wikiPreviewOpen = false; - this.viewState.wikiPreviewLoading = false; - this.viewState.wikiPreviewTitle = ""; - this.viewState.wikiPreviewPath = ""; - this.viewState.wikiPreviewUpdatedAt = null; - this.viewState.wikiPreviewContent = ""; - this.viewState.wikiPreviewTotalLines = null; - this.viewState.wikiPreviewTruncated = false; - this.viewState.wikiPreviewError = null; - } - private createGatewayState(snapshot = this.context.gateway.snapshot): DreamingState { return createDreamingState({ client: snapshot.client, @@ -515,8 +507,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { active: dreamingOn, selectedAgentId, shortTermCount: dreamingStatus?.shortTermCount ?? 0, - groundedSignalCount: dreamingStatus?.groundedSignalCount ?? 0, - totalSignalCount: dreamingStatus?.totalSignalCount ?? 0, promotedCount: dreamingStatus?.promotedToday ?? 0, phases: dreamingStatus?.phases ?? undefined, shortTermEntries: dreamingStatus?.shortTermEntries ?? [], @@ -524,7 +514,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { dreamingOf: null, nextCycle: resolveDreamingNextCycle(dreamingStatus), timezone: dreamingStatus?.timezone ?? null, - statusLoading: dreaming.dreamingStatusLoading, statusError: dreaming.dreamingStatusError, modeSaving: dreaming.dreamingModeSaving, dreamDiaryLoading: dreaming.dreamDiaryLoading, @@ -532,7 +521,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { dreamDiaryActionMessage: dreaming.dreamDiaryActionMessage, dreamDiaryActionArchivePath: dreaming.dreamDiaryActionArchivePath, dreamDiaryError: dreaming.dreamDiaryError, - dreamDiaryPath: dreaming.dreamDiaryPath, dreamDiaryContent: dreaming.dreamDiaryContent, memoryWikiEnabled: isPluginEnabledInConfigSnapshot( configState.configSnapshot, @@ -545,7 +533,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { wikiOverviewLoading: dreaming.wikiOverviewLoading, wikiOverviewError: dreaming.wikiOverviewError, wikiOverview: dreaming.wikiOverview, - onRefresh: () => void this.loadAll(true), onRefreshDiary: () => void this.runDreamingTask(loadDreamDiary), onRefreshImports: () => void this.refreshWikiData(loadWikiImportInsights), onRefreshWikiOverview: () => void this.refreshWikiData(loadWikiOverview), diff --git a/ui/src/pages/agents/memory/view.test.ts b/ui/src/pages/agents/memory/view.test.ts index d316f6cecef1..5c944ea6f81e 100644 --- a/ui/src/pages/agents/memory/view.test.ts +++ b/ui/src/pages/agents/memory/view.test.ts @@ -106,8 +106,6 @@ function buildProps(overrides?: Partial): DreamingProps { active: true, selectedAgentId: "main", shortTermCount: 47, - groundedSignalCount: 9, - totalSignalCount: 182, promotedCount: 12, phases: { light: { enabled: true, cron: "0 * * * *", nextRunAtMs: Date.parse("2026-04-05T11:30:00Z") }, @@ -150,7 +148,6 @@ function buildProps(overrides?: Partial): DreamingProps { dreamingOf: null, nextCycle: "4:00 AM", timezone: "America/Los_Angeles", - statusLoading: false, statusError: null, modeSaving: false, dreamDiaryLoading: false, @@ -158,7 +155,6 @@ function buildProps(overrides?: Partial): DreamingProps { dreamDiaryActionMessage: null, dreamDiaryActionArchivePath: null, dreamDiaryError: null, - dreamDiaryPath: "DREAMS.md", dreamDiaryContent: "# Dream Diary\n\n\n\n---\n\n*April 5, 2026, 3:00 AM*\n\nThe repository whispered of forgotten endpoints tonight.\n\n", memoryWikiEnabled: true, @@ -275,7 +271,6 @@ function buildProps(overrides?: Partial): DreamingProps { }, ], }, - onRefresh: () => {}, onRefreshDiary: () => {}, onRefreshImports: () => {}, onRefreshWikiOverview: () => {}, diff --git a/ui/src/pages/agents/memory/view.ts b/ui/src/pages/agents/memory/view.ts index 231bcec76d76..d29b2ba5bc2f 100644 --- a/ui/src/pages/agents/memory/view.ts +++ b/ui/src/pages/agents/memory/view.ts @@ -103,8 +103,6 @@ type DreamingProps = { active: boolean; selectedAgentId: string; shortTermCount: number; - groundedSignalCount: number; - totalSignalCount: number; promotedCount: number; phases?: { light: DreamingPhaseInfo; @@ -116,7 +114,6 @@ type DreamingProps = { dreamingOf: string | null; nextCycle: string | null; timezone: string | null; - statusLoading: boolean; statusError: string | null; modeSaving: boolean; dreamDiaryLoading: boolean; @@ -124,7 +121,6 @@ type DreamingProps = { dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null; dreamDiaryActionArchivePath: string | null; dreamDiaryError: string | null; - dreamDiaryPath: string | null; dreamDiaryContent: string | null; memoryWikiEnabled: boolean; wikiImportInsightsLoading: boolean; @@ -133,7 +129,6 @@ type DreamingProps = { wikiOverviewLoading: boolean; wikiOverviewError: string | null; wikiOverview: WikiOverview | null; - onRefresh: () => void; onRefreshDiary: () => void; onRefreshImports: () => void; onRefreshWikiOverview: () => void; @@ -465,19 +460,7 @@ function basename(value: string): string { } function formatKindLabel(kind: "entity" | "concept" | "source" | "synthesis" | "report"): string { - switch (kind) { - case "entity": - return t("dreaming.wiki.pageTypes.entity"); - case "concept": - return t("dreaming.wiki.pageTypes.concept"); - case "source": - return t("dreaming.wiki.pageTypes.source"); - case "synthesis": - return t("dreaming.wiki.pageTypes.synthesis"); - case "report": - return t("dreaming.wiki.pageTypes.report"); - } - return kind; + return t(`dreaming.wiki.pageTypes.${kind}`); } function formatPageCount(count: number): string { @@ -504,48 +487,20 @@ function formatContradictionCount(count: number): string { : t("dreaming.wiki.counts.contradictions", { count: String(count) }); } -function formatChatCount(count: number): string { - return t("dreaming.wiki.counts.chats", { count: String(count) }); -} - -function formatSignalCount(count: number): string { - return t("dreaming.wiki.counts.signals", { count: String(count) }); -} - -function formatMessageCount(count: number): string { - return t("dreaming.wiki.counts.messages", { count: String(count) }); -} - -const WIKI_OVERVIEW_PAGE_COUNT_ORDER: Array = [ - "source", - "synthesis", - "report", - "entity", - "concept", -]; - -function formatWikiOverviewPageCountLabel(kind: keyof WikiOverview["pageCounts"]): string { - switch (kind) { - case "source": - return t("dreaming.wiki.pageGroups.sources"); - case "synthesis": - return t("dreaming.wiki.pageGroups.syntheses"); - case "report": - return t("dreaming.wiki.pageGroups.reports"); - case "entity": - return t("dreaming.wiki.pageGroups.entities"); - case "concept": - return t("dreaming.wiki.pageGroups.concepts"); - } - return kind; -} +const WIKI_OVERVIEW_PAGE_GROUPS = [ + ["source", "sources"], + ["synthesis", "syntheses"], + ["report", "reports"], + ["entity", "entities"], + ["concept", "concepts"], +] as const; function formatWikiOverviewPageBreakdown(pageCounts: WikiOverview["pageCounts"]): string { - const parts = WIKI_OVERVIEW_PAGE_COUNT_ORDER.map((kind) => { + const parts = WIKI_OVERVIEW_PAGE_GROUPS.map(([kind, group]) => { const count = pageCounts[kind]; return count > 0 ? t("dreaming.wiki.pageGroupSummary", { - label: formatWikiOverviewPageCountLabel(kind), + label: t(`dreaming.wiki.pageGroups.${group}`), count: formatPageCount(count), }) : null; @@ -585,20 +540,11 @@ function formatImportBadge(item: { digestStatus: "available" | "withheld"; riskLevel: "low" | "medium" | "high" | "unknown"; }): string { - if (item.digestStatus === "withheld") { - return t("dreaming.wiki.risk.needsReview"); - } - switch (item.riskLevel) { - case "low": - return t("dreaming.wiki.risk.low"); - case "medium": - return t("dreaming.wiki.risk.medium"); - case "high": - return t("dreaming.wiki.risk.high"); - case "unknown": - return t("dreaming.wiki.risk.unknown"); - } - return t("dreaming.wiki.risk.unknown"); + return t( + item.digestStatus === "withheld" + ? "dreaming.wiki.risk.needsReview" + : `dreaming.wiki.risk.${item.riskLevel}`, + ); } function toggleExpandedCard(bucket: Set, key: string, onChange: () => void): void { @@ -651,7 +597,7 @@ async function openWikiPreview(lookup: string, props: DreamingProps): Promise${summary}
- - - - - + ${[ + { label: t("dreaming.scene.dedupeDiary"), onClick: props.onDedupeDreamDiary }, + { label: t("dreaming.scene.repairCache"), onClick: props.onRepairDreamingArtifacts }, + { + label: t( + props.dreamDiaryActionLoading + ? "dreaming.scene.working" + : "dreaming.scene.backfill", + ), + onClick: props.onBackfillDiary, + }, + { label: t("dreaming.scene.reset"), onClick: props.onResetDiary }, + { label: t("dreaming.scene.clearGrounded"), onClick: props.onResetGroundedShortTerm }, + ].map( + ({ label, onClick }) => html` + + `, + )}
${props.dreamDiaryActionMessage @@ -1023,264 +956,200 @@ function renderAdvancedSection(props: DreamingProps) { `; } -function renderDiaryImportsSection(props: DreamingProps) { +type ImportedInsightItem = WikiImportInsights["clusters"][number]["items"][number]; +type WikiPageItem = WikiOverview["clusters"][number]["items"][number]; +type WikiInsightCard = + | { kind: "import"; item: ImportedInsightItem } + | { kind: "wiki"; item: WikiPageItem }; + +function renderInsightList(labelKey: string, entries: string[]) { + return entries.length > 0 + ? html` +
+ ${t(labelKey)} + ${entries.map((entry) => html`

• ${entry}

`)} +
+ ` + : nothing; +} + +function renderInsightDetail(labelKey: string, value: string | undefined) { + return value + ? html` +

+ ${t(labelKey)} + ${value} +

+ ` + : nothing; +} + +function renderWikiInsightBody(card: WikiInsightCard, expanded: boolean) { + if (card.kind === "import") { + const item = card.item; + return html` +

${item.summary}

+ ${renderInsightList("dreaming.wiki.candidateSignals", item.candidateSignals)} + ${renderInsightList("dreaming.wiki.corrections", item.correctionSignals)} + ${expanded + ? html` +
+ ${t("dreaming.wiki.importDetails")} + ${renderInsightDetail("dreaming.wiki.startedWith", item.firstUserLine)} + ${renderInsightDetail( + "dreaming.wiki.endedOn", + item.lastUserLine !== item.firstUserLine ? item.lastUserLine : undefined, + )} + ${renderInsightDetail( + "dreaming.wiki.messages", + `${t("dreaming.wiki.counts.userMessages", { + count: String(item.userMessageCount), + })} · ${t("dreaming.wiki.counts.assistantMessages", { + count: String(item.assistantMessageCount), + })}`, + )} + ${renderInsightDetail("dreaming.wiki.riskReasons", item.riskReasons.join(", "))} + ${renderInsightDetail("dreaming.wiki.labels", item.labels.join(", "))} +
+ ` + : nothing} + ${item.preferenceSignals.length > 0 + ? html` +
+ ${item.preferenceSignals.map( + (signal) => html`${signal}`, + )} +
+ ` + : nothing} + `; + } + + const item = card.item; + return html` + ${item.snippet ? html`

${item.snippet}

` : nothing} + ${renderInsightList("dreaming.wiki.claims", item.claims)} + ${renderInsightList("dreaming.wiki.openQuestions", item.questions)} + ${renderInsightList("dreaming.wiki.contradictions", item.contradictions)} + ${expanded + ? html` +
+ ${t("dreaming.wiki.pageDetails")} + ${renderInsightDetail("dreaming.wiki.wikiPage", item.pagePath)} + ${renderInsightDetail("dreaming.wiki.id", item.id)} +
+ ` + : nothing} + `; +} + +function renderWikiInsightCard(props: DreamingProps, card: WikiInsightCard) { const state = props.viewState; - const importInsights = props.wikiImportInsights; - const clusters = importInsights?.clusters ?? []; - - if (props.wikiImportInsightsLoading && clusters.length === 0) { - return html` -
-
${t("dreaming.wiki.loadingInsights")}
-
- `; - } - - if (clusters.length === 0) { - return html` -
-
${t("dreaming.wiki.noInsights")}
-
${t("dreaming.wiki.noInsightsHint")}
-
- `; - } - - const clusterIndex = Math.max(0, Math.min(state.diaryPage, clusters.length - 1)); - const cluster = expectDefined(clusters[clusterIndex], "selected imported insight cluster"); - const clusterMeta = [ - formatChatCount(cluster.itemCount), - ...(cluster.highRiskCount > 0 - ? [ - t("dreaming.wiki.counts.sensitive", { - count: String(cluster.highRiskCount), - }), - ] - : []), - ...(cluster.preferenceSignalCount > 0 - ? [formatSignalCount(cluster.preferenceSignalCount)] - : []), - ]; - const importSummary = [ - t("dreaming.wiki.importedClusterSummary", { - label: cluster.label.toLowerCase(), - }), - ...(cluster.withheldCount > 0 - ? [ - cluster.withheldCount === 1 - ? t("dreaming.wiki.withheldDigestOne", { - count: String(cluster.withheldCount), - }) - : t("dreaming.wiki.withheldDigests", { - count: String(cluster.withheldCount), - }), - ] - : []), - ]; + const item = card.item; + const expandedCards = + card.kind === "import" ? state.expandedInsightCards : state.expandedWikiCards; + const expanded = expandedCards.has(item.pagePath); + const badgeClass = card.kind === "import" ? card.item.riskLevel : "wiki"; + const badgeLabel = + card.kind === "import" ? formatImportBadge(card.item) : formatKindLabel(card.item.kind); + const metadata = + card.kind === "import" + ? card.item.activeBranchMessages > 0 + ? ` · ${t("dreaming.wiki.counts.messages", { + count: String(card.item.activeBranchMessages), + })}` + : "" + : ` · ${item.pagePath}`; return html` -
- ${clusters.map( - (entry, index) => html` - - `, - )} -
- -
-
-
${cluster.label} · ${clusterMeta.join(" · ")}
-
-

${importSummary.join(" ")}

+
{ + if (card.kind === "wiki" && card.item.kind === "report") { + void openWikiPreview(item.pagePath, props); + return; + } + toggleExpandedCard(expandedCards, item.pagePath, props.onViewStateChange); + }} + > +
+
${item.title}
+ + ${badgeLabel} +
-
- ${cluster.items.map((item) => { - const expanded = state.expandedInsightCards.has(item.pagePath); - return html` -
- toggleExpandedCard( - state.expandedInsightCards, - item.pagePath, - props.onViewStateChange, - )} - > -
-
${item.title}
- - ${formatImportBadge(item)} - -
-
- ${item.updatedAt ? formatCompactDateTime(item.updatedAt) : basename(item.pagePath)} - ${item.activeBranchMessages > 0 - ? ` · ${formatMessageCount(item.activeBranchMessages)}` - : ""} -
-

${item.summary}

- ${item.candidateSignals.length > 0 - ? html` -
- ${t("dreaming.wiki.candidateSignals")} - ${item.candidateSignals.map( - (signal) => html`

• ${signal}

`, - )} -
- ` - : nothing} - ${item.correctionSignals.length > 0 - ? html` -
- ${t("dreaming.wiki.corrections")} - ${item.correctionSignals.map( - (signal) => html`

• ${signal}

`, - )} -
- ` - : nothing} - ${expanded - ? html` -
- ${t("dreaming.wiki.importDetails")} - ${item.firstUserLine - ? html` -

- ${t("dreaming.wiki.startedWith")} - ${item.firstUserLine} -

- ` - : nothing} - ${item.lastUserLine && item.lastUserLine !== item.firstUserLine - ? html` -

- ${t("dreaming.wiki.endedOn")} - ${item.lastUserLine} -

- ` - : nothing} -

- ${t("dreaming.wiki.messages")} - ${t("dreaming.wiki.counts.userMessages", { - count: String(item.userMessageCount), - })} - · - ${t("dreaming.wiki.counts.assistantMessages", { - count: String(item.assistantMessageCount), - })} -

- ${item.riskReasons.length > 0 - ? html` -

- ${t("dreaming.wiki.riskReasons")} - ${item.riskReasons.join(", ")} -

- ` - : nothing} - ${item.labels.length > 0 - ? html` -

- ${t("dreaming.wiki.labels")} - ${item.labels.join(", ")} -

- ` - : nothing} -
- ` - : nothing} - ${item.preferenceSignals.length > 0 - ? html` -
- ${item.preferenceSignals.map( - (signal) => - html`${signal}`, - )} -
- ` - : nothing} -
- - -
-
- `; - })} +
+ ${item.updatedAt + ? formatCompactDateTime(item.updatedAt) + : basename(item.pagePath)}${metadata} +
+ ${renderWikiInsightBody(card, expanded)} +
+ +
`; } -function renderWikiOverviewSection(props: DreamingProps) { - const state = props.viewState; - const overview = props.wikiOverview; - const clusters = overview?.clusters ?? []; - - if (props.wikiOverviewLoading && clusters.length === 0) { - return html` -
-
${t("dreaming.wiki.loadingWiki")}
-
- `; - } - +function renderWikiClusterSection< + Cluster extends { key: string; label: string; items: { pagePath: string }[] }, +>( + props: DreamingProps, + params: { + kind: "imports" | "wiki"; + clusters: Cluster[]; + loading: boolean; + loadingKey: string; + emptyKey: string; + emptyHintKey: string; + date: (cluster: Cluster) => string; + prose: (cluster: Cluster) => ReturnType; + renderItem: (item: Cluster["items"][number]) => ReturnType; + }, +) { + const { clusters } = params; if (clusters.length === 0) { return html`
-
${t("dreaming.wiki.emptyWiki")}
-
${t("dreaming.wiki.emptyWikiHint")}
+
+ ${t(params.loading ? params.loadingKey : params.emptyKey)} +
+ ${params.loading + ? nothing + : html`
${t(params.emptyHintKey)}
`}
`; } + const state = props.viewState; const clusterIndex = Math.max(0, Math.min(state.diaryPage, clusters.length - 1)); - const cluster = expectDefined(clusters[clusterIndex], "selected memory overview cluster"); - const totalPages = overview?.totalPages ?? overview?.totalItems ?? 0; - const totalClaims = overview?.totalClaims ?? 0; - const totalQuestions = overview?.totalQuestions ?? 0; - const totalContradictions = overview?.totalContradictions ?? 0; - const pageBreakdown = overview - ? formatWikiOverviewPageBreakdown(overview.pageCounts) - : t("dreaming.wiki.noPagesYet"); - const clusterSummary = formatWikiOverviewClusterSummary(cluster); - const vaultMeta = [ - formatPageCount(totalPages), - ...(totalClaims > 0 ? [formatClaimRowCount(totalClaims)] : []), - ...(totalQuestions > 0 ? [formatOpenQuestionCount(totalQuestions)] : []), - ...(totalContradictions > 0 ? [formatContradictionCount(totalContradictions)] : []), - ]; - + const cluster = expectDefined( + clusters[clusterIndex], + params.kind === "imports" + ? "selected imported insight cluster" + : "selected memory overview cluster", + ); return html`
${clusters.map( @@ -1299,132 +1168,100 @@ function renderWikiOverviewSection(props: DreamingProps) { `, )}
- -
+
-
${t("dreaming.wiki.vault")} · ${vaultMeta.join(" · ")}
-
-

- ${t("dreaming.wiki.fullVaultBreakdown", { breakdown: pageBreakdown })} -

-

- ${t("dreaming.wiki.selectedSection", { summary: clusterSummary })} - ${cluster.updatedAt - ? ` ${t("dreaming.wiki.latestUpdate", { - date: formatCompactDateTime(cluster.updatedAt), - })}` - : ""} -

-
-
- ${cluster.items.map((item) => { - const expanded = state.expandedWikiCards.has(item.pagePath); - return html` -
{ - if (item.kind === "report") { - void openWikiPreview(item.pagePath, props); - return; - } - toggleExpandedCard(state.expandedWikiCards, item.pagePath, props.onViewStateChange); - }} - > -
-
${item.title}
- - ${formatKindLabel(item.kind)} - -
-
- ${item.updatedAt ? formatCompactDateTime(item.updatedAt) : basename(item.pagePath)} - · ${item.pagePath} -
- ${item.snippet - ? html`

${item.snippet}

` - : nothing} - ${item.claims.length > 0 - ? html` -
- ${t("dreaming.wiki.claims")} - ${item.claims.map( - (claim) => html`

• ${claim}

`, - )} -
- ` - : nothing} - ${item.questions.length > 0 - ? html` -
- ${t("dreaming.wiki.openQuestions")} - ${item.questions.map( - (question) => html`

• ${question}

`, - )} -
- ` - : nothing} - ${item.contradictions.length > 0 - ? html` -
- ${t("dreaming.wiki.contradictions")} - ${item.contradictions.map( - (entry) => html`

• ${entry}

`, - )} -
- ` - : nothing} - ${expanded - ? html` -
- ${t("dreaming.wiki.pageDetails")} -

- ${t("dreaming.wiki.wikiPage")} - ${item.pagePath} -

- ${item.id - ? html` -

- ${t("dreaming.wiki.id")} - ${item.id} -

- ` - : nothing} -
- ` - : nothing} -
- - -
-
- `; - })} -
+
${params.date(cluster)}
+
${params.prose(cluster)}
+
${cluster.items.map(params.renderItem)}
`; } +function renderDiaryImportsSection(props: DreamingProps) { + return renderWikiClusterSection(props, { + kind: "imports", + clusters: props.wikiImportInsights?.clusters ?? [], + loading: props.wikiImportInsightsLoading, + loadingKey: "dreaming.wiki.loadingInsights", + emptyKey: "dreaming.wiki.noInsights", + emptyHintKey: "dreaming.wiki.noInsightsHint", + date: (cluster) => { + const metadata = [ + t("dreaming.wiki.counts.chats", { count: String(cluster.itemCount) }), + ...(cluster.highRiskCount > 0 + ? [t("dreaming.wiki.counts.sensitive", { count: String(cluster.highRiskCount) })] + : []), + ...(cluster.preferenceSignalCount > 0 + ? [t("dreaming.wiki.counts.signals", { count: String(cluster.preferenceSignalCount) })] + : []), + ]; + return `${cluster.label} · ${metadata.join(" · ")}`; + }, + prose: (cluster) => { + const summary = [ + t("dreaming.wiki.importedClusterSummary", { label: cluster.label.toLowerCase() }), + ...(cluster.withheldCount > 0 + ? [ + t( + cluster.withheldCount === 1 + ? "dreaming.wiki.withheldDigestOne" + : "dreaming.wiki.withheldDigests", + { count: String(cluster.withheldCount) }, + ), + ] + : []), + ]; + return html`

${summary.join(" ")}

`; + }, + renderItem: (item) => renderWikiInsightCard(props, { kind: "import", item }), + }); +} + +function renderWikiOverviewSection(props: DreamingProps) { + const overview = props.wikiOverview; + return renderWikiClusterSection(props, { + kind: "wiki", + clusters: overview?.clusters ?? [], + loading: props.wikiOverviewLoading, + loadingKey: "dreaming.wiki.loadingWiki", + emptyKey: "dreaming.wiki.emptyWiki", + emptyHintKey: "dreaming.wiki.emptyWikiHint", + date: () => { + const metadata = [ + formatPageCount(overview?.totalPages ?? 0), + ...((overview?.totalClaims ?? 0) > 0 ? [formatClaimRowCount(overview!.totalClaims)] : []), + ...((overview?.totalQuestions ?? 0) > 0 + ? [formatOpenQuestionCount(overview!.totalQuestions)] + : []), + ...((overview?.totalContradictions ?? 0) > 0 + ? [formatContradictionCount(overview!.totalContradictions)] + : []), + ]; + return `${t("dreaming.wiki.vault")} · ${metadata.join(" · ")}`; + }, + prose: (cluster) => html` +

+ ${t("dreaming.wiki.fullVaultBreakdown", { + breakdown: overview + ? formatWikiOverviewPageBreakdown(overview.pageCounts) + : t("dreaming.wiki.noPagesYet"), + })} +

+

+ ${t("dreaming.wiki.selectedSection", { + summary: formatWikiOverviewClusterSummary(cluster), + })} + ${cluster.updatedAt + ? ` ${t("dreaming.wiki.latestUpdate", { + date: formatCompactDateTime(cluster.updatedAt), + })}` + : ""} +

+ `, + renderItem: (item) => renderWikiInsightCard(props, { kind: "wiki", item }), + }); +} + function renderDreamDiaryEntries(props: DreamingProps) { const state = props.viewState; if (typeof props.dreamDiaryContent !== "string") { From b67fa6a2c4a35f4f0e1bfff196ec157d9dba2b3d Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Sat, 1 Aug 2026 10:51:06 -0700 Subject: [PATCH 23/53] fix(imessage): prevent duplicate messages after delayed sends (#110853) * fix(imessage): outlive imsg send fallback * test(imessage): prove delayed fallback resolves once * fix(imessage): preserve the send timeout floor --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> --- extensions/imessage/src/constants.ts | 13 ++++----- extensions/imessage/src/send.test.ts | 43 +++++++++++++++++++++++----- extensions/imessage/src/send.ts | 9 +++--- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/extensions/imessage/src/constants.ts b/extensions/imessage/src/constants.ts index cc3035aa3289..d36cbb05d65b 100644 --- a/extensions/imessage/src/constants.ts +++ b/extensions/imessage/src/constants.ts @@ -1,11 +1,8 @@ /** Default timeout for iMessage probe/RPC operations (10 seconds). */ export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000; -// Sends get a much longer default than probes: on macOS 26 (Tahoe) the private -// API bridge intermittently stalls up to ~124s before the send completes. The -// 10s probe timeout aborts those mid-flight, and non-recoverable shapes -// (attachment/reply) are then lost. This must clear the observed upper bound -// plus headroom, otherwise the long tail of stalls still loses sends — 150s -// covers 124s with margin. Decoupling keeps probes/health checks fast while -// letting real sends ride out the stall. Akin to the BlueBubbles fix (#69193). -export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 150_000; +// imsg waits up to 150s for a private-bridge send, then auto transport can fall +// back to AppleScript and spend up to 8s verifying the persisted row. The outer +// RPC timeout must outlive both stages; matching imsg's 150s deadline turns a +// successful fallback into an ambiguous timeout that callers may retry. +export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 180_000; diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index 37b5979a47c8..42df916e1efd 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -1709,17 +1709,46 @@ describe("sendMessageIMessage receipts", () => { ); }); - it("uses the dedicated send timeout (covers macOS 26 stalls), not the 10s probe default", async () => { - const client = createClient({ guid: "p:0/imsg-1" }); + it("floors a configured probe timeout so one delayed imsg fallback can resolve", async () => { + vi.useFakeTimers(); + const delayedFallbackMs = 158_000; + const client = { + request: vi.fn( + (_method: string, _params: Record, opts?: { timeoutMs?: number }) => + new Promise>((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("imsg rpc timeout (send)")), + opts?.timeoutMs, + ); + setTimeout(() => { + clearTimeout(timeout); + resolve({ guid: "p:0/imsg-delayed-fallback" }); + }, delayedFallbackMs); + }), + ), + stop: vi.fn(async () => {}), + } as unknown as IMessageRpcClient; - await sendMessageIMessage("chat_id:42", "hello", { - config: IMESSAGE_TEST_CFG, + const send = sendMessageIMessage("chat_id:42", "hello", { + config: { + channels: { + imessage: { + ...IMESSAGE_TEST_CFG.channels.imessage, + probeTimeoutMs: 10_000, + }, + }, + }, client, }); + await vi.advanceTimersByTimeAsync(delayedFallbackMs); - expect(getClientMocks(client).request).toHaveBeenCalledWith("send", expect.any(Object), { - timeoutMs: 150_000, - }); + await expect(send).resolves.toMatchObject({ messageId: "p:0/imsg-delayed-fallback" }); + expect(getClientMocks(client).request).toHaveBeenCalledTimes(1); + expect(getClientMocks(client).request).toHaveBeenCalledWith( + "send", + expect.any(Object), + expect.objectContaining({ timeoutMs: 180_000 }), + ); }); it("sends explicit chat media-only payloads through send-attachment auto transport", async () => { diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index a69d2a178627..e90ee9245692 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -768,11 +768,12 @@ export async function sendMessageIMessage( replyToId: opts.replyToId, conversationReadOrigin: opts.conversationReadOrigin, }); - // Sends use a dedicated longer default (not the 10s probe timeout) so macOS 26 - // bridge stalls aren't aborted mid-send. Explicit opts/probeTimeoutMs still win - // for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS. + // Sends use a dedicated longer floor (not the 10s probe timeout) so macOS 26 + // bridge stalls aren't aborted mid-send. A configured probe timeout may extend + // sends, but only an explicit per-call timeout may shorten them. const timeoutMs = - opts.timeoutMs ?? account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_SEND_TIMEOUT_MS; + opts.timeoutMs ?? + Math.max(account.config.probeTimeoutMs ?? 0, DEFAULT_IMESSAGE_SEND_TIMEOUT_MS); const pendingEchoTtlMs = resolvePendingPersistedEchoTtlMs(timeoutMs); const region = opts.region?.trim() || account.config.region?.trim() || "US"; const maxBytes = From 5da998fe8a44fd99d3da0f631d2ccae44e55e407 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:53:16 -0700 Subject: [PATCH 24/53] refactor(channels): consolidate lightweight plugin discovery (#117541) --- src/channels/plugins/bundled.ts | 745 ++++++++++--------------- src/channels/plugins/catalog.test.ts | 27 +- src/channels/plugins/catalog.ts | 262 ++++----- src/channels/plugins/read-only.test.ts | 134 +---- src/channels/plugins/read-only.ts | 235 ++------ 5 files changed, 447 insertions(+), 956 deletions(-) diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 5e2ccac8ba71..322207d8eafe 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -77,30 +77,35 @@ type BundledChannelPackageSetupFeature = | "legacyStateMigrations" | "legacySessionSurfaces"; -type GeneratedBundledChannelEntry = { - id: string; +type BundledChannelArtifactValues = { entry: BundledChannelEntryRuntimeContract; + setupEntry: BundledChannelSetupEntryRuntimeContract; + plugin: ChannelPlugin; + setupPlugin: ChannelPlugin; + secrets: NonNullable; + setupSecrets: NonNullable; + accountInspector: NonNullable; }; +type BundledChannelArtifactKind = keyof BundledChannelArtifactValues; +type BundledChannelEntryKind = "entry" | "setupEntry"; +type BundledChannelArtifacts = Partial<{ + [Kind in BundledChannelArtifactKind]: BundledChannelArtifactValues[Kind] | null; +}>; + type BundledChannelLoadContext = { - pluginLoadInProgressIds: Set; - setupPluginLoadInProgressIds: Set; - entryLoadInProgressIds: Set; - setupEntryLoadInProgressIds: Set; - lazyEntriesById: Map; - lazySetupEntriesById: Map; - lazyPluginsById: Map; - lazySetupPluginsById: Map; - lazySecretsById: Map; - lazySetupSecretsById: Map; - lazyAccountInspectorsById: Map< - ChannelId, - NonNullable | null - >; + artifactLoadsInProgress: Set; + artifactsById: Map; metadataById: Map; metadataLoaded: boolean; }; +type BundledChannelArtifactLoadParams = { + id: ChannelId; + rootScope: BundledChannelRootScope; + loadContext: BundledChannelLoadContext; +}; + const log = createSubsystemLogger("channels"); const MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS = 32; const MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS = 256; @@ -108,6 +113,23 @@ const bundledChannelLoadContextsByRoot = new Map(); const sourceBundledEntryLoaderCache: PluginModuleLoaderCache = new Map(); +function rememberBoundedBundledChannelValue( + cache: Map, + key: TKey, + value: TValue, + maxSize: number, +): TValue { + cache.delete(key); + cache.set(key, value); + if (cache.size > maxSize) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + return value; +} + function isSourceModulePath(modulePath: string): boolean { return /\.(?:c|m)?tsx?$/iu.test(modulePath); } @@ -141,51 +163,28 @@ function isPackageLocalBundledDistModulePath(params: { return distRoots.some((root) => isPathInsideCanonicalRoot(root, params.modulePath)); } -function resolveChannelPluginModuleEntry( +function resolveBundledChannelModuleEntry( moduleExport: unknown, -): BundledChannelEntryRuntimeContract | null { + kind: TKind, +): BundledChannelArtifactValues[TKind] | null { const resolved = unwrapDefaultModuleExport(moduleExport); if (!resolved || typeof resolved !== "object") { return null; } - const record = resolved as Partial; - if (record.kind !== "bundled-channel-entry") { + const record = resolved as Record; + const setup = kind === "setupEntry"; + if (record.kind !== (setup ? "bundled-channel-setup-entry" : "bundled-channel-entry")) { return null; } + const stringFields = setup ? [] : ["id", "name", "description"]; + const functionFields = setup ? ["loadSetupPlugin"] : ["register", "loadChannelPlugin"]; if ( - typeof record.id !== "string" || - typeof record.name !== "string" || - typeof record.description !== "string" || - typeof record.register !== "function" || - typeof record.loadChannelPlugin !== "function" + stringFields.some((field) => typeof record[field] !== "string") || + functionFields.some((field) => typeof record[field] !== "function") ) { return null; } - return record as BundledChannelEntryRuntimeContract; -} - -function resolveChannelSetupModuleEntry( - moduleExport: unknown, -): BundledChannelSetupEntryRuntimeContract | null { - const resolved = unwrapDefaultModuleExport(moduleExport); - if (!resolved || typeof resolved !== "object") { - return null; - } - const record = resolved as Partial; - if (record.kind !== "bundled-channel-setup-entry") { - return null; - } - if (typeof record.loadSetupPlugin !== "function") { - return null; - } - return record as BundledChannelSetupEntryRuntimeContract; -} - -function hasSetupEntryFeature( - entry: BundledChannelSetupEntryRuntimeContract | null | undefined, - feature: keyof NonNullable, -): boolean { - return entry?.features?.[feature] === true; + return record as BundledChannelArtifactValues[TKind]; } function resolveBundledChannelBoundaryRoot(params: { @@ -202,59 +201,33 @@ function resolveBundledChannelBoundaryRoot(params: { ].join("\0"); const cached = bundledChannelBoundaryRoots.get(cacheKey); if (cached) { - bundledChannelBoundaryRoots.delete(cacheKey); - bundledChannelBoundaryRoots.set(cacheKey, cached); - return cached; + return rememberBoundedBundledChannelValue( + bundledChannelBoundaryRoots, + cacheKey, + cached, + MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, + ); } const canonicalModulePath = resolveCanonicalPathOrAbsolute(params.modulePath); - const resolveMatchingRoot = (root: string): string | null => { - const canonicalRoot = resolveCanonicalPathOrAbsolute(root); - return isPathInside(canonicalRoot, canonicalModulePath) ? canonicalRoot : null; - }; - const overrideRoot = params.pluginsDir - ? path.resolve(params.pluginsDir, params.metadata.dirName) - : null; - let boundaryRoot: string; - const overrideBoundaryRoot = overrideRoot ? resolveMatchingRoot(overrideRoot) : null; - if (overrideBoundaryRoot) { - boundaryRoot = overrideBoundaryRoot; - } else { - const distRoot = path.resolve( - params.packageRoot, - "dist", - "extensions", - params.metadata.dirName, - ); - const distBoundaryRoot = resolveMatchingRoot(distRoot); - if (distBoundaryRoot) { - boundaryRoot = distBoundaryRoot; - } else { - const distRuntimeRoot = path.resolve( - params.packageRoot, - "dist-runtime", - "extensions", - params.metadata.dirName, - ); - boundaryRoot = - resolveMatchingRoot(distRuntimeRoot) ?? - resolveCanonicalPathOrAbsolute( - path.resolve(params.packageRoot, "extensions", params.metadata.dirName), - ); - } - } - bundledChannelBoundaryRoots.set(cacheKey, boundaryRoot); - while (bundledChannelBoundaryRoots.size > MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS) { - const oldestKey = bundledChannelBoundaryRoots.keys().next().value; - if (oldestKey === undefined) { - break; - } - bundledChannelBoundaryRoots.delete(oldestKey); - } - return boundaryRoot; -} - -function resolveBundledChannelScanDir(rootScope: BundledChannelRootScope): string | undefined { - return rootScope.pluginsDir; + const sourceRoot = path.resolve(params.packageRoot, "extensions", params.metadata.dirName); + const candidates = [ + ...(params.pluginsDir ? [path.resolve(params.pluginsDir, params.metadata.dirName)] : []), + ...["dist", "dist-runtime"].map((layout) => + path.resolve(params.packageRoot, layout, "extensions", params.metadata.dirName), + ), + sourceRoot, + ]; + const boundaryRoot = + candidates + .map(resolveCanonicalPathOrAbsolute) + .find((root) => isPathInside(root, canonicalModulePath)) ?? + resolveCanonicalPathOrAbsolute(sourceRoot); + return rememberBoundedBundledChannelValue( + bundledChannelBoundaryRoots, + cacheKey, + boundaryRoot, + MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, + ); } function resolveGeneratedBundledChannelModulePath(params: { @@ -269,7 +242,7 @@ function resolveGeneratedBundledChannelModulePath(params: { params.rootScope.packageRoot, params.entry, params.metadata.dirName, - resolveBundledChannelScanDir(params.rootScope), + params.rootScope.pluginsDir, ); if (generatedPath) { return generatedPath; @@ -318,7 +291,7 @@ function loadGeneratedBundledChannelModule(params: { if (!modulePath) { throw new Error(`missing generated module for bundled channel ${params.metadata.manifest.id}`); } - const scanDir = resolveBundledChannelScanDir(params.rootScope); + const scanDir = params.rootScope.pluginsDir; const boundaryRoot = resolveBundledChannelBoundaryRoot({ packageRoot: params.rootScope.packageRoot, ...(scanDir ? { pluginsDir: scanDir } : {}), @@ -382,79 +355,47 @@ function describeBundledChannelLoadError(error: unknown, channelId: string): str return detail; } -function loadGeneratedBundledChannelEntry(params: { - rootScope: BundledChannelRootScope; - metadata: BundledChannelPluginMetadata; -}): GeneratedBundledChannelEntry | null { +function loadGeneratedBundledChannelEntry( + kind: TKind, + rootScope: BundledChannelRootScope, + metadata: BundledChannelPluginMetadata, +): BundledChannelArtifactValues[TKind] | undefined { + const setup = kind === "setupEntry"; + const source = setup ? metadata.setupSource : metadata.source; + if (setup && !source) { + return undefined; + } try { - const entry = resolveChannelPluginModuleEntry( + const entry = resolveBundledChannelModuleEntry( loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.source, + rootScope, + metadata, + entry: source, }), + kind, ); if (!entry) { + const description = setup ? "setup entry" : "entry"; + const contract = setup ? "bundled-channel-setup-entry" : "bundled-channel-entry"; log.warn( - `[channels] bundled channel entry ${params.metadata.manifest.id} missing bundled-channel-entry contract; skipping`, + `[channels] bundled channel ${description} ${metadata.manifest.id} missing ${contract} contract; skipping`, ); - return null; } - return { - id: params.metadata.manifest.id, - entry, - }; + return entry ?? undefined; } catch (error) { - const detail = describeBundledChannelLoadError(error, params.metadata.manifest.id); - log.warn(`[channels] failed to load bundled channel ${params.metadata.manifest.id}: ${detail}`); - return null; - } -} - -function loadGeneratedBundledChannelSetupEntry(params: { - rootScope: BundledChannelRootScope; - metadata: BundledChannelPluginMetadata; -}): BundledChannelSetupEntryRuntimeContract | null { - if (!params.metadata.setupSource) { - return null; - } - try { - const setupEntry = resolveChannelSetupModuleEntry( - loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.setupSource, - }), - ); - if (!setupEntry) { - log.warn( - `[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`, - ); - return null; - } - return setupEntry; - } catch (error) { - const detail = describeBundledChannelLoadError(error, params.metadata.manifest.id); + const detail = describeBundledChannelLoadError(error, metadata.manifest.id); + const description = setup ? " setup entry" : ""; log.warn( - `[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`, + `[channels] failed to load bundled channel${description} ${metadata.manifest.id}: ${detail}`, ); - return null; + return undefined; } } function createBundledChannelLoadContext(): BundledChannelLoadContext { return { - pluginLoadInProgressIds: new Set(), - setupPluginLoadInProgressIds: new Set(), - entryLoadInProgressIds: new Set(), - setupEntryLoadInProgressIds: new Set(), - lazyEntriesById: new Map(), - lazySetupEntriesById: new Map(), - lazyPluginsById: new Map(), - lazySetupPluginsById: new Map(), - lazySecretsById: new Map(), - lazySetupSecretsById: new Map(), - lazyAccountInspectorsById: new Map(), + artifactLoadsInProgress: new Set(), + artifactsById: new Map(), metadataById: new Map(), metadataLoaded: false, }; @@ -465,24 +406,12 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e loadContext: BundledChannelLoadContext; } { const rootScope = resolveBundledChannelRootScope(env); - const cachedContext = bundledChannelLoadContextsByRoot.get(rootScope.cacheKey); - if (cachedContext) { - bundledChannelLoadContextsByRoot.delete(rootScope.cacheKey); - bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, cachedContext); - return { - rootScope, - loadContext: cachedContext, - }; - } - const loadContext = createBundledChannelLoadContext(); - bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, loadContext); - while (bundledChannelLoadContextsByRoot.size > MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS) { - const oldestKey = bundledChannelLoadContextsByRoot.keys().next().value; - if (oldestKey === undefined) { - break; - } - bundledChannelLoadContextsByRoot.delete(oldestKey); - } + const loadContext = rememberBoundedBundledChannelValue( + bundledChannelLoadContextsByRoot, + rootScope.cacheKey, + bundledChannelLoadContextsByRoot.get(rootScope.cacheKey) ?? createBundledChannelLoadContext(), + MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS, + ); return { rootScope, loadContext, @@ -492,7 +421,7 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e function listBundledChannelMetadata( rootScope = resolveBundledChannelRootScope(), ): readonly BundledChannelPluginMetadata[] { - const scanDir = resolveBundledChannelScanDir(rootScope); + const scanDir = rootScope.pluginsDir; return listBundledChannelPluginMetadata({ rootDir: rootScope.packageRoot, ...(scanDir ? { scanDir } : {}), @@ -554,28 +483,15 @@ function listBundledChannelPluginIdsForSetupFeature( feature: keyof NonNullable, options: { config?: OpenClawConfig } = {}, ): readonly ChannelId[] { - const hinted = listBundledChannelMetadata(rootScope) - .filter( - (metadata) => - metadata.packageManifest?.setupFeatures?.[feature] === true && - shouldIncludeBundledChannelSetupFeatureForConfig({ - metadata, - config: options.config, - }), - ) + const eligible = listBundledChannelMetadata(rootScope).filter((metadata) => + shouldIncludeBundledChannelSetupFeatureForConfig({ metadata, config: options.config }), + ); + const hinted = eligible.filter( + (metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true, + ); + return (hinted.length > 0 ? hinted : eligible) .map((metadata) => metadata.manifest.id) .toSorted((left, right) => left.localeCompare(right)); - return hinted.length > 0 - ? hinted - : listBundledChannelMetadata(rootScope) - .filter((metadata) => - shouldIncludeBundledChannelSetupFeatureForConfig({ - metadata, - config: options.config, - }), - ) - .map((metadata) => metadata.manifest.id) - .toSorted((left, right) => left.localeCompare(right)); } export function listBundledChannelPluginIds(): readonly ChannelId[] { @@ -621,241 +537,146 @@ function resolveBundledChannelMetadata( return undefined; } -function getLazyGeneratedBundledChannelEntryForRoot( +function rememberBundledChannelArtifact( + loadContext: BundledChannelLoadContext, + kind: TKind, + id: ChannelId, + artifact: BundledChannelArtifactValues[TKind] | undefined, +): void { + const artifacts = loadContext.artifactsById.get(id) ?? {}; + artifacts[kind] = artifact ?? null; + loadContext.artifactsById.set(id, artifacts); +} + +function getBundledChannelArtifactForRoot( + kind: TKind, id: ChannelId, rootScope: BundledChannelRootScope, loadContext: BundledChannelLoadContext, -): GeneratedBundledChannelEntry | null { - const previous = loadContext.lazyEntriesById.get(id); - if (previous) { - return previous; +): BundledChannelArtifactValues[TKind] | undefined { + const artifacts = loadContext.artifactsById.get(id); + if (artifacts && Object.hasOwn(artifacts, kind)) { + return artifacts[kind] ?? undefined; } - if (previous === null) { - return null; + // Keep failure and recursion state separate by artifact kind: broken secrets + // must never poison the runtime plugin, setup plugin, or entry contracts. + const loadKey = `${kind}\0${id}`; + if (loadContext.artifactLoadsInProgress.has(loadKey)) { + return undefined; } - const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - if (!metadata) { - loadContext.lazyEntriesById.set(id, null); - return null; - } - if (loadContext.entryLoadInProgressIds.has(id)) { - return null; - } - loadContext.entryLoadInProgressIds.add(id); + loadContext.artifactLoadsInProgress.add(loadKey); try { - const entry = loadGeneratedBundledChannelEntry({ - rootScope, - metadata, - }); - loadContext.lazyEntriesById.set(id, entry); - if (entry?.entry.id && entry.entry.id !== id) { - loadContext.lazyEntriesById.set(entry.entry.id, entry); + const artifact = bundledChannelArtifactLoaders[kind]({ id, rootScope, loadContext }); + rememberBundledChannelArtifact(loadContext, kind, id, artifact); + return artifact; + } catch (error) { + if (kind === "entry" || kind === "setupEntry") { + throw error; } - return entry; - } finally { - loadContext.entryLoadInProgressIds.delete(id); - } -} - -function rememberBundledChannelSetupEntry( - metadata: BundledChannelPluginMetadata, - loadContext: BundledChannelLoadContext, - entry: BundledChannelSetupEntryRuntimeContract | null, - requestedId?: ChannelId, -) { - const ids = new Set([ - metadata.manifest.id, - ...(metadata.manifest.channels ?? []), - ...(requestedId ? [requestedId] : []), - ]); - for (const id of ids) { - loadContext.lazySetupEntriesById.set(id, entry); - } -} - -function getLazyGeneratedBundledChannelSetupEntryForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): BundledChannelSetupEntryRuntimeContract | null { - if (loadContext.lazySetupEntriesById.has(id)) { - return loadContext.lazySetupEntriesById.get(id) ?? null; - } - const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - if (!metadata) { - loadContext.lazySetupEntriesById.set(id, null); - return null; - } - if (loadContext.setupEntryLoadInProgressIds.has(id)) { - return null; - } - loadContext.setupEntryLoadInProgressIds.add(id); - try { - const setupEntry = loadGeneratedBundledChannelSetupEntry({ - rootScope, - metadata, - }); - rememberBundledChannelSetupEntry(metadata, loadContext, setupEntry, id); - return setupEntry; - } finally { - loadContext.setupEntryLoadInProgressIds.delete(id); - } -} - -function getBundledChannelPluginForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin | undefined { - if (loadContext.lazyPluginsById.has(id)) { - return loadContext.lazyPluginsById.get(id) ?? undefined; - } - if (loadContext.pluginLoadInProgressIds.has(id)) { + const descriptions: Record = { + entry: "", + setupEntry: " setup entry", + plugin: "", + setupPlugin: " setup", + secrets: " secrets", + setupSecrets: " setup secrets", + accountInspector: " account inspector", + }; + const detail = describeBundledChannelLoadError(error, id); + log.warn(`[channels] failed to load bundled channel${descriptions[kind]} ${id}: ${detail}`); + rememberBundledChannelArtifact(loadContext, kind, id, undefined); return undefined; + } finally { + loadContext.artifactLoadsInProgress.delete(loadKey); } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry) { - return undefined; - } - loadContext.pluginLoadInProgressIds.add(id); - try { +} + +const bundledChannelArtifactLoaders: { + [Kind in BundledChannelArtifactKind]: ( + params: BundledChannelArtifactLoadParams, + ) => BundledChannelArtifactValues[Kind] | undefined; +} = { + entry({ id, rootScope, loadContext }) { const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; - if (!plugin) { - loadContext.lazyPluginsById.set(id, null); + if (!metadata) { return undefined; } - const normalizedPlugin = { - ...plugin, - meta: normalizeChannelMeta({ - id: plugin.id, - meta: plugin.meta, - existing: metadata?.packageManifest?.channel, - }), - }; - loadContext.lazyPluginsById.set(id, normalizedPlugin); - return normalizedPlugin; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel ${id}: ${detail}`); - loadContext.lazyPluginsById.set(id, null); - return undefined; - } finally { - loadContext.pluginLoadInProgressIds.delete(id); - } -} - -function getBundledChannelSecretsForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin["secrets"] | undefined { - if (loadContext.lazySecretsById.has(id)) { - return loadContext.lazySecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry) { - return undefined; - } - try { - const secrets = - entry.loadChannelSecrets?.() ?? - getBundledChannelPluginForRoot(id, rootScope, loadContext)?.secrets; - loadContext.lazySecretsById.set(id, secrets ?? null); - return secrets; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel secrets ${id}: ${detail}`); - loadContext.lazySecretsById.set(id, null); - return undefined; - } -} - -function getBundledChannelAccountInspectorForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): NonNullable | undefined { - if (loadContext.lazyAccountInspectorsById.has(id)) { - return loadContext.lazyAccountInspectorsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry?.loadChannelAccountInspector) { - loadContext.lazyAccountInspectorsById.set(id, null); - return undefined; - } - try { - const inspector = entry.loadChannelAccountInspector(); - loadContext.lazyAccountInspectorsById.set(id, inspector); - return inspector; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel account inspector ${id}: ${detail}`); - loadContext.lazyAccountInspectorsById.set(id, null); - return undefined; - } -} - -function getBundledChannelSetupPluginForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin | undefined { - if (loadContext.lazySetupPluginsById.has(id)) { - return loadContext.lazySetupPluginsById.get(id) ?? undefined; - } - if (loadContext.setupPluginLoadInProgressIds.has(id)) { - return undefined; - } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - if (!entry) { - return undefined; - } - loadContext.setupPluginLoadInProgressIds.add(id); - try { - const plugin = entry.loadSetupPlugin(); - loadContext.lazySetupPluginsById.set(id, plugin); - return plugin; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel setup ${id}: ${detail}`); - loadContext.lazySetupPluginsById.set(id, null); - return undefined; - } finally { - loadContext.setupPluginLoadInProgressIds.delete(id); - } -} - -function getBundledChannelSetupSecretsForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin["secrets"] | undefined { - if (loadContext.lazySetupSecretsById.has(id)) { - return loadContext.lazySetupSecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - if (!entry) { - return undefined; - } - try { - const secrets = - entry.loadSetupSecrets?.() ?? - getBundledChannelSetupPluginForRoot(id, rootScope, loadContext)?.secrets; - loadContext.lazySetupSecretsById.set(id, secrets ?? null); - return secrets; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel setup secrets ${id}: ${detail}`); - loadContext.lazySetupSecretsById.set(id, null); - return undefined; - } -} + const entry = loadGeneratedBundledChannelEntry("entry", rootScope, metadata); + if (entry && entry.id !== id) { + rememberBundledChannelArtifact(loadContext, "entry", entry.id, entry); + } + return entry; + }, + setupEntry({ id, rootScope, loadContext }) { + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); + if (!metadata) { + return undefined; + } + const entry = loadGeneratedBundledChannelEntry("setupEntry", rootScope, metadata); + const aliases = new Set([ + metadata.manifest.id, + ...(metadata.manifest.channels ?? []), + id, + ]); + for (const alias of aliases) { + rememberBundledChannelArtifact(loadContext, "setupEntry", alias, entry); + } + return entry; + }, + plugin({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("entry", id, rootScope, loadContext); + if (!entry) { + return undefined; + } + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); + const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; + return plugin + ? { + ...plugin, + meta: normalizeChannelMeta({ + id: plugin.id, + meta: plugin.meta, + existing: metadata?.packageManifest?.channel, + }), + } + : undefined; + }, + setupPlugin({ id, rootScope, loadContext }) { + return getBundledChannelArtifactForRoot( + "setupEntry", + id, + rootScope, + loadContext, + )?.loadSetupPlugin(); + }, + secrets({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("entry", id, rootScope, loadContext); + return entry + ? (entry.loadChannelSecrets?.() ?? + getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext)?.secrets) + : undefined; + }, + setupSecrets({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("setupEntry", id, rootScope, loadContext); + return entry + ? (entry.loadSetupSecrets?.() ?? + getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext)?.secrets) + : undefined; + }, + accountInspector({ id, rootScope, loadContext }) { + return getBundledChannelArtifactForRoot( + "entry", + id, + rootScope, + loadContext, + )?.loadChannelAccountInspector?.(); + }, +}; export function listBundledChannelPlugins(): readonly ChannelPlugin[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelPluginForRoot(id, rootScope, loadContext); + const plugin = getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext); return plugin ? [plugin] : []; }); } @@ -863,72 +684,70 @@ export function listBundledChannelPlugins(): readonly ChannelPlugin[] { export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); + const plugin = getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); return plugin ? [plugin] : []; }); } -export function listBundledChannelLegacySessionSurfaces( - options: { - config?: OpenClawConfig; - } = {}, -): readonly BundledChannelLegacySessionSurface[] { +function listBundledChannelLegacyArtifacts( + feature: keyof NonNullable, + options: { config?: OpenClawConfig }, + loadFromEntry: (entry: BundledChannelSetupEntryRuntimeContract) => TArtifact | undefined, + loadFromPlugin: (plugin: ChannelPlugin) => TArtifact | undefined, +): readonly TArtifact[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces", { - config: options.config, - }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const surface = setupEntry?.loadLegacySessionSurface?.(); - if (surface) { - return [surface]; + return listBundledChannelPluginIdsForSetupFeature(rootScope, feature, options).flatMap((id) => { + const entry = getBundledChannelArtifactForRoot("setupEntry", id, rootScope, loadContext); + const artifact = entry ? loadFromEntry(entry) : undefined; + if (artifact) { + return [artifact]; } - if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { + if (entry?.features?.[feature] !== true) { return []; } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); - return plugin?.messaging ? [plugin.messaging] : []; + const plugin = getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); + const fallback = plugin ? loadFromPlugin(plugin) : undefined; + return fallback ? [fallback] : []; }); } +export function listBundledChannelLegacySessionSurfaces( + options: { config?: OpenClawConfig } = {}, +): readonly BundledChannelLegacySessionSurface[] { + return listBundledChannelLegacyArtifacts( + "legacySessionSurfaces", + options, + (entry) => entry.loadLegacySessionSurface?.(), + (plugin) => plugin.messaging, + ); +} + export function listBundledChannelLegacyStateMigrationDetectors( - options: { - config?: OpenClawConfig; - } = {}, + options: { config?: OpenClawConfig } = {}, ): readonly BundledChannelLegacyStateMigrationDetector[] { - const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations", { - config: options.config, - }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); - if (detector) { - return [detector]; - } - if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { - return []; - } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); - return plugin?.lifecycle?.detectLegacyStateMigrations - ? [plugin.lifecycle.detectLegacyStateMigrations] - : []; - }); + return listBundledChannelLegacyArtifacts( + "legacyStateMigrations", + options, + (entry) => entry.loadLegacyStateMigrationDetector?.(), + (plugin) => plugin.lifecycle?.detectLegacyStateMigrations, + ); } export function getBundledChannelAccountInspector( id: ChannelId, ): NonNullable | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelAccountInspectorForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("accountInspector", id, rootScope, loadContext); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelPluginForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext); } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelSecretsForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("secrets", id, rootScope, loadContext); } export function getBundledChannelSetupPlugin( @@ -936,7 +755,7 @@ export function getBundledChannelSetupPlugin( env: NodeJS.ProcessEnv = process.env, ): ChannelPlugin | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env); - return getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); } export function getBundledChannelSetupSecrets( @@ -944,13 +763,17 @@ export function getBundledChannelSetupSecrets( env: NodeJS.ProcessEnv = process.env, ): ChannelPlugin["secrets"] | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env); - return getBundledChannelSetupSecretsForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("setupSecrets", id, rootScope, loadContext); } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry - .setChannelRuntime; + const setter = getBundledChannelArtifactForRoot( + "entry", + id, + rootScope, + loadContext, + )?.setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); } diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts index caf77cab1507..e8fa92947905 100644 --- a/src/channels/plugins/catalog.test.ts +++ b/src/channels/plugins/catalog.test.ts @@ -29,7 +29,12 @@ afterEach(() => { } }); -function writeChannelCatalog(catalogPath: string, id: string, label: string): void { +function writeChannelCatalog( + catalogPath: string, + id: string, + label: string, + defaultChoice?: string, +): void { fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); fs.writeFileSync( catalogPath, @@ -39,7 +44,7 @@ function writeChannelCatalog(catalogPath: string, id: string, label: string): vo name: `@example/${id}`, openclaw: { channel: { id, label, selectionLabel: label, docsPath: `/channels/${id}`, blurb: id }, - install: { npmSpec: `@example/${id}` }, + install: { npmSpec: `@example/${id}`, ...(defaultChoice ? { defaultChoice } : {}) }, }, }, ], @@ -105,6 +110,24 @@ describe("channel plugin catalog", () => { })?.origin, ).toBe("bundled"); }); + + it.each(["__proto__", "constructor", "toString"])( + "rejects inherited install default choice %s from external catalog input", + (defaultChoice) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-choice-")); + tempDirs.push(root); + const catalogPath = path.join(root, "catalog.json"); + writeChannelCatalog(catalogPath, "unsafe-choice", "Unsafe Choice", defaultChoice); + + const entry = getChannelPluginCatalogEntry("unsafe-choice", { + catalogPaths: [catalogPath], + workspaceDir: root, + env: {}, + }); + expect(entry?.install.defaultChoice).toBe("npm"); + }, + ); + it("reloads external catalog entries after the explicit plugin metadata lifecycle reset", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-external-catalog-")); tempDirs.push(root); diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index b819fbfbca0b..48f65b213221 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -79,28 +79,23 @@ const ORIGIN_PRIORITY: Record = { bundled: 3, }; -function shouldExcludeCatalogOrigin(options: CatalogOptions, origin: PluginOrigin): boolean { - if (options.excludeWorkspace && origin === "workspace") { - return true; - } - return options.excludeOrigins?.includes(origin) ?? false; -} - -function shouldExcludeCatalogPlugin( +function shouldExcludeCatalogEntry( options: CatalogOptions, pluginId?: string, origin?: PluginOrigin, ): boolean { const normalizedPluginId = normalizeOptionalString(pluginId); - if (!normalizedPluginId) { - return false; - } return ( - options.excludePluginRefs?.some( - (entry) => - entry.pluginId === normalizedPluginId && - (entry.origin === undefined || entry.origin === origin), - ) ?? false + (options.excludeWorkspace === true && origin === "workspace") || + (origin !== undefined && (options.excludeOrigins?.includes(origin) ?? false)) || + Boolean( + normalizedPluginId && + options.excludePluginRefs?.some( + (entry) => + entry.pluginId === normalizedPluginId && + (entry.origin === undefined || entry.origin === origin), + ), + ) ); } @@ -115,47 +110,21 @@ type ExternalCatalogEntry = { const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"]; const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json"); -const officialCatalogEntriesByPath = new Map(); -const externalCatalogEntriesByPath = new Map(); +const catalogEntriesByPath = new Map(); -registerPluginMetadataProcessMemoLifecycleClear(() => { - officialCatalogEntriesByPath.clear(); - externalCatalogEntriesByPath.clear(); -}); +registerPluginMetadataProcessMemoLifecycleClear(() => catalogEntriesByPath.clear()); type ManifestKey = typeof MANIFEST_KEY; function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { - if (Array.isArray(raw)) { - return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); - } - if (!isRecord(raw)) { - return []; - } - const list = raw.entries ?? raw.packages ?? raw.plugins; - if (!Array.isArray(list)) { - return []; - } - return list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); -} - -function splitEnvPaths(value: string): string[] { - const trimmed = value.trim(); - if (!trimmed) { - return []; - } - return normalizeStringEntries( - trimmed.split(/[;,]/g).flatMap((chunk) => chunk.split(path.delimiter)), - ); -} - -function resolveDefaultCatalogPaths(env: NodeJS.ProcessEnv): string[] { - const configDir = resolveConfigDir(env); - return [ - path.join(configDir, "mpm", "plugins.json"), - path.join(configDir, "mpm", "catalog.json"), - path.join(configDir, "plugins", "catalog.json"), - ]; + const list = Array.isArray(raw) + ? raw + : isRecord(raw) + ? (raw.entries ?? raw.packages ?? raw.plugins) + : undefined; + return Array.isArray(list) + ? list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)) + : []; } function resolveExternalCatalogPaths(options: CatalogOptions): string[] { @@ -165,23 +134,16 @@ function resolveExternalCatalogPaths(options: CatalogOptions): string[] { const env = options.env ?? process.env; for (const key of ENV_CATALOG_PATHS) { const raw = env[key]; - if (raw && raw.trim()) { - return splitEnvPaths(raw); + if (raw?.trim()) { + return normalizeStringEntries( + raw.split(/[;,]/g).flatMap((chunk) => chunk.split(path.delimiter)), + ); } } - return resolveDefaultCatalogPaths(env); -} - -function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] { - const paths = resolveExternalCatalogPaths(options).map((rawPath) => - resolveUserPath(rawPath, options.env ?? process.env), + const configDir = resolveConfigDir(env); + return ["mpm/plugins.json", "mpm/catalog.json", "plugins/catalog.json"].map((relativePath) => + path.join(configDir, relativePath), ); - return loadCatalogEntriesFromPaths(paths, externalCatalogEntriesByPath); -} - -function readCatalogEntriesFromPath(resolvedPath: string): ExternalCatalogEntry[] | null { - const payload = tryReadJsonSync(resolvedPath); - return payload === null ? null : parseCatalogEntries(payload); } function loadCatalogEntriesFromPaths( @@ -190,19 +152,15 @@ function loadCatalogEntriesFromPaths( ): ExternalCatalogEntry[] { const entries: ExternalCatalogEntry[] = []; for (const resolvedPath of paths) { - if (cache?.has(resolvedPath)) { - const cached = cache.get(resolvedPath); - if (cached) { - entries.push(...cached); - } - continue; + let parsed = cache?.get(resolvedPath); + if (parsed === undefined) { + const payload = tryReadJsonSync(resolvedPath); + parsed = payload === null ? null : parseCatalogEntries(payload); + cache?.set(resolvedPath, parsed); } - const parsed = readCatalogEntriesFromPath(resolvedPath); - cache?.set(resolvedPath, parsed); - if (parsed === null) { - continue; + if (parsed !== null) { + entries.push(...parsed); } - entries.push(...parsed); } return entries; } @@ -232,49 +190,6 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] { return uniqueStrings(candidates); } -function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] { - const builtInEntries = listOfficialExternalChannelCatalogEntries(); - const officialPaths = resolveOfficialCatalogPaths(options); - const fileEntries = loadCatalogEntriesFromPaths( - officialPaths, - options.officialCatalogPaths && options.officialCatalogPaths.length > 0 - ? undefined - : officialCatalogEntriesByPath, - ); - return [...builtInEntries, ...fileEntries] - .map((entry) => buildExternalCatalogEntry(entry, { trustedSourceLinkedOfficialInstall: true })) - .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); -} - -function toChannelMeta(params: { - channel: NonNullable; - id: string; -}): ChannelMeta | null { - const label = params.channel.label?.trim(); - if (!label) { - return null; - } - const selectionLabel = params.channel.selectionLabel?.trim() || label; - const detailLabel = params.channel.detailLabel?.trim(); - const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`; - const blurb = params.channel.blurb?.trim() || ""; - const systemImage = params.channel.systemImage?.trim(); - - return buildManifestChannelMeta({ - id: params.id, - channel: params.channel, - label, - selectionLabel, - docsPath, - docsLabel: normalizeOptionalString(params.channel.docsLabel), - blurb, - detailLabel, - ...(systemImage ? { systemImage } : {}), - arrayFieldMode: "defined", - selectionDocsPrefixMode: "truthy", - }); -} - function resolveInstallInfo(params: { install?: PluginPackageInstall; packageName?: string; @@ -306,18 +221,17 @@ function resolveInstallInfo(params: { localPath = path.relative(params.workspaceDir, params.packageDir) || undefined; } const requestedDefaultChoice = params.install?.defaultChoice; + const availableChoices = { clawhub: clawhubSpec, npm: npmSpec, local: localPath }; const defaultChoice: NonNullable = - requestedDefaultChoice === "clawhub" && clawhubSpec - ? "clawhub" - : requestedDefaultChoice === "npm" && npmSpec - ? "npm" - : requestedDefaultChoice === "local" && localPath + requestedDefaultChoice && + Object.hasOwn(availableChoices, requestedDefaultChoice) && + availableChoices[requestedDefaultChoice] + ? requestedDefaultChoice + : clawhubSpec + ? "clawhub" + : localPath ? "local" - : clawhubSpec - ? "clawhub" - : localPath - ? "local" - : "npm"; + : "npm"; const install = { ...(localPath ? { localPath } : {}), defaultChoice, @@ -356,28 +270,18 @@ function buildCatalogEntryFromManifest(params: { channel?: PluginPackageChannel; install?: PluginPackageInstall; }): ChannelPluginCatalogEntry | null { - if (!params.channel) { + const channel = params.channel; + const id = channel?.id?.trim(); + const label = channel?.label?.trim(); + if (!channel || !id || !label) { return null; } - const id = params.channel.id?.trim(); - if (!id) { - return null; - } - const meta = toChannelMeta({ channel: params.channel, id }); - if (!meta) { - return null; - } - const install = resolveInstallInfo({ - install: params.install, - packageName: params.packageName, - packageVersion: params.packageVersion, - packageDir: params.packageDir, - workspaceDir: params.workspaceDir, - }); + const install = resolveInstallInfo(params); if (!install) { return null; } const pluginId = normalizeOptionalString(params.pluginId); + const systemImage = channel.systemImage?.trim(); return { id, ...(pluginId ? { pluginId } : {}), @@ -385,8 +289,20 @@ function buildCatalogEntryFromManifest(params: { ...(params.trustedSourceLinkedOfficialInstall ? { trustedSourceLinkedOfficialInstall: true } : {}), - channel: params.channel, - meta, + channel, + meta: buildManifestChannelMeta({ + id, + channel, + label, + selectionLabel: channel.selectionLabel?.trim() || label, + docsPath: channel.docsPath?.trim() || `/channels/${id}`, + docsLabel: normalizeOptionalString(channel.docsLabel), + blurb: channel.blurb?.trim() || "", + detailLabel: channel.detailLabel?.trim(), + ...(systemImage ? { systemImage } : {}), + arrayFieldMode: "defined", + selectionDocsPrefixMode: "truthy", + }), install, installSource: describePluginInstallSource(install, { expectedPackageName: params.packageName, @@ -396,16 +312,14 @@ function buildCatalogEntryFromManifest(params: { function buildExternalCatalogEntry( entry: ExternalCatalogEntry, - options?: { - trustedSourceLinkedOfficialInstall?: boolean; - }, + trustedSourceLinkedOfficialInstall = false, ): ChannelPluginCatalogEntry | null { const manifest = entry[MANIFEST_KEY]; return buildCatalogEntryFromManifest({ pluginId: manifest?.plugin?.id, packageName: entry.name, packageVersion: entry.version, - trustedSourceLinkedOfficialInstall: options?.trustedSourceLinkedOfficialInstall, + trustedSourceLinkedOfficialInstall, channel: manifest?.channel, install: manifest?.install, }); @@ -467,10 +381,7 @@ export function listRawChannelPluginCatalogEntries( }; for (const candidate of manifestEntries) { - if ( - shouldExcludeCatalogOrigin(options, candidate.origin) || - shouldExcludeCatalogPlugin(options, candidate.pluginId, candidate.origin) - ) { + if (shouldExcludeCatalogEntry(options, candidate.pluginId, candidate.origin)) { continue; } const entry = buildCatalogEntryFromManifest({ @@ -488,18 +399,35 @@ export function listRawChannelPluginCatalogEntries( rememberCatalogEntry(entry, ORIGIN_PRIORITY[candidate.origin] ?? 99); } - for (const entry of loadOfficialCatalogEntries(options)) { - rememberCatalogEntry(entry, FALLBACK_CATALOG_PRIORITY); - } + const rememberExternalCatalogEntries = ( + entries: ExternalCatalogEntry[], + priority: number, + trustedSourceLinkedOfficialInstall = false, + ) => { + for (const candidate of entries) { + const entry = buildExternalCatalogEntry(candidate, trustedSourceLinkedOfficialInstall); + if (entry) { + rememberCatalogEntry(entry, priority); + } + } + }; + const officialFileEntries = loadCatalogEntriesFromPaths( + resolveOfficialCatalogPaths(options), + options.officialCatalogPaths?.length ? undefined : catalogEntriesByPath, + ); + rememberExternalCatalogEntries( + [...listOfficialExternalChannelCatalogEntries(), ...officialFileEntries], + FALLBACK_CATALOG_PRIORITY, + true, + ); - const externalEntries = loadExternalCatalogEntries(options) - .map((entry) => buildExternalCatalogEntry(entry)) - .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); - for (const entry of externalEntries) { - // External catalogs are the supported override seam for shipped fallback - // metadata, but discovered plugins should still win when they are present. - rememberCatalogEntry(entry, EXTERNAL_CATALOG_PRIORITY); - } + const externalCatalogPaths = resolveExternalCatalogPaths(options).map((rawPath) => + resolveUserPath(rawPath, options.env ?? process.env), + ); + const externalEntries = loadCatalogEntriesFromPaths(externalCatalogPaths, catalogEntriesByPath); + // External catalogs are the supported override seam for shipped fallback + // metadata, but discovered plugins should still win when they are present. + rememberExternalCatalogEntries(externalEntries, EXTERNAL_CATALOG_PRIORITY); return Array.from(resolved.values()) .map(({ entry }) => entry) diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 832cf280f47e..495aff01ac2c 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -1,7 +1,6 @@ // Read-only channel tests cover read-only plugin registration and runtime behavior. import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanupPluginLoaderFixturesForTest, @@ -17,7 +16,6 @@ import { createTestRegistry, } from "../../test-utils/channel-plugins.js"; import { - listPluginLoaderModuleCandidateUrls, listReadOnlyChannelPluginsForConfig, resolveReadOnlyChannelPluginsForConfig, } from "./read-only.js"; @@ -54,11 +52,6 @@ function createExternalChannelTestConfig(params: { }; } -function modulePathEndsWith(modulePath: string, suffix: string): boolean { - const normalized = modulePath.startsWith("file:") ? fileURLToPath(modulePath) : modulePath; - return normalized.replace(/\\/g, "/").endsWith(suffix); -} - function expectRecordFields(record: unknown, expected: Record) { if (!record || typeof record !== "object") { throw new Error("Expected record"); @@ -82,96 +75,6 @@ vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => { vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => { const actual = await importOriginal(); - const { createRequire } = await import("node:module"); - const require = createRequire(import.meta.url); - - type LoaderConfig = { - plugins?: { - load?: { paths?: unknown }; - }; - }; - type LoaderParams = { - config?: LoaderConfig; - onlyPluginIds?: readonly string[]; - workspaceDir?: string; - }; - - function readJson(filePath: string): unknown { - return JSON.parse(fs.readFileSync(filePath, "utf-8")); - } - - function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); - } - - function listCandidatePluginDirs(params: LoaderParams): string[] { - const paths = params.config?.plugins?.load?.paths; - const explicitPaths = Array.isArray(paths) - ? paths.filter((entry): entry is string => typeof entry === "string") - : []; - const workspaceExtensionsDir = params.workspaceDir - ? path.join(params.workspaceDir, ".openclaw", "extensions") - : undefined; - if (!workspaceExtensionsDir || !fs.existsSync(workspaceExtensionsDir)) { - return explicitPaths; - } - return explicitPaths.concat( - fs - .readdirSync(workspaceExtensionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(workspaceExtensionsDir, entry.name)), - ); - } - - function loadOpenClawPlugins(params: LoaderParams) { - const onlyPluginIds = new Set(params.onlyPluginIds ?? []); - const diagnostics: Array<{ - level: "error"; - pluginId: string; - source: string; - message: string; - }> = []; - const channelSetups = listCandidatePluginDirs(params).flatMap((pluginDir) => { - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - const packagePath = path.join(pluginDir, "package.json"); - if (!fs.existsSync(manifestPath) || !fs.existsSync(packagePath)) { - return []; - } - const manifest = readJson(manifestPath); - if (!isRecord(manifest) || typeof manifest.id !== "string") { - return []; - } - if (onlyPluginIds.size > 0 && !onlyPluginIds.has(manifest.id)) { - return []; - } - const packageJson = readJson(packagePath); - const openclaw = isRecord(packageJson) ? packageJson.openclaw : undefined; - const setupEntry = isRecord(openclaw) ? openclaw.setupEntry : undefined; - if (typeof setupEntry !== "string") { - return []; - } - const setupPath = path.join(pluginDir, setupEntry); - let setupModule: unknown; - try { - setupModule = require(setupPath); - } catch (error) { - diagnostics.push({ - level: "error", - pluginId: manifest.id, - source: setupPath, - message: `failed to load setup entry: ${String(error)}`, - }); - return []; - } - const entry = ((setupModule as { default?: unknown }).default ?? setupModule) as { - plugin?: unknown; - }; - const plugin = entry.plugin; - return plugin ? [{ pluginId: manifest.id, plugin }] : []; - }); - return { channelSetups, diagnostics }; - } - return { ...actual, getCachedPluginModuleLoader: ((params) => { @@ -179,16 +82,7 @@ vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => modulePath: params.modulePath, tryNative: params.tryNative, }); - const actualLoader = actual.getCachedPluginModuleLoader(params); - return ((modulePath: string) => { - if ( - modulePathEndsWith(modulePath, "/plugins/loader.js") || - modulePathEndsWith(modulePath, "/plugins/loader.ts") - ) { - return { loadOpenClawPlugins }; - } - return actualLoader(modulePath); - }) as ReturnType; + return actual.getCachedPluginModuleLoader(params); }) satisfies typeof actual.getCachedPluginModuleLoader, }; }); @@ -510,20 +404,6 @@ afterAll(() => { }); describe("listReadOnlyChannelPluginsForConfig", () => { - it("keeps built plugin loader candidates inside the installed package dist root", () => { - const packageRoot = path.join(makeTempDir(), "node_modules", "openclaw"); - const importerPath = path.join(packageRoot, "dist", "read-only-B4EkEtUx.js"); - const candidates = listPluginLoaderModuleCandidateUrls(pathToFileURL(importerPath).href).map( - (candidate) => fileURLToPath(candidate), - ); - - expect(candidates).toEqual([ - path.join(packageRoot, "dist", "plugins", "loader.js"), - path.join(packageRoot, "dist", "plugins", "build-smoke-entry.js"), - ]); - expect(candidates).not.toContain(path.join(packageRoot, "..", "plugins", "loader.js")); - }); - it("uses package channel metadata without loading setup or full runtime", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin(); const plugins = listReadOnlyChannelPluginsForConfig( @@ -551,14 +431,10 @@ describe("listReadOnlyChannelPluginsForConfig", () => { ); expectExternalChatSetupOnlyPluginLoaded({ plugins, setupMarker, fullMarker }); - expect( - moduleLoaderParams.some( - (entry) => - entry.tryNative === true && - (modulePathEndsWith(entry.modulePath, "/plugins/loader.js") || - modulePathEndsWith(entry.modulePath, "/plugins/loader.ts")), - ), - ).toBe(true); + expect(moduleLoaderParams).toContainEqual({ + modulePath: path.join(pluginDir, "setup-entry.cjs"), + tryNative: true, + }); }); it("uses activation source config to discover channel setup metadata after secret stripping", () => { diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 7dd48df94b10..803add7768b7 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -4,8 +4,6 @@ * Builds lightweight channel plugin views from config, manifests, and setup metadata. */ import { createHash } from "node:crypto"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { sortUniqueStrings, uniqueStrings, @@ -27,7 +25,6 @@ import { resolveSetupChannelRegistration, } from "../../plugins/loader-channel-setup.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; -import type { PluginDiagnostic } from "../../plugins/manifest-types.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js"; import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { @@ -52,92 +49,9 @@ import { import { listChannelPlugins } from "./registry.js"; import type { ChannelPlugin } from "./types.plugin.js"; -const SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES = [ - "../../plugins/loader.js", - "../../plugins/loader.ts", -] as const; -const BUILT_PLUGIN_LOADER_MODULE_CANDIDATES = [ - "plugins/loader.js", - "plugins/build-smoke-entry.js", -] as const; const moduleLoaders: PluginModuleLoaderCache = new Map(); const log = createSubsystemLogger("channels"); -type PluginLoaderModule = { - loadOpenClawPlugins: (params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; - cache?: boolean; - activate?: boolean; - includeSetupOnlyChannelPlugins?: boolean; - forceSetupOnlyChannelPlugins?: boolean; - requireSetupEntryForSetupOnlyChannelPlugins?: boolean; - onlyPluginIds?: readonly string[]; - }) => { - channelSetups: Iterable<{ - pluginId: string; - plugin: ChannelPlugin; - }>; - diagnostics?: readonly PluginDiagnostic[]; - }; -}; - -let pluginLoaderModule: PluginLoaderModule | undefined; - -function listBuiltPluginLoaderModuleCandidateUrls(importerUrl: string): URL[] { - let importerPath: string; - try { - importerPath = fileURLToPath(importerUrl); - } catch { - return []; - } - const distMarker = `${path.sep}dist${path.sep}`; - const distMarkerIndex = importerPath.lastIndexOf(distMarker); - if (distMarkerIndex < 0) { - return []; - } - // Bundled read-only chunks live under dist/ with hashed names. Source-relative - // ../../plugins candidates would escape the installed openclaw package there. - const distRoot = importerPath.slice(0, distMarkerIndex + distMarker.length - 1); - return BUILT_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) => - pathToFileURL(path.join(distRoot, candidate)), - ); -} - -export function listPluginLoaderModuleCandidateUrls(importerUrl = import.meta.url): URL[] { - const builtCandidates = listBuiltPluginLoaderModuleCandidateUrls(importerUrl); - if (builtCandidates.length > 0) { - return builtCandidates; - } - return SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) => new URL(candidate, importerUrl)); -} - -function loadPluginLoaderModule(): PluginLoaderModule { - if (pluginLoaderModule) { - return pluginLoaderModule; - } - for (const candidate of listPluginLoaderModuleCandidateUrls()) { - const modulePath = fileURLToPath(candidate); - try { - const moduleLoader = getCachedPluginModuleLoader({ - cache: moduleLoaders, - modulePath, - importerUrl: import.meta.url, - preferBuiltDist: true, - loaderFilename: import.meta.url, - tryNative: true, - }); - pluginLoaderModule = moduleLoader(modulePath) as PluginLoaderModule; - return pluginLoaderModule; - } catch { - // Try built/runtime source candidates in order. - } - } - throw new Error("Could not load plugin runtime loader for channel setup fallback."); -} - type ReadOnlyChannelPluginOptions = { env?: NodeJS.ProcessEnv; stateDir?: string; @@ -549,7 +463,7 @@ function loadSetupChannelPluginFromManifestRecord(params: { ) { return {}; } - return { plugin: cloneChannelPluginForChannelId(registration.plugin, params.channelId) }; + return { plugin: registration.plugin }; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load channel setup ${params.record.id}: ${detail}`); @@ -564,40 +478,6 @@ function loadSetupChannelPluginFromManifestRecord(params: { } } -function collectChannelPluginLoadFailuresFromDiagnostics(params: { - diagnostics: readonly PluginDiagnostic[] | undefined; - records: readonly PluginManifestRecord[]; - channelIds: readonly string[]; -}): ReadOnlyChannelPluginLoadFailure[] { - if (!params.diagnostics?.length || params.channelIds.length === 0) { - return []; - } - const configuredChannelIds = new Set(params.channelIds); - const recordsByPluginId = new Map(params.records.map((record) => [record.id, record] as const)); - const failures: ReadOnlyChannelPluginLoadFailure[] = []; - for (const diagnostic of params.diagnostics) { - if (diagnostic.level !== "error" || !diagnostic.pluginId) { - continue; - } - const record = recordsByPluginId.get(diagnostic.pluginId); - if (!record) { - continue; - } - for (const channelId of record.channels) { - if (!configuredChannelIds.has(channelId)) { - continue; - } - failures.push({ - channelId, - pluginId: record.id, - source: diagnostic.source, - message: diagnostic.message, - }); - } - } - return failures; -} - function rebindChannelPluginConfig( config: ChannelPlugin["config"], sourceChannelId: string, @@ -727,43 +607,6 @@ function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string }; } -function addSetupChannelPlugins( - byId: Map, - setups: Iterable<{ - pluginId: string; - plugin: ChannelPlugin; - }>, - options: { - ownedChannelIdsByPluginId: ReadonlyMap; - ownedMissingChannelIdsByPluginId: ReadonlyMap; - }, -): void { - for (const setup of setups) { - const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId - .get(setup.pluginId) - ?.filter(isSafeManifestChannelId); - if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) { - continue; - } - const ownedChannelIds = (options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []).filter( - isSafeManifestChannelId, - ); - if (setup.plugin.id !== setup.pluginId && !ownedChannelIds.includes(setup.plugin.id)) { - continue; - } - addChannelPlugins( - byId, - ownedMissingChannelIds.map((channelId) => - cloneChannelPluginForChannelId(setup.plugin, channelId), - ), - { - onlyIds: new Set(ownedMissingChannelIds), - allowOverwrite: false, - }, - ); - } -} - function addManifestChannelPlugins( byId: Map, records: readonly PluginManifestRecord[], @@ -930,7 +773,9 @@ export function resolveReadOnlyChannelPluginsForConfig( const bundledSetupPlugin = setupResults.map((result) => result.plugin).find((plugin) => plugin) ?? getBundledChannelSetupPlugin(channelId, env); - addChannelPlugins(byId, [bundledSetupPlugin]); + addChannelPlugins(byId, [ + bundledSetupPlugin && cloneChannelPluginForChannelId(bundledSetupPlugin, channelId), + ]); } } @@ -962,45 +807,41 @@ export function resolveReadOnlyChannelPluginsForConfig( }); if (externalPluginIds.length > 0) { const externalPluginIdSet = new Set(externalPluginIds); - const ownedChannelIdsByPluginId = new Map( - externalManifestRecords - .filter((record) => externalPluginIdSet.has(record.id)) - .map((record) => [record.id, record.channels] as const), - ); - if (missingConfiguredChannelIds.length > 0 && options.includeSetupFallbackPlugins === true) { + if (options.includeSetupFallbackPlugins === true) { const missingChannelIdSet = new Set(missingConfiguredChannelIds); - const ownedMissingChannelIdsByPluginId = new Map( - [...ownedChannelIdsByPluginId].map( - ([pluginId, channelIds]) => - [ - pluginId, - channelIds.filter((channelId) => missingChannelIdSet.has(channelId)), - ] as const, - ), - ); - const registry = loadPluginLoaderModule().loadOpenClawPlugins({ - config: cfg, - activationSourceConfig: options.activationSourceConfig ?? cfg, - env, - workspaceDir, - cache: false, - activate: false, - includeSetupOnlyChannelPlugins: true, - forceSetupOnlyChannelPlugins: true, - requireSetupEntryForSetupOnlyChannelPlugins: true, - onlyPluginIds: externalPluginIds, - }); - loadFailures.push( - ...collectChannelPluginLoadFailuresFromDiagnostics({ - diagnostics: registry.diagnostics, - records: externalManifestRecords, - channelIds: missingConfiguredChannelIds, - }), - ); - addSetupChannelPlugins(byId, registry.channelSetups, { - ownedChannelIdsByPluginId, - ownedMissingChannelIdsByPluginId, - }); + for (const record of externalManifestRecords) { + if (!externalPluginIdSet.has(record.id) || !record.setupSource) { + continue; + } + const ownedMissingChannelIds = record.channels.filter( + (channelId) => missingChannelIdSet.has(channelId) && !byId.has(channelId), + ); + const firstChannelId = ownedMissingChannelIds[0]; + if (!firstChannelId) { + continue; + } + const setupResult = loadSetupChannelPluginFromManifestRecord({ + record, + channelId: firstChannelId, + }); + const failure = setupResult.failure; + if (failure) { + loadFailures.push( + ...ownedMissingChannelIds.map((channelId) => ({ ...failure, channelId })), + ); + continue; + } + const plugin = setupResult.plugin; + if (plugin) { + addChannelPlugins( + byId, + ownedMissingChannelIds.map((channelId) => + cloneChannelPluginForChannelId(plugin, channelId), + ), + { allowOverwrite: false }, + ); + } + } } const externalManifestMissingChannelIds = missingConfiguredChannelIds.filter( (channelId) => !byId.has(channelId), From 8206a0ceb63dd82b3283cd54bd6210a858d1a25c Mon Sep 17 00:00:00 2001 From: MatthewSynthia Date: Sat, 1 Aug 2026 10:54:01 -0700 Subject: [PATCH 25/53] fix(media): keep an unquoted MEDIA path with spaces as one media item (#112464) Co-authored-by: MatthewSynthia --- src/media/parse.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ src/media/parse.ts | 37 +++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index 53ad9b154d2d..d8341b3706d4 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -49,12 +49,33 @@ describe("splitMediaFromOutput", () => { it.each([ ["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"], ["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'], + [ + "/Users/pete/My Files/Project Assets/render final.png", + "MEDIA:/Users/pete/My Files/Project Assets/render final.png", + ], + [ + "/Users/pete/My Files/Project Assets/render final.png", + 'MEDIA:"/Users/pete/My Files/Project Assets/render final.png"', + ], + ["/tmp/album.v1/photo.png copy.png", "MEDIA:/tmp/album.v1/photo.png copy.png"], ["./screenshots/image.png", "MEDIA:./screenshots/image.png"], ["media/inbound/image.png", "MEDIA:media/inbound/image.png"], ["./screenshot.png", " MEDIA:./screenshot.png"], ["~/Pictures/My File.png", "MEDIA:~/Pictures/My File.png"], ["~/.openclaw/media/browser/snap.png", "MEDIA:~/.openclaw/media/browser/snap.png"], ["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"], + [ + "C:\\Users\\First Last\\workspace\\shot.png", + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png", + ], + [ + "C:\\Users\\First Last\\workspace\\shot.png", + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png", + ], + [ + "\\\\server\\My Files\\Project Assets\\render final.png", + "MEDIA:\\\\server\\My Files\\Project Assets\\render final.png", + ], ["/tmp/tts-fAJy8C/voice-1770246885083.opus", "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus"], ["image.png", "MEDIA:image.png"], [ @@ -74,16 +95,56 @@ describe("splitMediaFromOutput", () => { expectAcceptedMediaPathCase(expectedPath, input); }); + it.each([ + ["MEDIA:/tmp/a.png /tmp/b.png", ["/tmp/a.png", "/tmp/b.png"]], + ["MEDIA:media/a.png media/b.png", ["media/a.png", "media/b.png"]], + ["MEDIA:/tmp/a.png media/b.png", ["/tmp/a.png", "media/b.png"]], + ["MEDIA:./a.png ./b.png", ["./a.png", "./b.png"]], + ["MEDIA:/tmp/a.png https://example.com/b.png", ["/tmp/a.png", "https://example.com/b.png"]], + [ + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png D:\\Other User\\second.png", + ["C:\\Users\\First Last\\workspace\\shot.png", "D:\\Other User\\second.png"], + ], + [ + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png media/second.png", + ["C:\\Users\\First Last\\workspace\\shot.png", "media/second.png"], + ], + [ + "MEDIA:/tmp/project screenshots/shot.png media\\second.png", + ["/tmp/project screenshots/shot.png", "media\\second.png"], + ], + [ + "MEDIA:/tmp/project screenshots/shot.png file:///tmp/second.png", + ["/tmp/project screenshots/shot.png", "/tmp/second.png"], + ], + ["MEDIA:C:\\Users\\First Last\\..\\secret.png D:\\safe\\second.png", ["D:\\safe\\second.png"]], + ["MEDIA:/tmp/project screenshots/../../.env /tmp/safe/second.png", ["/tmp/safe/second.png"]], + ] as const)("keeps separate media items on one directive line: %s", (input, mediaUrls) => { + expectParsedMediaOutputCase(input, { mediaUrls: [...mediaUrls] }); + }); + it.each([ "MEDIA:../../../etc/passwd", "MEDIA:../../.env", "MEDIA:~user/Pictures/My File.png", "MEDIA:~/Pictures/../../.ssh/id_rsa", "MEDIA:./foo/../../../etc/shadow", + "MEDIA:C:\\Users\\First Last\\..\\secret.png", + "MEDIA:/tmp/project screenshots/../../.env", ] as const)("rejects traversal and unsupported home-dir path: %s", (input) => { expectRejectedMediaPathCase(input); }); + it("does not absorb an unsafe remote URL into a spaced local media path", () => { + expectParsedMediaOutputCase( + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png https://127.0.0.1/secret.png", + { + mediaUrls: ["C:\\Users\\First Last\\workspace\\shot.png"], + text: "https://127.0.0.1/secret.png", + }, + ); + }); + it.each([ "MEDIA:http://example.com/a.png", "MEDIA:https://intranet/a.png", diff --git a/src/media/parse.ts b/src/media/parse.ts index f9c934dd3cb5..c2aed9dd5fb9 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -50,6 +50,7 @@ function cleanCandidate(raw: string) { } const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; +const MEDIA_SOURCE_ROOT_RE = /^(?:[a-z]:[\\/]|[/~]|\.{1,2}[\\/]|\\\\)/i; const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; const HAS_FILE_EXT = /\.\w{1,10}$/; @@ -198,6 +199,33 @@ function isValidMedia( return false; } +function beginsIndependentMediaSource(raw: string): boolean { + const candidate = normalizeMediaSource(cleanCandidate(raw)); + return MEDIA_SOURCE_ROOT_RE.test(candidate) || SCHEME_RE.test(candidate); +} + +function splitUnquotedMediaDirectiveParts(payload: string): string[] { + const parts: string[] = []; + let previousEnd = 0; + for (const match of payload.matchAll(/\S+/g)) { + const candidate = normalizeMediaSource(cleanCandidate(match[0])); + const previous = parts.at(-1); + const previousCandidate = previous ? normalizeMediaSource(cleanCandidate(previous)) : ""; + if ( + MEDIA_SOURCE_ROOT_RE.test(previousCandidate) && + !beginsIndependentMediaSource(candidate) && + (!HAS_FILE_EXT.test(previousCandidate) || !isValidMedia(candidate)) + ) { + // Preserve real filename whitespace while keeping independently valid attachments separate. + parts[parts.length - 1] = `${previous}${payload.slice(previousEnd, match.index)}${match[0]}`; + } else { + parts.push(match[0]); + } + previousEnd = match.index + match[0].length; + } + return parts; +} + function unwrapQuoted(value: string): string | undefined { const trimmed = value.trim(); if (trimmed.length < 2) { @@ -583,19 +611,21 @@ export function splitMediaFromOutput( const payload = expectDefined(match[1], "parse regex capture 1"); const unwrapped = unwrapQuoted(payload); const payloadValue = unwrapped ?? payload; - const parts = unwrapped ? [unwrapped] : payload.split(/\s+/).filter(Boolean); + const parts = unwrapped ? [unwrapped] : splitUnquotedMediaDirectiveParts(payload); const mediaStartIndex = media.length; let validCount = 0; const invalidParts: string[] = []; let hasValidMedia = false; for (const part of parts) { const candidate = normalizeMediaSource(cleanCandidate(part)); - if (isValidMedia(candidate, unwrapped ? { allowSpaces: true } : undefined)) { + if ( + isValidMedia(candidate, unwrapped || /\s/.test(part) ? { allowSpaces: true } : undefined) + ) { media.push(candidate); hasValidMedia = true; foundMediaToken = true; validCount += 1; - } else { + } else if (!/\s/.test(part) || !hasTraversalOrUnsupportedHomeDirPrefix(candidate)) { invalidParts.push(part); } } @@ -607,6 +637,7 @@ export function splitMediaFromOutput( !unwrapped && validCount === 1 && invalidParts.length > 0 && + !parts.slice(1).some(beginsIndependentMediaSource) && /\s/.test(payloadValue) && looksLikeLocalPath ) { From 3f9b24237b0c8086f13ed930449c00c812baea78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:55:53 -0700 Subject: [PATCH 26/53] fix(doctor): preserve surviving lint selections (#117543) Co-authored-by: Peter Steinberger --- src/flows/doctor-lint-flow.test.ts | 75 ++++++++++++++++++++++++++++++ src/flows/doctor-lint-flow.ts | 2 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/flows/doctor-lint-flow.test.ts b/src/flows/doctor-lint-flow.test.ts index 2c0c5db6ed75..1215118f4ae2 100644 --- a/src/flows/doctor-lint-flow.test.ts +++ b/src/flows/doctor-lint-flow.test.ts @@ -85,6 +85,81 @@ describe("runDoctorLintChecks", () => { }, ); + it.each(["array", "set"] as const)( + "runs surviving selected checks when selectors only partially overlap (%s)", + async (selectorShape) => { + const skippedId = "plugin/example/skipped"; + const selectedId = "plugin/example/selected"; + const detections: string[] = []; + const ids = [skippedId, selectedId]; + const result = await runDoctorLintChecks(ctx, { + checks: ids.map((id) => + check(id, async () => { + detections.push(id); + return []; + }), + ), + onlyIds: selectorShape === "set" ? new Set(ids) : ids, + skipIds: selectorShape === "set" ? new Set([skippedId]) : [skippedId], + }); + + expect(result).toEqual({ findings: [], checksRun: 1, checksSkipped: 1 }); + expect(detections).toEqual([selectedId]); + expect(exitCodeFromFindings(result.findings)).toBe(0); + }, + ); + + it("retains every overlap diagnostic when exclusion removes all selected checks", async () => { + const ids = ["plugin/example/first", "plugin/example/second"]; + const result = await runDoctorLintChecks(ctx, { + checks: ids.map((id) => check(id, async () => [])), + onlyIds: ids, + skipIds: ids, + }); + + expect(result.checksRun).toBe(0); + expect(result.checksSkipped).toBe(2); + expect(result.findings).toEqual( + ids.map((id) => ({ + checkId: "core/doctor/lint-selection", + severity: "error", + message: `Health check ${id} cannot be selected by --only and excluded by --skip.`, + path: id, + })), + ); + expect(exitCodeFromFindings(result.findings)).toBe(1); + }); + + it("keeps unknown-only diagnostics without treating partial overlap as an error", async () => { + const skippedId = "plugin/example/skipped"; + const selectedId = "plugin/example/selected"; + const unknownId = "plugin/future/not-loaded"; + const detections: string[] = []; + const result = await runDoctorLintChecks(ctx, { + checks: [skippedId, selectedId].map((id) => + check(id, async () => { + detections.push(id); + return []; + }), + ), + onlyIds: [skippedId, unknownId, selectedId], + skipIds: [skippedId, "plugin/future/ignored"], + }); + + expect(result.checksRun).toBe(1); + expect(result.checksSkipped).toBe(1); + expect(detections).toEqual([selectedId]); + expect(result.findings).toEqual([ + { + checkId: "core/doctor/lint-selection", + severity: "error", + message: `Unknown health check id selected by --only: ${unknownId}.`, + path: unknownId, + }, + ]); + expect(exitCodeFromFindings(result.findings)).toBe(1); + }); + it("keeps non-conflicting selection and exclusion filters independent", async () => { let selectedDetections = 0; let skippedDetections = 0; diff --git a/src/flows/doctor-lint-flow.ts b/src/flows/doctor-lint-flow.ts index 8161e948f747..d366bb6f89a9 100644 --- a/src/flows/doctor-lint-flow.ts +++ b/src/flows/doctor-lint-flow.ts @@ -53,7 +53,7 @@ export async function runDoctorLintChecks( let message: string; if (!allIds.has(id)) { message = `Unknown health check id selected by --only: ${id}.`; - } else if (skip.has(id)) { + } else if (selected.length === 0 && skip.has(id)) { message = `Health check ${id} cannot be selected by --only and excluded by --skip.`; } else { continue; From fd343ca8cc956d4af650a35c2957a8a8e700651c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:03:07 -0700 Subject: [PATCH 27/53] refactor(ui): unify channel status and setup controls (#117499) --- ui/src/components/wizard-step-controls.ts | 256 +++++++++++------- .../e2e/channels-whatsapp-logout.e2e.test.ts | 142 ++++++++++ ui/src/lib/channels/index.ts | 31 ++- ui/src/pages/channels/view.detail.ts | 254 ++++++++++------- ui/src/pages/channels/view.discord.ts | 55 ---- ui/src/pages/channels/view.googlechat.ts | 70 ----- ui/src/pages/channels/view.imessage.ts | 59 ---- ui/src/pages/channels/view.shared.ts | 24 +- ui/src/pages/channels/view.signal.ts | 56 ---- ui/src/pages/channels/view.slack.ts | 55 ---- ui/src/pages/channels/view.telegram.ts | 106 -------- ui/src/pages/channels/view.test.ts | 134 ++++++++- ui/src/pages/channels/view.ts | 39 ++- ui/src/pages/channels/wizard-controller.ts | 1 - ui/src/pages/channels/wizard-view.ts | 188 ++----------- 15 files changed, 650 insertions(+), 820 deletions(-) delete mode 100644 ui/src/pages/channels/view.discord.ts delete mode 100644 ui/src/pages/channels/view.googlechat.ts delete mode 100644 ui/src/pages/channels/view.imessage.ts delete mode 100644 ui/src/pages/channels/view.signal.ts delete mode 100644 ui/src/pages/channels/view.slack.ts delete mode 100644 ui/src/pages/channels/view.telegram.ts diff --git a/ui/src/components/wizard-step-controls.ts b/ui/src/components/wizard-step-controls.ts index 10c2f56e639a..a5412b6cb9aa 100644 --- a/ui/src/components/wizard-step-controls.ts +++ b/ui/src/components/wizard-step-controls.ts @@ -17,13 +17,31 @@ type WizardStepControlsProps = { inputId: string; onValueChange: (value: unknown) => void; onAnswer: (value: unknown, includeValue?: boolean) => void; + presentation?: "channels"; + answerLabel?: string; }; -function renderMessage(step: WizardStep) { - return step.message ? html`
${step.message}
` : nothing; +function stepClass(props: WizardStepControlsProps, name: string): string { + return `${props.presentation === "channels" ? "channels-wizard" : "wizard-step"}__${name}`; } -function renderOptionBody(option: WizardStepOption) { +function renderMessage(props: WizardStepControlsProps) { + return props.step.message + ? html`
${props.step.message}
` + : nothing; +} + +function renderOptionBody(option: WizardStepOption, presentation?: "channels", selected?: boolean) { + if (presentation === "channels") { + return html` + + ${selected === undefined ? nothing : selected ? "☑ " : "☐ "}${option.label} + + ${option.hint + ? html`${option.hint}` + : nothing} + `; + } return html` ${option.label} @@ -59,32 +77,95 @@ function renderDeviceCode(step: WizardStep) { `; } +function renderAnswerButton( + props: WizardStepControlsProps, + label: string, + onClick?: () => void, + disabled = props.busy, +) { + const button = html` + + `; + return props.presentation === "channels" + ? html`` + : button; +} + +function renderOption( + props: WizardStepControlsProps, + option: WizardStepOption, + index: number, + selected: unknown[], +) { + const checked = selected.some((value) => Object.is(value, option.value)); + if (props.presentation === "channels") { + return props.step.type === "select" + ? html` + ${renderOptionBody(option, props.presentation)} + ` + : html``; + } + return html``; +} + function renderContinueStep(props: WizardStepControlsProps) { const step = props.step; return html` - ${renderMessage(step)} + ${renderMessage(props)} ${step.externalUrl ? html` ${t("modelSetup.wizard.openSignIn")} ` : nothing} ${renderDeviceCode(step)} - + ${renderAnswerButton(props, t("modelSetup.wizard.continue"), () => + props.onAnswer(undefined, false), + )} `; } -function renderProgressStep(step: WizardStep) { +function renderProgressStep(props: WizardStepControlsProps) { return html`
- ${renderMessage(step)} + ${renderMessage(props)}
`; } @@ -97,11 +178,14 @@ function renderTextStep(props: WizardStepControlsProps) { class="wizard-step__form" @submit=${(event: Event) => { event.preventDefault(); - props.onAnswer(value); + const input = (event.currentTarget as HTMLFormElement).elements.namedItem( + "wizard-text", + ) as HTMLInputElement | null; + props.onAnswer(props.presentation === "channels" ? (input?.value ?? "") : value); }} > ${step.message - ? html`
+ ? html`
` : nothing} @@ -115,101 +199,74 @@ function renderTextStep(props: WizardStepControlsProps) { .value=${value} ?disabled=${props.busy} @input=${(event: Event) => + props.presentation !== "channels" && props.onValueChange((event.currentTarget as HTMLInputElement).value)} /> - + ${renderAnswerButton(props, t("modelSetup.wizard.submit"))} `; } -function renderSelectStep(props: WizardStepControlsProps) { +function renderOptionsStep(props: WizardStepControlsProps) { + const options = props.step.options ?? []; + const multiple = props.step.type === "multiselect"; + const selected = multiple ? (Array.isArray(props.value) ? props.value : []) : [props.value]; + if (props.presentation === "channels" && !multiple) { + const selectedIndex = options.findIndex((option) => Object.is(option.value, props.value)); + return html` + = 0 ? String(selectedIndex) : null} + ?disabled=${props.busy} + @change=${(event: Event) => { + const index = (event.currentTarget as HTMLElement & { value?: string | number | null }) + .value; + const option = options[Number(index)]; + if (option) { + props.onAnswer(option.value); + } + }} + > + ${options.map((option, index) => renderOption(props, option, index, selected))} + + `; + } + const answer = multiple + ? props.presentation === "channels" + ? [...selected] + : selected + : props.value; return html` - ${renderMessage(props.step)} -
- ${(props.step.options ?? []).map( - (option) => html` - - `, - )} + ${renderMessage(props)} +
+ ${options.map((option, index) => renderOption(props, option, index, selected))}
- + ${renderAnswerButton( + props, + t("modelSetup.wizard.continue"), + () => props.onAnswer(answer), + props.busy || (!multiple && props.value === undefined), + )} `; } function renderConfirmStep(props: WizardStepControlsProps) { return html` - ${renderMessage(props.step)} -
- - -
- `; -} - -function renderMultiselectStep(props: WizardStepControlsProps) { - const selected = Array.isArray(props.value) ? props.value : []; - return html` - ${renderMessage(props.step)} -
- ${(props.step.options ?? []).map( - (option) => html` - - `, + ${renderMessage(props)} +
+ ${[false, true].map( + (answer) => html``, )}
- `; } @@ -225,14 +282,13 @@ export function renderWizardStepControls( case "text": return renderTextStep(props); case "select": - return renderSelectStep(props); + case "multiselect": + return renderOptionsStep(props); case "confirm": return renderConfirmStep(props); - case "multiselect": - return renderMultiselectStep(props); case "progress": return props.step.executor === "gateway" - ? renderProgressStep(props.step) + ? renderProgressStep(props) : renderContinueStep(props); // These show whatever the step supplies behind a single Continue. case "note": diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index b8a8572221db..086bf1e2fe2c 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -106,4 +106,146 @@ describeControlUiE2e("Control UI WhatsApp logout mocked Gateway E2E", () => { await context.close(); } }); + + it("preserves standard channel details and the complete Telegram setup wizard", async () => { + const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const channelEntries = [ + ["discord", "Discord"], + ["googlechat", "Google Chat"], + ["imessage", "iMessage"], + ["signal", "Signal"], + ["slack", "Slack"], + ["telegram", "Telegram"], + ] as const; + const running = { configured: true, running: true }; + const details: Record> = { + googlechat: { + credentialSource: "service-account", + audienceType: "url", + audience: "https://chat.example.test", + }, + signal: { baseUrl: "https://signal.example.test" }, + }; + const bot = (accountId: string, username: string) => ({ + accountId, + ...running, + probe: { bot: { username } }, + }); + const step = (id: string, type: string, values: Record = {}) => ({ + done: false, + status: "running", + step: { id, type, ...values }, + }); + const gateway = await installMockGateway(page, { + featureMethods: ["channels.status", "channels.pairing.list", "wizard.start", "wizard.next"], + methodResponses: { + "channels.status": { + ts: Date.now(), + channelOrder: channelEntries.map(([id]) => id), + channelLabels: Object.fromEntries(channelEntries), + channelMeta: channelEntries.map(([id, label]) => ({ id, label })), + channels: Object.fromEntries( + channelEntries.map(([id]) => [id, { ...running, ...details[id] }]), + ), + channelAccounts: { telegram: [bot("personal", "alpha_bot"), bot("work", "work_bot")] }, + channelDefaultAccountId: { telegram: "personal" }, + }, + "channels.pairing.list": { + accounts: [], + requests: [], + commandOwnerConfigured: true, + limits: { pendingPerAccount: 3, ttlMs: 3_600_000 }, + }, + "wizard.start": { + sessionId: "channel-standard-proof", + ...step("account", "select", { + message: "Choose Telegram account", + initialValue: "personal", + options: ["personal", "work"].map((value) => ({ + value, + label: value === "work" ? "Work bot" : "Personal bot", + })), + }), + }, + "wizard.next": { + sequence: [ + step("token", "text", { message: "Telegram bot token", sensitive: true }), + step("features", "multiselect", { + initialValue: ["alpha"], + options: ["alpha", "beta"].map((value) => ({ + value, + label: value === "alpha" ? "Alpha" : "Beta", + })), + }), + step("confirm", "confirm", { message: "Apply Telegram settings?" }), + step("progress", "progress", { executor: "gateway", message: "Finish preparation" }), + { done: true, status: "done", channels: ["telegram"], accounts: [] }, + ], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}settings/channels`); + const expectedFields: Record = { + googlechat: ["service-account", "url · https://chat.example.test"], + signal: ["https://signal.example.test"], + telegram: ["@alpha_bot", "@work_bot", "2"], + }; + for (const [channelId, label] of channelEntries) { + await page.locator(".channels-item", { hasText: label }).first().click(); + const detail = page.locator(".channels-detail"); + await expect + .poll(() => detail.locator("h2.settings-section__heading").textContent()) + .toContain(label); + await detail.getByRole("button", { name: "Probe" }).waitFor(); + for (const value of expectedFields[channelId] ?? []) { + await detail.getByText(value, { exact: true }).waitFor(); + } + if (channelId !== "telegram") { + await detail.getByRole("button", { name: "Close" }).click(); + } + } + + await page.locator(".channels-detail").getByRole("button", { name: "Run setup" }).click(); + const wizard = page.locator(".channels-wizard"); + await gateway.deferNext("wizard.next"); + await wizard.getByRole("radio", { name: "Work bot" }).click(); + await expect.poll(async () => gateway.getRequests("wizard.next")).toHaveLength(1); + await expect + .poll(() => wizard.locator("wa-radio-group").getAttribute("disabled")) + .not.toBeNull(); + await gateway.resolveDeferred("wizard.next"); + + const token = wizard.getByLabel("Telegram bot token"); + await expect.poll(() => token.getAttribute("type")).toBe("password"); + await token.fill("123456:proof-secret"); + await wizard.getByRole("button", { name: "Continue" }).click(); + const beta = wizard.getByRole("button", { name: /Beta/u }); + await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("false"); + await beta.click(); + await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("true"); + await wizard.getByRole("button", { name: "Continue" }).click(); + await wizard.getByRole("button", { name: "Yes" }).click(); + await wizard.getByRole("button", { name: "Continue" }).click(); + await wizard.getByRole("button", { name: "Finish" }).waitFor(); + + const answers = [ + ["account", "work"], + ["token", "123456:proof-secret"], + ["features", ["alpha", "beta"]], + ["confirm", true], + ["progress", null], + ] as const; + expect((await gateway.getRequests("wizard.next")).map(({ params }) => params)).toEqual( + answers.map(([stepId, value]) => ({ + sessionId: "channel-standard-proof", + answer: { stepId, value }, + })), + ); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/lib/channels/index.ts b/ui/src/lib/channels/index.ts index ee0a5b188267..68cf7e7f94be 100644 --- a/ui/src/lib/channels/index.ts +++ b/ui/src/lib/channels/index.ts @@ -1,6 +1,7 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { roleScopesAllow } from "../../../../src/shared/operator-scope-compat.ts"; import type { + ChannelAccountSnapshot, ChannelsPairingApproveResult, ChannelsPairingListResult, ChannelsStatusSnapshot, @@ -81,6 +82,15 @@ export type ChannelCapability = { dispose: () => void; }; +export function resolveChannelAccounts( + channelAccounts: ChannelsStatusSnapshot["channelAccounts"] | null | undefined, + channelId: string, +): ChannelAccountSnapshot[] { + const accounts = + channelAccounts && Object.hasOwn(channelAccounts, channelId) && channelAccounts[channelId]; + return Array.isArray(accounts) ? accounts : []; +} + export function channelSnapshotEntryIsActive( snapshot: ChannelsStatusSnapshot | null, channelId: string, @@ -88,11 +98,13 @@ export function channelSnapshotEntryIsActive( if (!snapshot) { return false; } - const status = asRecord(snapshot.channels[channelId]); + const status = asRecord( + Object.hasOwn(snapshot.channels, channelId) ? snapshot.channels[channelId] : undefined, + ); if (status?.configured === true || status?.running === true || status?.connected === true) { return true; } - return (snapshot.channelAccounts[channelId] ?? []).some( + return resolveChannelAccounts(snapshot.channelAccounts, channelId).some( (account) => account.configured === true || account.running === true || account.connected === true, ); @@ -159,9 +171,9 @@ function createInitialChannelsState(snapshot: Partial = }; } -function delay(ms: number): Promise<"timeout"> { +function delay(ms: number): Promise { return new Promise((resolve) => { - setTimeout(() => resolve("timeout"), ms); + setTimeout(resolve, ms); }); } @@ -220,14 +232,9 @@ async function loadChannels( })(); const softTimeoutMs = options.softTimeoutMs; - if (typeof softTimeoutMs === "number" && softTimeoutMs > 0) { - const outcome = await Promise.race([refresh.then(() => "done" as const), delay(softTimeoutMs)]); - if (outcome === "timeout") { - return; - } - return; - } - await refresh; + await (typeof softTimeoutMs === "number" && softTimeoutMs > 0 + ? Promise.race([refresh, delay(softTimeoutMs)]) + : refresh); } function isCurrentPairingRefresh( diff --git a/ui/src/pages/channels/view.detail.ts b/ui/src/pages/channels/view.detail.ts index 5108524cf93e..84d565f87d69 100644 --- a/ui/src/pages/channels/view.detail.ts +++ b/ui/src/pages/channels/view.detail.ts @@ -1,32 +1,172 @@ // Channel detail overlay: full status + advanced schema config form for one // channel, reusing the per-channel settings-language renderers. +import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type TemplateResult } from "lit"; -import type { ChannelAccountSnapshot, NostrProfile } from "../../api/types.ts"; +import type { NostrProfile } from "../../api/types.ts"; import { renderSettingsSection } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; +import { resolveChannelAccounts } from "../../lib/channels/index.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; import { channelDocsUrl, renderChannelArt } from "./hub-meta.ts"; import { renderChannelConfigSection } from "./view.config.ts"; -import { renderDiscordCard } from "./view.discord.ts"; -import { renderGoogleChatCard } from "./view.googlechat.ts"; -import { renderIMessageCard } from "./view.imessage.ts"; import { renderNostrCard } from "./view.nostr.ts"; import { renderChannelPairingDetail } from "./view.pairing.ts"; import { boolStatusKind, formatNullableBoolean, renderChannelAccountRow, + renderChannelActionRow, renderChannelErrorRow, renderChannelFacts, + renderChannelProbeRow, resolveChannelAccountCount, resolveChannelDisplayState, } from "./view.shared.ts"; -import { renderSignalCard } from "./view.signal.ts"; -import { renderSlackCard } from "./view.slack.ts"; -import { renderTelegramCard } from "./view.telegram.ts"; import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./view.types.ts"; import { renderWhatsAppCard } from "./view.whatsapp.ts"; +const STANDARD_CHANNEL_LOCALE_KEYS = { + discord: "discord", + googlechat: "googleChat", + imessage: "imessage", + signal: "signal", + slack: "slack", + telegram: "telegram", +} as const; + +type StandardChannelKey = keyof typeof STANDARD_CHANNEL_LOCALE_KEYS; + +function isStandardChannel(key: ChannelKey): key is StandardChannelKey { + return Object.hasOwn(STANDARD_CHANNEL_LOCALE_KEYS, key); +} + +function renderChannelStatusBody( + key: ChannelKey, + props: ChannelsProps, + data: ChannelsChannelData, + accountCount: number | undefined, +) { + const standardKey = isStandardChannel(key) ? key : null; + const localeKey = standardKey ? STANDARD_CHANNEL_LOCALE_KEYS[standardKey] : null; + const status = standardKey ? data[standardKey] : undefined; + const displayState = resolveChannelDisplayState(key, props); + const configured = displayState.configured; + const accounts = resolveChannelAccounts(data.channelAccounts, key); + const showAccounts = + standardKey === "telegram" ? accounts.length > 1 : !standardKey && accounts.length > 0; + const extraRows = + standardKey === "googlechat" + ? [ + { + label: t("common.credential"), + value: data.googlechat?.credentialSource ?? t("common.na"), + }, + { + label: t("common.audience"), + value: data.googlechat?.audienceType + ? `${data.googlechat.audienceType}${data.googlechat.audience ? ` · ${data.googlechat.audience}` : ""}` + : t("common.na"), + }, + ] + : standardKey === "signal" + ? [{ label: t("common.baseUrl"), value: data.signal?.baseUrl ?? t("common.na") }] + : standardKey === "telegram" + ? [{ label: t("common.mode"), value: data.telegram?.mode ?? t("common.na") }] + : []; + const statusRows = [ + { + label: t("common.configured"), + value: formatNullableBoolean(configured), + kind: boolStatusKind(configured), + }, + { + label: t("common.running"), + value: !standardKey + ? formatNullableBoolean(displayState.running) + : standardKey === "googlechat" && !status + ? t("common.na") + : formatNullableBoolean(status?.running ?? false), + kind: boolStatusKind(standardKey ? status?.running : displayState.running), + }, + ...(standardKey + ? [ + ...extraRows, + ...(["lastStartAt", "lastProbeAt"] as const).map((field) => ({ + label: t(field === "lastStartAt" ? "common.lastStart" : "common.lastProbe"), + value: status?.[field] ? formatRelativeTimestamp(status[field]) : t("common.na"), + })), + ] + : [ + { + label: t("common.connected"), + value: formatNullableBoolean(displayState.connected), + kind: boolStatusKind(displayState.connected), + }, + ]), + ]; + const lastError = readStringField( + asNullableRecord(standardKey ? status : displayState.status), + "lastError", + ); + + return renderSettingsSection( + { + title: localeKey + ? t(`channels.${localeKey}.title`) + : (readStringField(props.snapshot?.channelLabels, key) ?? key), + description: localeKey ? t(`channels.${localeKey}.subtitle`) : t("channels.generic.subtitle"), + ...(accountCount !== undefined ? { count: accountCount } : {}), + }, + html` + ${showAccounts + ? accounts.map((account) => { + const username = + standardKey === "telegram" + ? readStringField( + asNullableRecord(asNullableRecord(account.probe)?.bot), + "username", + ) + : undefined; + return renderChannelAccountRow({ + title: username ? `@${username}` : account.name || account.accountId, + accountId: account.accountId, + ...(standardKey === "telegram" + ? { + facts: [ + `${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`, + ], + } + : {}), + status: { + kind: boolStatusKind( + standardKey === "telegram" + ? account.running + : (account.running ?? account.configured), + ), + label: account.running + ? t("common.running") + : !standardKey && account.configured + ? t("common.configured") + : t("common.no"), + }, + lastInboundAt: account.lastInboundAt, + lastError: account.lastError, + }); + }) + : renderChannelFacts(statusRows)} + ${lastError ? renderChannelErrorRow(lastError) : nothing} + ${standardKey && status?.probe ? renderChannelProbeRow(status.probe) : nothing} + ${renderChannelConfigSection({ channelId: key, props })} + ${standardKey + ? renderChannelActionRow(html``) + : nothing} + `, + ); +} + function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) { const accountCount = resolveChannelAccountCount(key, data.channelAccounts); switch (key) { @@ -36,45 +176,8 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels whatsapp: data.whatsapp, accountCount, }); - case "telegram": - return renderTelegramCard({ - props, - telegram: data.telegram, - telegramAccounts: data.channelAccounts?.telegram ?? [], - accountCount, - }); - case "discord": - return renderDiscordCard({ - props, - discord: data.discord, - accountCount, - }); - case "googlechat": - return renderGoogleChatCard({ - props, - googleChat: data.googlechat, - accountCount, - }); - case "slack": - return renderSlackCard({ - props, - slack: data.slack, - accountCount, - }); - case "signal": - return renderSignalCard({ - props, - signal: data.signal, - accountCount, - }); - case "imessage": - return renderIMessageCard({ - props, - imessage: data.imessage, - accountCount, - }); case "nostr": { - const nostrAccounts = data.channelAccounts?.nostr ?? []; + const nostrAccounts = resolveChannelAccounts(data.channelAccounts, "nostr"); const primaryAccount = nostrAccounts[0]; const accountId = primaryAccount?.accountId ?? "default"; const profile = @@ -101,69 +204,10 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels }); } default: - return renderGenericChannelBody(key, props, data.channelAccounts ?? {}); + return renderChannelStatusBody(key, props, data, accountCount); } } -function renderGenericChannelBody( - key: ChannelKey, - props: ChannelsProps, - channelAccounts: Record, -) { - const label = props.snapshot?.channelLabels?.[key] ?? key; - const displayState = resolveChannelDisplayState(key, props); - const lastError = - typeof displayState.status?.lastError === "string" ? displayState.status.lastError : undefined; - const accounts = channelAccounts[key] ?? []; - const accountCount = resolveChannelAccountCount(key, channelAccounts); - - return renderSettingsSection( - { - title: label, - description: t("channels.generic.subtitle"), - ...(accountCount !== undefined ? { count: accountCount } : {}), - }, - html` - ${accounts.length > 0 - ? accounts.map((account) => - renderChannelAccountRow({ - title: account.name || account.accountId, - accountId: account.accountId, - status: { - kind: boolStatusKind(account.running ?? account.configured), - label: account.running - ? t("common.running") - : account.configured - ? t("common.configured") - : t("common.no"), - }, - lastInboundAt: account.lastInboundAt, - lastError: account.lastError, - }), - ) - : renderChannelFacts([ - { - label: t("common.configured"), - value: formatNullableBoolean(displayState.configured), - kind: boolStatusKind(displayState.configured), - }, - { - label: t("common.running"), - value: formatNullableBoolean(displayState.running), - kind: boolStatusKind(displayState.running), - }, - { - label: t("common.connected"), - value: formatNullableBoolean(displayState.connected), - kind: boolStatusKind(displayState.connected), - }, - ])} - ${lastError ? renderChannelErrorRow(lastError) : nothing} - ${renderChannelConfigSection({ channelId: key, props })} - `, - ); -} - export function renderChannelDetail(params: { channelId: string; label: string; diff --git a/ui/src/pages/channels/view.discord.ts b/ui/src/pages/channels/view.discord.ts deleted file mode 100644 index 78d6047bd467..000000000000 --- a/ui/src/pages/channels/view.discord.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Channels page renders Discord status. -import { html, nothing } from "lit"; -import type { DiscordStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderDiscordCard(params: { - props: ChannelsProps; - discord?: DiscordStatus | null; - accountCount?: number; -}) { - const { props, discord, accountCount } = params; - const configured = resolveChannelConfigured("discord", props); - - return renderSingleAccountChannelCard({ - title: t("channels.discord.title"), - subtitle: t("channels.discord.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: discord?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(discord?.running), - }, - { - label: t("common.lastStart"), - value: discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : t("common.na"), - }, - ], - lastError: discord?.lastError, - secondaryCallout: discord?.probe ? renderChannelProbeRow(discord.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "discord", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.googlechat.ts b/ui/src/pages/channels/view.googlechat.ts deleted file mode 100644 index 2f06c0fadb8d..000000000000 --- a/ui/src/pages/channels/view.googlechat.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Channels page renders Google Chat status. -import { html, nothing } from "lit"; -import type { GoogleChatStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderGoogleChatCard(params: { - props: ChannelsProps; - googleChat?: GoogleChatStatus | null; - accountCount?: number; -}) { - const { props, googleChat, accountCount } = params; - const configured = resolveChannelConfigured("googlechat", props); - - return renderSingleAccountChannelCard({ - title: t("channels.googleChat.title"), - subtitle: t("channels.googleChat.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: googleChat - ? googleChat.running - ? t("common.yes") - : t("common.no") - : t("common.na"), - kind: boolStatusKind(googleChat?.running), - }, - { label: t("common.credential"), value: googleChat?.credentialSource ?? t("common.na") }, - { - label: t("common.audience"), - value: googleChat?.audienceType - ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` - : t("common.na"), - }, - { - label: t("common.lastStart"), - value: googleChat?.lastStartAt - ? formatRelativeTimestamp(googleChat.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: googleChat?.lastProbeAt - ? formatRelativeTimestamp(googleChat.lastProbeAt) - : t("common.na"), - }, - ], - lastError: googleChat?.lastError, - secondaryCallout: googleChat?.probe ? renderChannelProbeRow(googleChat.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "googlechat", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.imessage.ts b/ui/src/pages/channels/view.imessage.ts deleted file mode 100644 index ad4e05169054..000000000000 --- a/ui/src/pages/channels/view.imessage.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Channels page renders iMessage status. -import { html, nothing } from "lit"; -import type { IMessageStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderIMessageCard(params: { - props: ChannelsProps; - imessage?: IMessageStatus | null; - accountCount?: number; -}) { - const { props, imessage, accountCount } = params; - const configured = resolveChannelConfigured("imessage", props); - - return renderSingleAccountChannelCard({ - title: t("channels.imessage.title"), - subtitle: t("channels.imessage.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: imessage?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(imessage?.running), - }, - { - label: t("common.lastStart"), - value: imessage?.lastStartAt - ? formatRelativeTimestamp(imessage.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: imessage?.lastProbeAt - ? formatRelativeTimestamp(imessage.lastProbeAt) - : t("common.na"), - }, - ], - lastError: imessage?.lastError, - secondaryCallout: imessage?.probe ? renderChannelProbeRow(imessage.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "imessage", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.shared.ts b/ui/src/pages/channels/view.shared.ts index 54c3342980a8..40fc8bfd8a41 100644 --- a/ui/src/pages/channels/view.shared.ts +++ b/ui/src/pages/channels/view.shared.ts @@ -1,9 +1,10 @@ // Channels page shared view helpers. +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; import type { ChannelAccountSnapshot } from "../../api/types.ts"; import { renderSettingsSection, renderSettingsStatus } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; -import { channelSnapshotEntryIsActive } from "../../lib/channels/index.ts"; +import { channelSnapshotEntryIsActive, resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; import type { ChannelKey, ChannelsProps } from "./view.types.ts"; @@ -28,16 +29,20 @@ function resolveChannelStatus( key: ChannelKey, props: ChannelsProps, ): Record | undefined { - const channels = props.snapshot?.channels as Record | null; - return channels?.[key] as Record | undefined; + const channels = props.snapshot?.channels; + return channels && Object.hasOwn(channels, key) + ? (asNullableRecord(channels[key]) ?? undefined) + : undefined; } function resolveDefaultChannelAccount( key: ChannelKey, props: ChannelsProps, ): ChannelAccountSnapshot | null { - const accounts = props.snapshot?.channelAccounts?.[key] ?? []; - const defaultAccountId = props.snapshot?.channelDefaultAccountId?.[key]; + const accounts = resolveChannelAccounts(props.snapshot?.channelAccounts, key); + const defaultAccountIds = props.snapshot?.channelDefaultAccountId; + const defaultAccountId = + defaultAccountIds && Object.hasOwn(defaultAccountIds, key) ? defaultAccountIds[key] : undefined; return ( (defaultAccountId ? accounts.find((account) => account.accountId === defaultAccountId) @@ -219,18 +224,11 @@ export function renderSingleAccountChannelCard(params: { ); } -function getChannelAccountCount( - key: ChannelKey, - channelAccounts?: Record | null, -): number { - return channelAccounts?.[key]?.length ?? 0; -} - /** Multi-account channels surface the account count next to the heading. */ export function resolveChannelAccountCount( key: ChannelKey, channelAccounts?: Record | null, ): number | undefined { - const count = getChannelAccountCount(key, channelAccounts); + const count = resolveChannelAccounts(channelAccounts, key).length; return count >= 2 ? count : undefined; } diff --git a/ui/src/pages/channels/view.signal.ts b/ui/src/pages/channels/view.signal.ts deleted file mode 100644 index e94b32de2b5f..000000000000 --- a/ui/src/pages/channels/view.signal.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Channels page renders Signal status. -import { html, nothing } from "lit"; -import type { SignalStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderSignalCard(params: { - props: ChannelsProps; - signal?: SignalStatus | null; - accountCount?: number; -}) { - const { props, signal, accountCount } = params; - const configured = resolveChannelConfigured("signal", props); - - return renderSingleAccountChannelCard({ - title: t("channels.signal.title"), - subtitle: t("channels.signal.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: signal?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(signal?.running), - }, - { label: t("common.baseUrl"), value: signal?.baseUrl ?? t("common.na") }, - { - label: t("common.lastStart"), - value: signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : t("common.na"), - }, - ], - lastError: signal?.lastError, - secondaryCallout: signal?.probe ? renderChannelProbeRow(signal.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "signal", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.slack.ts b/ui/src/pages/channels/view.slack.ts deleted file mode 100644 index 4c9457af36ac..000000000000 --- a/ui/src/pages/channels/view.slack.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Channels page renders Slack status. -import { html, nothing } from "lit"; -import type { SlackStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderSlackCard(params: { - props: ChannelsProps; - slack?: SlackStatus | null; - accountCount?: number; -}) { - const { props, slack, accountCount } = params; - const configured = resolveChannelConfigured("slack", props); - - return renderSingleAccountChannelCard({ - title: t("channels.slack.title"), - subtitle: t("channels.slack.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: slack?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(slack?.running), - }, - { - label: t("common.lastStart"), - value: slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : t("common.na"), - }, - ], - lastError: slack?.lastError, - secondaryCallout: slack?.probe ? renderChannelProbeRow(slack.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "slack", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.telegram.ts b/ui/src/pages/channels/view.telegram.ts deleted file mode 100644 index 2a32597e39c3..000000000000 --- a/ui/src/pages/channels/view.telegram.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Channels page renders Telegram status. -import { html, nothing } from "lit"; -import type { ChannelAccountSnapshot, TelegramStatus } from "../../api/types.ts"; -import { renderSettingsSection } from "../../components/settings-ui.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelAccountRow, - renderChannelActionRow, - renderChannelErrorRow, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderTelegramCard(params: { - props: ChannelsProps; - telegram?: TelegramStatus; - telegramAccounts: ChannelAccountSnapshot[]; - accountCount?: number; -}) { - const { props, telegram, telegramAccounts, accountCount } = params; - const hasMultipleAccounts = telegramAccounts.length > 1; - const configured = resolveChannelConfigured("telegram", props); - - const renderAccountRow = (account: ChannelAccountSnapshot) => { - const probe = account.probe as { bot?: { username?: string } } | undefined; - const botUsername = probe?.bot?.username; - const label = account.name || account.accountId; - return renderChannelAccountRow({ - title: botUsername ? `@${botUsername}` : label, - accountId: account.accountId, - facts: [ - `${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`, - ], - status: { - kind: boolStatusKind(account.running), - label: account.running ? t("common.running") : t("common.no"), - }, - lastInboundAt: account.lastInboundAt, - lastError: account.lastError, - }); - }; - - if (hasMultipleAccounts) { - return renderSettingsSection( - { - title: t("channels.telegram.title"), - description: t("channels.telegram.subtitle"), - ...(accountCount !== undefined ? { count: accountCount } : {}), - }, - html` - ${telegramAccounts.map((account) => renderAccountRow(account))} - ${telegram?.lastError ? renderChannelErrorRow(telegram.lastError) : nothing} - ${telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing} - ${renderChannelConfigSection({ channelId: "telegram", props })} - ${renderChannelActionRow( - html``, - )} - `, - ); - } - - return renderSingleAccountChannelCard({ - title: t("channels.telegram.title"), - subtitle: t("channels.telegram.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: telegram?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(telegram?.running), - }, - { label: t("common.mode"), value: telegram?.mode ?? t("common.na") }, - { - label: t("common.lastStart"), - value: telegram?.lastStartAt - ? formatRelativeTimestamp(telegram.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: telegram?.lastProbeAt - ? formatRelativeTimestamp(telegram.lastProbeAt) - : t("common.na"), - }, - ], - lastError: telegram?.lastError, - secondaryCallout: telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "telegram", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.test.ts b/ui/src/pages/channels/view.test.ts index 6c3386f7878c..5285dfea8a94 100644 --- a/ui/src/pages/channels/view.test.ts +++ b/ui/src/pages/channels/view.test.ts @@ -8,7 +8,8 @@ import { resolveChannelConfigured, resolveChannelDisplayState, } from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; +import { renderChannels } from "./view.ts"; +import type { ChannelsChannelData, ChannelsProps } from "./view.types.ts"; import { renderWhatsAppCard } from "./view.whatsapp.ts"; function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps { @@ -122,6 +123,40 @@ function renderWhatsAppButtons(params: { }; } +function renderChannelDetailFixture( + channelId: string, + data: ChannelsChannelData, + options: { label?: string; onRefresh?: ChannelsProps["onRefresh"] } = {}, +) { + const status = Object.entries(data).find(([key]) => key === channelId)?.[1] ?? {}; + const channelAccounts = data.channelAccounts ?? {}; + const accounts = Object.hasOwn(channelAccounts, channelId) ? channelAccounts[channelId] : []; + const props = createProps({ + ts: Date.now(), + channelOrder: [channelId], + channelLabels: { [channelId]: options.label ?? channelId }, + channels: { [channelId]: status }, + channelAccounts, + channelDefaultAccountId: accounts?.length ? { [channelId]: accounts[0]!.accountId } : {}, + }); + if (options.onRefresh) { + props.onRefresh = options.onRefresh; + } + const container = document.createElement("div"); + render( + renderChannelDetail({ + channelId, + label: options.label ?? channelId, + props, + data: { ...data, channelAccounts }, + onClose: () => {}, + onSetup: () => {}, + }), + container, + ); + return container; +} + // Mirrors the tiers the gateway materializes on every channel schema path. const CHANNEL_TIER_SCHEMA = { type: "object", @@ -257,6 +292,103 @@ describe("channel detail", () => { expect(docs?.href).toBe("https://docs.openclaw.ai/channels/telegram"); expect(docs?.textContent?.trim()).toBe("Docs"); }); + + it.each([ + ["discord", "Discord", []], + ["slack", "Slack", []], + ["signal", "Signal", [["Base URL", "https://signal.example"]]], + ["imessage", "iMessage", []], + [ + "googlechat", + "Google Chat", + [ + ["Credential", "service-account"], + ["Audience", "url · https://chat.example"], + ], + ], + ["telegram", "Telegram", [["Mode", "polling"]]], + ] satisfies Array<[string, string, Array<[string, string]>]>)( + "preserves localized status facts and probe actions for %s", + (channelId, title, extraFacts) => { + const onRefresh = vi.fn(); + const status = { + configured: true, + running: true, + baseUrl: "https://signal.example", + credentialSource: "service-account", + audienceType: "url", + audience: "https://chat.example", + mode: "polling", + }; + const data: ChannelsChannelData = { channelAccounts: {}, [channelId]: status }; + const container = renderChannelDetailFixture(channelId, data, { onRefresh }); + const facts = Array.from(container.querySelectorAll("dt"), (node) => [ + node.textContent?.trim(), + node.nextElementSibling?.textContent?.trim(), + ]); + + expect(container.querySelector(".settings-section__heading")?.textContent?.trim()).toBe( + title, + ); + expect(facts).toEqual([ + ["Configured", "Yes"], + ["Running", "Yes"], + ...extraFacts, + ["Last start", "n/a"], + ["Last probe", "n/a"], + ]); + container.querySelector(".settings-row--actions button")!.click(); + expect(onRefresh).toHaveBeenCalledWith(true); + }, + ); + + it("keeps missing Google Chat status unknown while other known channels are stopped", () => { + const google = renderChannelDetailFixture("googlechat", { googlechat: null }); + const discord = renderChannelDetailFixture("discord", { discord: null }); + const fact = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll("dt")) + .find((node) => node.textContent?.trim() === label) + ?.nextElementSibling?.textContent?.trim(); + + expect(fact(google, "Running")).toBe("n/a"); + expect(fact(discord, "Running")).toBe("No"); + }); + + it.each(["guildchat", "constructor", "__proto__"])( + "opens accountless plugin %s from its actual hub row without inherited account values", + (channelId) => { + for (const configured of [false, true]) { + const props = createProps({ + ts: Date.now(), + channelOrder: [channelId], + channelLabels: { [channelId]: "Custom channel" }, + channels: { [channelId]: { configured, running: configured } }, + channelAccounts: {}, + channelDefaultAccountId: {}, + }); + const container = document.createElement("div"); + props.onShowDetail = (selected) => { + props.selectedChannel = selected; + render(renderChannels(props), container); + }; + render(renderChannels(props), container); + const trigger = container.querySelector( + configured ? "button.channels-item" : ".channels-item__detail", + ); + + expect(trigger).toBeInstanceOf(HTMLButtonElement); + trigger!.click(); + const detail = container.querySelector(".channels-detail"); + expect(detail?.querySelector(".settings-section__heading")?.textContent?.trim()).toBe( + "Custom channel", + ); + expect(detail?.textContent).toContain("Channel status and configuration."); + expect( + Array.from(detail!.querySelectorAll("dt"), (node) => node.textContent?.trim()), + ).toEqual(["Configured", "Running", "Connected"]); + } + }, + ); }); describe("channel display selectors", () => { diff --git a/ui/src/pages/channels/view.ts b/ui/src/pages/channels/view.ts index 2276f10e0842..0e9caaf5c992 100644 --- a/ui/src/pages/channels/view.ts +++ b/ui/src/pages/channels/view.ts @@ -3,9 +3,7 @@ import { html, nothing } from "lit"; import "../../styles/channels.css"; import type { - ChannelAccountSnapshot, ChannelsStatusSnapshot, - ChannelUiMetaEntry, DiscordStatus, GoogleChatStatus, IMessageStatus, @@ -24,6 +22,7 @@ import { renderSettingsStatus, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; +import { resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; import { renderChannelArt } from "./hub-meta.ts"; import { renderChannelDetail } from "./view.detail.ts"; @@ -156,26 +155,23 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"]; } -function resolveChannelMetaMap( - snapshot: ChannelsStatusSnapshot | null, -): Record { - if (!snapshot?.channelMeta?.length) { - return {}; - } - return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); -} - function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string { - const meta = resolveChannelMetaMap(snapshot)[key]; - return meta?.label ?? snapshot?.channelLabels?.[key] ?? key; + const labels = snapshot?.channelLabels; + return ( + snapshot?.channelMeta?.find((entry) => entry.id === key)?.label ?? + (labels && Object.hasOwn(labels, key) ? labels[key] : undefined) ?? + key + ); } function resolveChannelDetailLabel( snapshot: ChannelsStatusSnapshot | null, key: string, ): string | null { - const meta = resolveChannelMetaMap(snapshot)[key]; - const detail = meta?.detailLabel ?? snapshot?.channelDetailLabels?.[key] ?? null; + const labels = snapshot?.channelDetailLabels; + const detail = + snapshot?.channelMeta?.find((entry) => entry.id === key)?.detailLabel ?? + (labels && Object.hasOwn(labels, key) ? labels[key] : null); return detail && detail !== resolveChannelLabel(snapshot, key) ? detail : null; } @@ -184,8 +180,9 @@ function resolveRowState(key: ChannelKey, props: ChannelsProps): ChannelCardStat const lastError = typeof displayState.status?.lastError === "string" && displayState.status.lastError.trim() ? displayState.status.lastError - : (props.snapshot?.channelAccounts?.[key] ?? []).find((account) => account.lastError) - ?.lastError; + : resolveChannelAccounts(props.snapshot?.channelAccounts, key).find( + (account) => account.lastError, + )?.lastError; if (lastError) { return "attention"; } @@ -209,10 +206,10 @@ function rowStatus(state: ChannelCardState) { } function lastActivityLine(key: ChannelKey, props: ChannelsProps): string | null { - const accounts: ChannelAccountSnapshot[] = props.snapshot?.channelAccounts?.[key] ?? []; - const lastInbound = accounts - .map((account) => account.lastInboundAt ?? 0) - .reduce((a, b) => Math.max(a, b), 0); + const lastInbound = resolveChannelAccounts(props.snapshot?.channelAccounts, key).reduce( + (latest, account) => Math.max(latest, account.lastInboundAt ?? 0), + 0, + ); if (!lastInbound) { return null; } diff --git a/ui/src/pages/channels/wizard-controller.ts b/ui/src/pages/channels/wizard-controller.ts index 2cba18ccbff5..474febd85610 100644 --- a/ui/src/pages/channels/wizard-controller.ts +++ b/ui/src/pages/channels/wizard-controller.ts @@ -38,7 +38,6 @@ async function requestWithTimeout( } } -export type ChannelWizardStepOption = NonNullable[number]; export type ChannelWizardStep = WizardStep; type WizardNextResult = { diff --git a/ui/src/pages/channels/wizard-view.ts b/ui/src/pages/channels/wizard-view.ts index 2feabe472b07..04e1eca53f88 100644 --- a/ui/src/pages/channels/wizard-view.ts +++ b/ui/src/pages/channels/wizard-view.ts @@ -3,15 +3,12 @@ import "@awesome.me/webawesome/dist/components/radio/radio.js"; import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js"; import { html, nothing, type TemplateResult } from "lit"; +import { renderWizardStepControls } from "../../components/wizard-step-controls.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; import { copyToClipboard } from "../../lib/clipboard.ts"; import { channelDocsUrl, channelHubMeta, renderChannelArt } from "./hub-meta.ts"; -import type { - ChannelWizardState, - ChannelWizardStep, - ChannelWizardStepOption, -} from "./wizard-controller.ts"; +import type { ChannelWizardState, ChannelWizardStep } from "./wizard-controller.ts"; type ChannelWizardViewProps = { wizard: ChannelWizardState; @@ -30,10 +27,6 @@ type ChannelWizardViewProps = { onWhatsAppWait: () => void; }; -function stepKeyboardValue(step: ChannelWizardStep): string { - return typeof step.initialValue === "string" ? step.initialValue : ""; -} - function stepIsBusy(props: ChannelWizardViewProps): boolean { return props.wizard.phase === "step" && props.wizard.busy; } @@ -72,151 +65,20 @@ function renderNoteStep(step: ChannelWizardStep, props: ChannelWizardViewProps) `; } -function renderSelectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const options = step.options ?? []; - const selectedIndex = options.findIndex((option) => option.value === step.initialValue); - return html` - = 0 ? String(selectedIndex) : null} - ?disabled=${stepIsBusy(props)} - @change=${(event: Event) => { - const rawIndex = (event.currentTarget as HTMLElement & { value?: string | number | null }) - .value; - const option = options[Number(rawIndex)]; - if (option) { - props.onAnswer(option.value); - } - }} - > - ${options.map( - (option: ChannelWizardStepOption, index) => html` - - ${option.label} - ${option.hint - ? html`${option.hint}` - : nothing} - - `, - )} - - `; -} - -function renderMultiselectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const options = step.options ?? []; - const selected = new Set(props.multiselectValues); - return html` -
${step.message ?? ""}
-
- ${options.map( - (option: ChannelWizardStepOption) => html` - - `, - )} -
- - `; -} - -function renderTextStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const submit = (event: Event) => { - event.preventDefault(); - const form = event.currentTarget as HTMLFormElement; - const input = form.elements.namedItem("wizard-text") as HTMLInputElement | null; - props.onAnswer(input?.value ?? ""); - }; - return html` -
-
- -
- - -
- `; -} - -function renderConfirmStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - return html` -
${step.message ?? ""}
- - `; -} - function renderStepBody(step: ChannelWizardStep, props: ChannelWizardViewProps) { - switch (step.type) { - case "select": - return renderSelectStep(step, props); - case "multiselect": - return renderMultiselectStep(step, props); - case "text": - return renderTextStep(step, props); - case "confirm": - return renderConfirmStep(step, props); - default: - return renderNoteStep(step, props); + if (step.type === "note" || step.type === "progress" || step.type === "action") { + return renderNoteStep(step, props); } + return renderWizardStepControls({ + step, + value: step.type === "multiselect" ? props.multiselectValues : step.initialValue, + busy: stepIsBusy(props), + inputId: "channel-wizard-text-input", + presentation: "channels", + answerLabel: t("channels.setup.continue"), + onValueChange: props.onToggleMultiselect, + onAnswer: props.onAnswer, + }); } function renderWhatsAppLinking(props: ChannelWizardViewProps) { @@ -285,23 +147,17 @@ function renderDoneBody(channels: readonly string[], props: ChannelWizardViewPro if (channels.includes("whatsapp")) { return renderWhatsAppLinking(props); } - if (channels.length === 0) { - return html` -
${t("channels.setup.doneNoChangesTitle")}
-
${t("channels.setup.doneNoChangesBody")}
- - `; - } + const changed = channels.length > 0; return html` -
${t("channels.setup.doneTitle")}
-
${t("channels.setup.doneBody")}
+
+ ${t(changed ? "channels.setup.doneTitle" : "channels.setup.doneNoChangesTitle")} +
+
+ ${t(changed ? "channels.setup.doneBody" : "channels.setup.doneNoChangesBody")} +
`; From 2e9d4de131ce905087e948020b0f92684e421a14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:10:07 -0700 Subject: [PATCH 28/53] fix(auto-reply): apply mixed chat directives in one session transaction (#117542) --- .../reply/directive-handling.fast-lane.ts | 120 --- .../reply/directive-handling.impl.ts | 312 ++++---- .../directive-handling.mixed-inline.test.ts | 690 ++++++++++-------- .../reply/directive-handling.model.test.ts | 278 ++++--- .../reply/directive-handling.params.ts | 29 +- .../directive-handling.persist.runtime.ts | 4 +- .../reply/directive-handling.persist.ts | 398 ---------- .../reply/directive-handling.shared.ts | 90 ++- .../reply/get-reply-directives-apply.test.ts | 56 +- .../reply/get-reply-directives-apply.ts | 218 ++---- .../apply-session-model-selection.ts | 7 +- 11 files changed, 914 insertions(+), 1288 deletions(-) delete mode 100644 src/auto-reply/reply/directive-handling.fast-lane.ts delete mode 100644 src/auto-reply/reply/directive-handling.persist.ts diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts deleted file mode 100644 index be5a1f112392..000000000000 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Applies fast-lane reply directives before normal delivery processing. -import type { ReplyPayload } from "../types.js"; -import { isDirectiveOnly } from "./directive-handling.directive-only.js"; -import { handleDirectiveOnly } from "./directive-handling.impl.js"; -import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js"; -import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; - -export async function applyInlineDirectivesFastLane( - params: ApplyInlineDirectivesFastLaneParams, -): Promise<{ - directiveAck?: ReplyPayload; - provider: string; - model: string; - sessionChangesApplied: boolean; -}> { - const { - directives, - commandAuthorized, - ctx, - cfg, - agentId, - isGroup, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - policyAliasIndex, - allowedModelKeys, - allowedModelCatalog, - resetModelOverride, - formatModelSwitchEvent, - modelState, - } = params; - - let { provider, model } = params; - if ( - !commandAuthorized || - isDirectiveOnly({ - directives, - cleanedBody: directives.cleaned, - ctx, - cfg, - agentId, - isGroup, - }) - ) { - return { directiveAck: undefined, provider, model, sessionChangesApplied: true }; - } - - const agentCfg = params.agentCfg; - const { - currentThinkLevel, - currentFastMode, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - } = await resolveCurrentDirectiveLevels({ - sessionEntry, - agentCfg, - resolveDefaultThinkingLevel: directives.hasThinkDirective - ? () => modelState.resolveDefaultThinkingLevel() - : async () => undefined, - }); - - const persistenceState = { sessionChangesApplied: true }; - const directiveAck = await handleDirectiveOnly({ - cfg, - directives, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - policyAliasIndex, - allowedModelKeys, - allowedModelCatalog, - thinkingCatalog: await modelState.resolveThinkingCatalog(), - resetModelOverride, - provider, - model, - initialModelLabel: params.initialModelLabel, - formatModelSwitchEvent, - canPersistStickyModelSelection: params.canPersistStickyModelSelection, - currentThinkLevel, - currentFastMode, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - ctx, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - commandAuthorized, - senderIsOwner: params.senderIsOwner, - workspaceDir: params.workspaceDir, - persistenceState, - }); - - if (sessionEntry?.providerOverride) { - provider = sessionEntry.providerOverride; - } - if (sessionEntry?.modelOverride) { - model = sessionEntry.modelOverride; - } - - return { directiveAck, provider, model, ...persistenceState }; -} diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 65d279847508..7f2ba0766efc 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -35,8 +35,11 @@ import { maybeHandleUnexpectedNativeDirectiveArguments } from "./directive-handl import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; import { + acknowledgeIgnoredSessionDirective, applySessionDirectiveFields, canPersistSessionDirectiveDefaults, + DIRECTIVE_ACK_MESSAGES, + type IgnoredSessionDirectiveFlag, formatDirectiveAck, formatElevatedRuntimeHint, formatElevatedUnavailableText, @@ -45,6 +48,7 @@ import { formatInternalVerbosePersistenceDeniedText, enqueueModeSwitchEvents, persistSessionDirectiveSnapshot, + rejectSessionDirectiveTransaction, resolveDirectiveTouchedSessionFields, withOptions, } from "./directive-handling.shared.js"; @@ -52,30 +56,6 @@ import type { ReasoningLevel, ThinkLevel } from "./directives.js"; import { refreshQueuedFollowupSession } from "./queue.js"; import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; -const DIRECTIVE_ACK_MESSAGES = { - verbose: { - off: "Verbose logging disabled.", - on: "Verbose logging enabled.", - full: "Verbose logging set to full.", - }, - trace: { - off: "Trace disabled.", - on: "Trace enabled. Warning: trace output may contain sensitive information.", - raw: "Trace set to raw. Warning: trace output may contain sensitive information.", - }, - reasoning: { - off: "Reasoning visibility disabled.", - on: "Reasoning visibility enabled.", - stream: "Reasoning stream enabled.", - }, - elevated: { - off: "Elevated mode disabled.", - on: "Elevated mode set to ask (approvals may still apply).", - ask: "Elevated mode set to ask (approvals may still apply).", - full: "Elevated mode set to full (auto-approve).", - }, -} as const; - /** Handles inline directives that can be acknowledged without a model turn. */ export async function handleDirectiveOnly( params: HandleDirectiveOnlyParams, @@ -105,11 +85,28 @@ export async function handleDirectiveOnly( currentReasoningLevel, currentElevatedLevel, } = params; + const allowPrivilegedPersistence = canPersistSessionDirectiveDefaults(params); + const rejectModelTransaction = (errorText: string) => + rejectSessionDirectiveTransaction(params.persistenceState, errorText); + const acknowledgeIgnoredDirective = ( + reply: ReplyPayload, + ignoredDirective: IgnoredSessionDirectiveFlag, + ) => + acknowledgeIgnoredSessionDirective({ + reply, + directives, + ignoredDirective, + persistenceState: params.persistenceState, + allowPrivilegedPersistence, + applyRemainingDirectives: (remainingDirectives) => + handleDirectiveOnly({ ...params, directives: remainingDirectives }), + }); const delegatedTraceAllowed = (params.gatewayClientScopes ?? []).includes("operator.admin"); if (directives.hasTraceDirective && !params.senderIsOwner && !delegatedTraceAllowed) { - return { - text: "❌ /trace is restricted to owners and gateway clients with operator.admin scope.", - }; + return acknowledgeIgnoredDirective( + { text: "❌ /trace is restricted to owners and gateway clients with operator.admin scope." }, + "hasTraceDirective", + ); } const activeAgentId = resolveSessionAgentId({ sessionKey: params.sessionKey, @@ -126,13 +123,6 @@ export async function handleDirectiveOnly( sessionKey: runtimePolicySessionKey, }).sandboxed; const shouldHintDirectRuntime = directives.hasElevatedDirective && !runtimeIsSandboxed; - const allowPrivilegedPersistence = canPersistSessionDirectiveDefaults({ - messageProvider: params.messageProvider, - surface: params.surface, - gatewayClientScopes: params.gatewayClientScopes, - commandAuthorized: params.commandAuthorized, - senderIsOwner: params.senderIsOwner, - }); const thinkingCatalog = params.thinkingCatalog && params.thinkingCatalog.length > 0 ? params.thinkingCatalog @@ -161,7 +151,7 @@ export async function handleDirectiveOnly( sessionEntry, }); if (modelInfo) { - return modelInfo; + return acknowledgeIgnoredDirective(modelInfo, "hasModelDirective"); } const modelResolution = resolveModelSelectionFromDirective({ @@ -177,12 +167,12 @@ export async function handleDirectiveOnly( agentId: activeAgentId, }); if (modelResolution.errorText) { - return { text: modelResolution.errorText }; + return rejectModelTransaction(modelResolution.errorText); } const modelSelection = modelResolution.modelSelection; const profileOverride = modelResolution.profileOverride; if (modelSelection && isModelSelectionLocked(sessionEntry)) { - return { text: MODEL_SELECTION_LOCKED_MESSAGE }; + return rejectModelTransaction(MODEL_SELECTION_LOCKED_MESSAGE); } const resolvedProvider = modelSelection?.provider ?? provider; @@ -196,7 +186,7 @@ export async function handleDirectiveOnly( }) : ({ kind: "unchanged" } as const); if (modelRuntimeResolution.kind === "invalid") { - return { text: modelRuntimeResolution.errorText }; + return rejectModelTransaction(modelRuntimeResolution.errorText); } const prospectiveSessionEntry = { ...sessionEntry }; applyModelRuntimeDirective(prospectiveSessionEntry, modelRuntimeResolution); @@ -232,30 +222,45 @@ export async function handleDirectiveOnly( catalog: thinkingCatalog, agentRuntime: thinkingRuntime, }); - return { - text: withOptions( - `Current thinking level: ${level}.`, - `default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}`, - ), - }; + return acknowledgeIgnoredDirective( + { + text: withOptions( + `Current thinking level: ${level}.`, + `default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}`, + ), + }, + "hasThinkDirective", + ); } - return { - text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, - }; + return acknowledgeIgnoredDirective( + { + text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, + }, + "hasThinkDirective", + ); } if (directives.hasVerboseDirective && !directives.verboseLevel) { - return { - text: directives.rawVerboseLevel - ? `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on, full.` - : withOptions(`Current verbose level: ${currentVerboseLevel ?? "off"}.`, "on, full, off"), - }; + return acknowledgeIgnoredDirective( + { + text: directives.rawVerboseLevel + ? `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on, full.` + : withOptions(`Current verbose level: ${currentVerboseLevel ?? "off"}.`, "on, full, off"), + }, + "hasVerboseDirective", + ); } if (directives.hasTraceDirective && !directives.traceLevel) { - return { - text: directives.rawTraceLevel - ? `Unrecognized trace level "${directives.rawTraceLevel}". Valid levels: off, on, raw.` - : withOptions(`Current trace level: ${sessionEntry.traceLevel ?? "off"}.`, "on, off, raw"), - }; + return acknowledgeIgnoredDirective( + { + text: directives.rawTraceLevel + ? `Unrecognized trace level "${directives.rawTraceLevel}". Valid levels: off, on, raw.` + : withOptions( + `Current trace level: ${sessionEntry.traceLevel ?? "off"}.`, + "on, off, raw", + ), + }, + "hasTraceDirective", + ); } if ( directives.hasFastDirective && @@ -269,64 +274,85 @@ export async function handleDirectiveOnly( source: effectiveFastModeSource, fastAutoOnSeconds: fastModeState.fastAutoOnSeconds, }); - return { - text: isFastStatus - ? statusText - : withOptions( - statusText, - formatFastModeCommandOptions({ - fastAutoOnSeconds: fastModeState.fastAutoOnSeconds, - }), - ), - }; + return acknowledgeIgnoredDirective( + { + text: isFastStatus + ? statusText + : withOptions( + statusText, + formatFastModeCommandOptions({ + fastAutoOnSeconds: fastModeState.fastAutoOnSeconds, + }), + ), + }, + "hasFastDirective", + ); } - return { - text: `Unrecognized fast mode "${directives.rawFastMode}". Valid levels: on, off, auto, default, status.`, - }; + return acknowledgeIgnoredDirective( + { + text: `Unrecognized fast mode "${directives.rawFastMode}". Valid levels: on, off, auto, default, status.`, + }, + "hasFastDirective", + ); } if (directives.hasReasoningDirective && !directives.reasoningLevel) { - return { - text: directives.rawReasoningLevel - ? `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.` - : withOptions( - `Current reasoning level: ${currentReasoningLevel ?? "off"}.`, - "on, off, stream", - ), - }; + return acknowledgeIgnoredDirective( + { + text: directives.rawReasoningLevel + ? `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.` + : withOptions( + `Current reasoning level: ${currentReasoningLevel ?? "off"}.`, + "on, off, stream", + ), + }, + "hasReasoningDirective", + ); } if (directives.hasElevatedDirective && !directives.elevatedLevel) { if (!directives.rawElevatedLevel) { if (!elevatedEnabled || !elevatedAllowed) { - return { - text: formatElevatedUnavailableText({ - runtimeSandboxed: runtimeIsSandboxed, - failures: params.elevatedFailures, - sessionKey: params.sessionKey, - }), - }; + return acknowledgeIgnoredDirective( + { + text: formatElevatedUnavailableText({ + runtimeSandboxed: runtimeIsSandboxed, + failures: params.elevatedFailures, + sessionKey: params.sessionKey, + }), + }, + "hasElevatedDirective", + ); } const level = currentElevatedLevel ?? "off"; - return { - text: [ - withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"), - shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, - ] - .filter(Boolean) - .join("\n"), - }; + return acknowledgeIgnoredDirective( + { + text: [ + withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"), + shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, + ] + .filter(Boolean) + .join("\n"), + }, + "hasElevatedDirective", + ); } - return { - text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`, - }; + return acknowledgeIgnoredDirective( + { + text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`, + }, + "hasElevatedDirective", + ); } if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) { - return { - text: formatElevatedUnavailableText({ - runtimeSandboxed: runtimeIsSandboxed, - failures: params.elevatedFailures, - sessionKey: params.sessionKey, - }), - }; + return acknowledgeIgnoredDirective( + { + text: formatElevatedUnavailableText({ + runtimeSandboxed: runtimeIsSandboxed, + failures: params.elevatedFailures, + sessionKey: params.sessionKey, + }), + }, + "hasElevatedDirective", + ); } if (directives.hasExecDirective) { const invalidExecMessage = directives.invalidExecHost @@ -339,7 +365,7 @@ export async function handleDirectiveOnly( ? "Exec node requires a value." : undefined; if (invalidExecMessage) { - return { text: invalidExecMessage }; + return acknowledgeIgnoredDirective({ text: invalidExecMessage }, "hasExecDirective"); } const unexpectedExecArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); if (unexpectedExecArguments) { @@ -353,12 +379,15 @@ export async function handleDirectiveOnly( sandboxAvailable: runtimeIsSandboxed, }); const nodeLabel = execDefaults.node ? `node=${execDefaults.node}` : "node=(unset)"; - return { - text: withOptions( - `Current exec defaults: host=${renderExecTargetLabel(execDefaults.host)}, effective=${execDefaults.effectiveHost}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`, - "host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=", - ), - }; + return acknowledgeIgnoredDirective( + { + text: withOptions( + `Current exec defaults: host=${renderExecTargetLabel(execDefaults.host)}, effective=${execDefaults.effectiveHost}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`, + "host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=", + ), + }, + "hasExecDirective", + ); } } @@ -369,7 +398,7 @@ export async function handleDirectiveOnly( sessionEntry, }); if (queueAck) { - return queueAck; + return acknowledgeIgnoredDirective(queueAck, "hasQueueDirective"); } const unexpectedNativeArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); @@ -388,14 +417,13 @@ export async function handleDirectiveOnly( agentRuntime: thinkingRuntime, }) ) { - return { - text: `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, - }; + return rejectModelTransaction( + `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, + ); } - const resolvedDirectiveThinkLevel = directives.thinkLevel; const nextThinkLevel = directives.hasThinkDirective - ? resolvedDirectiveThinkLevel + ? directives.thinkLevel : ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? currentThinkLevel); const remappedUnsupportedThinkLevel = !directives.hasThinkDirective && @@ -423,11 +451,10 @@ export async function handleDirectiveOnly( const elevatedChanged = directives.hasElevatedDirective && directives.elevatedLevel !== undefined && + directives.elevatedLevel !== (currentElevatedLevel ?? sessionEntry.elevatedLevel ?? "off") && elevatedEnabled && elevatedAllowed; let modelSelectionUpdated = false; - let modelSelectionApplied = true; - let sessionChangesApplied = true; const appliedSessionEntry = sessionEntry; const touchedSessionFields = resolveDirectiveTouchedSessionFields({ directives, @@ -484,27 +511,19 @@ export async function handleDirectiveOnly( modelSelectionUpdated && sessionEntry.liveModelSwitchPending === true, touchedFields: touchedSessionFields, }); - sessionChangesApplied = persistence.sessionChangesApplied; - modelSelectionApplied = persistence.modelSelectionApplied; - } - if (modelSelection && !modelSelectionApplied) { - sessionChangesApplied = false; - } - if (!sessionChangesApplied) { - if (params.persistenceState) { - params.persistenceState.sessionChangesApplied = false; + if (persistence.status !== "applied") { + const errorText = + persistence.status === "model-selection-locked" + ? MODEL_SELECTION_LOCKED_MESSAGE + : modelSelection + ? "Model change was not applied because the session changed. Retry." + : "Session settings were not applied because the session changed. Retry."; + return rejectModelTransaction(errorText); } - return { - text: modelSelection - ? "Model change was not applied because the session changed. Retry." - : "Session settings were not applied because the session changed. Retry.", - }; } if ( modelSelection && - modelSelectionApplied && !modelSelection.isDefault && - !params.persistenceState && params.canPersistStickyModelSelection === true ) { persistStickyModelSelectionBestEffort({ @@ -512,7 +531,7 @@ export async function handleDirectiveOnly( model: `${modelSelection.provider}/${modelSelection.model}`, }); } - if (modelSelection && modelSelectionUpdated && modelSelectionApplied && sessionKey) { + if (modelSelection && modelSelectionUpdated && sessionKey) { triggerSessionPatchHook({ cfg: params.cfg, sessionEntry: appliedSessionEntry, @@ -549,7 +568,7 @@ export async function handleDirectiveOnly( }); } } - if (modelSelection && modelSelectionApplied) { + if (modelSelection) { const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; if (nextLabel !== initialModelLabel) { enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), { @@ -565,22 +584,23 @@ export async function handleDirectiveOnly( elevatedChanged, reasoningChanged, }); + if (params.persistenceState) { + params.persistenceState.outcome = { + kind: "applied", + provider: resolvedProvider, + model: resolvedModel, + }; + } const parts: string[] = []; if (directives.clearThinkLevel) { parts.push("Thinking level reset to default."); } else if (directives.hasThinkDirective && directives.thinkLevel) { - const displayedThinkLevel = resolvedDirectiveThinkLevel ?? directives.thinkLevel; parts.push( - displayedThinkLevel === "off" + directives.thinkLevel === "off" ? "Thinking disabled." - : `Thinking level set to ${displayedThinkLevel}.`, + : `Thinking level set to ${directives.thinkLevel}.`, ); - if (directives.thinkLevel === "max" && displayedThinkLevel !== "max") { - parts.push( - `max not supported for ${resolvedProvider}/${resolvedModel}; using ${displayedThinkLevel}.`, - ); - } } if (directives.clearFastMode) { parts.push(formatDirectiveAck("Fast mode reset to default.")); @@ -630,7 +650,7 @@ export async function handleDirectiveOnly( if (directives.hasExecDirective && directives.hasExecOptions && !allowPrivilegedPersistence) { parts.push(formatDirectiveAck(formatInternalExecPersistenceDeniedText())); } - if (modelSelection && modelSelectionApplied) { + if (modelSelection) { const label = `${modelSelection.provider}/${modelSelection.model}`; const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label; parts.push( @@ -646,8 +666,6 @@ export async function handleDirectiveOnly( } else if (modelRuntimeResolution.kind === "set") { parts.push(`Runtime set to ${modelRuntimeResolution.runtime} for this session.`); } - } else if (modelSelection) { - parts.push("Model change was not applied because the session changed. Retry."); } // Report the model change before the thinking remap it triggered: the remap is a // consequence of the model switch, so the cause should be announced first. diff --git a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts index 60b46482b661..2f211efab658 100644 --- a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts +++ b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts @@ -1,12 +1,25 @@ -// Tests mixed inline directives in user text and command bodies. +// Tests mixed directives through the real reply admission and transaction boundary. import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; +import { persistStickyModelSelectionBestEffort } from "../../agents/sticky-model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js"; +import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../../sessions/model-overrides.js"; import { parseInlineDirectives } from "./directive-handling.parse.js"; -import { persistInlineDirectives } from "./directive-handling.persist.js"; +import { applyInlineDirectiveOverrides } from "./get-reply-directives-apply.js"; import { refreshQueuedFollowupSession } from "./queue.js"; +type PersistenceResult = + | { status: "current"; entry: SessionEntry } + | { status: "model-selection-locked"; entry: SessionEntry } + | { status: "lifecycle-invalidated"; error: string; entry?: SessionEntry }; + +const persistenceMocks = vi.hoisted(() => ({ + persist: vi.fn<(params: { entry: SessionEntry }) => Promise>(), +})); + vi.mock("../../agents/agent-scope.js", () => ({ listAgentEntries: vi.fn(() => []), resolveAgentConfig: vi.fn(() => ({})), @@ -20,6 +33,14 @@ vi.mock("../../agents/sandbox.js", () => ({ resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })), })); +vi.mock("../../agents/sticky-model-selection.js", () => ({ + persistStickyModelSelectionBestEffort: vi.fn(), +})); + +vi.mock("../../gateway/session-patch-hooks.js", () => ({ + triggerSessionPatchHook: vi.fn(), +})); + vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: vi.fn(), })); @@ -28,213 +49,190 @@ vi.mock("./queue.js", () => ({ refreshQueuedFollowupSession: vi.fn(), })); +vi.mock("./session-entry-persistence.js", () => ({ + persistReplySessionEntry: (params: { entry: SessionEntry }) => persistenceMocks.persist(params), +})); + function createSessionEntry(overrides?: Partial): SessionEntry { - return { - sessionId: "session-1", - updatedAt: Date.now(), - ...overrides, - }; + return { sessionId: "session-1", updatedAt: 1, ...overrides }; } -function createConfig(): OpenClawConfig { - return { - commands: { text: true }, - agents: { defaults: {} }, - } as unknown as OpenClawConfig; +async function applyMixedDirectives(params: { + body: string; + cfg?: OpenClawConfig; + sessionEntry?: SessionEntry; + sessionKey?: string; + storePath?: string; + channel?: string; + provider?: string; + model?: string; + defaultProvider?: string; + defaultModel?: string; + allowedModels?: ModelCatalogEntry[]; + senderIsOwner?: boolean; + gatewayClientScopes?: string[]; +}) { + const cfg = + params.cfg ?? ({ commands: { text: true }, agents: { defaults: {} } } as OpenClawConfig); + const provider = params.provider ?? "anthropic"; + const model = params.model ?? "claude-opus-4-6"; + const channel = params.channel ?? "telegram"; + const sessionKey = params.sessionKey ?? "agent:main:dm:1"; + const sessionEntry = params.sessionEntry ?? createSessionEntry(); + const sessionStore = { [sessionKey]: sessionEntry }; + const directives = parseInlineDirectives(params.body); + const allowedModels = params.allowedModels ?? []; + const modelState: Parameters[0]["modelState"] = { + provider, + model, + requestedRouteResolution: "resolved", + allowedModelKeys: new Set(allowedModels.map((entry) => `${entry.provider}/${entry.id}`)), + allowedModelCatalog: allowedModels, + policyAliasIndex: { byAlias: new Map(), byKey: new Map() }, + resetModelOverride: false, + resolveThinkingCatalog: async () => allowedModels, + resolveDefaultThinkingLevel: async () => "off", + resolveDefaultReasoningLevel: async () => "off", + needsModelCatalog: false, + }; + const typing = { + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: vi.fn(), + }; + + const result = await applyInlineDirectiveOverrides({ + ctx: { + Body: params.body, + Provider: channel, + Surface: channel, + ...(params.gatewayClientScopes ? { GatewayClientScopes: params.gatewayClientScopes } : {}), + }, + cfg, + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + agentCfg: cfg.agents?.defaults ?? {}, + sessionEntry, + sessionStore, + sessionKey, + storePath: params.storePath, + sessionScope: undefined, + isGroup: false, + allowTextCommands: true, + command: { + surface: channel, + channel, + ownerList: [], + senderIsOwner: params.senderIsOwner ?? false, + isAuthorizedSender: true, + rawBodyNormalized: params.body, + commandBodyNormalized: params.body, + }, + directives, + messageProviderKey: channel, + elevatedEnabled: true, + elevatedAllowed: true, + elevatedFailures: [], + defaultProvider: params.defaultProvider ?? provider, + defaultModel: params.defaultModel ?? model, + aliasIndex: { byAlias: new Map(), byKey: new Map() }, + provider, + model, + modelState, + initialModelLabel: `${provider}/${model}`, + formatModelSwitchEvent: (label) => `Model switched to ${label}.`, + resolvedElevatedLevel: "off", + defaultActivation: () => "always", + contextTokens: 8192, + effectiveModelDirective: directives.rawModelDirective, + typing, + }); + + return { result, sessionEntry, sessionStore, typing }; } describe("mixed inline directives", () => { beforeEach(() => { vi.clearAllMocks(); + persistenceMocks.persist.mockImplementation(async ({ entry }) => ({ + status: "current", + entry: { ...entry }, + })); }); - it("emits directive ack while persisting inline reasoning in mixed messages", async () => { - const directives = parseInlineDirectives("please reply\n/reasoning on"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry(); - const sessionStore = { "agent:main:dm:1": sessionEntry }; - - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Surface: "whatsapp" } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "whatsapp", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, + it("commits mixed reasoning exactly once and emits one transition", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/reasoning on", + storePath: "/tmp/sessions.json", }); - expect(fastLane.directiveAck).toEqual({ - text: "⚙️ Reasoning visibility enabled.", + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: "⚙️ Reasoning visibility enabled." }, }); - - const persisted = await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "whatsapp", - surface: "whatsapp", - gatewayClientScopes: [], - }); - expect(sessionEntry.reasoningLevel).toBe("on"); - expect(persisted.provider).toBe("anthropic"); - expect(persisted.model).toBe("claude-opus-4-6"); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).toHaveBeenCalledOnce(); }); - it("persists reasoning off and emits the disabled ack", async () => { - const directives = parseInlineDirectives("please reply\n/reasoning off"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry({ reasoningLevel: "on" }); - const sessionStore = { "agent:main:discord:user": sessionEntry }; + it.each([ + { mode: "off", initial: "on", expectedAck: "Reasoning visibility disabled." }, + { mode: "stream", initial: undefined, expectedAck: "Reasoning stream enabled." }, + ])( + "persists reasoning $mode with a channel-neutral acknowledgement", + async ({ mode, initial, expectedAck }) => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: `please reply\n/reasoning ${mode}`, + sessionEntry: createSessionEntry({ reasoningLevel: initial }), + channel: "discord", + }); - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Surface: "discord" } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:discord:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "discord", - defaultProvider: "openrouter", - defaultModel: "x-ai/grok-4.1-fast", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "openrouter", - model: "x-ai/grok-4.1-fast", - initialModelLabel: "openrouter/x-ai/grok-4.1-fast", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, - }); + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: `⚙️ ${expectedAck}` }, + }); + expect(sessionEntry.reasoningLevel).toBe(mode); + }, + ); - expect(fastLane.directiveAck).toEqual({ - text: "⚙️ Reasoning visibility disabled.", - }); - - await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:discord:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "openrouter", - defaultModel: "x-ai/grok-4.1-fast", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "openrouter", - model: "x-ai/grok-4.1-fast", - initialModelLabel: "openrouter/x-ai/grok-4.1-fast", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "discord", - surface: "discord", - gatewayClientScopes: [], - }); - - expect(sessionEntry.reasoningLevel).toBe("off"); - }); - - it("retargets queued thinking after a mixed-content model switch", async () => { - const directives = parseInlineDirectives("please reply /model openai/gpt-5.6-luna"); - const sessionEntry = createSessionEntry({ thinkingLevel: "ultra" }); - const sessionKey = "agent:main:dm:1"; + it("commits a model switch and retargets queued followups once", async () => { const cfg = { commands: { text: true }, agents: { - defaults: { - models: { - "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } }, - }, - }, + defaults: { models: { "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } } } }, }, - } as unknown as OpenClawConfig; - - await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, + } as OpenClawConfig; + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply /model openai/gpt-5.6-luna", cfg, - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "openai", - defaultModel: "gpt-5.6-sol", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(["openai/gpt-5.6-luna"]), - modelCatalog: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + sessionEntry: createSessionEntry({ thinkingLevel: "ultra" }), + storePath: "/tmp/sessions.json", provider: "openai", model: "gpt-5.6-sol", - initialModelLabel: "openai/gpt-5.6-sol", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, + allowedModels: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + senderIsOwner: true, }); + expect(result).toMatchObject({ kind: "continue", provider: "openai", model: "gpt-5.6-luna" }); expect(sessionEntry.thinkingLevel).toBe("max"); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(triggerSessionPatchHook).toHaveBeenCalledOnce(); + expect(refreshQueuedFollowupSession).toHaveBeenCalledOnce(); + expect(persistStickyModelSelectionBestEffort).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).toHaveBeenCalledWith("Model switched to openai/gpt-5.6-luna.", { + sessionKey: "agent:main:dm:1", + contextKey: "model:openai/gpt-5.6-luna", + }); expect(refreshQueuedFollowupSession).toHaveBeenCalledWith( expect.objectContaining({ - key: sessionKey, + key: "agent:main:dm:1", nextProvider: "openai", nextModel: "gpt-5.6-luna", nextThinking: expect.objectContaining({ level: "max", agentRuntime: "codex" }), @@ -242,159 +240,217 @@ describe("mixed inline directives", () => { ); }); - it("emits a channel-neutral ack for reasoning stream", async () => { - const directives = parseInlineDirectives("please reply\n/reasoning stream"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry(); - const sessionStore = { "agent:main:discord:user": sessionEntry }; - - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Surface: "discord" } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:discord:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "discord", - defaultProvider: "openrouter", - defaultModel: "x-ai/grok-4.1-fast", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "openrouter", - model: "x-ai/grok-4.1-fast", - initialModelLabel: "openrouter/x-ai/grok-4.1-fast", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, + it("routes a mixed default reset to the actual default after clearing override fields", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply /model default", + provider: "openai", + model: "gpt-5.6-sol", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + sessionEntry: createSessionEntry({ + providerOverride: "openai", + modelOverride: "gpt-5.6-sol", + modelOverrideSource: "user", + }), + allowedModels: [ + { + provider: "anthropic", + id: "claude-opus-4-6", + name: "Claude Opus", + contextTokens: 90_000, + }, + ], }); - expect(fastLane.directiveAck).toEqual({ - text: "⚙️ Reasoning stream enabled.", + expect(result).toMatchObject({ + kind: "continue", + provider: "anthropic", + model: "claude-opus-4-6", + contextTokens: 90_000, }); + expect(sessionEntry.providerOverride).toBeUndefined(); + expect(sessionEntry.modelOverride).toBeUndefined(); }); - it("persists mixed exec defaults for authorized external senders with empty gateway scopes", async () => { - const directives = parseInlineDirectives( - "please reply\n/exec host=node security=allowlist ask=always node=worker-1", - ); - const cfg = createConfig(); - const sessionEntry = createSessionEntry(); - const sessionStore = { "agent:main:telegram:user": sessionEntry }; - - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Provider: "telegram", GatewayClientScopes: [] } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "telegram", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, + it("preserves persisted and per-message queue options in one mixed transaction", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/queue collect debounce:1500 cap:4 drop:old", + storePath: "/tmp/sessions.json", }); - expect(fastLane.directiveAck?.text).toContain("Exec defaults set"); - expect(fastLane.directiveAck?.text).not.toContain("operator.admin"); + expect(result).toMatchObject({ + kind: "continue", + perMessageQueueMode: "collect", + perMessageQueueOptions: { debounceMs: 1500, cap: 4, dropPolicy: "old" }, + }); + expect(sessionEntry).toMatchObject({ + queueMode: "collect", + queueDebounceMs: 1500, + queueCap: 4, + }); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }); - await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "telegram", + it("persists fast-mode and external exec defaults for authorized mixed messages", async () => { + const fast = await applyMixedDirectives({ body: "please reply\n/fast on" }); + expect(fast.sessionEntry.fastMode).toBe(true); + + const exec = await applyMixedDirectives({ + body: "please reply\n/exec host=node security=allowlist ask=always node=worker-1", gatewayClientScopes: [], - commandAuthorized: true, }); - - expect(sessionEntry.execHost).toBe("node"); - expect(sessionEntry.execSecurity).toBe("allowlist"); - expect(sessionEntry.execAsk).toBe("always"); - expect(sessionEntry.execNode).toBe("worker-1"); + expect(exec.result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining("Exec defaults set") }, + }); + expect(exec.sessionEntry).toMatchObject({ + execHost: "node", + execSecurity: "allowlist", + execAsk: "always", + execNode: "worker-1", + }); }); it("does not persist trace directives for unauthorized mixed messages", async () => { - const directives = parseInlineDirectives("please reply\n/trace raw"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry({ traceLevel: "off" as const }); - const sessionStore = { "agent:main:telegram:user": sessionEntry }; - - await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "telegram", - surface: "telegram", + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/trace raw", + sessionEntry: createSessionEntry({ traceLevel: "off" }), gatewayClientScopes: [], - senderIsOwner: false, }); + expect(result).toMatchObject({ kind: "continue" }); expect(sessionEntry.traceLevel).toBe("off"); + expect(persistenceMocks.persist).not.toHaveBeenCalled(); + }); + + it.each([ + { + ignored: "/trace raw", + expectedAck: "/trace is restricted to owners", + }, + { + ignored: "/verbose nonsense", + expectedAck: "Current verbose level:", + }, + { + ignored: "/fast status", + expectedAck: "Current fast mode:", + }, + ])( + "applies valid sibling settings despite an ignored $ignored directive", + async ({ ignored, expectedAck }) => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: `please reply\n${ignored}\n/reasoning on`, + storePath: "/tmp/sessions.json", + gatewayClientScopes: [], + }); + + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining(expectedAck) }, + }); + expect(sessionEntry.reasoningLevel).toBe("on"); + expect(sessionEntry.traceLevel).toBeUndefined(); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }, + ); + + it("keeps authorized exec fields when a sibling exec option is invalid", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/exec host=node security=bogus\n/reasoning on", + storePath: "/tmp/sessions.json", + }); + + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining('Unrecognized exec security "bogus"') }, + }); + expect(sessionEntry).toMatchObject({ execHost: "node", reasoningLevel: "on" }); + expect(sessionEntry.execSecurity).toBeUndefined(); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }); + + it("normalizes nested informational and unauthorized siblings into one commit", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/trace raw\n/verbose nonsense\n/reasoning on", + storePath: "/tmp/sessions.json", + gatewayClientScopes: [], + }); + + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining("/trace is restricted to owners") }, + }); + expect(sessionEntry.reasoningLevel).toBe("on"); + expect(sessionEntry.traceLevel).toBeUndefined(); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }); + + it("does not announce unchanged elevated mode as a transition", async () => { + const { result } = await applyMixedDirectives({ + body: "please reply\n/elevated full", + sessionEntry: createSessionEntry({ elevatedLevel: "full" }), + storePath: "/tmp/sessions.json", + }); + + expect(result).toMatchObject({ kind: "continue" }); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("adopts an authoritative model lock and emits no losing side effects", async () => { + const sessionEntry = createSessionEntry({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "user", + }); + const lockedEntry = { ...sessionEntry, updatedAt: 2, modelSelectionLocked: true }; + persistenceMocks.persist.mockResolvedValueOnce({ + status: "model-selection-locked", + entry: lockedEntry, + }); + + const { result, sessionStore } = await applyMixedDirectives({ + body: "please reply /model openai/gpt-5.6-luna", + sessionEntry, + storePath: "/tmp/sessions.json", + allowedModels: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + senderIsOwner: true, + }); + + expect(result).toEqual({ kind: "reply", reply: { text: MODEL_SELECTION_LOCKED_MESSAGE } }); + expect(persistenceMocks.persist).toHaveBeenCalledWith( + expect.objectContaining({ requireModelSelectionUnlocked: true }), + ); + expect(sessionEntry).toEqual(lockedEntry); + expect(sessionStore["agent:main:dm:1"]).toEqual(lockedEntry); + expect(triggerSessionPatchHook).not.toHaveBeenCalled(); + expect(refreshQueuedFollowupSession).not.toHaveBeenCalled(); + expect(persistStickyModelSelectionBestEffort).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("reports a locked valid model instead of an ignored unauthorized sibling", async () => { + const sessionEntry = createSessionEntry(); + const lockedEntry = { ...sessionEntry, updatedAt: 2, modelSelectionLocked: true }; + persistenceMocks.persist.mockResolvedValueOnce({ + status: "model-selection-locked", + entry: lockedEntry, + }); + + const { result } = await applyMixedDirectives({ + body: "please reply\n/trace raw\n/model openai/gpt-5.6-luna", + sessionEntry, + storePath: "/tmp/sessions.json", + allowedModels: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + gatewayClientScopes: [], + }); + + expect(result).toEqual({ kind: "reply", reply: { text: MODEL_SELECTION_LOCKED_MESSAGE } }); + expect(sessionEntry).toEqual(lockedEntry); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(triggerSessionPatchHook).not.toHaveBeenCalled(); + expect(refreshQueuedFollowupSession).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index a49220443ca3..c62eb0843cfc 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -317,7 +317,8 @@ let createModelVisibilityPolicy: typeof import("../../agents/model-visibility-po let buildModelAliasIndex: typeof import("../../agents/model-selection.js").buildModelAliasIndex; let resolveModelSelectionFromDirective: typeof import("./directive-handling.model-selection.js").resolveModelSelectionFromDirective; let parseInlineDirectives: typeof import("./directive-handling.parse.js").parseInlineDirectives; -let persistInlineDirectives: typeof import("./directive-handling.persist.js").persistInlineDirectives; +let applyInlineDirectiveOverrides: typeof import("./get-reply-directives-apply.js").applyInlineDirectiveOverrides; +let createFastTestModelSelectionState: typeof import("./model-selection.js").createFastTestModelSelectionState; beforeAll(async () => { ({ testing: cliBackendsTesting } = await import("../../agents/cli-backends.test-support.js")); @@ -328,7 +329,8 @@ beforeAll(async () => { ({ resolveModelSelectionFromDirective } = await import("./directive-handling.model-selection.js")); ({ parseInlineDirectives } = await import("./directive-handling.parse.js")); - ({ persistInlineDirectives } = await import("./directive-handling.persist.js")); + ({ applyInlineDirectiveOverrides } = await import("./get-reply-directives-apply.js")); + ({ createFastTestModelSelectionState } = await import("./model-selection.js")); }); const queueMocks = vi.hoisted(() => ({ refreshQueuedFollowupSession: vi.fn(), @@ -570,67 +572,136 @@ async function persistModelDirectiveForTest(params: { if (params.profiles) { setAuthProfiles(params.profiles); } - const directives = parseInlineDirectives(params.command); + const originalDirectives = parseInlineDirectives(params.command); + const commandBody = originalDirectives.cleaned.trim() + ? params.command + : `${params.command} continue with the request`; + const directives = parseInlineDirectives(commandBody); const cfg = params.cfg ?? baseConfig(); const sessionEntry = params.sessionEntry ?? createSessionEntry(); - const persisted = await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, + const provider = params.provider ?? "anthropic"; + const model = params.model ?? "claude-opus-4-6"; + const sessionKey = "agent:main:dm:1"; + const modelState = createFastTestModelSelectionState({ + agentCfg: cfg.agents?.defaults, + provider, + model, + }); + modelState.allowedModelKeys = new Set(params.allowedModelKeys); + modelState.allowedModelCatalog = params.allowedModelCatalog ?? []; + modelState.resolveThinkingCatalog = async () => params.allowedModelCatalog; + const result = await applyInlineDirectiveOverrides({ + ctx: { Body: commandBody, Provider: "telegram", Surface: "telegram" }, cfg, + agentId: "main", agentDir: TEST_AGENT_DIR, + workspaceDir: "/tmp/workspace", + agentCfg: cfg.agents?.defaults ?? {}, sessionEntry, - sessionStore: { "agent:main:dm:1": sessionEntry }, - sessionKey: "agent:main:dm:1", - storePath: undefined, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + sessionScope: undefined, + isGroup: false, + allowTextCommands: true, + command: { + surface: "telegram", + channel: "telegram", + ownerList: [], + senderIsOwner: params.canPersistStickyModelSelection ?? true, + isAuthorizedSender: true, + rawBodyNormalized: commandBody, + commandBodyNormalized: commandBody, + }, + directives, + messageProviderKey: "telegram", elevatedEnabled: false, elevatedAllowed: false, + elevatedFailures: [], defaultProvider: "anthropic", defaultModel: "claude-opus-4-6", aliasIndex: params.aliasIndex ?? baseAliasIndex(), - allowedModelKeys: new Set(params.allowedModelKeys), - modelCatalog: params.allowedModelCatalog, - provider: params.provider ?? "anthropic", - model: params.model ?? "claude-opus-4-6", - initialModelLabel: - params.initialModelLabel ?? - `${params.provider ?? "anthropic"}/${params.model ?? "claude-opus-4-6"}`, + provider, + model, + modelState, + initialModelLabel: params.initialModelLabel ?? `${provider}/${model}`, formatModelSwitchEvent: (label) => label, - canPersistStickyModelSelection: params.canPersistStickyModelSelection ?? true, - agentCfg: cfg.agents?.defaults, + resolvedElevatedLevel: "off", + defaultActivation: () => "always", + contextTokens: 8192, + effectiveModelDirective: directives.rawModelDirective, + typing: { + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: () => {}, + }, }); + const persisted = + result.kind === "continue" + ? { + provider: result.provider, + model: result.model, + contextTokens: result.contextTokens, + directiveAck: result.directiveAck, + errorText: undefined, + } + : { + provider, + model, + contextTokens: 8192, + directiveAck: undefined, + errorText: Array.isArray(result.reply) ? result.reply[0]?.text : result.reply?.text, + }; return { persisted, sessionEntry }; } -type PersistInlineDirectivesParams = Parameters[0]; +type HandleDirectiveParams = Parameters[0]; -async function persistInternalOperatorWriteDirective( - command: string, - overrides: Partial = {}, -) { +function createDirectiveHandlingParams( + overrides: Partial, +): HandleDirectiveParams { + const sessionKey = overrides.sessionKey ?? "agent:main:main"; const sessionEntry = overrides.sessionEntry ?? createSessionEntry(); - const sessionStore = overrides.sessionStore ?? { "agent:main:main": sessionEntry }; - await persistInlineDirectives({ - directives: parseInlineDirectives(command), + return { cfg: baseConfig(), + directives: parseInlineDirectives(""), sessionEntry, - sessionStore, - sessionKey: "agent:main:main", - storePath: "/tmp/sessions.json", + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, elevatedEnabled: true, elevatedAllowed: true, defaultProvider: "anthropic", defaultModel: "claude-opus-4-6", aliasIndex: baseAliasIndex(), allowedModelKeys: new Set(["anthropic/claude-opus-4-6", "openai/gpt-4o"]), + allowedModelCatalog: [], + resetModelOverride: false, provider: "anthropic", model: "claude-opus-4-6", initialModelLabel: "anthropic/claude-opus-4-6", formatModelSwitchEvent: (label) => `Switched to ${label}`, - agentCfg: undefined, - surface: "webchat", - gatewayClientScopes: ["operator.write"], ...overrides, - }); + }; +} + +async function persistInternalOperatorWriteDirective( + command: string, + overrides: Partial = {}, +) { + const sessionEntry = overrides.sessionEntry ?? createSessionEntry(); + await handleDirectiveOnly( + createDirectiveHandlingParams({ + directives: parseInlineDirectives(command), + sessionEntry, + surface: "webchat", + gatewayClientScopes: ["operator.write"], + ...overrides, + }), + ); return sessionEntry; } @@ -1549,7 +1620,7 @@ describe("/model chat UX", () => { expect(sessionEntry.providerOverride).toBe(provider); expect(sessionEntry.modelOverride).toBe(model); expect(sessionEntry.agentRuntimeOverride).toBe("codex"); - expect(persisted.runtimeChange).toEqual({ kind: "set", runtime: "codex" }); + expect(persisted.directiveAck?.text).toContain("Runtime set to codex for this session."); }); it("normalizes legacy Codex app-server runtime overrides during persistence", async () => { @@ -1650,7 +1721,7 @@ describe("/model chat UX", () => { }); expect(sessionEntry.agentRuntimeOverride).toBeUndefined(); - expect(persisted.runtimeChange).toEqual({ kind: "clear" }); + expect(persisted.directiveAck?.text).toContain("Runtime reset to configured policy."); }); it("rejects model/runtime transactions that target an unsupported runtime", async () => { @@ -2004,6 +2075,44 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { expect(sessionEntry).toEqual(initialSessionEntry); }); + it("rechecks a newly persisted model lock before committing directive changes", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-directive-lock-")); + const storePath = path.join(tempRoot, "sessions.json"); + const sessionEntry = createSessionEntry({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "user", + }); + const lockedEntry: SessionEntry = { + ...sessionEntry, + updatedAt: sessionEntry.updatedAt + 1, + modelSelectionLocked: true, + }; + await replaceSessionEntry({ sessionKey, storePath }, lockedEntry); + const sessionStore = { [sessionKey]: sessionEntry }; + + try { + const result = await handleDirectiveOnly( + createHandleParams({ + directives: parseInlineDirectives("/model openai/gpt-4o"), + sessionEntry, + sessionStore, + storePath, + }), + ); + + expect(result?.text).toBe(MODEL_SELECTION_LOCKED_MESSAGE); + expect(sessionEntry).toEqual(lockedEntry); + expect(sessionStore[sessionKey]).toEqual(lockedEntry); + expect(loadSessionEntry({ sessionKey, storePath })).toEqual(lockedEntry); + expect(queueMocks.refreshQueuedFollowupSession).not.toHaveBeenCalled(); + expect(stickyModelMock.persistBestEffort).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("persists /model only on the targeted session entry", async () => { const targetEntry = createSessionEntry(); const otherEntry = createSessionEntry(); @@ -2065,12 +2174,9 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }); expect(sessionEntry.thinkingLevel).toBe("medium"); - expect(persisted.thinkingRemap).toEqual({ - from: "adaptive", - to: "medium", - provider: "openai", - model: "gpt-4o", - }); + expect(persisted.directiveAck?.text).toContain( + "Thinking level set to medium (adaptive not supported for openai/gpt-4o).", + ); }); it("announces the model change before the thinking remap in the ack", async () => { @@ -2185,7 +2291,9 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }; await replaceSessionEntry({ sessionKey, storePath }, concurrentEntry); const sessionStore = { [sessionKey]: sessionEntry }; - const persistenceState = { sessionChangesApplied: true }; + const persistenceState: NonNullable = { + outcome: { kind: "pending", provider: "anthropic", model: "claude-opus-4-6" }, + }; try { const result = await handleDirectiveOnly( @@ -2199,7 +2307,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { ); expect(result?.text).toContain("Model change was not applied"); - expect(persistenceState.sessionChangesApplied).toBe(false); + expect(persistenceState.outcome).toMatchObject({ kind: "rejected" }); expect(queueMocks.refreshQueuedFollowupSession).not.toHaveBeenCalled(); expect(enqueueSystemEvent).not.toHaveBeenCalledWith( expect.stringContaining("openai/gpt-4o"), @@ -2837,7 +2945,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }); }); -describe("persistInlineDirectives session directive persistence policy", () => { +describe("canonical session directive persistence policy", () => { it("checks an explicit same-value model selection against persisted state", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-inline-model-race-")); const storePath = path.join(tempRoot, "sessions.json"); @@ -2856,31 +2964,22 @@ describe("persistInlineDirectives session directive persistence policy", () => { const directives = parseInlineDirectives("hello /model openai/gpt-4o"); try { - const result = await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, - cfg: baseConfig(), - agentDir: TEST_AGENT_DIR, - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - storePath, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: baseAliasIndex(), - allowedModelKeys: new Set(["openai/gpt-4o"]), - modelCatalog: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }], - provider: "openai", - model: "gpt-4o", - initialModelLabel: "openai/gpt-4o", - formatModelSwitchEvent: (label) => `Switched to ${label}`, - agentCfg: undefined, - }); + const result = await handleDirectiveOnly( + createDirectiveHandlingParams({ + directives, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }], + provider: "openai", + model: "gpt-4o", + initialModelLabel: "openai/gpt-4o", + }), + ); - expect(result.sessionChangesApplied).toBe(false); - expect(result).toMatchObject({ provider: "openai", model: "gpt-5.5" }); + expect(result?.text).toContain("Model change was not applied"); expect(sessionEntry).toMatchObject({ providerOverride: "openai", modelOverride: "gpt-5.5", @@ -2914,35 +3013,22 @@ describe("persistInlineDirectives session directive persistence policy", () => { }); try { - const result = await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, - cfg: baseConfig(), - agentDir: TEST_AGENT_DIR, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: baseAliasIndex(), - allowedModelKeys: new Set(["anthropic/claude-opus-4-6", "openai/gpt-4o"]), - modelCatalog: [ - { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.5" }, - { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, - ], - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => `Switched to ${label}`, - canPersistStickyModelSelection: true, - agentCfg: undefined, - }); + const result = await handleDirectiveOnly( + createDirectiveHandlingParams({ + directives, + sessionEntry, + sessionStore, + sessionKey, + storePath, + allowedModelCatalog: [ + { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.5" }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + ], + canPersistStickyModelSelection: true, + }), + ); - expect(result).toMatchObject({ provider: "openai", model: "gpt-5.5" }); - expect(result.sessionChangesApplied).toBe(false); + expect(result?.text).toContain("Model change was not applied"); expect(enqueueSystemEvent).not.toHaveBeenCalledWith( expect.stringContaining("openai/gpt-4o"), expect.anything(), diff --git a/src/auto-reply/reply/directive-handling.params.ts b/src/auto-reply/reply/directive-handling.params.ts index 4aafee665696..83e7d937226c 100644 --- a/src/auto-reply/reply/directive-handling.params.ts +++ b/src/auto-reply/reply/directive-handling.params.ts @@ -1,5 +1,5 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce"; -/** Parameter contracts shared by directive-only and fast-lane directive handlers. */ +/** Parameter contracts for the canonical directive transaction handler. */ import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -51,27 +51,10 @@ export type HandleDirectiveOnlyParams = HandleDirectiveOnlyCoreParams & { gatewayClientScopes?: string[]; commandAuthorized?: boolean; senderIsOwner?: boolean; - /** Internal handoff for mixed inline directives to avoid retrying rejected writes. */ - persistenceState?: { sessionChangesApplied: boolean }; -}; - -/** Inputs for applying inline directives before the full reply run is prepared. */ -export type ApplyInlineDirectivesFastLaneParams = HandleDirectiveOnlyCoreParams & { - commandAuthorized: boolean; - senderIsOwner: boolean; - ctx: MsgContext; - workspaceDir?: string; - agentId?: string; - isGroup: boolean; - agentCfg?: NonNullable["defaults"]; - modelState: { - resolveDefaultThinkingLevel: () => Promise; - resolveThinkingCatalog: () => Promise; - allowedModelKeys: Set; - allowedModelCatalog: Awaited< - ReturnType - >; - policyAliasIndex?: ModelAliasIndex; - resetModelOverride: boolean; + /** Mixed messages consume the transaction outcome without repeating persistence. */ + persistenceState?: { + outcome: + | { kind: "pending" | "applied"; provider: string; model: string } + | { kind: "rejected"; errorText: string }; }; }; diff --git a/src/auto-reply/reply/directive-handling.persist.runtime.ts b/src/auto-reply/reply/directive-handling.persist.runtime.ts index 394957a28bad..6d78a0846469 100644 --- a/src/auto-reply/reply/directive-handling.persist.runtime.ts +++ b/src/auto-reply/reply/directive-handling.persist.runtime.ts @@ -1,4 +1,2 @@ -/** Runtime facade for persisting inline directive state after parsing. */ -export { persistInlineDirectives } from "./directive-handling.persist.js"; -// This facade is the lazy boundary used by get-reply's directive-only fast path. +/** Lazy runtime boundary for the shipped model-selection service. */ export { applySessionModelSelection } from "../../model-picker/apply-session-model-selection.js"; diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts deleted file mode 100644 index 953e7ffaaf58..000000000000 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ /dev/null @@ -1,398 +0,0 @@ -// Persists directive-derived session preferences such as model and auth choices. -import { - resolveAgentDir, - resolveDefaultAgentId, - resolveSessionAgentId, -} from "../../agents/agent-scope.js"; -import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; -import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; -import { modelKey, type ModelAliasIndex } from "../../agents/model-selection.js"; -import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js"; -import { persistStickyModelSelectionBestEffort } from "../../agents/sticky-model-selection.js"; -import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { applySessionModelSelectionToEntry } from "../../model-picker/apply-session-model-selection.js"; -import { - formatThinkingLevels, - isThinkingLevelSupported, - resolveSupportedThinkingLevel, -} from "../thinking.js"; -import { - applyModelRuntimeDirective, - resolveModelRuntimeDirective, -} from "./directive-handling.model-runtime.js"; -import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; -import type { InlineDirectives } from "./directive-handling.parse.js"; -import { - applySessionDirectiveFields, - canPersistSessionDirectiveDefaults, - enqueueModeSwitchEvents, - persistSessionDirectiveSnapshot, - resolveDirectiveTouchedSessionFields, -} from "./directive-handling.shared.js"; -import type { ThinkLevel } from "./directives.js"; -import { resolveContextTokens } from "./model-selection.js"; -import { refreshQueuedFollowupSession } from "./queue.js"; - -type PersistedThinkingLevelRemap = { - from: ThinkLevel; - to: ThinkLevel; - provider: string; - model: string; -}; - -export async function persistInlineDirectives(params: { - directives: InlineDirectives; - effectiveModelDirective?: string; - cfg: OpenClawConfig; - agentDir?: string; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - storePath?: string; - elevatedEnabled: boolean; - elevatedAllowed: boolean; - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; - allowedModelKeys: Set; - provider: string; - model: string; - initialModelLabel: string; - formatModelSwitchEvent: (label: string, alias?: string) => string; - canPersistStickyModelSelection?: boolean; - agentCfg: NonNullable["defaults"] | undefined; - messageProvider?: string; - surface?: string; - gatewayClientScopes?: string[]; - commandAuthorized?: boolean; - senderIsOwner?: boolean; - markLiveSwitchPending?: boolean; - modelCatalog?: ModelCatalogEntry[]; - thinkingCatalog?: ModelCatalogEntry[]; -}): Promise<{ - provider: string; - model: string; - contextTokens: number; - sessionChangesApplied: boolean; - thinkingRemap?: PersistedThinkingLevelRemap; - errorText?: string; - runtimeChange?: { kind: "clear" } | { kind: "set"; runtime: string }; -}> { - const { - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - initialModelLabel, - formatModelSwitchEvent, - agentCfg, - } = params; - let { provider, model } = params; - let thinkingRemap: PersistedThinkingLevelRemap | undefined; - let sessionChangesApplied = true; - const allowPrivilegedPersistence = canPersistSessionDirectiveDefaults({ - messageProvider: params.messageProvider, - surface: params.surface, - gatewayClientScopes: params.gatewayClientScopes, - commandAuthorized: params.commandAuthorized, - senderIsOwner: params.senderIsOwner, - }); - const touchedSessionFields = resolveDirectiveTouchedSessionFields({ - directives, - allowPrivilegedPersistence, - }); - const thinkingCatalog = - params.thinkingCatalog && params.thinkingCatalog.length > 0 - ? params.thinkingCatalog - : undefined; - const delegatedTraceAllowed = (params.gatewayClientScopes ?? []).includes("operator.admin"); - const activeAgentId = sessionKey - ? resolveSessionAgentId({ sessionKey, config: cfg }) - : resolveDefaultAgentId(cfg); - const agentDir = resolveAgentDir(cfg, activeAgentId) ?? params.agentDir; - const modelDirective = - directives.hasModelDirective && params.effectiveModelDirective - ? params.effectiveModelDirective - : undefined; - const modelResolution = modelDirective - ? resolveModelSelectionFromDirective({ - directives: { - ...directives, - hasModelDirective: true, - rawModelDirective: modelDirective, - }, - cfg, - agentDir, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - allowedModelCatalog: params.modelCatalog ?? [], - provider, - agentId: activeAgentId, - }) - : undefined; - const modelRuntimeResolution = modelResolution?.modelSelection - ? resolveModelRuntimeDirective({ - rawRuntime: directives.rawModelRuntime, - provider: modelResolution.modelSelection.provider, - cfg, - sessionEntry, - }) - : ({ kind: "unchanged" } as const); - let thinkingErrorText: string | undefined; - if (directives.hasThinkDirective && directives.thinkLevel) { - const resolvedProvider = modelResolution?.modelSelection?.provider ?? provider; - const resolvedModel = modelResolution?.modelSelection?.model ?? model; - const prospectiveSessionEntry = { ...sessionEntry }; - applyModelRuntimeDirective(prospectiveSessionEntry, modelRuntimeResolution); - const prospectiveThinkingRuntime = resolveEffectiveAgentRuntime({ - cfg, - provider: resolvedProvider, - modelId: resolvedModel, - agentId: activeAgentId, - sessionKey, - sessionEntry: prospectiveSessionEntry, - }); - if ( - !isThinkingLevelSupported({ - provider: resolvedProvider, - model: resolvedModel, - level: directives.thinkLevel, - catalog: thinkingCatalog, - agentRuntime: prospectiveThinkingRuntime, - }) - ) { - thinkingErrorText = `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, prospectiveThinkingRuntime)}.`; - } - } - const errorText = - modelResolution?.errorText ?? - (modelRuntimeResolution.kind === "invalid" ? modelRuntimeResolution.errorText : undefined) ?? - thinkingErrorText; - let modelRuntimeApplied = false; - - if (!errorText && sessionEntry && sessionStore && sessionKey) { - const initialSessionEntry = { ...sessionEntry }; - const appliedSessionEntry = sessionEntry; - const elevatedChanged = - directives.hasElevatedDirective && - directives.elevatedLevel !== undefined && - elevatedEnabled && - elevatedAllowed; - const reasoningChanged = - directives.hasReasoningDirective && directives.reasoningLevel !== undefined; - let updated = applySessionDirectiveFields({ - directives, - sessionEntry, - allowPrivilegedPersistence, - allowTracePersistence: params.senderIsOwner === true || delegatedTraceAllowed, - allowElevatedPersistence: elevatedEnabled && elevatedAllowed, - persistDirectiveOnlyFields: false, - }); - - let modelUpdated = false; - let modelApplied = true; - let modelSwitchEvent: { alias?: string; label: string } | undefined; - if (modelDirective && modelResolution?.modelSelection) { - if (modelRuntimeResolution.kind === "invalid") { - throw new Error("invalid model runtime reached persistence"); - } - const appliedSelection = applySessionModelSelectionToEntry({ - entry: sessionEntry, - request: { - ...modelResolution.modelSelection, - profileOverride: modelResolution.profileOverride, - runtime: modelRuntimeResolution, - }, - runtime: modelRuntimeResolution, - markLiveSwitchPending: params.markLiveSwitchPending, - }); - modelUpdated = appliedSelection.changed; - provider = modelResolution.modelSelection.provider; - model = modelResolution.modelSelection.model; - const thinkingRuntime = resolveEffectiveAgentRuntime({ - cfg, - provider, - modelId: model, - agentId: activeAgentId, - sessionKey, - sessionEntry, - }); - const currentThinkingLevel = sessionEntry.thinkingLevel as ThinkLevel | undefined; - if ( - currentThinkingLevel && - !directives.hasThinkDirective && - !isThinkingLevelSupported({ - provider, - model, - level: currentThinkingLevel, - catalog: thinkingCatalog, - agentRuntime: thinkingRuntime, - }) - ) { - const remappedThinkingLevel = resolveSupportedThinkingLevel({ - provider, - model, - level: currentThinkingLevel, - catalog: thinkingCatalog, - agentRuntime: thinkingRuntime, - }); - if (remappedThinkingLevel !== currentThinkingLevel) { - sessionEntry.thinkingLevel = remappedThinkingLevel; - thinkingRemap = { - from: currentThinkingLevel, - to: remappedThinkingLevel, - provider, - model, - }; - } - } - const nextLabel = `${provider}/${model}`; - if (nextLabel !== initialModelLabel) { - modelSwitchEvent = { - label: nextLabel, - ...(modelResolution.modelSelection.alias - ? { alias: modelResolution.modelSelection.alias } - : {}), - }; - } - // Explicit model selections must still perform the atomic persisted - // winner check when their value matches the local snapshot. - updated = true; - } - if (updated) { - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - const persistence = await persistSessionDirectiveSnapshot({ - storePath, - sessionKey, - initialEntry: initialSessionEntry, - sessionEntry, - sessionStore, - hasModelSelection: Boolean(modelDirective), - reassertLiveModelSwitchPending: - modelUpdated && - params.markLiveSwitchPending === true && - sessionEntry.liveModelSwitchPending === true, - touchedFields: touchedSessionFields, - }); - sessionChangesApplied = persistence.sessionChangesApplied; - modelApplied = persistence.modelSelectionApplied; - } - if (modelDirective && !modelApplied) { - sessionChangesApplied = false; - const persistedEntry = sessionStore[sessionKey]; - provider = persistedEntry?.providerOverride?.trim() || defaultProvider; - model = persistedEntry?.modelOverride?.trim() || defaultModel; - thinkingRemap = undefined; - } - if ( - modelDirective && - modelResolution?.modelSelection && - modelApplied && - !modelResolution.modelSelection.isDefault && - params.canPersistStickyModelSelection === true - ) { - persistStickyModelSelectionBestEffort({ - agentId: activeAgentId, - model: `${provider}/${model}`, - }); - } - if (modelDirective && modelUpdated && modelApplied) { - triggerSessionPatchHook({ - cfg, - sessionEntry: appliedSessionEntry, - sessionKey, - patch: { key: sessionKey, model: modelDirective }, - }); - refreshQueuedFollowupSession({ - key: sessionKey, - nextProvider: provider, - nextModel: model, - nextRouteResolution: "resolved", - nextModelOverrideSource: "user", - nextAuthProfileId: appliedSessionEntry.authProfileOverride, - nextAuthProfileIdSource: appliedSessionEntry.authProfileOverrideSource, - nextThinking: { - level: appliedSessionEntry.thinkingLevel, - catalog: thinkingCatalog, - agentRuntime: resolveEffectiveAgentRuntime({ - cfg, - provider, - modelId: model, - agentId: activeAgentId, - sessionKey, - sessionEntry: appliedSessionEntry, - }), - }, - }); - } - if (sessionChangesApplied) { - enqueueModeSwitchEvents({ - enqueueSystemEvent, - sessionEntry: appliedSessionEntry, - sessionKey, - elevatedChanged, - reasoningChanged, - }); - } - } - modelRuntimeApplied = - modelApplied && - (modelRuntimeResolution.kind === "clear" || modelRuntimeResolution.kind === "set"); - if (modelSwitchEvent && modelApplied) { - enqueueSystemEvent(formatModelSwitchEvent(modelSwitchEvent.label, modelSwitchEvent.alias), { - sessionKey, - contextKey: `model:${modelSwitchEvent.label}`, - }); - } - } - - const selectedCatalogEntry = params.modelCatalog?.find( - (entry) => modelKey(entry.provider, entry.id) === modelKey(provider, model), - ); - return { - provider, - model, - thinkingRemap, - errorText, - runtimeChange: - modelRuntimeApplied && - (modelRuntimeResolution.kind === "clear" || modelRuntimeResolution.kind === "set") - ? modelRuntimeResolution - : undefined, - sessionChangesApplied, - contextTokens: resolveContextTokens({ - cfg, - agentCfg, - provider: resolveContextConfigProviderForRuntime({ - provider, - runtimeId: resolveAgentHarnessPolicy({ - provider, - modelId: model, - config: cfg, - agentId: activeAgentId, - sessionKey, - }).runtime, - config: cfg, - }), - model, - modelContextWindow: selectedCatalogEntry?.contextWindow, - modelContextTokens: selectedCatalogEntry?.contextTokens, - }), - }; -} diff --git a/src/auto-reply/reply/directive-handling.shared.ts b/src/auto-reply/reply/directive-handling.shared.ts index c11daa812ea2..f9c11d270a49 100644 --- a/src/auto-reply/reply/directive-handling.shared.ts +++ b/src/auto-reply/reply/directive-handling.shared.ts @@ -11,10 +11,36 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import { SYSTEM_MARK, prefixSystemMessage } from "../../infra/system-message.js"; import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import type { ReplyPayload } from "../types.js"; +import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; import { persistReplySessionEntry } from "./session-entry-persistence.js"; +export const DIRECTIVE_ACK_MESSAGES = { + verbose: { + off: "Verbose logging disabled.", + on: "Verbose logging enabled.", + full: "Verbose logging set to full.", + }, + trace: { + off: "Trace disabled.", + on: "Trace enabled. Warning: trace output may contain sensitive information.", + raw: "Trace set to raw. Warning: trace output may contain sensitive information.", + }, + reasoning: { + off: "Reasoning visibility disabled.", + on: "Reasoning visibility enabled.", + stream: "Reasoning stream enabled.", + }, + elevated: { + off: "Elevated mode disabled.", + on: "Elevated mode set to ask (approvals may still apply).", + ask: "Elevated mode set to ask (approvals may still apply).", + full: "Elevated mode set to full (auto-approve).", + }, +} as const; + export const formatDirectiveAck = (text: string): string => { return prefixSystemMessage(text); }; @@ -118,6 +144,60 @@ export function resolveDirectiveTouchedSessionFields(params: { return [...fields]; } +export type IgnoredSessionDirectiveFlag = Extract; + +export function rejectSessionDirectiveTransaction( + persistenceState: HandleDirectiveOnlyParams["persistenceState"], + errorText: string, +): ReplyPayload { + if (persistenceState) { + persistenceState.outcome = { kind: "rejected", errorText }; + } + return { text: errorText }; +} + +/** Keeps the first informational/denied acknowledgement while committing valid siblings once. */ +export async function acknowledgeIgnoredSessionDirective(params: { + reply: ReplyPayload; + directives: InlineDirectives; + ignoredDirective: IgnoredSessionDirectiveFlag; + persistenceState: HandleDirectiveOnlyParams["persistenceState"]; + allowPrivilegedPersistence: boolean; + applyRemainingDirectives: (directives: InlineDirectives) => Promise; +}): Promise { + if (!params.persistenceState) { + return params.reply; + } + const { directives, ignoredDirective } = params; + const remainingDirectives = + ignoredDirective === "hasExecDirective" && directives.hasExecOptions + ? { + ...directives, + invalidExecHost: false, + invalidExecSecurity: false, + invalidExecAsk: false, + invalidExecNode: false, + } + : { + ...directives, + [ignoredDirective]: false, + ...(ignoredDirective === "hasThinkDirective" ? { clearThinkLevel: false } : {}), + ...(ignoredDirective === "hasFastDirective" ? { clearFastMode: false } : {}), + ...(ignoredDirective === "hasModelDirective" ? { rawModelProfile: undefined } : {}), + }; + const touchedFields = resolveDirectiveTouchedSessionFields({ + directives: remainingDirectives, + allowPrivilegedPersistence: params.allowPrivilegedPersistence, + }); + if (touchedFields.length > 0) { + const siblingReply = await params.applyRemainingDirectives(remainingDirectives); + if (params.persistenceState.outcome.kind === "rejected") { + return siblingReply ?? params.reply; + } + } + return params.reply; +} + /** Applies canonical session settings while each caller retains its authorization boundaries. */ export function applySessionDirectiveFields(params: { directives: InlineDirectives; @@ -219,7 +299,7 @@ export async function persistSessionDirectiveSnapshot(params: { touchedFields: Array; hasModelSelection: boolean; reassertLiveModelSwitchPending: boolean; -}): Promise<{ sessionChangesApplied: boolean; modelSelectionApplied: boolean }> { +}): Promise<{ status: "applied" | "conflict" | "model-selection-locked" }> { const { sessionEntry, sessionKey, sessionStore } = params; const persistence = await persistReplySessionEntry({ storePath: params.storePath, @@ -227,13 +307,17 @@ export async function persistSessionDirectiveSnapshot(params: { initialEntry: params.initialEntry, entry: sessionEntry, reassertLiveModelSwitchPending: params.reassertLiveModelSwitchPending, + requireModelSelectionUnlocked: params.hasModelSelection, touchedFields: params.touchedFields, }); if (persistence.status !== "current") { if (persistence.entry) { sessionStore[sessionKey] = persistence.entry; + adoptPersistedSessionSnapshot(sessionEntry, persistence.entry); } - return { sessionChangesApplied: false, modelSelectionApplied: false }; + return { + status: persistence.status === "model-selection-locked" ? persistence.status : "conflict", + }; } const persistedEntry = persistence.entry; @@ -254,7 +338,7 @@ export async function persistSessionDirectiveSnapshot(params: { reassertLiveModelSwitchPending: params.reassertLiveModelSwitchPending, })); adoptPersistedSessionSnapshot(sessionEntry, persistedEntry); - return { sessionChangesApplied, modelSelectionApplied }; + return { status: sessionChangesApplied && modelSelectionApplied ? "applied" : "conflict" }; } const formatElevatedEvent = (level: ElevatedLevel) => { diff --git a/src/auto-reply/reply/get-reply-directives-apply.test.ts b/src/auto-reply/reply/get-reply-directives-apply.test.ts index a1393ddbc3f8..c6be6ca37edc 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.test.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.test.ts @@ -7,8 +7,8 @@ import { createFastTestModelSelectionState } from "./model-selection.js"; import { buildTestCtx } from "./test-ctx.js"; const mocks = vi.hoisted(() => ({ - fastLane: vi.fn(), - persist: vi.fn(), + handleDirective: vi.fn(), + applyModelSelection: vi.fn(), systemEvent: vi.fn(), })); @@ -16,17 +16,17 @@ vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => mocks.systemEvent(...args), })); -vi.mock("./directive-handling.fast-lane.js", () => ({ - applyInlineDirectivesFastLane: (...args: unknown[]) => mocks.fastLane(...args), +vi.mock("./directive-handling.impl.js", () => ({ + handleDirectiveOnly: (...args: unknown[]) => mocks.handleDirective(...args), })); vi.mock("./directive-handling.persist.runtime.js", () => ({ - persistInlineDirectives: (...args: unknown[]) => mocks.persist(...args), + applySessionModelSelection: (...args: unknown[]) => mocks.applyModelSelection(...args), })); beforeEach(() => { - mocks.fastLane.mockReset(); - mocks.persist.mockReset(); + mocks.handleDirective.mockReset(); + mocks.applyModelSelection.mockReset(); mocks.systemEvent.mockReset(); }); @@ -155,8 +155,8 @@ describe("applyInlineDirectiveOverrides", () => { reply: { text: MODEL_SELECTION_LOCKED_MESSAGE }, }); expect(typing.cleanup).toHaveBeenCalledOnce(); - expect(mocks.fastLane).not.toHaveBeenCalled(); - expect(mocks.persist).not.toHaveBeenCalled(); + expect(mocks.handleDirective).not.toHaveBeenCalled(); + expect(mocks.applyModelSelection).not.toHaveBeenCalled(); expect(mocks.systemEvent).toHaveBeenCalledWith(expected, { sessionKey: "agent:main:main", contextKey: "model:reset:openai/gpt-5.5", @@ -173,19 +173,12 @@ describe("applyInlineDirectiveOverrides", () => { }, ); - it("stops a mixed inline turn when final directive persistence loses", async () => { + it("stops a mixed inline turn when its single directive transaction loses", async () => { const directives = parseInlineDirectives("hello /elevated full"); - mocks.fastLane.mockResolvedValue({ - directiveAck: { text: "Elevated FULL enabled." }, - provider: "openai", - model: "gpt-5.5", - sessionChangesApplied: true, - }); - mocks.persist.mockResolvedValue({ - provider: "openai", - model: "gpt-5.5", - contextTokens: 8192, - sessionChangesApplied: false, + const errorText = "Session settings were not applied because the session changed. Retry."; + mocks.handleDirective.mockImplementation(async (params) => { + params.persistenceState.outcome = { kind: "rejected", errorText }; + return { text: errorText }; }); const typing = { onReplyStart: async () => {}, @@ -246,27 +239,19 @@ describe("applyInlineDirectiveOverrides", () => { expect(result).toEqual({ kind: "reply", - reply: { text: "Session settings were not applied because the session changed. Retry." }, + reply: { text: errorText }, }); expect(typing.cleanup).toHaveBeenCalledOnce(); + expect(mocks.handleDirective).toHaveBeenCalledOnce(); }); - it("stops a mixed inline turn when final thinking validation fails", async () => { + it("stops a mixed inline turn when its transaction rejects unsupported thinking", async () => { const errorText = 'Thinking level "ultra" is not supported for openai/gpt-5.6-luna. Use one of: off, low, medium, high, max.'; const directives = parseInlineDirectives("/think ultra please solve"); - mocks.fastLane.mockResolvedValue({ - directiveAck: { text: errorText }, - provider: "openai", - model: "gpt-5.6-luna", - sessionChangesApplied: true, - }); - mocks.persist.mockResolvedValue({ - provider: "openai", - model: "gpt-5.6-luna", - contextTokens: 372_000, - sessionChangesApplied: true, - errorText, + mocks.handleDirective.mockImplementation(async (params) => { + params.persistenceState.outcome = { kind: "rejected", errorText }; + return { text: errorText }; }); const typing = { onReplyStart: async () => {}, @@ -327,5 +312,6 @@ describe("applyInlineDirectiveOverrides", () => { expect(result).toEqual({ kind: "reply", reply: { text: errorText } }); expect(typing.cleanup).toHaveBeenCalledOnce(); + expect(mocks.handleDirective).toHaveBeenCalledOnce(); }); }); diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 610acb3aad26..f2ded06432df 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -1,4 +1,8 @@ // Applies parsed directives to session state, config overrides, and run options. +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; +import { modelKey } from "../../agents/model-selection.js"; +import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js"; import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; @@ -15,9 +19,10 @@ import { isDirectiveOnly } from "./directive-handling.directive-only.js"; import { resolveModelRuntimeDirective } from "./directive-handling.model-runtime.js"; import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; import { maybeHandleUnexpectedNativeDirectiveArguments } from "./directive-handling.native.js"; -import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; +import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; +import { resolveContextTokens } from "./model-selection-context.js"; import type { createModelSelectionState } from "./model-selection.js"; import type { TypingController } from "./typing.js"; @@ -29,9 +34,6 @@ const directiveLevelsLoader = createLazyImportLoader( () => import("./directive-handling.levels.js"), ); const directiveImplLoader = createLazyImportLoader(() => import("./directive-handling.impl.js")); -const directiveFastLaneLoader = createLazyImportLoader( - () => import("./directive-handling.fast-lane.js"), -); const directivePersistLoader = createLazyImportLoader( () => import("./directive-handling.persist.runtime.js"), ); @@ -48,10 +50,6 @@ function loadDirectiveImpl() { return directiveImplLoader.load(); } -function loadDirectiveFastLane() { - return directiveFastLaneLoader.load(); -} - function loadDirectivePersist() { return directivePersistLoader.load(); } @@ -139,7 +137,7 @@ export async function applyInlineDirectiveOverrides(params: { elevatedFailures: Array<{ gate: string; key: string }>; defaultProvider: string; defaultModel: string; - aliasIndex: ApplyInlineDirectivesFastLaneParams["aliasIndex"]; + aliasIndex: HandleDirectiveOnlyParams["aliasIndex"]; provider: string; model: string; modelState: Awaited>; @@ -288,34 +286,6 @@ export async function applyInlineDirectiveOverrides(params: { }; } - const directivePersistenceContext = { - directives, - effectiveModelDirective, - cfg, - agentDir, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, - modelCatalog: modelState.allowedModelCatalog, - thinkingCatalog: modelState.allowedModelCatalog, - initialModelLabel, - formatModelSwitchEvent, - canPersistStickyModelSelection, - agentCfg, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - commandAuthorized: command.isAuthorizedSender, - senderIsOwner: command.senderIsOwner, - }; - // Model-only directives have a focused persistence service; reject leftovers before that mutation. if (directives.nativeCommand?.name === "model") { const unexpectedNativeArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); @@ -325,16 +295,49 @@ export async function applyInlineDirectiveOverrides(params: { } } - if ( - isDirectiveOnly({ - directives, - cleanedBody: directives.cleaned, + const directiveOnly = isDirectiveOnly({ + directives, + cleanedBody: directives.cleaned, + ctx, + cfg, + agentId, + isGroup, + }); + + const handleDirectives = async ( + persistenceState?: NonNullable, + ) => { + const currentLevels = await ( + await loadDirectiveLevels() + ).resolveCurrentDirectiveLevels({ + sessionEntry, + agentEntry: persistenceState ? undefined : agentEntry, + agentCfg, + resolveDefaultThinkingLevel: + !persistenceState || directives.hasThinkDirective + ? () => modelState.resolveDefaultThinkingLevel() + : async () => undefined, + }); + const thinkingCatalog = await modelState.resolveThinkingCatalog(); + const reply = await ( + await loadDirectiveImpl() + ).handleDirectiveOnly({ + ...createDirectiveHandlingBase(), + ...currentLevels, + thinkingCatalog, ctx, - cfg, - agentId, - isGroup, - }) - ) { + messageProvider: ctx.Provider, + surface: ctx.Surface, + gatewayClientScopes: ctx.GatewayClientScopes, + commandAuthorized: command.isAuthorizedSender, + senderIsOwner: command.senderIsOwner, + workspaceDir, + ...(persistenceState ? { persistenceState } : {}), + }); + return { reply, currentLevels, thinkingCatalog }; + }; + + if (directiveOnly) { if (!command.isAuthorizedSender) { typing.cleanup(); return { kind: "reply", reply: undefined }; @@ -430,40 +433,12 @@ export async function applyInlineDirectiveOverrides(params: { return { kind: "reply", reply: { text: parts.join(" ") } }; } } + const { reply: directiveReply, currentLevels, thinkingCatalog } = await handleDirectives(); const { currentThinkLevel: resolvedDefaultThinkLevel, - currentFastMode, currentVerboseLevel, currentReasoningLevel, - currentElevatedLevel, - } = await ( - await loadDirectiveLevels() - ).resolveCurrentDirectiveLevels({ - sessionEntry, - agentEntry, - agentCfg, - resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), - }); - const currentThinkLevel = resolvedDefaultThinkLevel; - const thinkingCatalog = await modelState.resolveThinkingCatalog(); - const directiveReply = await ( - await loadDirectiveImpl() - ).handleDirectiveOnly({ - ...createDirectiveHandlingBase(), - thinkingCatalog, - currentThinkLevel, - currentFastMode, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - ctx, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - commandAuthorized: command.isAuthorizedSender, - senderIsOwner: command.senderIsOwner, - workspaceDir, - }); + } = currentLevels; let statusReply: ReplyPayload | undefined; if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) { const { buildStatusReply } = await loadCommandsStatus(); @@ -502,80 +477,41 @@ export async function applyInlineDirectiveOverrides(params: { } if (hasAnyDirective && command.isAuthorizedSender) { - const fastLane = await ( - await loadDirectiveFastLane() - ).applyInlineDirectivesFastLane({ - directives, - commandAuthorized: command.isAuthorizedSender, - senderIsOwner: command.senderIsOwner, - ctx, - workspaceDir, - cfg, - agentId, - isGroup, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - ...directiveModelState, - provider, - model, - initialModelLabel, - formatModelSwitchEvent, - canPersistStickyModelSelection, - agentCfg, - modelState: { - resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, - resolveThinkingCatalog: modelState.resolveThinkingCatalog, - ...directiveModelState, - }, - }); - directiveAck = fastLane.directiveAck; - provider = fastLane.provider; - model = fastLane.model; - if (!fastLane.sessionChangesApplied) { + const persistenceState: NonNullable = { + outcome: { kind: "pending", provider, model }, + }; + directiveAck = (await handleDirectives(persistenceState)).reply; + if (persistenceState.outcome.kind === "rejected") { typing.cleanup(); return { kind: "reply", - reply: - directiveAck ?? - ({ - text: "Session settings were not applied because the session changed. Retry.", - } satisfies ReplyPayload), + reply: { text: persistenceState.outcome.errorText }, }; } + ({ provider, model } = persistenceState.outcome); } - const persisted = await ( - await loadDirectivePersist() - ).persistInlineDirectives({ - ...directivePersistenceContext, - provider, + const selectedCatalogEntry = modelState.allowedModelCatalog.find( + (entry) => modelKey(entry.provider, entry.id) === modelKey(provider, model), + ); + contextTokens = resolveContextTokens({ + cfg, + agentCfg, + provider: resolveContextConfigProviderForRuntime({ + provider, + runtimeId: resolveAgentHarnessPolicy({ + provider, + modelId: model, + config: cfg, + agentId: resolveSessionAgentId({ sessionKey, config: cfg }), + sessionKey, + }).runtime, + config: cfg, + }), model, + modelContextWindow: selectedCatalogEntry?.contextWindow, + modelContextTokens: selectedCatalogEntry?.contextTokens, }); - provider = persisted.provider; - model = persisted.model; - contextTokens = persisted.contextTokens; - if (persisted.errorText) { - typing.cleanup(); - return { kind: "reply", reply: { text: persisted.errorText } }; - } - if (!persisted.sessionChangesApplied) { - typing.cleanup(); - return { - kind: "reply", - reply: { - text: "Session settings were not applied because the session changed. Retry.", - }, - }; - } const perMessageQueueMode = directives.hasQueueDirective && !directives.queueReset ? directives.queueMode : undefined; diff --git a/src/model-picker/apply-session-model-selection.ts b/src/model-picker/apply-session-model-selection.ts index a6cda1ff3ed4..a5ef2f024953 100644 --- a/src/model-picker/apply-session-model-selection.ts +++ b/src/model-picker/apply-session-model-selection.ts @@ -95,11 +95,8 @@ type ApplySessionModelSelectionToEntryResult = { runtimeChange?: { kind: "clear" } | { kind: "set"; runtime: string }; }; -/** - * Applies the model transaction field family to one caller-owned snapshot. - * Mixed directives reuse this mutator and retain their single broad persistence transaction. - */ -export function applySessionModelSelectionToEntry(params: { +/** Applies the model transaction field family to one caller-owned snapshot. */ +function applySessionModelSelectionToEntry(params: { entry: SessionEntry; request: SessionModelSelectionRequest; runtime: AppliedRuntimeDirective; From 428a9e9cbcaf038f26d5486762e30b947dc74cae Mon Sep 17 00:00:00 2001 From: WhatsSkiLL Date: Sat, 1 Aug 2026 20:11:19 +0200 Subject: [PATCH 29/53] fix(update): return failure when dirty checkout blocks update (#117452) Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> --- src/cli/update-cli.test.ts | 2 +- .../update-command-post-update.test.ts | 39 +++++++++++++++++++ .../update-cli/update-command-post-update.ts | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 1ca2851c9ff1..84fa03cf52b4 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -6140,7 +6140,7 @@ describe("update-cli", () => { "Commit, stash, or discard the local changes, then rerun `openclaw update`.", ); expect(serviceStop).not.toHaveBeenCalled(); - expect(defaultRuntime.exit).toHaveBeenCalledWith(0); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); it.each([ { diff --git a/src/cli/update-cli/update-command-post-update.test.ts b/src/cli/update-cli/update-command-post-update.test.ts index 27c6f701283d..f8eeb2faceae 100644 --- a/src/cli/update-cli/update-command-post-update.test.ts +++ b/src/cli/update-cli/update-command-post-update.test.ts @@ -44,6 +44,45 @@ async function finishFailedUpdate(result: UpdateRunResult): Promise { } as unknown as FinishUpdateParams); } +async function finishSkippedUpdate(reason: string): Promise { + await finishUpdate({ + result: { + status: "skipped", + mode: reason === "dirty" ? "git" : "unknown", + reason, + steps: [], + durationMs: 1, + }, + opts: {}, + showProgress: false, + controlPlaneUpdateSentinelMeta: undefined, + } as unknown as FinishUpdateParams); +} + +describe("skipped update exit status", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never); + vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined); + }); + + it("exits nonzero when local changes block a Git update", async () => { + await finishSkippedUpdate("dirty"); + + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Update blocked: local files are edited"), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("keeps a non-Git install skip successful", async () => { + await finishSkippedUpdate("not-git-install"); + + expect(defaultRuntime.exit).toHaveBeenCalledWith(0); + }); +}); + describe("failed Git update recovery restart", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index ac7d91854660..59477277c0a3 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -169,7 +169,7 @@ export async function finishUpdate(params: { ), ); } - defaultRuntime.exit(0); + defaultRuntime.exit(params.result.reason === "dirty" ? 1 : 0); return; } From d876a8338722792b02eb763b6974a5e1cca48058 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:21:48 -0700 Subject: [PATCH 30/53] refactor(tui): consolidate runtime ownership (#117368) --- src/auto-reply/reply/commands-goal.test.ts | 18 +- src/auto-reply/reply/commands-goal.ts | 248 +++++++------ src/tui/embedded-backend.test.ts | 65 ++-- src/tui/embedded-backend.ts | 172 ++-------- src/tui/tui-backend.ts | 4 +- src/tui/tui-command-handlers.test.ts | 22 +- src/tui/tui-command-handlers.ts | 250 +++++--------- src/tui/tui-session-run-coordinator.test.ts | 20 +- src/tui/tui-session-run-coordinator.ts | 18 + src/tui/tui.ts | 363 +++++++------------- 10 files changed, 443 insertions(+), 737 deletions(-) diff --git a/src/auto-reply/reply/commands-goal.test.ts b/src/auto-reply/reply/commands-goal.test.ts index ccc481d61226..325921d6aafd 100644 --- a/src/auto-reply/reply/commands-goal.test.ts +++ b/src/auto-reply/reply/commands-goal.test.ts @@ -7,11 +7,7 @@ import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/ses import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { takeCommandSessionMetadataChanges } from "./command-session-metadata.js"; -import { - formatGoalContinuationPrompt, - handleGoalCommand, - parseGoalCommand, -} from "./commands-goal.js"; +import { handleGoalCommand, parseGoalCommand } from "./commands-goal.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { parseInlineDirectives } from "./directive-handling.parse.js"; @@ -108,18 +104,6 @@ describe("goal commands", () => { }); }); - it("formats command-looking continuation prompts so inline directives leave them intact", () => { - const prompt = formatGoalContinuationPrompt("ship /fast off"); - expect(prompt).toBe( - `Pursue this goal exactly as written from this JSON string: "ship \\/fast off"`, - ); - - const directives = parseInlineDirectives(prompt); - - expect(directives.cleaned).toBe(prompt); - expect(directives.hasFastDirective).toBe(false); - }); - it("starts a goal from Codex-style bare /goal objective text", async () => { const storePath = await createStorePath(); await upsertSessionEntry({ diff --git a/src/auto-reply/reply/commands-goal.ts b/src/auto-reply/reply/commands-goal.ts index aabd88a3ae21..e36eaa233fc9 100644 --- a/src/auto-reply/reply/commands-goal.ts +++ b/src/auto-reply/reply/commands-goal.ts @@ -12,6 +12,7 @@ import { updateSessionGoalStatus, } from "../../config/sessions.js"; import { loadSessionEntry as getSessionEntry } from "../../config/sessions/session-accessor.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import { applyCommandTextToParams } from "./command-context-rewrite.js"; import { commandReply as goalReply, defineAuthorizedTextCommand } from "./command-gates.js"; import { markCommandSessionMetadataChanged } from "./command-session-metadata.js"; @@ -84,16 +85,14 @@ function encodeGoalJsonString(trimmed: string): string { return JSON.stringify(trimmed).replaceAll("/", "\\/"); } -/** Formats the model prompt used to continue a newly started goal. */ -export function formatGoalContinuationPrompt(objective: string): string { +function formatGoalContinuationPrompt(objective: string): string { const trimmed = objective.trim(); return hasCommandLikeGoalText(trimmed) ? `${GOAL_CONTINUATION_PROMPT_PREFIX} ${encodeGoalJsonString(trimmed)}` : trimmed; } -/** Formats the model prompt used when resuming a paused goal. */ -export function formatGoalResumeContinuationPrompt(note: string): string { +function formatGoalResumeContinuationPrompt(note: string): string { const trimmed = note.trim(); if (!trimmed) { return "Continue pursuing the current goal."; @@ -121,135 +120,130 @@ function goalErrorReply(error: unknown): CommandHandlerResult { return goalReply(`Goal error: ${message}`); } +type ParsedGoalCommand = NonNullable>; + +type SessionGoalCommandResult = { + text: string; + continuationPrompt?: string; + changed: boolean; +}; + +/** Execute goal storage policy once for auto-reply, Gateway, and embedded callers. */ +export async function executeSessionGoalCommand(params: { + parsed: ParsedGoalCommand; + sessionKey: string; + storePath?: string; + fallbackEntry?: SessionEntry; + agentId?: string; + readOnlyStatus?: boolean; +}): Promise { + const common = { + sessionKey: params.sessionKey, + storePath: params.storePath, + actor: { type: "human" as const }, + agentId: params.agentId, + }; + const note = params.parsed.text ? { note: params.parsed.text } : {}; + + switch (params.parsed.action) { + case "status": { + const snapshot = await getSessionGoal({ + sessionKey: params.sessionKey, + storePath: params.storePath, + ...(params.readOnlyStatus ? { fallbackEntry: params.fallbackEntry, persist: false } : {}), + }); + return { text: formatSessionGoalStatus(snapshot.goal), changed: false }; + } + case "start": + case "set": + case "create": { + const objective = normalizeOptionalString(params.parsed.text); + if (!objective) { + return { text: "Usage: /goal start ", changed: false }; + } + const goal = await createSessionGoal({ + ...common, + objective, + fallbackEntry: params.fallbackEntry, + }); + return { + text: `Goal started: ${goal.objective}`, + continuationPrompt: formatGoalContinuationPrompt(goal.objective), + changed: true, + }; + } + case "edit": { + const objective = normalizeOptionalString(params.parsed.text); + if (!objective) { + return { text: "Usage: /goal edit ", changed: false }; + } + const goal = await updateSessionGoalObjective({ ...common, objective }); + return { text: `Goal updated: ${goal.objective}`, changed: true }; + } + case "pause": { + const goal = await updateSessionGoalStatus({ ...common, status: "paused", ...note }); + return { text: `Goal paused: ${goal.objective}`, changed: true }; + } + case "resume": { + const goal = await updateSessionGoalStatus({ ...common, status: "active", ...note }); + return { + text: `Goal resumed: ${goal.objective}`, + continuationPrompt: formatGoalResumeContinuationPrompt(params.parsed.text), + changed: true, + }; + } + case "complete": + case "done": { + const goal = await updateSessionGoalStatus({ ...common, status: "complete", ...note }); + return { + text: `Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}`, + changed: true, + }; + } + case "block": + case "blocked": { + const goal = await updateSessionGoalStatus({ ...common, status: "blocked", ...note }); + return { text: `Goal blocked: ${goal.objective}`, changed: true }; + } + case "clear": { + const removed = await clearSessionGoal(common); + return { + text: removed ? "Goal cleared." : "No goal to clear.", + changed: removed, + }; + } + default: + return { + text: "Usage: /goal | /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear", + changed: false, + }; + } +} + /** Command handler for /goal lifecycle commands. */ export const handleGoalCommand: CommandHandler = defineAuthorizedTextCommand( { label: "/goal", match: parseGoalCommand }, async (params, parsed) => { - const actor = { type: "human" as const }; - const goalAgentId = params.agentId; - try { - switch (parsed.action) { - case "status": { - const snapshot = await getSessionGoal({ - sessionKey: params.sessionKey, - storePath: params.storePath, - fallbackEntry: params.sessionEntry, - persist: false, - }); - syncGoalSessionEntry(params); - return goalReply(formatSessionGoalStatus(snapshot.goal)); - } - case "start": - case "set": - case "create": { - const objective = normalizeOptionalString(parsed.text); - if (!objective) { - return goalReply("Usage: /goal start "); - } - const goal = await createSessionGoal({ - sessionKey: params.sessionKey, - storePath: params.storePath, - objective, - fallbackEntry: params.sessionEntry, - actor, - agentId: goalAgentId, - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - applyCommandTextToParams(params, formatGoalContinuationPrompt(goal.objective)); - return goalContinuation(); - } - case "edit": { - const objective = normalizeOptionalString(parsed.text); - if (!objective) { - return goalReply("Usage: /goal edit "); - } - const goal = await updateSessionGoalObjective({ - sessionKey: params.sessionKey, - storePath: params.storePath, - objective, - actor, - agentId: goalAgentId, - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal updated: ${goal.objective}`); - } - case "pause": { - const goal = await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "paused", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal paused: ${goal.objective}`); - } - case "resume": { - await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "active", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - const message = formatGoalResumeContinuationPrompt(parsed.text); - applyCommandTextToParams(params, message); - return goalContinuation(); - } - case "complete": - case "done": { - const goal = await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "complete", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}`); - } - case "block": - case "blocked": { - const goal = await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "blocked", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal blocked: ${goal.objective}`); - } - case "clear": { - const removed = await clearSessionGoal({ - sessionKey: params.sessionKey, - storePath: params.storePath, - actor, - agentId: goalAgentId, - }); - syncGoalSessionEntry(params); - if (removed) { - markCommandSessionMetadataChanged(params); - } - return goalReply(removed ? "Goal cleared." : "No goal to clear."); - } - default: - return goalReply( - "Usage: /goal | /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear", - ); + const result = await executeSessionGoalCommand({ + parsed, + sessionKey: params.sessionKey, + storePath: params.storePath, + fallbackEntry: params.sessionEntry, + agentId: params.agentId, + readOnlyStatus: true, + }); + if (result.changed || parsed.action === "status" || parsed.action === "clear") { + syncGoalSessionEntry(params); } + if (result.changed) { + markCommandSessionMetadataChanged(params); + } + if (result.continuationPrompt) { + applyCommandTextToParams(params, result.continuationPrompt); + return goalContinuation(); + } + return goalReply(result.text); } catch (error) { return goalErrorReply(error); } diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 2b3bac7c6184..8b589e852ccf 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -24,6 +24,7 @@ const projectSessionsPatchEntryMock = vi.fn(); const createSessionGoalMock = vi.fn(); const clearSessionGoalMock = vi.fn(); const getSessionGoalMock = vi.fn(); +const updateSessionGoalObjectiveMock = vi.fn(); const updateSessionGoalStatusMock = vi.fn(); const ensureRuntimePluginsLoadedMock = vi.fn(); const ensureContextWindowCacheLoadedMock = vi.fn(async () => undefined); @@ -54,13 +55,10 @@ const getRuntimeConfigMock = vi.fn(() => ({})); const loadGatewayModelCatalogMock = vi.fn( (_params?: unknown): Array<{ id: string; name: string; provider: string }> => [], ); -const readSessionMessagesAsyncMock = vi.fn( - async ( - _sessionId?: string, - _storePath?: string, - _sessionFile?: string, - _opts?: unknown, - ): Promise => [], +const readChatHistoryPageMock = vi.fn( + async (_params?: unknown): Promise<{ messages: unknown[] }> => ({ + messages: [], + }), ); type LoadSessionEntryMockResult = { cfg: Record; @@ -122,6 +120,7 @@ vi.mock("../config/sessions.js", () => ({ getSessionGoal: (...args: unknown[]) => getSessionGoalMock(...args), resolveAgentMainSessionKey: () => "agent:main:main", resolveStorePath: () => "/tmp/openclaw-sessions.json", + updateSessionGoalObjective: (...args: unknown[]) => updateSessionGoalObjectiveMock(...args), updateSessionGoalStatus: (...args: unknown[]) => updateSessionGoalStatusMock(...args), updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args), })); @@ -178,11 +177,6 @@ vi.mock("../config/sessions/startup-migration.js", () => ({ runSessionStartupMigrationMock(...args), })); -vi.mock("../gateway/cli-session-history.js", () => ({ - augmentChatHistoryWithCliSessionImports: ({ localMessages }: { localMessages?: unknown[] }) => - localMessages ?? [], -})); - vi.mock("../gateway/chat-display-projection.js", () => ({ projectChatDisplayMessages: (messages: unknown[]) => messages, projectRecentChatDisplayMessages: (messages: unknown[]) => messages, @@ -200,6 +194,11 @@ vi.mock("../gateway/server-methods/chat.js", () => ({ replaceOversizedChatHistoryMessages: ({ messages }: { messages: unknown[] }) => ({ messages }), })); +vi.mock("../gateway/server-methods/chat-history-pages.js", () => ({ + enrichChatHistoryCompactionMarkers: (messages: unknown[]) => messages, + readChatHistoryPage: (params: unknown) => readChatHistoryPageMock(params), +})); + vi.mock("../gateway/session-utils.js", () => ({ buildGatewaySessionInfo: (params: Parameters[0]) => buildGatewaySessionInfoMock(params), @@ -242,8 +241,6 @@ vi.mock("../gateway/session-reset-service.js", () => ({ vi.mock("../gateway/session-transcript-readers.js", () => ({ capArrayByJsonBytes: (items: unknown[]) => ({ items }), - readSessionMessagesAsync: (...args: Parameters) => - readSessionMessagesAsyncMock(...args), })); vi.mock("../gateway/sessions-patch.js", () => ({ @@ -309,6 +306,7 @@ describe("EmbeddedTuiBackend", () => { clearSessionGoalMock.mockResolvedValue(false); getSessionGoalMock.mockReset(); getSessionGoalMock.mockResolvedValue({ status: "missing" }); + updateSessionGoalObjectiveMock.mockReset(); updateSessionGoalStatusMock.mockReset(); updateSessionGoalStatusMock.mockImplementation(async ({ status }: { status: string }) => ({ objective: "ship", @@ -355,8 +353,8 @@ describe("EmbeddedTuiBackend", () => { getRuntimeConfigMock.mockReturnValue({}); loadGatewayModelCatalogMock.mockReset(); loadGatewayModelCatalogMock.mockReturnValue([]); - readSessionMessagesAsyncMock.mockReset(); - readSessionMessagesAsyncMock.mockResolvedValue([]); + readChatHistoryPageMock.mockReset(); + readChatHistoryPageMock.mockResolvedValue({ messages: [] }); loadSessionEntryMock.mockReset(); loadSessionEntryMock.mockImplementation((sessionKey: string) => ({ cfg: {}, @@ -859,7 +857,10 @@ describe("EmbeddedTuiBackend", () => { sessionKey: "agent:main:main", command: "/GOAL start Ship Goal", }), - ).resolves.toEqual({ text: "Goal started: Ship Goal" }); + ).resolves.toEqual({ + text: "Goal started: Ship Goal", + continuationPrompt: "Ship Goal", + }); expect(createSessionGoalMock).toHaveBeenCalledWith({ sessionKey: "agent:main:main", storePath: "/tmp/openclaw-sessions.json", @@ -1001,7 +1002,7 @@ describe("EmbeddedTuiBackend", () => { await backend.stop(); }); - it("uses reset-archive fallback for embedded TUI history reads", async () => { + it("uses the canonical gateway projector for embedded TUI history reads", async () => { loadSessionEntryMock.mockReturnValue({ cfg: {}, canonicalKey: "agent:main:main", @@ -1014,21 +1015,19 @@ describe("EmbeddedTuiBackend", () => { await backend.loadHistory({ sessionKey: "agent:main:main" }); - expect(readSessionMessagesAsyncMock).toHaveBeenCalledWith( - { - agentId: "main", - sessionEntry: { sessionId: "sess-main" }, - sessionId: "sess-main", - sessionKey: "agent:main:main", - storePath: "/tmp/openclaw-sessions.json", - }, - { - mode: "recent", - maxMessages: 200, - maxBytes: 1024 * 1024, - allowResetArchiveFallback: true, - }, - ); + expect(readChatHistoryPageMock).toHaveBeenCalledWith({ + entry: { sessionId: "sess-main" }, + provider: "openai", + sessionId: "sess-main", + storePath: "/tmp/openclaw-sessions.json", + sessionAgentId: "main", + canonicalKey: "agent:main:main", + max: 200, + maxHistoryBytes: 100_000, + effectiveMaxChars: 100_000, + offset: undefined, + messageId: undefined, + }); }); it("loads runtime plugins for the send-path workspace before returning embedded history", async () => { diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index f7e1d3728b2f..53dbe5c36700 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -29,7 +29,7 @@ import { import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js"; import { readToolValidationErrorSummary } from "../agents/tool-error-summary.js"; import { resolveTextCommand } from "../auto-reply/commands-registry.js"; -import { parseGoalCommand } from "../auto-reply/reply/commands-goal.js"; +import { executeSessionGoalCommand, parseGoalCommand } from "../auto-reply/reply/commands-goal.js"; import { resolveQueueSettings } from "../auto-reply/reply/queue/settings.js"; import { DEFAULT_QUEUE_CAP, @@ -39,22 +39,10 @@ import { import type { QueueSettings } from "../auto-reply/reply/queue/types.js"; import { createDefaultDeps } from "../cli/deps.js"; import { getRuntimeConfig } from "../config/config.js"; -import { - clearSessionGoal, - createSessionGoal, - formatSessionGoalStatus, - getSessionGoal, - updateSessionGoalObjective, - updateSessionGoalStatus, -} from "../config/sessions.js"; import { applySessionPatchProjection } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isChatStopCommandText } from "../gateway/chat-abort.js"; -import { - projectRecentChatDisplayMessages, - resolveEffectiveChatHistoryMaxChars, -} from "../gateway/chat-display-projection.js"; -import { augmentChatHistoryWithCliSessionImports } from "../gateway/cli-session-history.js"; +import { resolveEffectiveChatHistoryMaxChars } from "../gateway/chat-display-projection.js"; import { normalizeLiveAssistantBufferedText, projectLiveAssistantBufferedText, @@ -64,7 +52,10 @@ import { } from "../gateway/live-chat-projector.js"; import { getMaxChatHistoryMessagesBytes } from "../gateway/server-constants.js"; import { - augmentChatHistoryWithCanvasBlocks, + enrichChatHistoryCompactionMarkers, + readChatHistoryPage, +} from "../gateway/server-methods/chat-history-pages.js"; +import { CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, enforceChatHistoryFinalBudget, replaceOversizedChatHistoryMessages, @@ -72,10 +63,7 @@ import { import { loadGatewayModelCatalog } from "../gateway/server-model-catalog.js"; import { createGatewaySession } from "../gateway/session-create-service.js"; import { performGatewaySessionReset } from "../gateway/session-reset-service.js"; -import { - capArrayByJsonBytes, - readSessionMessagesAsync, -} from "../gateway/session-transcript-readers.js"; +import { capArrayByJsonBytes } from "../gateway/session-transcript-readers.js"; import { buildGatewaySessionInfo, getSessionDefaults, @@ -626,36 +614,21 @@ export class EmbeddedTuiBackend implements TuiBackend { const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); const max = Math.min(1000, typeof opts.limit === "number" ? opts.limit : 200); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); - const localMessages = - sessionId && storePath - ? await readSessionMessagesAsync( - { - agentId: sessionAgentId, - sessionEntry: entry, - sessionId, - sessionKey: canonicalKey, - storePath, - }, - { - mode: "recent", - maxMessages: max, - maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024), - allowResetArchiveFallback: true, - }, - ) - : []; - const rawMessages = augmentChatHistoryWithCliSessionImports({ + const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg); + const historyPage = await readChatHistoryPage({ entry, provider: resolvedSessionModel.provider, - localMessages, + sessionId, + storePath, + sessionAgentId, + canonicalKey, + max, + maxHistoryBytes, + effectiveMaxChars, + offset: undefined, + messageId: undefined, }); - const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg); - const normalized = augmentChatHistoryWithCanvasBlocks( - projectRecentChatDisplayMessages(rawMessages, { - maxChars: effectiveMaxChars, - maxMessages: max, - }), - ); + const normalized = enrichChatHistoryCompactionMarkers(historyPage.messages, entry); const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes); const replaced = replaceOversizedChatHistoryMessages({ messages: normalized, @@ -933,103 +906,16 @@ export class EmbeddedTuiBackend implements TuiBackend { throw new Error("invalid goal command"); } - switch (parsed.action) { - case "status": { - const snapshot = await getSessionGoal({ sessionKey, storePath }); - return { text: formatSessionGoalStatus(snapshot.goal) }; - } - case "start": - case "set": - case "create": { - const objective = parsed.text.trim(); - if (!objective) { - return { text: "Usage: /goal start " }; - } - const fallbackEntry = entry ?? { sessionId: randomUUID(), updatedAt: Date.now() }; - const goal = await createSessionGoal({ - sessionKey, - storePath, - objective, - fallbackEntry, - actor: { type: "human" }, - agentId: opts.agentId, - }); - return { text: `Goal started: ${goal.objective}` }; - } - case "edit": { - const objective = parsed.text.trim(); - if (!objective) { - return { text: "Usage: /goal edit " }; - } - const goal = await updateSessionGoalObjective({ - sessionKey, - storePath, - objective, - actor: { type: "human" }, - agentId: opts.agentId, - }); - return { text: `Goal updated: ${goal.objective}` }; - } - case "pause": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "paused", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal paused: ${goal.objective}` }; - } - case "resume": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "active", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal resumed: ${goal.objective}` }; - } - case "complete": - case "done": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "complete", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}` }; - } - case "block": - case "blocked": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "blocked", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal blocked: ${goal.objective}` }; - } - case "clear": { - const removed = await clearSessionGoal({ - sessionKey, - storePath, - actor: { type: "human" }, - agentId: opts.agentId, - }); - return { text: removed ? "Goal cleared." : "No goal to clear." }; - } - default: - return { - text: "Usage: /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear", - }; - } + const result = await executeSessionGoalCommand({ + parsed, + sessionKey, + storePath, + fallbackEntry: entry ?? { sessionId: randomUUID(), updatedAt: Date.now() }, + agentId: opts.agentId, + }); + return result.continuationPrompt + ? { text: result.text, continuationPrompt: result.continuationPrompt } + : { text: result.text }; } private enqueuePendingLocalMessage(params: { diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index 6aeae5adaae6..1df715d7fdee 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -213,5 +213,7 @@ export type TuiBackend = { listTaskSuggestions?: () => Promise; acceptTaskSuggestion?: (taskId: string) => Promise; dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>; - runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>; + runGoalCommand?: ( + opts: TuiGoalCommandOptions, + ) => Promise<{ text: string; continuationPrompt?: string }>; }; diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 512bd22c1506..12691f08392e 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -589,7 +589,9 @@ describe("tui command handlers", () => { }); it("starts local goals and sends the objective to the model", async () => { - const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" }); + const runGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal started: ship", continuationPrompt: "ship" }); const { handleCommand, sendChat, addSystem, refreshSessionInfo, addPendingUser } = createHarness({ opts: { local: true }, @@ -613,28 +615,32 @@ describe("tui command handlers", () => { }); it("wraps command-prefixed local goal objectives before sending", async () => { - const slashRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" }); + const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; + const slashRunGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal started", continuationPrompt: slashPrompt }); const slashHarness = createHarness({ opts: { local: true }, runGoalCommand: slashRunGoalCommand, }); await slashHarness.handleCommand("/goal start /status"); - const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; expectSendChatFields(slashHarness.sendChat, { sessionKey: "agent:main:main", message: slashPrompt, }); expect(slashHarness.addPendingUser).toHaveBeenCalledWith(expect.any(String), slashPrompt); - const bangRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" }); + const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`; + const bangRunGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal started", continuationPrompt: bangPrompt }); const bangHarness = createHarness({ opts: { local: true }, runGoalCommand: bangRunGoalCommand, }); await bangHarness.handleCommand("/goal start !npm test"); - const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`; expectSendChatFields(bangHarness.sendChat, { sessionKey: "agent:main:main", message: bangPrompt, @@ -656,7 +662,10 @@ describe("tui command handlers", () => { }); it("wraps command-prefixed local goal resume notes before sending", async () => { - const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal resumed: ship" }); + const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`; + const runGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal resumed: ship", continuationPrompt: prompt }); const { handleCommand, sendChat, addPendingUser } = createHarness({ opts: { local: true }, runGoalCommand, @@ -664,7 +673,6 @@ describe("tui command handlers", () => { await handleCommand("/goal resume /fast off"); - const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`; expectSendChatFields(sendChat, { sessionKey: "agent:main:main", message: prompt, diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 1df264f75916..c7c617d076a9 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -6,11 +6,6 @@ import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/in import { modelKey } from "../agents/model-ref-shared.js"; import { shouldForwardModelCommandToServer } from "../auto-reply/commands-registry.shared.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; -import { - formatGoalContinuationPrompt, - formatGoalResumeContinuationPrompt, - parseGoalCommand, -} from "../auto-reply/reply/commands-goal.js"; import { formatThinkingLevels, isSessionDefaultDirectiveValue, @@ -125,21 +120,6 @@ function isTerminalChatSendAckSuccess(status: unknown): boolean { const TERMINAL_CHAT_SEND_FAILURE_MESSAGE = "Chat failed before the run started; try again."; -function goalContinuationPrompt(text: string): string | null { - const parsed = parseGoalCommand(text); - if (!parsed) { - return null; - } - const action = parsed.action; - if (action === "start" || action === "set" || action === "create") { - return formatGoalContinuationPrompt(parsed.text) || null; - } - if (action === "resume") { - return formatGoalResumeContinuationPrompt(parsed.text); - } - return null; -} - export function createCommandHandlers(context: CommandHandlerContext) { const { client, @@ -294,6 +274,29 @@ export function createCommandHandlers(context: CommandHandlerContext) { } }; + const applySessionSetting = async ( + patch: Omit[0], "key" | "agentId">, + success: string | ((result: SessionsPatchResult) => string), + failure: string, + after?: (result: SessionsPatchResult) => void | Promise, + ) => { + try { + const result = await patchCurrentSession(patch); + if (!result) { + return; + } + chatLog.addSystem(typeof success === "function" ? success(result) : success); + applySessionInfoFromPatch(result); + if (after) { + await after(result); + } else { + await refreshSessionInfo(); + } + } catch (err) { + chatLog.addSystem(`${failure}: ${formatTuiErrorMessage(err)}`); + } + }; + const openSelector = ( selector: { onSelect?: (item: SelectItem) => void; @@ -336,17 +339,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { }); const selector = createSearchableSelectList(items, 9); openSelector(selector, async (value) => { - try { - const result = await patchCurrentSession({ model: value }); - if (!result) { - return; - } - chatLog.addSystem(`model set to ${value}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`model set failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting({ model: value }, `model set to ${value}`, "model set failed"); }); } catch (err) { if (!isCurrentSessionSelection(selection)) { @@ -608,9 +601,8 @@ export function createCommandHandlers(context: CommandHandlerContext) { }); chatLog.addSystem(result.text); await refreshSessionInfo(); - const continuation = goalContinuationPrompt(raw); - if (continuation) { - await sendMessage(continuation); + if (result.continuationPrompt) { + await sendMessage(result.continuationPrompt); } } catch (err) { chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`); @@ -654,24 +646,20 @@ export function createCommandHandlers(context: CommandHandlerContext) { } else if (!args) { await openModelSelector(); } else { - try { - const result = await patchCurrentSession({ model: args }); - if (!result) { - return; - } - const resolvedModel = result.resolved?.model; - const resolvedProvider = result.resolved?.modelProvider; - const resolvedModelRef = resolvedModel - ? resolvedProvider - ? modelKey(resolvedProvider, resolvedModel) - : resolvedModel - : args; - chatLog.addSystem(`model set to ${resolvedModelRef}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`model set failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { model: args }, + (result) => { + const resolvedModel = result.resolved?.model; + const resolvedProvider = result.resolved?.modelProvider; + const resolvedModelRef = resolvedModel + ? resolvedProvider + ? modelKey(resolvedProvider, resolvedModel) + : resolvedModel + : args; + return `model set to ${resolvedModelRef}`; + }, + "model set failed", + ); } break; case "models": @@ -691,56 +679,37 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem(`usage: /think <${levels}>`); break; } - try { - const result = await patchCurrentSession({ thinkingLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`thinking set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`think failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { thinkingLevel: args }, + `thinking set to ${args}`, + "think failed", + ); break; case "verbose": if (!args) { chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("verbose")}`); break; } - try { - const result = await patchCurrentSession({ verboseLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`verbose set to ${args}`); - applySessionInfoFromPatch(result); - if (args === "off") { - chatLog.clearTools(); - await refreshSessionInfo(); - } else { - await loadHistory(); - } - } catch (err) { - chatLog.addSystem(`verbose failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { verboseLevel: args }, + `verbose set to ${args}`, + "verbose failed", + async () => { + if (args === "off") { + chatLog.clearTools(); + await refreshSessionInfo(); + } else { + await loadHistory(); + } + }, + ); break; case "trace": if (!args) { chatLog.addSystem("usage: /trace "); break; } - try { - const result = await patchCurrentSession({ traceLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`trace set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`trace failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting({ traceLevel: args }, `trace set to ${args}`, "trace failed"); break; case "fast": if (!args || args === "status") { @@ -751,36 +720,22 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem("usage: /fast "); break; } - try { - const result = await patchCurrentSession({ - fastMode: args === "auto" ? "auto" : args === "on", - }); - if (!result) { - return; - } - chatLog.addSystem(`fast mode set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`fast failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { fastMode: args === "auto" ? "auto" : args === "on" }, + `fast mode set to ${args}`, + "fast failed", + ); break; case "reasoning": if (!args) { chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("reasoning")}`); break; } - try { - const result = await patchCurrentSession({ reasoningLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`reasoning set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`reasoning failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { reasoningLevel: args }, + `reasoning set to ${args}`, + "reasoning failed", + ); break; case "usage": { const isReset = args ? isSessionDefaultDirectiveValue(args) : false; @@ -790,19 +745,16 @@ export function createCommandHandlers(context: CommandHandlerContext) { break; } if (isReset) { - try { - const result = await patchCurrentSession({ responseUsage: null }); - if (!result) { - return; - } - chatLog.addSystem("usage footer: reset to default"); - applySessionInfoFromPatch(result); - delete state.sessionInfo.responseUsage; - delete state.sessionInfo.effectiveResponseUsage; - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`usage failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { responseUsage: null }, + "usage footer: reset to default", + "usage failed", + async () => { + delete state.sessionInfo.responseUsage; + delete state.sessionInfo.effectiveResponseUsage; + await refreshSessionInfo(); + }, + ); break; } const current = @@ -810,17 +762,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { resolveResponseUsageMode(state.sessionInfo.responseUsage); const next = normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); - try { - const result = await patchCurrentSession({ responseUsage: next }); - if (!result) { - return; - } - chatLog.addSystem(`usage footer: ${next}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`usage failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting({ responseUsage: next }, `usage footer: ${next}`, "usage failed"); break; } case "elevated": @@ -832,17 +774,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem("usage: /elevated "); break; } - try { - const result = await patchCurrentSession({ elevatedLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`elevated set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`elevated failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { elevatedLevel: args }, + `elevated set to ${args}`, + "elevated failed", + ); break; case "activation": { if (!args) { @@ -854,17 +790,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem("usage: /activation "); break; } - try { - const result = await patchCurrentSession({ groupActivation: activation }); - if (!result) { - return; - } - chatLog.addSystem(`activation set to ${activation}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`activation failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { groupActivation: activation }, + `activation set to ${activation}`, + "activation failed", + ); break; } case "new": { diff --git a/src/tui/tui-session-run-coordinator.test.ts b/src/tui/tui-session-run-coordinator.test.ts index 8c9bc1742dc9..90c1bc56ac9b 100644 --- a/src/tui/tui-session-run-coordinator.test.ts +++ b/src/tui/tui-session-run-coordinator.test.ts @@ -1,6 +1,6 @@ // Covers bounded TUI run ownership and transcript persistence coordination. import { describe, expect, it, vi } from "vitest"; -import { TuiSessionRunCoordinator } from "./tui-session-run-coordinator.js"; +import { createTuiRunIdTracker, TuiSessionRunCoordinator } from "./tui-session-run-coordinator.js"; import type { ChatEvent, TuiHistoryLoadResult, TuiStateAccess } from "./tui-types.js"; function makeState(overrides?: Partial): TuiStateAccess { @@ -65,6 +65,24 @@ function createCoordinator(overrides?: { }; } +describe("createTuiRunIdTracker", () => { + it("bounds FIFO membership and supports explicit cleanup", () => { + const tracker = createTuiRunIdTracker(); + tracker.note(""); + for (let index = 0; index <= 200; index += 1) { + tracker.note(`run-${index}`); + } + + expect(tracker.has("")).toBe(false); + expect(tracker.has("run-0")).toBe(false); + expect(tracker.has("run-200")).toBe(true); + tracker.forget("run-200"); + expect(tracker.has("run-200")).toBe(false); + tracker.clear(); + expect(tracker.has("run-100")).toBe(false); + }); +}); + describe("TuiSessionRunCoordinator", () => { it("keeps the active session run while pruning hundreds of abandoned runs", () => { const { coordinator, state } = createCoordinator({ diff --git a/src/tui/tui-session-run-coordinator.ts b/src/tui/tui-session-run-coordinator.ts index 75c23d5c5d54..83763ae367a2 100644 --- a/src/tui/tui-session-run-coordinator.ts +++ b/src/tui/tui-session-run-coordinator.ts @@ -11,6 +11,24 @@ const HISTORY_RELOAD_OWNED = 1 << 1; const HISTORY_RELOAD_DISPLAYED = 1 << 2; const HISTORY_RELOAD_GAP_RECOVERY = 1 << 3; +/** A small FIFO membership tracker for run IDs that need no lifecycle metadata. */ +export function createTuiRunIdTracker() { + const runIds = new Set(); + return { + note: (runId: string) => { + if (runId) { + runIds.add(runId); + } + if (runIds.size > MAX_TRACKED_RUNS) { + runIds.delete(runIds.values().next().value as string); + } + }, + forget: (runId: string) => void runIds.delete(runId), + has: (runId: string) => runIds.has(runId), + clear: () => runIds.clear(), + }; +} + type HistoryOwnedRun = { runId: string; result: TuiHistoryLoadResult; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index d5483f8f68f3..96fc17a2f940 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -61,7 +61,7 @@ import { createOverlayHandlers } from "./tui-overlays.js"; import { createTuiPluginApprovalController } from "./tui-plugin-approvals.js"; import { createSessionActions } from "./tui-session-actions.js"; import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js"; -import type { TuiPendingSubmit } from "./tui-submit-state.js"; +import { createTuiRunIdTracker } from "./tui-session-run-coordinator.js"; import { createEditorSubmitHandler, createSubmitBurstCoalescer, @@ -70,7 +70,6 @@ import { } from "./tui-submit.js"; import { createTuiTaskSuggestionController } from "./tui-task-suggestions.js"; import type { - AgentSummary, SessionInfo, SessionScope, TuiOptions, @@ -609,54 +608,40 @@ export async function runTui(opts: RunTuiOptions): Promise { const resolveUsableCwd = () => tryProcessCwd() ?? fallbackCwd; const emptySessionInfoDefaults = resolveEmptySessionInfoDefaults(config); const initialSessionInput = (opts.session ?? "").trim(); - let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; - let sessionMainKey = normalizeMainKey(config.session?.mainKey); - let agentDefaultId = resolveDefaultAgentId(config); + const sessionScope = (config.session?.scope ?? "per-sender") as SessionScope; + const sessionMainKey = normalizeMainKey(config.session?.mainKey); + const agentDefaultId = resolveDefaultAgentId(config); let currentAgentId = resolveInitialTuiAgentId({ cfg: config, fallbackAgentId: agentDefaultId, initialSessionInput, }); - let agents: AgentSummary[] = []; const agentNames = new Map(); let currentSessionKey = ""; - let initialSessionApplied = false; let rememberedSessionApplied = false; let currentSessionId: string | null = null; const sessionGenerations = new Map(); const sessionIds = new Map(); - let activeChatRunId: string | null = null; - let pendingSubmit: TuiPendingSubmit | null = null; - let historyLoaded = false; - let isConnected = false; let connectionGeneration = 0; let wasDisconnected = false; - let toolsExpanded = false; - let showThinking = false; let pairingHintShown = false; - const localRunIds = new Set(); - const localBtwRunIds = new Set(); + const localRunIds = createTuiRunIdTracker(); + const localBtwRunIds = createTuiRunIdTracker(); const deliverDefault = opts.deliver ?? false; const autoMessage = opts.message?.trim(); const thinkingLevelOverride = normalizeThinkLevel(opts.thinking); - let autoMessageSent = false; - let sessionInfo: SessionInfo = { ...emptySessionInfoDefaults }; let dynamicSlashCommands: CommandEntry[] = []; let dynamicSlashCommandsKey: string | null = null; let dynamicSlashCommandsInFlightKey: string | null = null; let dynamicSlashCommandsRequestId = 0; let dynamicSlashCommandsReady = false; let dynamicSlashCommandsRefreshTimer: ReturnType | null = null; - let lastCtrlCAt = 0; let exitRequested = false; let exitResult: TuiResult = { exitReason: "exit" }; - let activityStatus = "idle"; - let connectionStatus = isLocalMode ? "starting local runtime" : "connecting"; - let statusTimeout: NodeJS.Timeout | null = null; let statusTimer: NodeJS.Timeout | null = null; let statusStartedAt: number | null = null; - let lastActivityStatus = activityStatus; + let lastActivityStatus = "idle"; let invalidateSessionRunOwnership: () => void = () => undefined; let retireHistoryAbsentRun: (_runId: string) => void = () => undefined; @@ -669,30 +654,10 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const state: TuiStateAccess = { - get agentDefaultId() { - return agentDefaultId; - }, - set agentDefaultId(value) { - agentDefaultId = value; - }, - get sessionMainKey() { - return sessionMainKey; - }, - set sessionMainKey(value) { - sessionMainKey = value; - }, - get sessionScope() { - return sessionScope; - }, - set sessionScope(value) { - sessionScope = value; - }, - get agents() { - return agents; - }, - set agents(value) { - agents = value; - }, + agentDefaultId, + sessionMainKey, + sessionScope, + agents: [], get currentAgentId() { return currentAgentId; }, @@ -734,130 +699,19 @@ export async function runTui(opts: RunTuiOptions): Promise { set sessionGeneration(value) { writeCurrentSessionGeneration(Math.max(readCurrentSessionGeneration(), value)); }, - get activeChatRunId() { - return activeChatRunId; - }, - set activeChatRunId(value) { - activeChatRunId = value; - }, - get pendingSubmit() { - return pendingSubmit; - }, - set pendingSubmit(value) { - pendingSubmit = value; - }, - get historyLoaded() { - return historyLoaded; - }, - set historyLoaded(value) { - historyLoaded = value; - }, - get sessionInfo() { - return sessionInfo; - }, - set sessionInfo(value) { - sessionInfo = value; - }, - get initialSessionApplied() { - return initialSessionApplied; - }, - set initialSessionApplied(value) { - initialSessionApplied = value; - }, - get isConnected() { - return isConnected; - }, - set isConnected(value) { - isConnected = value; - }, - get autoMessageSent() { - return autoMessageSent; - }, - set autoMessageSent(value) { - autoMessageSent = value; - }, - get toolsExpanded() { - return toolsExpanded; - }, - set toolsExpanded(value) { - toolsExpanded = value; - }, - get showThinking() { - return showThinking; - }, - set showThinking(value) { - showThinking = value; - }, - get connectionStatus() { - return connectionStatus; - }, - set connectionStatus(value) { - connectionStatus = value; - }, - get activityStatus() { - return activityStatus; - }, - set activityStatus(value) { - activityStatus = value; - }, - get statusTimeout() { - return statusTimeout; - }, - set statusTimeout(value) { - statusTimeout = value; - }, - get lastCtrlCAt() { - return lastCtrlCAt; - }, - set lastCtrlCAt(value) { - lastCtrlCAt = value; - }, - }; - - const noteLocalRunId = (runId: string) => { - if (!runId) { - return; - } - localRunIds.add(runId); - if (localRunIds.size > 200) { - const [first] = localRunIds; - if (first) { - localRunIds.delete(first); - } - } - }; - - const forgetLocalRunId = (runId: string) => { - localRunIds.delete(runId); - }; - - const isLocalRunId = (runId: string) => localRunIds.has(runId); - - const clearLocalRunIds = () => { - localRunIds.clear(); - }; - - const noteLocalBtwRunId = (runId: string) => { - if (!runId) { - return; - } - localBtwRunIds.add(runId); - if (localBtwRunIds.size > 200) { - const [first] = localBtwRunIds; - if (first) { - localBtwRunIds.delete(first); - } - } - }; - - const forgetLocalBtwRunId = (runId: string) => { - localBtwRunIds.delete(runId); - }; - - const isLocalBtwRunId = (runId: string) => localBtwRunIds.has(runId); - - const clearLocalBtwRunIds = () => { - localBtwRunIds.clear(); + activeChatRunId: null, + pendingSubmit: null, + historyLoaded: false, + sessionInfo: { ...emptySessionInfoDefaults }, + initialSessionApplied: false, + isConnected: false, + autoMessageSent: false, + toolsExpanded: false, + showThinking: false, + connectionStatus: isLocalMode ? "starting local runtime" : "connecting", + activityStatus: "idle", + statusTimeout: null, + lastCtrlCAt: 0, }; let client: TuiBackend; @@ -920,17 +774,17 @@ export async function runTui(opts: RunTuiOptions): Promise { root.addChild(footer); root.addChild(editor); - const resolveDynamicSlashCommandsKey = () => currentAgentId; + const resolveDynamicSlashCommandsKey = () => state.currentAgentId; const applyAutocompleteProvider = () => { const dynamicKey = resolveDynamicSlashCommandsKey(); const slashCommands = getSlashCommands({ cfg: config, local: isLocalMode, - provider: sessionInfo.modelProvider, - model: sessionInfo.model, - agentRuntime: sessionInfo.agentRuntime?.id, - thinkingLevels: sessionInfo.thinkingLevels, + provider: state.sessionInfo.modelProvider, + model: state.sessionInfo.model, + agentRuntime: state.sessionInfo.agentRuntime?.id, + thinkingLevels: state.sessionInfo.thinkingLevels, dynamicCommands: dynamicSlashCommandsKey === dynamicKey ? dynamicSlashCommands : [], }); editor.shouldSubmitAutocomplete = (text) => @@ -953,7 +807,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const key = resolveDynamicSlashCommandsKey(); if ( !dynamicSlashCommandsReady || - !isConnected || + !state.isConnected || !client.listCommands || dynamicSlashCommandsKey === key || dynamicSlashCommandsInFlightKey === key @@ -962,7 +816,7 @@ export async function runTui(opts: RunTuiOptions): Promise { } dynamicSlashCommandsInFlightKey = key; const requestId = ++dynamicSlashCommandsRequestId; - const agentId = currentAgentId; + const agentId = state.currentAgentId; void client .listCommands({ agentId, @@ -1024,9 +878,9 @@ export async function runTui(opts: RunTuiOptions): Promise { const resolveSessionKey = (raw?: string) => { return resolveTuiSessionKey({ raw, - sessionScope, - currentAgentId, - sessionMainKey, + sessionScope: state.sessionScope, + currentAgentId: state.currentAgentId, + sessionMainKey: state.sessionMainKey, }); }; @@ -1036,8 +890,8 @@ export async function runTui(opts: RunTuiOptions): Promise { const parsed = parseAgentSessionKey(sessionKey); return buildTuiLastSessionScopeKey({ connectionUrl: client.connection.url, - agentId: parsed?.agentId ?? currentAgentId, - sessionScope, + agentId: parsed?.agentId ?? state.currentAgentId, + sessionScope: state.sessionScope, }); }; @@ -1059,7 +913,11 @@ export async function runTui(opts: RunTuiOptions): Promise { const remembered = await readTuiLastSessionKey({ scopeKey: buildLastSessionScopeKeyFor(), }); - if (expectedConnectionGeneration !== connectionGeneration || !isConnected || exitRequested) { + if ( + expectedConnectionGeneration !== connectionGeneration || + !state.isConnected || + exitRequested + ) { return; } const rememberedKey = remembered ? resolveSessionKey(remembered) : null; @@ -1068,7 +926,7 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } const rememberedAgent = parseAgentSessionKey(rememberedKey)?.agentId; - if (rememberedAgent && normalizeAgentId(rememberedAgent) !== currentAgentId) { + if (rememberedAgent && normalizeAgentId(rememberedAgent) !== state.currentAgentId) { rememberedSessionApplied = true; return; } @@ -1078,13 +936,13 @@ export async function runTui(opts: RunTuiOptions): Promise { search: rememberedKey, includeGlobal: false, includeUnknown: false, - agentId: currentAgentId, + agentId: state.currentAgentId, }) .catch(() => null); if ( !sessions || expectedConnectionGeneration !== connectionGeneration || - !isConnected || + !state.isConnected || exitRequested ) { return; @@ -1093,7 +951,7 @@ export async function runTui(opts: RunTuiOptions): Promise { rememberedSessionApplied = true; const restored = resolveRememberedTuiSessionKey({ rememberedKey, - currentAgentId, + currentAgentId: state.currentAgentId, sessions: sessions.sessions, }); if (!restored || restored === currentSessionKey) { @@ -1106,7 +964,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const updateHeader = () => { const sessionLabel = formatSessionKey(currentSessionKey); - const agentLabel = formatAgentLabel(currentAgentId); + const agentLabel = formatAgentLabel(state.currentAgentId); const title = opts.title ?? "openclaw tui"; header.setText( theme.header( @@ -1164,21 +1022,21 @@ export async function runTui(opts: RunTuiOptions): Promise { } const elapsed = formatElapsed(statusStartedAt); - if (activityStatus === "waiting") { + if (state.activityStatus === "waiting") { waitingTick++; statusLoader.setMessage( buildWaitingStatusMessage({ theme, tick: waitingTick, elapsed, - connectionStatus, + connectionStatus: state.connectionStatus, phrases: waitingPhrase ? [waitingPhrase] : undefined, }), ); return; } - statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`); + statusLoader.setMessage(`${state.activityStatus} • ${elapsed} | ${state.connectionStatus}`); }; const startStatusTimer = () => { @@ -1186,7 +1044,7 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } statusTimer = setInterval(() => { - if (!isTuiBusyActivityStatus(activityStatus)) { + if (!isTuiBusyActivityStatus(state.activityStatus)) { return; } updateBusyStatusMessage(); @@ -1202,11 +1060,11 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const stopStatusTimeout = () => { - if (!statusTimeout) { + if (!state.statusTimeout) { return; } - clearTimeout(statusTimeout); - statusTimeout = null; + clearTimeout(state.statusTimeout); + state.statusTimeout = null; }; const startWaitingTimer = () => { @@ -1223,7 +1081,7 @@ export async function runTui(opts: RunTuiOptions): Promise { waitingTick = 0; waitingTimer = setInterval(() => { - if (activityStatus !== "waiting") { + if (state.activityStatus !== "waiting") { return; } updateBusyStatusMessage(); @@ -1250,13 +1108,13 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const renderStatus = () => { - const isBusy = isTuiBusyActivityStatus(activityStatus); + const isBusy = isTuiBusyActivityStatus(state.activityStatus); if (isBusy) { - if (!statusStartedAt || lastActivityStatus !== activityStatus) { + if (!statusStartedAt || lastActivityStatus !== state.activityStatus) { statusStartedAt = Date.now(); } ensureStatusLoader(); - if (activityStatus === "waiting") { + if (state.activityStatus === "waiting") { stopStatusTimer(); startWaitingTimer(); } else { @@ -1271,21 +1129,23 @@ export async function runTui(opts: RunTuiOptions): Promise { statusLoader?.stop(); statusLoader = null; ensureStatusText(); - const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus; + const text = state.activityStatus + ? `${state.connectionStatus} | ${state.activityStatus}` + : state.connectionStatus; statusText?.setText(theme.dim(text)); } - lastActivityStatus = activityStatus; + lastActivityStatus = state.activityStatus; }; const setConnectionStatus = (text: string, ttlMs?: number) => { - connectionStatus = text; + state.connectionStatus = text; renderStatus(); - if (statusTimeout) { + if (state.statusTimeout) { stopStatusTimeout(); } if (ttlMs && ttlMs > 0) { - statusTimeout = setTimeout(() => { - connectionStatus = isConnected + state.statusTimeout = setTimeout(() => { + state.connectionStatus = state.isConnected ? isLocalMode ? "local ready" : "connected" @@ -1298,7 +1158,7 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const setActivityStatus = (text: string) => { - activityStatus = text; + state.activityStatus = text; renderStatus(); }; @@ -1329,7 +1189,7 @@ export async function runTui(opts: RunTuiOptions): Promise { // Codex owns its auth store; delegate when the CLI is available. const codexBin = provider === OPENAI_CODEX_PROVIDER || - (!provider && sessionInfo.modelProvider === OPENAI_CODEX_PROVIDER) + (!provider && state.sessionInfo.modelProvider === OPENAI_CODEX_PROVIDER) ? await resolveCodexCliBin() : null; @@ -1365,26 +1225,33 @@ export async function runTui(opts: RunTuiOptions): Promise { const updateFooter = () => { const sessionKeyLabel = formatSessionKey(currentSessionKey); - const sessionLabel = sessionInfo.displayName - ? `${sessionKeyLabel} (${sessionInfo.displayName})` + const sessionLabel = state.sessionInfo.displayName + ? `${sessionKeyLabel} (${state.sessionInfo.displayName})` : sessionKeyLabel; - const agentLabel = formatAgentLabel(currentAgentId); + const agentLabel = formatAgentLabel(state.currentAgentId); const modelLabel = formatModelFooter({ - model: sessionInfo.model, - thinkingLevel: thinkingLevelOverride ?? sessionInfo.thinkingLevel, + model: state.sessionInfo.model, + thinkingLevel: thinkingLevelOverride ?? state.sessionInfo.thinkingLevel, }); - const tokens = formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null); + const tokens = formatTokens( + state.sessionInfo.totalTokens ?? null, + state.sessionInfo.contextTokens ?? null, + ); const fastLabel = - sessionInfo.fastMode === "auto" ? "fast:auto" : sessionInfo.fastMode === true ? "fast" : null; - const verbose = sessionInfo.verboseLevel ?? "off"; - const reasoning = sessionInfo.reasoningLevel ?? "off"; + state.sessionInfo.fastMode === "auto" + ? "fast:auto" + : state.sessionInfo.fastMode === true + ? "fast" + : null; + const verbose = state.sessionInfo.verboseLevel ?? "off"; + const reasoning = state.sessionInfo.reasoningLevel ?? "off"; const reasoningLabel = reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null; const footerParts = [ `agent ${agentLabel}`, `session ${sessionLabel}`, modelLabel, - formatGoalFooter(sessionInfo.goal), + formatGoalFooter(state.sessionInfo.goal), fastLabel, verbose !== "off" ? `verbose ${verbose}` : null, reasoningLabel, @@ -1397,7 +1264,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const pluginApprovals = createTuiPluginApprovalController({ client, chatLog, - getAgentId: () => currentAgentId, + getAgentId: () => state.currentAgentId, getSessionKey: () => currentSessionKey, openOverlay, closeOverlay, @@ -1435,7 +1302,7 @@ export async function runTui(opts: RunTuiOptions): Promise { updateFooter, updateAutocompleteProvider, setActivityStatus, - clearLocalRunIds, + clearLocalRunIds: localRunIds.clear, rememberSessionKey: rememberCurrentSessionKey, }); const { @@ -1497,13 +1364,13 @@ export async function runTui(opts: RunTuiOptions): Promise { setActivityStatus, refreshSessionInfo, loadHistory, - noteLocalRunId, - isLocalRunId, - forgetLocalRunId, - clearLocalRunIds, - isLocalBtwRunId, - forgetLocalBtwRunId, - clearLocalBtwRunIds, + noteLocalRunId: localRunIds.note, + isLocalRunId: localRunIds.has, + forgetLocalRunId: localRunIds.forget, + clearLocalRunIds: localRunIds.clear, + isLocalBtwRunId: localBtwRunIds.has, + forgetLocalBtwRunId: localBtwRunIds.forget, + clearLocalBtwRunIds: localBtwRunIds.clear, }); retireHistoryAbsentRun = () => reconnectStreamingWatchdog(null); invalidateSessionRunOwnership = () => { @@ -1590,10 +1457,10 @@ export async function runTui(opts: RunTuiOptions): Promise { abortActive, setActivityStatus, formatSessionKey, - noteLocalRunId, - noteLocalBtwRunId, - forgetLocalRunId, - forgetLocalBtwRunId, + noteLocalRunId: localRunIds.note, + noteLocalBtwRunId: localBtwRunIds.note, + forgetLocalRunId: localRunIds.forget, + forgetLocalBtwRunId: localBtwRunIds.forget, consumeCompletedRunForPendingSend, isRunObserved, flushPendingHistoryRefreshIfIdle, @@ -1645,7 +1512,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const decision = resolveTuiCtrlCAction({ hasInput: editor.getText().length > 0, now, - lastCtrlCAt, + lastCtrlCAt: state.lastCtrlCAt, exitRequested, wasDisconnected, exitWindowMs: opts.ctrlCExitWindowMs, @@ -1654,7 +1521,7 @@ export async function runTui(opts: RunTuiOptions): Promise { forceExit(); return; } - lastCtrlCAt = decision.nextLastCtrlCAt; + state.lastCtrlCAt = decision.nextLastCtrlCAt; if (decision.action === "clear") { editor.setText(""); setActivityStatus("cleared input; press ctrl+c again to exit"); @@ -1675,14 +1542,14 @@ export async function runTui(opts: RunTuiOptions): Promise { requestExit(); }; editor.onCtrlO = () => { - toolsExpanded = !toolsExpanded; - chatLog.setToolsExpanded(toolsExpanded); + state.toolsExpanded = !state.toolsExpanded; + chatLog.setToolsExpanded(state.toolsExpanded); // Ctrl+O is presentation-only; preserve busy activity so the status loader // does not disappear before the run lifecycle ends. setActivityStatus( resolveTuiToolsToggleActivityStatus({ - currentStatus: activityStatus, - toolsExpanded, + currentStatus: state.activityStatus, + toolsExpanded: state.toolsExpanded, }), ); tui.requestRender(); @@ -1697,7 +1564,7 @@ export async function runTui(opts: RunTuiOptions): Promise { void openSessionSelector(); }; editor.onCtrlT = () => { - showThinking = !showThinking; + state.showThinking = !state.showThinking; void loadHistory(); }; @@ -1746,8 +1613,8 @@ export async function runTui(opts: RunTuiOptions): Promise { } const connectedGeneration = ++connectionGeneration; const ownsConnection = () => - connectedGeneration === connectionGeneration && isConnected && !exitRequested; - isConnected = true; + connectedGeneration === connectionGeneration && state.isConnected && !exitRequested; + state.isConnected = true; pairingHintShown = false; const reconnected = wasDisconnected; wasDisconnected = false; @@ -1757,7 +1624,7 @@ export async function runTui(opts: RunTuiOptions): Promise { setConnectionStatus(isLocalMode ? "local ready" : "connected"); // A reconnect may already have restored a live run's busy status. Only // claim the status line when startup owns it, then release that exact state. - if (!isTuiBusyActivityStatus(activityStatus)) { + if (!isTuiBusyActivityStatus(state.activityStatus)) { setActivityStatus("starting up"); } void (async () => { @@ -1771,7 +1638,7 @@ export async function runTui(opts: RunTuiOptions): Promise { } if (attempt + 1 === SESSION_SUBSCRIPTION_MAX_ATTEMPTS) { chatLog.addSystem(`session event subscribe failed: ${formatTuiErrorMessage(err)}`); - if (activityStatus === "starting up") { + if (state.activityStatus === "starting up") { setActivityStatus("idle"); } setConnectionStatus("session event subscription failed"); @@ -1825,7 +1692,7 @@ export async function runTui(opts: RunTuiOptions): Promise { if (!ownsConnection()) { return; } - if (activityStatus === "starting up") { + if (state.activityStatus === "starting up") { setActivityStatus("idle"); } if (reconnected) { @@ -1838,8 +1705,8 @@ export async function runTui(opts: RunTuiOptions): Promise { tui.requestRender(); dynamicSlashCommandsReady = true; scheduleDynamicSlashCommandsRefresh(); - if (!autoMessageSent && autoMessage) { - autoMessageSent = true; + if (!state.autoMessageSent && autoMessage) { + state.autoMessageSent = true; await sendMessage(autoMessage); if (!ownsConnection()) { return; @@ -1852,7 +1719,7 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } chatLog.addSystem(`startup failed: ${formatTuiErrorMessage(err)}`); - if (activityStatus === "starting up") { + if (state.activityStatus === "starting up") { setActivityStatus("idle"); } setConnectionStatus("startup failed", 5000); @@ -1865,9 +1732,9 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } connectionGeneration += 1; - isConnected = false; + state.isConnected = false; wasDisconnected = true; - historyLoaded = false; + state.historyLoaded = false; dynamicSlashCommands = []; dynamicSlashCommandsKey = null; dynamicSlashCommandsInFlightKey = null; @@ -1898,7 +1765,7 @@ export async function runTui(opts: RunTuiOptions): Promise { client.onDisconnected = handleBackendDisconnected; client.onGap = (info) => { - if (exitRequested || !isConnected) { + if (exitRequested || !state.isConnected) { return; } setConnectionStatus(`event gap: expected ${info.expected}, got ${info.received}`, 5000); From b340e682a2590c1c053b54bd08270981116288a4 Mon Sep 17 00:00:00 2001 From: zengLingbiao Date: Sun, 2 Aug 2026 02:22:30 +0800 Subject: [PATCH 31/53] fix(ui): preserve full graphemes in session owner initials (#117350) --- ui/src/components/session-owner-chip.ts | 8 +++-- .../app-sidebar-cases/session-ownership.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/ui/src/components/session-owner-chip.ts b/ui/src/components/session-owner-chip.ts index 947af11f63ea..4f16b7428e8c 100644 --- a/ui/src/components/session-owner-chip.ts +++ b/ui/src/components/session-owner-chip.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import type { SessionCreatedActor as ProtocolSessionCreatedActor } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import { t } from "../i18n/index.ts"; +import { takeGraphemes } from "../lib/graphemes.ts"; import { resolveAvatar } from "../lib/identity-avatar.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import "./viewer-facepile.ts"; @@ -66,8 +67,11 @@ function ownerInitials(createdActor: SessionCreatedActor): string { .replace(/@.*$/u, "") .split(/[\s._-]+/u) .filter(Boolean); - const initials = ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); - return initials || source[0]!.toUpperCase(); + // Grapheme clusters, not UTF-16 units or bare code points: emoji display names + // must render their complete visible initial (no lone surrogates or split ZWJ sequences). + const firstChar = (value: string | undefined): string => (value ? takeGraphemes(value, 1) : ""); + const initials = (firstChar(parts[0]) + firstChar(parts[1])).toUpperCase(); + return initials || firstChar(source).toUpperCase(); } // Deterministic hue per identity so a person keeps one color everywhere. diff --git a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts index f537cfa092bc..12c9e29d307b 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts @@ -130,6 +130,38 @@ describe("AppSidebar session ownership", () => { expect(carolChip?.textContent?.trim()).toBe("C"); }); + it("keeps emoji display-name initials as whole grapheme clusters", async () => { + for (const { label, expected } of [ + { label: "🦞小明", expected: "🦞" }, + { label: "👨‍👩‍👧‍👦Family", expected: "👨‍👩‍👧‍👦" }, + ]) { + const gateway = createGateway({} as GatewayBrowserClient); + const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:lobster"]); + const result = harness.sessions.state.result; + if (!result) { + throw new Error("expected session list"); + } + const lobster = result.sessions.find((row) => row.key.endsWith(":lobster")); + if (!lobster) { + throw new Error("expected creator row"); + } + lobster.createdActor = { type: "human", id: "profile-lobster", label }; + result.creators = [ + { id: "profile-lobster", label }, + { id: "profile-ada", label: "Ada" }, + ]; + + const { sidebar } = await mountSidebar(gateway, harness.sessions); + harness.publishList({ result, agentId: "main" }); + await sidebar.updateComplete; + + const chip = sidebar.querySelector( + '[data-session-key="agent:main:lobster"] .session-owner-chip', + ); + expect(chip?.textContent?.trim()).toBe(expected); + } + }); + it("uses the complete facet and requests unloaded creators from the Gateway", async () => { const gateway = createGateway({} as GatewayBrowserClient); const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:ada"]); From a624ba7b96aa8b15d4a859e69853f9842906212d Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Sat, 1 Aug 2026 11:31:29 -0700 Subject: [PATCH 32/53] feat(gateway): advertise chat attachment limits on hello-ok (#116188) * feat(gateway): advertise chat attachment limits on hello-ok Clients had no way to learn the gateway attachment ceilings, so external clients hardcoded guesses that drifted from server enforcement. Publish the two unconditional decoded-size ceilings on hello-ok policy.attachments from one shared resolver so advertised values cannot drift from the parser. MIME acceptance and per-message counts stay server-side: they depend on the entrypoint, the resolved model, and payload sniffing, so they cannot be stated once per connection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 17d6c355-8948-4b48-a936-e08b1c8806ef * feat(gateway): advertise chat attachment limits on hello-ok --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Copilot-Session: 17d6c355-8948-4b48-a936-e08b1c8806ef --- docs/gateway/clients.md | 23 +++++++ docs/gateway/protocol.md | 37 ++++++++-- .../gateway-protocol/src/schema/frames.ts | 11 +++ src/gateway/chat-attachment-policy.test.ts | 69 +++++++++++++++++++ src/gateway/chat-attachment-policy.ts | 42 +++++++++++ src/gateway/chat-attachments.test.ts | 60 +++++++++++----- src/gateway/chat-attachments.ts | 16 +---- .../server-methods/agent-content-phase.ts | 2 +- .../server-methods/chat-send-attachments.ts | 2 +- src/gateway/server-node-events.runtime.ts | 2 +- src/gateway/server.hello-attachments.test.ts | 62 +++++++++++++++++ .../server/ws-connection/connect-hello.ts | 2 + 12 files changed, 289 insertions(+), 39 deletions(-) create mode 100644 src/gateway/chat-attachment-policy.test.ts create mode 100644 src/gateway/chat-attachment-policy.ts create mode 100644 src/gateway/server.hello-attachments.test.ts diff --git a/docs/gateway/clients.md b/docs/gateway/clients.md index 0369b63f4108..dbc85e097d63 100644 --- a/docs/gateway/clients.md +++ b/docs/gateway/clients.md @@ -111,6 +111,29 @@ Capability-gated agent tools are a separate use of the same declaration. If an agent tool requires a client capability, the Gateway omits that tool unless the originating client advertised every required capability. +## Validate attachments before sending + +Attachment limits are operator-tunable, so do not hardcode them. Read +`hello-ok.policy.attachments` and validate locally before uploading: + +```ts +const attachments = hello.policy.attachments; +if (attachments) { + const ceiling = isImage ? attachments.maxImageBytes : attachments.maxBytes; + if (file.byteLength > ceiling) rejectLocally(); +} +``` + +Both values are decoded per-attachment ceilings. Still check the serialized +request against `policy.maxPayload`: attachments travel as base64, so a file near +`maxBytes` can exceed the frame limit on its own. Older gateways omit +`policy.attachments`; when it is absent, send and handle the server outcome. +Accepted MIME types and per-message handling are not advertised because they +depend on the entrypoint and the resolved model. The gateway can return a typed +rejection, while text-only model runs can omit additional images after their +offload cap and still complete the request. The values are a connection-time +snapshot, so re-read them on every reconnect. + ## Recover state after reconnect Treat every successful reconnect as a new projection over durable history and diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 7723fa50359c..14f440bcba45 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -153,7 +153,8 @@ Gateway responds with `hello-ok`: "policy": { "maxPayload": 26214400, "maxBufferedBytes": 52428800, - "tickIntervalMs": 15000 + "tickIntervalMs": 15000, + "attachments": { "maxBytes": 20971520, "maxImageBytes": 6291456 } } } } @@ -162,7 +163,32 @@ Gateway responds with `hello-ok`: `server`, `features`, `snapshot`, `policy`, and `auth` are all required by `HelloOkSchema` (`packages/gateway-protocol/src/schema/frames.ts`). `auth` reports the negotiated role/scopes even when no device token is issued (shape -above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g. +above). `policy.attachments` is optional (older gateways omit it) and advertises +the decoded-size ceilings chat attachments face on `chat.send`, `sessions.send`, +and session-creation initial turns: + +| Field | Meaning | +| --------------- | --------------------------------------------------------------------------------------------------- | +| `maxBytes` | Largest decoded size accepted for a single attachment (`agents.defaults.mediaMaxMb`, default 20 MB) | +| `maxImageBytes` | Largest decoded size accepted for a single image: `min(maxBytes, 6 MB agent-hydration cap)` | + +Validating before send: + +1. Check each file's decoded size against `maxImageBytes` for images and + `maxBytes` for everything else. +2. Serialize the whole request and check its encoded size against + `policy.maxPayload`. `policy.attachments` is a per-attachment ceiling, never a + promise the frame fits: attachments travel as base64, so a 20 MB file is about + 26.7 MB on the wire and exceeds the default 25 MiB frame limit on its own. +3. Treat the server as authoritative for everything else. Accepted MIME types and + per-message handling are deliberately not advertised because they depend on + the entrypoint, the resolved model, and payload sniffing. The gateway can + return a typed rejection, while text-only model runs can omit additional + images after their offload cap and still complete the request. +4. Re-read the values on every reconnect. They are a connection-time snapshot, so + a live `mediaMaxMb` edit reaches existing connections only after they reconnect. + +`pluginSurfaceUrls` is optional and maps plugin surface names (e.g. `canvas`) to scoped hosted URLs; it may expire, so nodes call `node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry. The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh` @@ -1034,10 +1060,13 @@ third-party clients. | Default tick interval (pre `hello-ok`) | `30_000` ms | `packages/gateway-client/src/client.ts` | | Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `packages/gateway-client/src/client.ts` | | `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` | +| Chat attachment ceiling | `agents.defaults.mediaMaxMb`, default 20 MB decoded | `src/gateway/chat-attachment-policy.ts` | +| Chat attachment image ceiling | `min(attachment ceiling, 6 MB)` | `src/gateway/chat-attachment-policy.ts`, `packages/media-core/src/constants.ts` | The server advertises the effective `policy.tickIntervalMs`, -`policy.maxPayload`, and `policy.maxBufferedBytes` in `hello-ok`; clients -should honor those values rather than the pre-handshake defaults. +`policy.maxPayload`, `policy.maxBufferedBytes`, and `policy.attachments` in +`hello-ok`; clients should honor those values rather than the pre-handshake +defaults or hardcoded attachment sizes. The reference client lets finite requests own their configured deadline when every pending request has one. An `expectFinal` request without a finite diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 3ff0cd70baae..9ec1f5c0f646 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -138,6 +138,17 @@ export const HelloOkSchema = closedObject({ maxPayload: Type.Integer({ minimum: 1 }), maxBufferedBytes: Type.Integer({ minimum: 1 }), tickIntervalMs: Type.Integer({ minimum: 1 }), + // Additive: unconditional decoded-size ceilings for chat attachments, so + // clients can validate a file before sending instead of hardcoding guesses. + // Per attachment, not per frame: the encoded request must still fit + // `maxPayload`. MIME acceptance and per-message counts stay server-side + // because they depend on the entrypoint, resolved model, and payload sniffing. + attachments: Type.Optional( + closedObject({ + maxBytes: Type.Integer({ minimum: 1 }), + maxImageBytes: Type.Integer({ minimum: 1 }), + }), + ), allowedSessionVisibilities: Type.Optional(Type.Array(SessionVisibilitySchema)), hasMultipleSessionSharingIdentities: Type.Optional(Type.Boolean()), }), diff --git a/src/gateway/chat-attachment-policy.test.ts b/src/gateway/chat-attachment-policy.test.ts new file mode 100644 index 000000000000..35f3ba32cc20 --- /dev/null +++ b/src/gateway/chat-attachment-policy.test.ts @@ -0,0 +1,69 @@ +// Attachment policy tests guard the numbers advertised on `hello-ok` against the +// ceilings the parser actually enforces. +import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + resolveChatAttachmentMaxBytes, + resolveChatAttachmentPolicy, +} from "./chat-attachment-policy.js"; + +const MB = 1024 * 1024; + +const cfgWithMediaMaxMb = (value: unknown): OpenClawConfig => + ({ agents: { defaults: { mediaMaxMb: value } } }) as unknown as OpenClawConfig; + +describe("resolveChatAttachmentMaxBytes", () => { + it("honours a configured agents.defaults.mediaMaxMb", () => { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(10))).toBe(10 * MB); + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(50))).toBe(50 * MB); + }); + + it("falls back to the default ceiling when unset", () => { + expect(resolveChatAttachmentMaxBytes({} as OpenClawConfig)).toBe( + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + ); + expect(resolveChatAttachmentMaxBytes({ agents: {} } as unknown as OpenClawConfig)).toBe( + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + ); + }); + + it("rejects non-positive, non-finite, or non-number values", () => { + for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY, "50", null, undefined]) { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(bad))).toBe( + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + ); + } + }); + + it("never floors a legal sub-byte mediaMaxMb to zero", () => { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(0.0000001))).toBe(1); + }); + + it("keeps an enormous mediaMaxMb representable instead of overflowing", () => { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(1e308))).toBe(Number.MAX_SAFE_INTEGER); + }); +}); + +describe("resolveChatAttachmentPolicy", () => { + it("advertises the configured ceiling with the image hydration cap applied", () => { + expect(resolveChatAttachmentPolicy(cfgWithMediaMaxMb(20))).toEqual({ + maxBytes: 20 * MB, + maxImageBytes: MAX_IMAGE_BYTES, + }); + }); + + it("clamps maxImageBytes to the configured ceiling when it is the smaller limit", () => { + expect(resolveChatAttachmentPolicy(cfgWithMediaMaxMb(1))).toEqual({ + maxBytes: MB, + maxImageBytes: MB, + }); + }); + + it("keeps both ceilings positive so the hello-ok schema stays satisfiable", () => { + const policy = resolveChatAttachmentPolicy(cfgWithMediaMaxMb(0.0000001)); + expect(policy.maxBytes).toBeGreaterThanOrEqual(1); + expect(policy.maxImageBytes).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/gateway/chat-attachment-policy.ts b/src/gateway/chat-attachment-policy.ts new file mode 100644 index 000000000000..0332f41f2701 --- /dev/null +++ b/src/gateway/chat-attachment-policy.ts @@ -0,0 +1,42 @@ +// Connection-level chat attachment ceilings shared by the parser and the +// `hello-ok` handshake. Kept out of chat-attachments.ts so the handshake path +// does not pull the media probe/store graph in just to read two numbers. +import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const DEFAULT_CHAT_ATTACHMENT_MAX_MB = 20; + +/** Default decoded-size ceiling when `agents.defaults.mediaMaxMb` is unset or invalid. */ +export const DEFAULT_CHAT_ATTACHMENT_MAX_BYTES = DEFAULT_CHAT_ATTACHMENT_MAX_MB * 1024 * 1024; + +/** Resolve the maximum decoded attachment size accepted for chat inputs. */ +export function resolveChatAttachmentMaxBytes(cfg: OpenClawConfig): number { + const configured = cfg.agents?.defaults?.mediaMaxMb; + const mb = + typeof configured === "number" && Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_CHAT_ATTACHMENT_MAX_MB; + // mediaMaxMb only has to be positive, so a sub-byte value would floor to 0 and + // a huge one overflows to Infinity, which serializes as null on the handshake + // frame and fails its integer schema. Both ends have to stay representable. + return Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, Math.floor(mb * 1024 * 1024))); +} + +/** Unconditional decoded-size ceilings advertised on `hello-ok.policy.attachments`. */ +type ChatAttachmentPolicy = { + maxBytes: number; + maxImageBytes: number; +}; + +/** + * Resolve the decoded-size ceilings every chat attachment faces regardless of + * entrypoint or model. Images are checked against the configured ceiling first + * and the agent-hydration cap second, so their effective limit is the smaller of + * the two. MIME acceptance and per-message counts are deliberately absent: they + * depend on the entrypoint, the resolved model, and payload sniffing, so they + * cannot be stated once per connection. + */ +export function resolveChatAttachmentPolicy(cfg: OpenClawConfig): ChatAttachmentPolicy { + const maxBytes = resolveChatAttachmentMaxBytes(cfg); + return { maxBytes, maxImageBytes: Math.min(maxBytes, MAX_IMAGE_BYTES) }; +} diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 441ffd0a1af8..cbe8260b9295 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -33,11 +33,14 @@ vi.mock("../media/media-probe.js", () => ({ import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + resolveChatAttachmentMaxBytes, + resolveChatAttachmentPolicy, +} from "./chat-attachment-policy.js"; import { type ChatAttachment, parseMessageWithAttachments, persistInboundImagesForTranscript, - resolveChatAttachmentMaxBytes, stripImageMediaMarkers, UnsupportedAttachmentError, } from "./chat-attachments.js"; @@ -67,13 +70,17 @@ function pdfAttachment(overrides: Partial = {}): ChatAttachment }; } -function oversizedPngBase64(): string { +function pngBase64OfBytes(bytes: number): string { const pngHeader = PNG_1x1.slice(0, 64); - let base64Length = Math.ceil(((MAX_IMAGE_BYTES + 1) * 4) / 3); + let base64Length = Math.ceil((bytes * 4) / 3); base64Length += (4 - (base64Length % 4)) % 4; return `${pngHeader}${"A".repeat(base64Length - pngHeader.length)}`; } +function oversizedPngBase64(): string { + return pngBase64OfBytes(MAX_IMAGE_BYTES + 1); +} + async function parseWithWarnings( message: string, attachments: ChatAttachment[], @@ -623,30 +630,47 @@ describe("parseMessageWithAttachments validation errors", () => { }); }); -describe("resolveChatAttachmentMaxBytes", () => { +describe("advertised attachment policy matches enforcement", () => { const MB = 1024 * 1024; - const DEFAULT_BYTES = 20 * MB; - const cfgWithMediaMaxMb = (value: unknown): OpenClawConfig => + const cfgWithMediaMaxMb = (value: number): OpenClawConfig => ({ agents: { defaults: { mediaMaxMb: value } } }) as unknown as OpenClawConfig; - it("honours a configured agents.defaults.mediaMaxMb", () => { - expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(10))).toBe(10 * MB); - expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(50))).toBe(50 * MB); - }); - - it("falls back to DEFAULT_CHAT_ATTACHMENT_MAX_MB when unset", () => { - expect(resolveChatAttachmentMaxBytes({} as OpenClawConfig)).toBe(DEFAULT_BYTES); - expect(resolveChatAttachmentMaxBytes({ agents: {} } as unknown as OpenClawConfig)).toBe( - DEFAULT_BYTES, + async function parseImageWithPolicy(cfg: OpenClawConfig, imageBytes: number) { + const policy = resolveChatAttachmentPolicy(cfg); + const parse = parseMessageWithAttachments( + "x", + [pngAttachment({ fileName: "big.png", content: pngBase64OfBytes(imageBytes) })], + { maxBytes: policy.maxBytes, log: { warn: () => {} } }, ); + return { policy, parse }; + } + + it("rejects images above the advertised maxImageBytes when the config ceiling is the smaller limit", async () => { + const { policy, parse } = await parseImageWithPolicy(cfgWithMediaMaxMb(1), 3 * MB); + expect(policy.maxImageBytes).toBe(MB); + await expect(parse).rejects.toThrow(/exceeds size limit/i); }); - it("rejects non-positive, non-finite, or non-number values", () => { - for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY, "50", null, undefined]) { - expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(bad))).toBe(DEFAULT_BYTES); + it("accepts images under the advertised maxImageBytes", async () => { + const { policy, parse } = await parseImageWithPolicy(cfgWithMediaMaxMb(20), 3 * MB); + expect(policy.maxImageBytes).toBe(MAX_IMAGE_BYTES); + const parsed = await parse; + try { + expect(parsed.offloadedRefs).toHaveLength(1); + } finally { + await cleanupOffloadedRefs(parsed.offloadedRefs); } }); + + it("rejects images above the advertised maxImageBytes when the hydration cap is the smaller limit", async () => { + const { policy, parse } = await parseImageWithPolicy( + cfgWithMediaMaxMb(20), + MAX_IMAGE_BYTES + 3, + ); + expect(policy.maxImageBytes).toBe(MAX_IMAGE_BYTES); + await expect(parse).rejects.toThrow(/image exceeds size limit/i); + }); }); describe("attachment validation", () => { diff --git a/src/gateway/chat-attachments.ts b/src/gateway/chat-attachments.ts index c8ec2520772a..116494ddabd0 100644 --- a/src/gateway/chat-attachments.ts +++ b/src/gateway/chat-attachments.ts @@ -5,7 +5,6 @@ import { MAX_IMAGE_BYTES, type MediaKind } from "@openclaw/media-core/constants" import { extensionForMime, kindFromMime, mimeTypeFromFilePath } from "@openclaw/media-core/mime"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js"; import type { SubsystemLogger } from "../logging/subsystem.js"; import type { MediaFact } from "../media/media-facts.js"; @@ -13,6 +12,7 @@ import { probeMediaFilesWithinBudget } from "../media/media-probe.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js"; import { deleteMediaBuffer, saveMediaBuffer, type SavedMedia } from "../media/store.js"; +import { DEFAULT_CHAT_ATTACHMENT_MAX_BYTES } from "./chat-attachment-policy.js"; import { formatForLog } from "./ws-log.js"; export type ChatAttachment = { @@ -78,8 +78,6 @@ const MAX_CHAT_ATTACHMENT_MEDIA_PROBES = 8; const CHAT_ATTACHMENT_MEDIA_PROBE_CONCURRENCY = 2; const CHAT_ATTACHMENT_MEDIA_PROBE_BUDGET_MS = 3000; -const DEFAULT_CHAT_ATTACHMENT_MAX_MB = 20; - async function enrichOffloadedMediaMetadata(refs: OffloadedRef[]): Promise { const candidates = refs.flatMap((ref) => { const kind = kindFromMime(ref.mimeType); @@ -174,16 +172,6 @@ export async function persistInboundImagesForTranscript(params: { return ordered; } -/** Resolve the maximum decoded attachment size accepted for chat image inputs. */ -export function resolveChatAttachmentMaxBytes(cfg: OpenClawConfig): number { - const configured = cfg.agents?.defaults?.mediaMaxMb; - const mb = - typeof configured === "number" && Number.isFinite(configured) && configured > 0 - ? configured - : DEFAULT_CHAT_ATTACHMENT_MAX_MB; - return Math.floor(mb * 1024 * 1024); -} - type UnsupportedAttachmentReason = | "empty-payload" | "text-only-image" @@ -374,7 +362,7 @@ export async function parseMessageWithAttachments( acceptNonImage?: boolean; }, ): Promise { - const maxBytes = opts?.maxBytes ?? DEFAULT_CHAT_ATTACHMENT_MAX_MB * 1024 * 1024; + const maxBytes = opts?.maxBytes ?? DEFAULT_CHAT_ATTACHMENT_MAX_BYTES; const log = opts?.log; const shouldForceImageOffload = opts?.supportsImages === false; const supportsInlineImages = opts?.supportsInlineImages !== false; diff --git a/src/gateway/server-methods/agent-content-phase.ts b/src/gateway/server-methods/agent-content-phase.ts index 709648470c44..cdc2e635efd2 100644 --- a/src/gateway/server-methods/agent-content-phase.ts +++ b/src/gateway/server-methods/agent-content-phase.ts @@ -29,11 +29,11 @@ import { isInternalNonDeliveryChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { resolveChatAttachmentMaxBytes } from "../chat-attachment-policy.js"; import { MediaOffloadError, logAttachmentFailure, parseMessageWithAttachments, - resolveChatAttachmentMaxBytes, type ChatAttachment, } from "../chat-attachments.js"; import { diff --git a/src/gateway/server-methods/chat-send-attachments.ts b/src/gateway/server-methods/chat-send-attachments.ts index 0f8f47f5812b..ae3317e5d001 100644 --- a/src/gateway/server-methods/chat-send-attachments.ts +++ b/src/gateway/server-methods/chat-send-attachments.ts @@ -14,12 +14,12 @@ import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline import { formatErrorMessage } from "../../infra/errors.js"; import { parseInboundMediaUri } from "../../media/media-reference.js"; import { deleteMediaBuffer, MEDIA_MAX_BYTES } from "../../media/store.js"; +import { resolveChatAttachmentMaxBytes } from "../chat-attachment-policy.js"; import { MediaOffloadError, type OffloadedRef, logAttachmentFailure, parseMessageWithAttachments, - resolveChatAttachmentMaxBytes, stripImageMediaMarkers, UnsupportedAttachmentError, } from "../chat-attachments.js"; diff --git a/src/gateway/server-node-events.runtime.ts b/src/gateway/server-node-events.runtime.ts index fed5907a0be2..c39b49fc69ba 100644 --- a/src/gateway/server-node-events.runtime.ts +++ b/src/gateway/server-node-events.runtime.ts @@ -20,10 +20,10 @@ export { enqueueSystemEvent } from "../infra/system-events.js"; export { deleteMediaBuffer } from "../media/store.js"; export { normalizeMainKey } from "../routing/session-key.js"; export { defaultRuntime } from "../runtime.js"; +export { resolveChatAttachmentMaxBytes } from "./chat-attachment-policy.js"; export { parseMessageWithAttachments, persistInboundImagesForTranscript, - resolveChatAttachmentMaxBytes, } from "./chat-attachments.js"; export { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js"; export { diff --git a/src/gateway/server.hello-attachments.test.ts b/src/gateway/server.hello-attachments.test.ts new file mode 100644 index 000000000000..3da69d195e3c --- /dev/null +++ b/src/gateway/server.hello-attachments.test.ts @@ -0,0 +1,62 @@ +// Handshake coverage for the additive chat-attachment limits on `hello-ok`, so +// clients can validate a file before sending instead of hardcoding guesses. +import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; +import { + connectOk, + createGatewaySuiteHarness, + installGatewayTestHooks, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +type GatewayHarness = Awaited>; +let gateway: GatewayHarness; + +type HelloPolicy = { + policy?: { attachments?: { maxBytes?: number; maxImageBytes?: number } }; +}; + +async function readAdvertisedAttachments(mediaMaxMb: number): Promise { + testState.agentConfig = { mediaMaxMb }; + // The handshake reads the pinned runtime snapshot, so drop it to pick the + // override up on the next connection. + clearConfigCache(); + clearRuntimeConfigSnapshot(); + const socket = await gateway.openWs(); + try { + const hello = (await connectOk(socket)) as HelloPolicy; + return hello.policy?.attachments; + } finally { + socket.close(); + } +} + +describe("hello-ok attachment limits", () => { + beforeAll(async () => { + gateway = await createGatewaySuiteHarness(); + }); + + afterAll(async () => { + await gateway.close(); + testState.agentConfig = undefined; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + }); + + test("advertises the configured ceiling and the image hydration cap", async () => { + expect(await readAdvertisedAttachments(7)).toEqual({ + maxBytes: 7 * 1024 * 1024, + maxImageBytes: MAX_IMAGE_BYTES, + }); + }); + + test("clamps the advertised image ceiling to a smaller configured ceiling", async () => { + expect(await readAdvertisedAttachments(2)).toEqual({ + maxBytes: 2 * 1024 * 1024, + maxImageBytes: 2 * 1024 * 1024, + }); + }); +}); diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index 633c4f86553d..8c4ccb6a7e56 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -14,6 +14,7 @@ import { } from "../../../infra/node-pairing.js"; import { listProfiles } from "../../../state/user-profiles.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; +import { resolveChatAttachmentPolicy } from "../../chat-attachment-policy.js"; import { listControlUiPluginTabs, listControlUiPluginWidgetKinds, @@ -124,6 +125,7 @@ export async function sendGatewayHello( maxPayload: MAX_PAYLOAD_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES, tickIntervalMs: TICK_INTERVAL_MS, + attachments: resolveChatAttachmentPolicy(context.configSnapshot), allowedSessionVisibilities: allowedSessionVisibilities(context.configSnapshot), hasMultipleSessionSharingIdentities: listProfiles().filter((profile) => !profile.mergedInto).length >= 2, From c5dd9c3095bdf01bb1d0950de88de1f6ef04a32d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:48:07 -0700 Subject: [PATCH 33/53] test(secrets): dedupe runtime state fixtures (#117563) --- src/secrets/provider-integrations.test.ts | 608 +++++++---------- src/secrets/runtime-state.test.ts | 768 +++++----------------- 2 files changed, 390 insertions(+), 986 deletions(-) diff --git a/src/secrets/provider-integrations.test.ts b/src/secrets/provider-integrations.test.ts index 458020d3faf0..4d81aa736fe3 100644 --- a/src/secrets/provider-integrations.test.ts +++ b/src/secrets/provider-integrations.test.ts @@ -35,6 +35,22 @@ function writeSecureFile(file: string, contents: string): void { fs.chmodSync(file, 0o600); } +function writePluginManifest(rootDir: string, manifest: Record): void { + fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + ...manifest, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }), + "utf8", + ); +} + function createCandidate( rootDir: string, idHint: string, @@ -48,6 +64,16 @@ function createCandidate( }; } +function loadTestRegistry( + rootDir: string, + idHint: string, + origin: PluginOrigin = "global", +): PluginManifestRegistry { + return loadPluginManifestRegistry({ + candidates: [createCandidate(rootDir, idHint, origin)], + }); +} + function pluginIntegrationProviderConfig(pluginId: string, integrationId: string) { return { source: "exec" as const, @@ -67,45 +93,33 @@ afterEach(() => { describe("secret provider integration presets", () => { it("materializes plugin manifest exec providers without provider-specific core code", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.join(rootDir, "bin")); writeSecureFile(path.join(rootDir, "bin", "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "acme-secrets", - name: "Acme Secrets", - secretProviderIntegrations: { - acme: { - providerAlias: "acme", - displayName: "Acme Vault", - description: "Acme exec resolver", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs", "--profile", "work"], - timeoutMs: 3000, - noOutputTimeoutMs: 3000, - maxOutputBytes: 4096, - passEnv: ["HOME"], - env: { - ACME_PROFILE: "work", - }, - jsonOnly: false, + writePluginManifest(rootDir, { + id: "acme-secrets", + name: "Acme Secrets", + secretProviderIntegrations: { + acme: { + providerAlias: "acme", + displayName: "Acme Vault", + description: "Acme exec resolver", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs", "--profile", "work"], + timeoutMs: 3000, + noOutputTimeoutMs: 3000, + maxOutputBytes: 4096, + passEnv: ["HOME"], + env: { + ACME_PROFILE: "work", }, + jsonOnly: false, }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "acme-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "acme-secrets"); + expect(registry.diagnostics).toEqual([]); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -144,36 +158,24 @@ describe("secret provider integration presets", () => { it("normalizes manifest exec provider options to SecretRef provider schema limits", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bounded-secrets", - secretProviderIntegrations: { - bounded: { - source: "exec", - command: "${node}", - args: ["./resolve.mjs", "ok", "x".repeat(1025)], - timeoutMs: 120001, - noOutputTimeoutMs: 1.5, - maxOutputBytes: 20 * 1024 * 1024 + 1, - passEnv: ["GOOD_ENV", "bad-env"], - }, + writePluginManifest(rootDir, { + id: "bounded-secrets", + secretProviderIntegrations: { + bounded: { + source: "exec", + command: "${node}", + args: ["./resolve.mjs", "ok", "x".repeat(1025)], + timeoutMs: 120001, + noOutputTimeoutMs: 1.5, + maxOutputBytes: 20 * 1024 * 1024 + 1, + passEnv: ["GOOD_ENV", "bad-env"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bounded-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bounded-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { id: "bounded", @@ -203,31 +205,19 @@ describe("secret provider integration presets", () => { it("skips presets whose provider alias cannot be used as a SecretRef provider", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bad-secrets", - secretProviderIntegrations: { - bad: { - providerAlias: "../bad", - source: "exec", - command: "${node}", - }, + writePluginManifest(rootDir, { + id: "bad-secrets", + secretProviderIntegrations: { + bad: { + providerAlias: "../bad", + source: "exec", + command: "${node}", }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bad-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bad-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }); @@ -236,54 +226,34 @@ describe("secret provider integration presets", () => { const longPluginRootDir = makeTempDir(); const longPluginId = `plugin-${"x".repeat(129)}`; const longIntegrationId = `integration-${"x".repeat(129)}`; - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8"); - fs.writeFileSync(path.join(longPluginRootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync( path.join(longPluginRootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8", ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "long-integration-secrets", - secretProviderIntegrations: { - [longIntegrationId]: { - providerAlias: "short-alias", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "long-integration-secrets", + secretProviderIntegrations: { + [longIntegrationId]: { + providerAlias: "short-alias", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, + }, + }); + writePluginManifest(longPluginRootDir, { + id: longPluginId, + secretProviderIntegrations: { + vault: { + providerAlias: "short-plugin-alias", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(longPluginRootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: longPluginId, - secretProviderIntegrations: { - vault: { - providerAlias: "short-plugin-alias", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, - }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const registry = loadPluginManifestRegistry({ candidates: [ @@ -299,61 +269,39 @@ describe("secret provider integration presets", () => { "skips non-node manifest preset commands for %s plugin roots", (origin) => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(path.join(rootDir, "bin")); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: `${origin}-secrets`, - ...(origin === "bundled" ? { enabledByDefault: true } : {}), - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "./bin/vault-resolver", - }, + writePluginManifest(rootDir, { + id: `${origin}-secrets`, + ...(origin === "bundled" ? { enabledByDefault: true } : {}), + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "./bin/vault-resolver", }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)], + }, }); + const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); it("skips presets from disabled installed plugins", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "disabled-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "disabled-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const registry = loadPluginManifestRegistry({ candidates: [createCandidate(rootDir, "disabled-secrets", "global")], @@ -386,28 +334,18 @@ describe("secret provider integration presets", () => { it("applies plugin id aliases when filtering disabled presets", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "openai", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "openai", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const config = { plugins: { entries: { @@ -429,32 +367,20 @@ describe("secret provider integration presets", () => { it("exposes bundled presets enabled by platform default", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "platform-secrets", - enabledByDefaultOnPlatforms: [process.platform], - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "platform-secrets", + enabledByDefaultOnPlatforms: [process.platform], + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "platform-secrets", "bundled")], + }, }); + const registry = loadTestRegistry(rootDir, "platform-secrets", "bundled"); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -473,33 +399,21 @@ describe("secret provider integration presets", () => { const rootDir = makeTempDir(); const linkParent = makeTempDir(); const linkRoot = path.join(linkParent, "plugin-link"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "linked-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "linked-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); fs.symlinkSync(rootDir, linkRoot); - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(linkRoot, "linked-secrets", "global")], - }); + const registry = loadTestRegistry(linkRoot, "linked-secrets", "global"); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -517,32 +431,20 @@ describe("secret provider integration presets", () => { "skips secret provider presets from %s plugin roots", (origin) => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: `${origin}-secrets`, - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: `${origin}-secrets`, + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)], + }, }); + const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); @@ -550,7 +452,6 @@ describe("secret provider integration presets", () => { it("resolves a node-based plugin preset with plugin trusted dirs", async () => { const rootDir = makeTempDir(); const resolverPath = path.join(rootDir, "bin", "resolve.mjs"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.dirname(resolverPath)); writeSecureFile( resolverPath, @@ -565,32 +466,21 @@ describe("secret provider integration presets", () => { "});", ].join("\n"), ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "vault-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - allowInsecurePath: true, - }, + writePluginManifest(rootDir, { + id: "vault-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], + allowInsecurePath: true, }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); await withSecureTestNodeExecPath(async () => { - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "vault-secrets", "global")], - }); + const registry = loadTestRegistry(rootDir, "vault-secrets", "global"); const [preset] = listSecretProviderIntegrationPresets({ manifestRegistry: registry }); if (!preset) { throw new Error("Expected vault preset"); @@ -624,28 +514,18 @@ describe("secret provider integration presets", () => { it("fails closed when a plugin-managed provider is disabled", async () => { const rootDir = makeTempDir(); const resolverPath = path.join(rootDir, "resolve.mjs"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync(resolverPath, "process.stdin.resume();\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "revoked-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "revoked-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const config = { plugins: { entries: { @@ -715,31 +595,19 @@ describe("secret provider integration presets", () => { it("skips node presets without a plugin-root relative entrypoint arg", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bad-trust-secrets", - secretProviderIntegrations: { - bad: { - source: "exec", - command: "${node}", - args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "bad-trust-secrets", + secretProviderIntegrations: { + bad: { + source: "exec", + command: "${node}", + args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bad-trust-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bad-trust-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }); @@ -748,38 +616,26 @@ describe("secret provider integration presets", () => { () => { const rootDir = makeTempDir(); const outsideDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(path.join(rootDir, "bin")); fs.writeFileSync(path.join(outsideDir, "resolve.mjs"), "process.stdin.resume();\n"); fs.symlinkSync( path.join(outsideDir, "resolve.mjs"), path.join(rootDir, "bin", "resolve.mjs"), ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "symlink-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "symlink-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "symlink-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "symlink-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); @@ -790,34 +646,22 @@ describe("secret provider integration presets", () => { const linkedRoot = path.join(parentDir, "linked-plugin"); makeSecureDir(realRoot); fs.symlinkSync(realRoot, linkedRoot, "dir"); - fs.writeFileSync(path.join(realRoot, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.join(realRoot, "bin")); writeSecureFile(path.join(realRoot, "bin", "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(realRoot, "openclaw.plugin.json"), - JSON.stringify({ - id: "linked-root-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(realRoot, { + id: "linked-root-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(linkedRoot, "linked-root-secrets")], + }, }); + const registry = loadTestRegistry(linkedRoot, "linked-root-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { id: "vault", @@ -845,36 +689,24 @@ describe("secret provider integration presets", () => { () => { const rootDir = makeTempDir(); const binDir = path.join(rootDir, "bin"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(binDir); fs.writeFileSync(path.join(binDir, "resolve.mjs"), "process.stdin.resume();\n"); fs.chmodSync(binDir, 0o777); try { - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "writable-parent-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "writable-parent-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "writable-parent-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "writable-parent-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); } finally { fs.chmodSync(binDir, 0o700); diff --git a/src/secrets/runtime-state.test.ts b/src/secrets/runtime-state.test.ts index d5e56e103c49..54df4aec2e6a 100644 --- a/src/secrets/runtime-state.test.ts +++ b/src/secrets/runtime-state.test.ts @@ -73,6 +73,61 @@ function preparedGatewayAuthSnapshot( }); } +type ActivateOptions = Omit< + Parameters[0], + "snapshot" | "refreshContext" | "refreshHandler" +>; + +function activateSnapshot( + snapshot: PreparedSecretsRuntimeSnapshot, + options: ActivateOptions = {}, +): void { + activateSecretsRuntimeSnapshotState({ + snapshot, + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + +type ActivateIfCurrentOptions = Omit< + Parameters[0], + "snapshot" | "expectedRevision" | "refreshContext" | "refreshHandler" +> & { expectedRevision?: number }; + +function activateSnapshotIfCurrent( + snapshot: PreparedSecretsRuntimeSnapshot, + options: ActivateIfCurrentOptions = {}, +): boolean { + return activateSecretsRuntimeSnapshotStateIfCurrent({ + snapshot, + expectedRevision: options.expectedRevision ?? getActiveSecretsRuntimeSnapshotRevision(), + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + +type RestoreIfCurrentOptions = Omit< + Parameters[0], + "snapshot" | "ownedSnapshot" | "expectedRevision" | "refreshContext" | "refreshHandler" +> & { expectedRevision?: number }; + +function restoreSnapshotIfCurrent( + snapshot: PreparedSecretsRuntimeSnapshot, + ownedSnapshot: PreparedSecretsRuntimeSnapshot, + options: RestoreIfCurrentOptions = {}, +): boolean { + return restoreSecretsRuntimeSnapshotStateIfCurrent({ + snapshot, + ownedSnapshot, + expectedRevision: options.expectedRevision ?? getActiveSecretsRuntimeSnapshotRevision(), + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + describe("secrets runtime state", () => { let envSnapshot: ReturnType; const autoCleanupTempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -121,11 +176,7 @@ describe("secrets runtime state", () => { authStores: [], }); - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot); const configSnapshot = getActiveSecretsRuntimeConfigSnapshot(); const fullSnapshot = getActiveSecretsRuntimeSnapshot(); @@ -147,11 +198,7 @@ describe("secrets runtime state", () => { config: { gateway: { auth: { mode: "token", token: "resolved-debug-token" } } }, authStores: [], }); - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot); const rawSourceConfig = { gateway: { port: 19_030 } } satisfies OpenClawConfig; const secretsSourceConfig = { ...rawSourceConfig, @@ -159,13 +206,12 @@ describe("secrets runtime state", () => { } satisfies OpenClawConfig; expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: { ...snapshot, sourceConfig: secretsSourceConfig }, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - runtimeSourceConfig: rawSourceConfig, - }), + activateSnapshotIfCurrent( + { ...snapshot, sourceConfig: secretsSourceConfig }, + { + runtimeSourceConfig: rawSourceConfig, + }, + ), ).toBe(true); expect(getRuntimeConfigSourceSnapshot()).toEqual(rawSourceConfig); @@ -176,15 +222,13 @@ describe("secrets runtime state", () => { it("rejects a source-only secrets write after runtime config ownership changes", () => { const initialConfig = { gateway: { port: 19_030 } } satisfies OpenClawConfig; const concurrentConfig = { gateway: { port: 19_031 } } satisfies OpenClawConfig; - activateSecretsRuntimeSnapshotState({ - snapshot: preparedSnapshot({ + activateSnapshot( + preparedSnapshot({ sourceConfig: initialConfig, config: initialConfig, authStores: [], }), - refreshContext: null, - refreshHandler: null, - }); + ); const staleMetadata = getRuntimeConfigSnapshotMetadata(); if (!staleMetadata) { throw new Error("expected runtime config metadata"); @@ -213,16 +257,14 @@ describe("secrets runtime state", () => { }, }, } satisfies OpenClawConfig; - activateSecretsRuntimeSnapshotState({ - snapshot: preparedSnapshot({ + activateSnapshot( + preparedSnapshot({ sourceConfig: initialSource, config: runtimeConfig, authStores: [], }), - refreshContext: null, - refreshHandler: null, - runtimeSourceConfig: initialSource, - }); + { runtimeSourceConfig: initialSource }, + ); const runtimeMetadata = getRuntimeConfigSnapshotMetadata(); if (!runtimeMetadata) { throw new Error("expected runtime config metadata"); @@ -240,11 +282,8 @@ describe("secrets runtime state", () => { const descendant = structuredClone(active); descendant.config.models!.providers!.openai!.baseUrl = "https://refreshed.example.invalid/v1"; expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: descendant, + activateSnapshotIfCurrent(descendant, { expectedRevision: committedRevision, - refreshContext: null, - refreshHandler: null, runtimeSourceConfig: nextSource, preserveActivationLineage: true, }), @@ -303,11 +342,7 @@ describe("secrets runtime state", () => { agentDir, ); - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot); expect( getRuntimeAuthProfileStoreSnapshot(agentDir)?.usageStats?.["openai:default"], @@ -323,11 +358,7 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot(); const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const candidate = snapshot("sk-old", 19_002); @@ -337,23 +368,10 @@ describe("secrets runtime state", () => { key: "sk-rejected-candidate", }; expect(previous).not.toBeNull(); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous!, - expectedRevision: candidateRevision, - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous!, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_001); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ @@ -389,11 +407,7 @@ describe("secrets runtime state", () => { lastGood: { provider: "provider-a:default" }, usageStats: { "provider-b:default": { lastUsed: 1 } }, }; - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(predecessorProfiles, 19_001, predecessorState), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(predecessorProfiles, 19_001, predecessorState)); const previous = getActiveSecretsRuntimeSnapshot()!; const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const activationProfiles = { @@ -426,14 +440,7 @@ describe("secrets runtime state", () => { 19_002, preparedState, ); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); const liveAfterActivation = getRuntimeAuthProfileStoreSnapshot(agentDir)!; liveAfterActivation.order = { provider: ["provider-q:login", "provider-b:default"] }; liveAfterActivation.lastGood = { provider: "provider-q:login" }; @@ -442,15 +449,7 @@ describe("secrets runtime state", () => { }; setRuntimeAuthProfileStoreSnapshot(liveAfterActivation, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles; expect(restored?.["provider-a:default"]).toMatchObject({ key: "a-old" }); expect(restored?.["provider-b:default"]).toMatchObject({ key: "b-external" }); @@ -475,11 +474,7 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); setRuntimeAuthProfileStoreSnapshot( @@ -492,23 +487,8 @@ describe("secrets runtime state", () => { provider: "anthropic", key: "sk-rejected-candidate", }; - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_001); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: finalKey, @@ -577,21 +557,10 @@ describe("secrets runtime state", () => { }, runtimeExternalProfileIds: aExternal ? ["provider-a:default"] : undefined, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(baselineAKey, "b-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(baselineAKey, "b-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(candidateAKey, "b-old", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot(currentAKey, "b-external", 19_002, currentAExternal).authStores[0]!.store, agentDir, @@ -602,15 +571,7 @@ describe("secrets runtime state", () => { profileIds: ["provider-b:default"], }); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles; if (expectedAKey === null) { expect(restored?.["provider-a:default"]).toBeUndefined(); @@ -639,21 +600,10 @@ describe("secrets runtime state", () => { }, runtimeLocalProfileIds, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -661,15 +611,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: expected, }); @@ -697,25 +639,12 @@ describe("secrets runtime state", () => { provider: "openai", key: "sk-external-y", }; - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot( - { "openai:x": profileX, "openai:y": profileY }, - ["openai:x", "openai:y"], - 19_001, - ), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot( + snapshot({ "openai:x": profileX, "openai:y": profileY }, ["openai:x", "openai:y"], 19_001), + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ "openai:y": profileY }, ["openai:y"], 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -723,15 +652,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -753,21 +674,10 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-external-old", "external", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-external-old", "external", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateOwner, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (mutateCandidateOwner) { noteRuntimeAuthProfileStorePersistedMutation( candidateOwner === "local" ? agentDir : undefined, @@ -784,15 +694,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (mutateCandidateOwner) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -827,25 +729,16 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], runtimeLocalProfileIds: ["anthropic:stable", ...(owner === "local" ? ["openai:x"] : [])], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot( + activateSnapshot( + snapshot( baselineOwner === "absent" ? null : "sk-baseline", baselineOwner === "local" ? "local" : "inherited", 19_001, ), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-external", "external", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation( baselineOwner === "inherited" ? undefined : agentDir, { @@ -856,15 +749,7 @@ describe("secrets runtime state", () => { ); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); @@ -898,35 +783,16 @@ describe("secrets runtime state", () => { baselineOwner === "local" ? "local" : "inherited", 19_001, ); - activateSecretsRuntimeSnapshotState({ - snapshot: baseline, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(baseline); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-external", "external", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-external-refresh", "external", 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); if (baselineOwner === "absent") { expect(restored?.profiles["openai:x"]).toBeUndefined(); @@ -953,35 +819,16 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", candidateOwner, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", candidateOwner, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateOwner, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-candidate", currentOwner, 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); expect(restored?.profiles["openai:x"]).toMatchObject({ key: "sk-candidate" }); if (currentOwner === "local") { @@ -1003,31 +850,12 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toMatchObject({ runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: true, @@ -1046,35 +874,16 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", false, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", false, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-old", true, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-current", true, 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); expect(restored?.profiles["openai:x"]).toMatchObject({ key: "sk-current" }); expect(restored?.runtimeExternalProfileIdsAuthoritative).toBeUndefined(); @@ -1093,21 +902,10 @@ describe("secrets runtime state", () => { }, runtimeExternalProfileIds: ["openai:external"], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -1115,15 +913,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(snapshot(current, 19_002).authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:external"]).toMatchObject( { key: expected, @@ -1146,21 +936,10 @@ describe("secrets runtime state", () => { }, runtimeLocalProfileIds: ["anthropic:stable", "openai:default"], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); for (let index = 0; index < 300; index += 1) { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, @@ -1169,15 +948,7 @@ describe("secrets runtime state", () => { }); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1206,21 +977,10 @@ describe("secrets runtime state", () => { snapshot("sk-old", previousRef, 19_001).authStores[0]!.store, agentDir, ); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", previousRef, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", previousRef, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateRef, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-descendant", candidateRef, 19_002).authStores[0]!.store, agentDir, @@ -1236,15 +996,7 @@ describe("secrets runtime state", () => { ); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); expect( ensureAuthProfileStoreWithoutExternalProfiles(agentDir).profiles["openai:default"], @@ -1356,21 +1108,10 @@ describe("secrets runtime state", () => { ] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (mutationOwner !== "none") { noteRuntimeAuthProfileStorePersistedMutation( mutationOwner === "custom" ? agentDir : undefined, @@ -1382,15 +1123,7 @@ describe("secrets runtime state", () => { ); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (expectMissing) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -1425,21 +1158,10 @@ describe("secrets runtime state", () => { ] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, profileSetChanged: true, @@ -1447,15 +1169,7 @@ describe("secrets runtime state", () => { profileIds: ["openai:new-main"], }); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1468,32 +1182,13 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); clearRuntimeAuthProfileStoreSnapshots(); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1515,40 +1210,20 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key, keyRef }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", previousRef, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", previousRef, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateRef, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: snapshot("sk-refreshed", candidateRef, 19_002), + activateSnapshotIfCurrent(snapshot("sk-refreshed", candidateRef, 19_002), { expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, preserveActivationLineage: true, }), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: candidateRevision, - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: changedRef ? "sk-old" : "sk-refreshed", @@ -1565,11 +1240,7 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_011), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_011)); setRuntimeAuthProfileStoreSnapshot( { version: 1, @@ -1583,24 +1254,9 @@ describe("secrets runtime state", () => { const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const candidate = snapshot("sk-live", 19_012); expect(previous).not.toBeNull(); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous!, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous!, candidate)).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_011); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: "sk-live", @@ -1667,16 +1323,14 @@ describe("secrets runtime state", () => { }, authStores: [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ sourcePort: 19_021, runtimePort: 19_021, apiKey: "sk-old", keyRef: previousKeyInput, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ sourcePort: 19_022, @@ -1684,14 +1338,7 @@ describe("secrets runtime state", () => { apiKey: "sk-candidate", keyRef: candidateKeyInput, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); const providerRefresh = snapshot({ sourcePort: 19_022, @@ -1700,23 +1347,14 @@ describe("secrets runtime state", () => { keyRef: candidateKeyInput, }); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: providerRefresh, + activateSnapshotIfCurrent(providerRefresh, { expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, preserveActivationLineage: true, }), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - ownedSnapshot: candidate, - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_021); expect(getActiveSecretsRuntimeSnapshot()?.config.models?.providers?.openai?.apiKey).toBe( @@ -1822,25 +1460,16 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ sourceConfig: previousSourceConfig, apiKey: "sk-old", port: 19_031 }), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot( + snapshot({ sourceConfig: previousSourceConfig, apiKey: "sk-old", port: 19_031 }), + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ sourceConfig: candidateSourceConfig, apiKey: "sk-candidate", port: 19_032, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (evictLineage) { for (let index = 0; index < 300; index += 1) { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { @@ -1852,27 +1481,21 @@ describe("secrets runtime state", () => { } const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: snapshot({ + activateSnapshotIfCurrent( + snapshot({ sourceConfig: candidateSourceConfig, apiKey: "sk-refreshed", port: 19_032, }), - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - preserveActivationLineage: true, - }), + { + expectedRevision: candidateRevision, + preserveActivationLineage: true, + }, + ), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - ownedSnapshot: candidate, - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); const restored = getActiveSecretsRuntimeSnapshot(); expect(restored?.sourceConfig).toMatchObject(previousSourceConfig); @@ -1935,16 +1558,14 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ key: "sk-old", owner: capturedOwner, providerPath: "/tmp/old-secrets.json", port: 19_041, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ key: "sk-candidate", @@ -1952,14 +1573,7 @@ describe("secrets runtime state", () => { providerPath: "/tmp/rejected-secrets.json", port: 19_042, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, stateChanged: false, @@ -1975,15 +1589,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); @@ -2048,16 +1654,14 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ key: "sk-old", keyRef: previousRef, port: 19_051, sourceConfig: previousSourceConfig, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ key: "sk-candidate", @@ -2065,14 +1669,7 @@ describe("secrets runtime state", () => { port: 19_052, sourceConfig: candidateSourceConfig, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, stateChanged: false, @@ -2088,15 +1685,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (affectedProvider) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -2156,29 +1745,20 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ includeProfile: false, providerPath: "/tmp/old-secrets.json", port: 19_061, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ includeProfile: false, providerPath: "/tmp/rejected-secrets.json", port: 19_062, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (currentOwner === "local") { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, @@ -2196,15 +1776,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); From 02eb7988574e42a9fe0026f0e700b25b96f45c7e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:48:20 +0800 Subject: [PATCH 34/53] fix(ui): reclaim reentrant Talk audio meters --- ui/src/pages/chat/realtime-talk-audio.ts | 4 +++- ui/src/pages/chat/realtime-talk-google-live.ts | 5 ----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts index 8b4511d69ecd..e4a264ee1f41 100644 --- a/ui/src/pages/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -160,8 +160,10 @@ export class RealtimeTalkMediaStreamMeter { analyser.fftSize = this.samples.length; analyser.smoothingTimeConstant = 0; source.connect(analyser); - this.publishCurrentLevel(); this.timer = globalThis.setInterval(() => this.publishCurrentLevel(), 100); + // The initial level callback can synchronously stop its owning transport. + // Own the interval first so that reentrant cleanup cannot leave it behind. + this.publishCurrentLevel(); } catch { // Metering is feedback only; capture must still work if Web Audio analysis // is unavailable in an otherwise functional WebRTC browser. diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index d507c45ec251..2dbc4f909723 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -216,11 +216,6 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { const inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel); this.inputMeter = inputMeter; inputMeter.start(this.media, this.inputContext); - if (this.closed || !this.lifecycle.isActive || this.inputMeter !== inputMeter) { - // start() publishes synchronously before installing its interval. A - // reentrant stop must reclaim the interval that start() installs next. - inputMeter.stop(false); - } this.assertActivationCurrent(); } this.startMicrophonePump(); From f35c34343a9b18b6e6c721edf658841f209b35bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:48:24 -0700 Subject: [PATCH 35/53] fix(plugins): fail blocked enable commands (#117536) Co-authored-by: Peter Steinberger --- src/cli/plugins-cli.policy.test.ts | 8 +++++--- src/cli/plugins-cli.runtime.ts | 8 +++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index 87fc744612a3..14cc09d25a50 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -118,7 +118,7 @@ describe("plugins cli policy mutations", () => { plugins: { allow: ["other-plugin"] }, reason: "blocked by allowlist", }, - ])("does not mutate plugin state when $policy blocks enablement", async ({ plugins, reason }) => { + ])("fails without mutations when $policy blocks enablement", async ({ plugins, reason }) => { const sourceConfig = { plugins } as OpenClawConfig; loadConfig.mockReturnValue(sourceConfig); enablePluginInConfig.mockReturnValue({ @@ -129,11 +129,13 @@ describe("plugins cli policy mutations", () => { }); mockPluginRegistry(["alpha"]); - await runPluginsCommand(["plugins", "enable", "alpha"]); + await expect(runPluginsCommand(["plugins", "enable", "alpha"])).rejects.toThrow("__exit__:1"); + expect(replaceConfigFile).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled(); - expect(runtimeLogs).toContain(`Plugin "alpha" could not be enabled (${reason}).`); + expect(runtimeErrors).toContain(`Plugin "alpha" could not be enabled (${reason}).`); + expect(runtimeLogs).not.toContain(`Plugin "alpha" could not be enabled (${reason}).`); }); it("refuses plugin enablement in Nix mode before config mutation", async () => { diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index e52f5dfa4679..1d1c44761cb7 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -205,12 +205,10 @@ async function runPluginsEnableCommandUnlocked(idInput: string): Promise { }); // A blocked request must not displace the active slot or rewrite persisted state. if (!enableResult.enabled) { - defaultRuntime.log( - theme.warn( - `Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`, - ), + defaultRuntime.error( + `Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`, ); - return; + return defaultRuntime.exit(1); } const { applySlotSelectionForPlugin } = await loadPluginSlotSelection(); From b9a84f49a91c7d11fa88bb87b8c033f772de3cda Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:48:24 +0800 Subject: [PATCH 36/53] test(ui): cover reentrant Talk meter cleanup --- ui/src/pages/chat/realtime-talk-audio.test.ts | 38 +++++++++++++++++++ .../chat/realtime-talk-gateway-relay.test.ts | 19 ++++++++++ .../pages/chat/realtime-talk-webrtc.test.ts | 36 ++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/ui/src/pages/chat/realtime-talk-audio.test.ts b/ui/src/pages/chat/realtime-talk-audio.test.ts index b0db4684e9b6..bb771a9e6263 100644 --- a/ui/src/pages/chat/realtime-talk-audio.test.ts +++ b/ui/src/pages/chat/realtime-talk-audio.test.ts @@ -95,6 +95,44 @@ describe("RealtimeTalkMediaStreamMeter", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims its interval when the initial level callback stops it", () => { + vi.useFakeTimers(); + const close = vi.fn(async () => undefined); + const disconnectSource = vi.fn(); + const disconnectAnalyser = vi.fn(); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: disconnectSource }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: disconnectAnalyser, + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onLevel = vi.fn((level: number) => { + if (level > 0) { + meter.stop(); + } + }); + const meter = new RealtimeTalkMediaStreamMeter(onLevel); + + meter.start({} as MediaStream); + meter.stop(); + meter.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(disconnectSource).toHaveBeenCalledOnce(); + expect(disconnectAnalyser).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("closes an owned AudioContext when analyser setup fails", () => { const close = vi.fn(async () => undefined); class MockAudioContext { diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts index aac2ba302117..a729faa6a7e8 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -614,6 +614,25 @@ describe("GatewayRelayRealtimeTalkTransport", () => { expect(onInputLevel).toHaveBeenLastCalledWith(0); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + const client = createClient(); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createTransport({ client, callbacks: { onInputLevel } }); + + await expect(transport.start()).resolves.toBe("ready"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1); + }); + it("bounds stalled microphone appends and aborts every owner on stop", async () => { const onStatus = vi.fn(); const client = createClient(); diff --git a/ui/src/pages/chat/realtime-talk-webrtc.test.ts b/ui/src/pages/chat/realtime-talk-webrtc.test.ts index 6bef1ac0e370..8a662e211f2d 100644 --- a/ui/src/pages/chat/realtime-talk-webrtc.test.ts +++ b/ui/src/pages/chat/realtime-talk-webrtc.test.ts @@ -223,6 +223,42 @@ describe("WebRtcSdpRealtimeTalkTransport", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + stubAnswerSdpFetch(); + const close = vi.fn(async () => undefined); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: vi.fn() }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: vi.fn(), + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createOpenAiTransport({}, { onInputLevel }); + + await expect(transport.start()).resolves.toBe("cancelled"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(stopInputTrack).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("does not continue WebRTC setup when stopped while microphone access is pending", async () => { const fetchMock = vi.fn(async () => new Response("answer-sdp")); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); From 063907e9701c9bc7baef52e7e1e86d8e4395d8a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:53:50 -0700 Subject: [PATCH 37/53] refactor(plugins): consolidate registry snapshots (#117561) --- src/plugins/installed-plugin-index-hash.ts | 22 - ...try-contributions.current-snapshot.test.ts | 23 +- ...plugin-registry-snapshot.lifecycle.test.ts | 24 - src/plugins/plugin-registry-snapshot.test.ts | 312 ++++++-- src/plugins/plugin-registry-snapshot.ts | 751 ++++++++---------- src/plugins/plugin-registry.test.ts | 158 ++-- 6 files changed, 664 insertions(+), 626 deletions(-) delete mode 100644 src/plugins/plugin-registry-snapshot.lifecycle.test.ts diff --git a/src/plugins/installed-plugin-index-hash.ts b/src/plugins/installed-plugin-index-hash.ts index dce1f01186e4..f66b1a992344 100644 --- a/src/plugins/installed-plugin-index-hash.ts +++ b/src/plugins/installed-plugin-index-hash.ts @@ -59,25 +59,3 @@ export function safeFileSignature(filePath: string): InstalledPluginFileSignatur return undefined; } } - -/** Compares current file metadata with a stored installed-plugin file signature. */ -export function fileSignatureMatches( - filePath: string, - signature: InstalledPluginFileSignature | undefined, -): boolean | undefined { - if (!signature) { - return undefined; - } - if (typeof signature.ctimeMs !== "number") { - return undefined; - } - const current = safeFileSignature(filePath); - if (!current) { - return false; - } - return ( - current.size === signature.size && - current.mtimeMs === signature.mtimeMs && - current.ctimeMs === signature.ctimeMs - ); -} diff --git a/src/plugins/plugin-registry-contributions.current-snapshot.test.ts b/src/plugins/plugin-registry-contributions.current-snapshot.test.ts index c32ada20d875..fb17ac9d974b 100644 --- a/src/plugins/plugin-registry-contributions.current-snapshot.test.ts +++ b/src/plugins/plugin-registry-contributions.current-snapshot.test.ts @@ -1,5 +1,6 @@ // Verifies current plugin registry contribution snapshots. -import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js"; @@ -8,6 +9,7 @@ import type { InstalledPluginIndex } from "./installed-plugin-index.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; afterEach(() => { clearCurrentPluginMetadataSnapshot(); @@ -141,7 +143,7 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => { expect(loadPluginManifestRegistryForPluginRegistry({ config, env }).plugins).toEqual([]); }); - it("does not reuse current metadata for explicit registry inputs or diagnostics", () => { + it("keeps explicit registry inputs authoritative and reuses current diagnostics", () => { const config: OpenClawConfig = {}; const env = { HOME: "/tmp/openclaw-test-home", @@ -190,11 +192,26 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => { }), { config, env, workspaceDir }, ); + const readDirectory = vi.spyOn(fs, "readdirSync"); + const readFile = vi.spyOn(fs, "readFileSync"); + const statFile = vi.spyOn(fs, "statSync"); expect( loadPluginManifestRegistryForPluginRegistry({ config, env, workspaceDir }).plugins.map( (plugin) => plugin.id, ), - ).toEqual([]); + ).toEqual(["enabled"]); + expect( + loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }).diagnostics, + ).toEqual([ + { + level: "info", + code: "persisted-registry-missing", + message: "missing", + }, + ]); + expect(readDirectory).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(statFile).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/plugin-registry-snapshot.lifecycle.test.ts b/src/plugins/plugin-registry-snapshot.lifecycle.test.ts deleted file mode 100644 index 92c662cb16c4..000000000000 --- a/src/plugins/plugin-registry-snapshot.lifecycle.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getCurrentPluginMetadataSnapshotState, - setCurrentPluginMetadataSnapshotState, -} from "./current-plugin-metadata-state.js"; -import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; -import "./plugin-registry-snapshot.js"; - -vi.mock("./current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: vi.fn(() => undefined), -})); - -afterEach(() => { - clearPluginMetadataLifecycleCaches(); -}); - -describe("plugin registry snapshot lifecycle", () => { - it("clears registry metadata when the snapshot facade is mocked", () => { - setCurrentPluginMetadataSnapshotState({ plugins: [] }, "mocked-snapshot-facade"); - - expect(() => clearPluginMetadataLifecycleCaches()).not.toThrow(); - expect(getCurrentPluginMetadataSnapshotState().snapshot).toBeUndefined(); - }); -}); diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index 31b741432694..360c80bff4e8 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -16,7 +16,6 @@ import { } from "./installed-plugin-index.js"; import { markRetainedManagedNpmInstall } from "./managed-npm-retention.js"; import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; -import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; @@ -275,7 +274,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }); }); - it("does not treat diagnostic current metadata as provided registry input", () => { + it("reuses diagnostic current metadata without promoting its registry source", () => { const env = { ...createHermeticEnv(makeTempDir()), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", @@ -300,6 +299,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { configFingerprint: "", workspaceDir, index, + registrySource: "derived", registryDiagnostics: [ { level: "info", @@ -333,10 +333,27 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }, { config, env, workspaceDir }, ); + const readDirectory = vi.spyOn(fs, "readdirSync"); + const readFile = vi.spyOn(fs, "readFileSync"); + const statFile = vi.spyOn(fs, "statSync"); const result = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }); - expect(result.source).not.toBe("provided"); + expect(result).toEqual({ + snapshot: index, + source: "derived", + diagnostics: [ + { + level: "info", + code: "persisted-registry-missing", + message: "missing", + }, + ], + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + expect(readDirectory).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(statFile).not.toHaveBeenCalled(); }); it("does not reuse current metadata when explicit derivation inputs are supplied", () => { @@ -559,75 +576,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.diagnostics).toStrictEqual([]); }); - it("reuses a memoized registry without polling plugin files", () => { - const tempRoot = makeTempDir(); - const workspaceDir = path.join(tempRoot, "workspace"); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - const config = {}; - const first = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }); - const readDirectory = vi.spyOn(fs, "readdirSync"); - const readFile = vi.spyOn(fs, "readFileSync"); - const statFile = vi.spyOn(fs, "statSync"); - - expect(loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir })).toBe(first); - expect(readDirectory).not.toHaveBeenCalled(); - expect(readFile).not.toHaveBeenCalled(); - expect(statFile).not.toHaveBeenCalled(); - }); - - it("retains only the current process-lifecycle registry graph", () => { - const tempRoot = makeTempDir(); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - const firstWorkspace = path.join(tempRoot, "first-workspace"); - const secondWorkspace = path.join(tempRoot, "second-workspace"); - - const first = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }); - const second = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: secondWorkspace, - }); - const refreshedFirst = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }); - - expect(second).not.toBe(first); - expect(refreshedFirst).not.toBe(first); - expect( - loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }), - ).toBe(refreshedFirst); - }); - - it("refreshes workspace plugin discovery on explicit metadata invalidation", () => { - const tempRoot = makeTempDir(); - const workspaceDir = path.join(tempRoot, "workspace"); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - - const first = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(first.snapshot.plugins.map((plugin) => plugin.pluginId)).not.toContain("demo"); - - writePackagePlugin(path.join(workspaceDir, ".openclaw", "extensions", "demo")); - - const second = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(second).toBe(first); - - clearPluginMetadataLifecycleCaches(); - - const refreshed = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(refreshed.snapshot.plugins.map((plugin) => plugin.pluginId)).toContain("demo"); - }); - - it("ignores malformed load paths while memoizing snapshots", () => { + it("ignores malformed load paths while deriving snapshots", () => { const tempRoot = makeTempDir(); const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; const config = { @@ -673,6 +622,36 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.diagnostics).toStrictEqual([]); }); + it("rebuilds when an explicit candidate moves identical package metadata", () => { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const packageContents = JSON.stringify({ name: "demo", version: "1.0.0" }); + const baseCandidate = createCandidate(rootDir); + fs.writeFileSync(path.join(rootDir, "package.json"), packageContents, "utf8"); + const persisted = loadInstalledPluginIndex({ + candidates: [{ ...baseCandidate, packageDir: rootDir }], + config: {}, + env, + }); + writePersistedInstalledPluginIndexSync(persisted, { stateDir }); + const nestedPackageDir = path.join(rootDir, "nested"); + fs.mkdirSync(nestedPackageDir, { recursive: true }); + fs.writeFileSync(path.join(nestedPackageDir, "package.json"), packageContents, "utf8"); + + const result = loadPluginRegistrySnapshotWithMetadata({ + candidates: [{ ...baseCandidate, packageDir: nestedPackageDir }], + config: {}, + env, + stateDir, + }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + expect(result.snapshot.plugins[0]?.packageJson?.path).toBe("nested/package.json"); + }); + it("derives a complete index when a configured load-path plugin is missing", () => { const tempRoot = makeTempDir(); const firstRoot = path.join(tempRoot, "first"); @@ -810,7 +789,24 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { const metaDir = path.join(rootDir, "..meta"); fs.mkdirSync(metaDir, { recursive: true }); const packageJsonPath = path.join(metaDir, "package.json"); - fs.writeFileSync(packageJsonPath, JSON.stringify({ name: "demo", version: "1.0.0" }), "utf8"); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: "demo", + version: "1.0.0", + openclaw: { + channel: { + id: "demo", + label: "Demo", + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, + }, + }, + }), + "utf8", + ); const index = loadInstalledPluginIndex({ config, env }); const [plugin] = index.plugins; if (!plugin) { @@ -842,6 +838,17 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.source).toBe("persisted"); expect(result.diagnostics).toStrictEqual([]); + expect(result.manifestRegistry).toBeUndefined(); + const registry = loadPluginManifestRegistryForInstalledIndex({ + index: result.snapshot, + config, + env, + includeDisabled: true, + }); + expect(registry.plugins[0]?.channelCatalogMeta?.commands).toEqual({ + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }); }); it.runIf(process.platform !== "win32")( @@ -857,6 +864,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { const config = { plugins: { load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, }, }; writePackagePlugin(rootDir); @@ -902,6 +910,72 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }, ); + it.runIf(process.platform !== "win32")( + "rejects dangling root, source, and manifest links for disabled records", + () => { + for (const artifact of ["root", "source", "manifest"] as const) { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, + }, + }; + writePackagePlugin(rootDir); + writePersistedInstalledPluginIndexSync(loadInstalledPluginIndex({ config, env }), { + stateDir, + }); + const artifactPath = + artifact === "root" + ? rootDir + : path.join(rootDir, artifact === "source" ? "index.ts" : "openclaw.plugin.json"); + fs.rmSync(artifactPath, { recursive: artifact === "root" }); + fs.symlinkSync(path.join(tempRoot, "missing"), artifactPath); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect([artifact, result.source]).toEqual([artifact, "derived"]); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + } + }, + ); + + it("rejects escaped missing package metadata for disabled records", () => { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, + }, + }; + writePackagePlugin(rootDir); + const index = loadInstalledPluginIndex({ config, env }); + const plugin = requirePluginRecord(index.plugins, "demo"); + writePersistedInstalledPluginIndexSync( + { + ...index, + plugins: [ + { + ...plugin, + packageJson: { path: "../gone/package.json", hash: "missing" }, + }, + ], + }, + { stateDir }, + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + }); + it("detects same-size same-mtime manifest replacements", () => { const tempRoot = makeTempDir(); const rootDir = path.join(tempRoot, "workspace"); @@ -1048,10 +1122,10 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]); }); - it("resolves a persisted bundled root only once per registry load", () => { + it("keeps missing disabled bundled records under the trusted bundled root", () => { const tempRoot = makeTempDir(); - const packageRoot = path.join(tempRoot, "openclaw"); - const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const bundledRoot = path.join(tempRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledRoot, "whatsapp"); const stateDir = path.join(tempRoot, "state"); const env = { OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, @@ -1059,22 +1133,41 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { OPENCLAW_VERSION: "2026.4.26", VITEST: "true", }; - const pluginIds = ["bundled-one", "bundled-two", "bundled-three", "bundled-four"]; - - for (const pluginId of pluginIds) { - writeBundledPlugin(path.join(bundledRoot, pluginId), pluginId, "index.js"); - } - const index = loadInstalledPluginIndex({ config: {}, env, stateDir }); + const config = { plugins: { entries: { whatsapp: { enabled: false } } } }; + writeBundledPlugin(pluginRoot, "whatsapp", "index.js"); + const index = loadInstalledPluginIndex({ config, env, stateDir }); writePersistedInstalledPluginIndexSync(index, { stateDir }); - const realpathSpy = vi.spyOn(fs, "realpathSync"); + fs.rmSync(pluginRoot, { recursive: true }); - const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir }); + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); expect(result.source).toBe("persisted"); - expect(result.snapshot.plugins.map((plugin) => plugin.pluginId).toSorted()).toEqual( - pluginIds.toSorted(), - ); - expect(realpathSpy.mock.calls.filter(([filePath]) => filePath === bundledRoot)).toHaveLength(1); + expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["whatsapp"]); + expect(result.snapshot.plugins[0]?.enabled).toBe(false); + }); + + it("keeps missing disabled inventory beside unchanged configured plugins", () => { + const tempRoot = makeTempDir(); + const liveRoot = path.join(tempRoot, "live"); + const missingRoot = path.join(tempRoot, "missing"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [liveRoot, missingRoot] }, + entries: { missing: { enabled: false } }, + }, + }; + writePackagePlugin(liveRoot, { pluginId: "live" }); + writePackagePlugin(missingRoot, { pluginId: "missing" }); + const index = loadInstalledPluginIndex({ config, env }); + writePersistedInstalledPluginIndexSync(index, { stateDir }); + fs.rmSync(missingRoot, { recursive: true }); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect(result.source).toBe("persisted"); + expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["live", "missing"]); }); it("treats a persisted source bundled root as stale once its built peer appears", () => { @@ -1112,6 +1205,49 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { ]); }); + it("replaces a persisted built root when its source plugin opts out of bundled output", () => { + const tempRoot = makeTempDir(); + const packageRoot = path.join(tempRoot, "openclaw"); + const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const sourcePluginDir = path.join(packageRoot, "extensions", "whatsapp"); + const stateDir = path.join(tempRoot, "state"); + const env = { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_VERSION: "2026.4.26", + VITEST: "true", + }; + + fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8"); + fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8"); + writeBundledPlugin(sourcePluginDir, "whatsapp", "index.ts"); + writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js"); + + const builtIndex = loadInstalledPluginIndex({ config: {}, env, stateDir }); + expect(builtIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([ + fs.realpathSync(path.join(bundledRoot, "whatsapp")), + ]); + writePersistedInstalledPluginIndexSync(builtIndex, { stateDir }); + fs.writeFileSync( + path.join(sourcePluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/whatsapp", + version: "1.0.0", + openclaw: { extensions: ["./index.ts"], build: { bundledDist: false } }, + }), + "utf8", + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([ + fs.realpathSync(sourcePluginDir), + ]); + }); + it("keeps a persisted bind-mounted source overlay when its built peer exists", () => { const tempRoot = makeTempDir(); const packageRoot = path.join(tempRoot, "openclaw"); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index 147590d83bb6..151692c971c4 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -1,20 +1,16 @@ // Builds stable snapshots of plugin registry contributions. -import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { tryReadJsonSync } from "../infra/json-files.js"; -import { resolveUserPath } from "../utils.js"; -import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js"; import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js"; import { normalizePluginsConfig } from "./config-state.js"; import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; -import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js"; -import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js"; -import { resolveActivePluginInstallRoots } from "./install-root-context.js"; -import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js"; +import type { PluginDiscoveryResult } from "./discovery.js"; +import { safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js"; import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { @@ -26,7 +22,6 @@ import { } from "./installed-plugin-index-store.js"; import { getInstalledPluginRecord, - extractPluginInstallRecordsFromInstalledPluginIndex, hasMissingConfigPathActivationMetadata, isInstalledPluginEnabled, loadInstalledPluginIndexWithDiscovery, @@ -36,12 +31,67 @@ import { type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, } from "./installed-plugin-index.js"; -import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginManifestRegistry } from "./manifest-registry.js"; import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js"; -import { safeRealpathSync } from "./path-safety.js"; -import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; +import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js"; +function resolvePluginRegistryContent( + index: InstalledPluginIndex, + comparePackageJsonPath: boolean, + excludedPlugins?: ReadonlyMap, +): unknown { + const { + generatedAtMs: _generatedAtMs, + refreshReason: _refreshReason, + warning: _warning, + ...content + } = index; + const excludedRoots = [...(excludedPlugins?.values() ?? [])].map((root) => path.resolve(root)); + const exclusionPathCache = new Map(); + return { + ...content, + diagnostics: excludedPlugins + ? content.diagnostics.filter( + (diagnostic) => + !( + (diagnostic.pluginId && excludedPlugins.has(diagnostic.pluginId)) || + (diagnostic.source && + excludedRoots.some((root) => + isContainedPluginPath(root, diagnostic.source!, exclusionPathCache), + )) + ), + ) + : content.diagnostics, + installRecords: excludedPlugins + ? Object.fromEntries( + Object.entries(content.installRecords).filter( + ([pluginId]) => !excludedPlugins.has(pluginId), + ), + ) + : content.installRecords, + plugins: content.plugins + .filter((plugin) => !excludedPlugins?.has(plugin.pluginId)) + .map((plugin) => { + const { manifestFile: _manifestFile, packageJson, ...record } = plugin; + if (!packageJson) { + return record; + } + if (!comparePackageJsonPath) { + return record; + } + const { + fileSignature: _fileSignature, + path: packageJsonPath, + ...stablePackageJson + } = packageJson; + return Object.assign(record, { + packageJson: Object.assign(stablePackageJson, { path: packageJsonPath }), + }); + }), + }; +} + export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; type PluginRegistryInspection = InstalledPluginIndexStoreInspection; @@ -65,36 +115,6 @@ type PluginRegistrySnapshotResult = { manifestRegistry?: PluginManifestRegistry; }; -const REGISTRY_SNAPSHOT_MEMO_ENV_KEYS = [ - "APPDATA", - "HOME", - "OPENCLAW_BUNDLED_PLUGINS_DIR", - "OPENCLAW_COMPATIBILITY_HOST_VERSION", - "OPENCLAW_CONFIG_PATH", - "OPENCLAW_DISABLE_BUNDLED_PLUGINS", - "OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS", - "OPENCLAW_HOME", - "OPENCLAW_NIX_MODE", - "OPENCLAW_STATE_DIR", - "USERPROFILE", - "XDG_CONFIG_HOME", -] as const; - -type PluginRegistrySnapshotMemo = { - key: string; - result: PluginRegistrySnapshotResult; -}; - -let pluginRegistrySnapshotMemo: PluginRegistrySnapshotMemo | undefined; - -function clearLoadPluginRegistrySnapshotMemo(): void { - pluginRegistrySnapshotMemo = undefined; - // A retired registry must not leave its published metadata graph behind. - clearCurrentPluginMetadataSnapshot(); -} - -registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginRegistrySnapshotMemo); - export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions & { index?: PluginRegistrySnapshot; @@ -105,68 +125,6 @@ type GetPluginRecordParams = LoadPluginRegistryParams & { pluginId: string; }; -function pickRegistrySnapshotMemoEnv(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - REGISTRY_SNAPSHOT_MEMO_ENV_KEYS.flatMap((key) => { - const value = env[key]; - return value === undefined ? [] : [[key, value]]; - }), - ); -} - -function canMemoizePluginRegistrySnapshot(params: LoadPluginRegistryParams): boolean { - return ( - params.index === undefined && - params.candidates === undefined && - params.diagnostics === undefined && - params.discovery === undefined && - params.installRecords === undefined && - params.now === undefined && - params.filePath === undefined && - params.pluginIndexFilePath === undefined - ); -} - -function resolvePluginRegistrySnapshotMemoKey( - params: LoadPluginRegistryParams, - env: NodeJS.ProcessEnv, -): string | undefined { - if (!canMemoizePluginRegistrySnapshot(params)) { - return undefined; - } - return hashJson({ - config: params.config ?? null, - cwd: process.cwd(), - env: pickRegistrySnapshotMemoEnv(env), - installRoots: resolveActivePluginInstallRoots(env), - hostContractVersion: resolveCompatibilityHostVersion(env), - preferPersisted: params.preferPersisted ?? null, - // Install, reload, and persisted-index writes clear this memo explicitly. - // Polling roots or SQLite here would put discovery back on every hot lookup. - stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null, - workspaceDir: params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : null, - }); -} - -function findPluginRegistrySnapshotMemo( - key: string | undefined, -): PluginRegistrySnapshotResult | undefined { - return key && pluginRegistrySnapshotMemo?.key === key - ? pluginRegistrySnapshotMemo.result - : undefined; -} - -function rememberPluginRegistrySnapshotMemo( - key: string | undefined, - result: PluginRegistrySnapshotResult, -): PluginRegistrySnapshotResult { - if (!key) { - return result; - } - pluginRegistrySnapshotMemo = { key, result }; - return result; -} - function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean { return ( params.preferPersisted !== false && @@ -176,6 +134,7 @@ function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams) params.installRecords === undefined && params.candidates === undefined && params.diagnostics === undefined && + params.discovery === undefined && params.now === undefined ); } @@ -186,266 +145,194 @@ function loadCurrentPluginRegistrySnapshotResult( if (!canReuseCurrentPluginMetadataSnapshot(params)) { return undefined; } - const env = params.env ?? process.env; const current = getCurrentPluginMetadataSnapshot({ config: params.config, - env, + env: params.env ?? process.env, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); - if (!current || current.registryDiagnostics.length > 0) { + if (!current) { return undefined; } return { snapshot: current.index, - source: "provided", + source: + current.registrySource ?? (current.registryDiagnostics.length > 0 ? "derived" : "provided"), diagnostics: current.registryDiagnostics, + ...(current.discovery ? { discovery: current.discovery } : {}), manifestRegistry: current.manifestRegistry, }; } -function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean { +function fileContentMatches( + filePath: string, + hash: string, + signature?: InstalledPluginIndexRecord["manifestFile"], + trustSignature = true, +): boolean { + const current = safeFileSignature(filePath); + if (!current) { + return false; + } + if ( + trustSignature && + signature?.ctimeMs !== undefined && + current.size === signature.size && + current.mtimeMs === signature.mtimeMs && + current.ctimeMs === signature.ctimeMs + ) { + return true; + } + return safeHashFile({ filePath, diagnostics: [], required: false }) === hash; +} + +function isContainedPluginPath( + rootPath: string, + targetPath: string, + cache: Map, +): boolean { + // Project unresolved suffixes from the nearest real ancestor so missing disabled + // artifacts stay inspectable without accepting symlink or path-alias escapes. + const resolveProjectedPath = (inputPath: string): string | null => { + const target = path.resolve(inputPath); + for (let cursor = target; ; cursor = path.dirname(cursor)) { + try { + fs.lstatSync(cursor); + const realCursor = safeRealpathSync(cursor, cache); + return realCursor ? path.resolve(realCursor, path.relative(cursor, target)) : null; + } catch { + if (cursor === path.dirname(cursor)) { + return null; + } + } + } + }; + const root = resolveProjectedPath(rootPath); + const target = resolveProjectedPath(targetPath); + return Boolean(root && target && isPathInside(root, target)); +} + +function hasStalePersistedPluginFiles(index: InstalledPluginIndex): boolean { + const realpathCache = new Map(); return index.plugins.some((plugin) => { - if (!plugin.enabled) { + if (!isContainedPluginPath(plugin.rootDir, plugin.rootDir, realpathCache)) { + return true; + } + if (!fs.existsSync(plugin.rootDir) && plugin.enabled) { + return true; + } + for (const artifactPath of [plugin.source, plugin.setupSource, plugin.manifestPath]) { + if (artifactPath && !isContainedPluginPath(plugin.rootDir, artifactPath, realpathCache)) { + return true; + } + } + if ( + plugin.enabled && + ((plugin.source ? !fs.existsSync(plugin.source) : false) || + (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)) + ) { + return true; + } + if (!hasOptionalMissingPluginManifestFile(plugin)) { + if (!fs.existsSync(plugin.manifestPath)) { + if (plugin.enabled) { + return true; + } + } else if ( + !fileContentMatches(plugin.manifestPath, plugin.manifestHash, plugin.manifestFile) + ) { + return true; + } + } + if (!plugin.packageJson) { return false; } - return ( - !fs.existsSync(plugin.rootDir) || - (!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) || - (plugin.source ? !fs.existsSync(plugin.source) : false) || - (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false) + const packageJsonPath = path.resolve(plugin.rootDir, plugin.packageJson.path); + if (!isContainedPluginPath(plugin.rootDir, packageJsonPath, realpathCache)) { + return true; + } + if (!fs.existsSync(packageJsonPath)) { + return plugin.enabled; + } + if (!isRealPathInside(plugin.rootDir, packageJsonPath, realpathCache)) { + return true; + } + return !fileContentMatches( + packageJsonPath, + plugin.packageJson.hash, + plugin.packageJson.fileSignature, + plugin.origin === "bundled", ); }); } -function hasMismatchedPersistedConfigPathPlugins( - index: InstalledPluginIndex, - params: LoadPluginRegistryParams, - env: NodeJS.ProcessEnv, - realpathCache: Map, -): boolean { - const loadPaths = normalizePluginsConfig(params.config?.plugins).loadPaths; - const discovery = discoverConfiguredPluginLoadPaths({ - loadPaths, - workspaceDir: params.workspaceDir, - env, - }); - const configuredRoots = loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index), - }).plugins.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache)); - const persistedRoots = index.plugins - .filter((plugin) => plugin.origin === "config") - .map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache)); - if (configuredRoots.length !== persistedRoots.length) { - return true; - } - return configuredRoots.some((rootDir, position) => rootDir !== persistedRoots[position]); -} - -function resolveComparablePath(filePath: string, realpathCache: Map): string { - return safeRealpathSync(filePath, realpathCache) ?? path.resolve(filePath); -} - -function isRelativePathInsideOrEqual(relativePath: string): boolean { - return ( - relativePath === "" || - (relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath)) - ); -} - -function isPathInsideOrEqual( - childPath: string, +function isRealPathInside( parentPath: string, - realpathCache: Map, + childPath: string, + cache: Map, ): boolean { - const relative = path.relative( - resolveComparablePath(parentPath, realpathCache), - resolveComparablePath(childPath, realpathCache), - ); - return isRelativePathInsideOrEqual(relative); + const parent = safeRealpathSync(parentPath, cache); + const child = safeRealpathSync(childPath, cache); + return Boolean(parent && child && isPathInside(parent, child)); } -function hasMismatchedPersistedBundledPluginRoot( +function hasMismatchedPersistedBundledRoot( index: InstalledPluginIndex, env: NodeJS.ProcessEnv, - realpathCache: Map, ): boolean { - const bundledPluginsDir = resolveBundledPluginsDir(env); - if (!bundledPluginsDir) { + const bundledRoot = resolveBundledPluginsDir(env); + if (!bundledRoot) { return false; } - let sourceOverlayDirs: string[] | undefined; + const realpathCache = new Map(); + const overlays = listBundledSourceOverlayDirs({ bundledRoot, env }); + const legacyRoot = buildLegacyBundledRootPath(bundledRoot); + const sourceCheckout = + legacyRoot && + fs.existsSync(path.join(path.dirname(legacyRoot), ".git")) && + fs.existsSync(path.join(path.dirname(legacyRoot), "pnpm-workspace.yaml")) && + fs.existsSync(path.join(path.dirname(legacyRoot), "src")); return index.plugins.some((plugin) => { if (plugin.origin !== "bundled") { return false; } - sourceOverlayDirs ??= listBundledSourceOverlayDirs({ - bundledRoot: bundledPluginsDir, - env, - }); - return !isAllowedPersistedBundledPluginRoot( - plugin, - bundledPluginsDir, - sourceOverlayDirs, - realpathCache, - ); - }); -} - -function isAllowedPersistedBundledPluginRoot( - plugin: InstalledPluginIndexRecord, - bundledPluginsDir: string, - sourceOverlayDirs: readonly string[], - realpathCache: Map, -): boolean { - const pluginRootDir = plugin.rootDir; - const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir); - if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir, realpathCache)) { - if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) { - return true; - } - const relativePluginRoot = path.relative( - resolveComparablePath(bundledPluginsDir, realpathCache), - resolveComparablePath(pluginRootDir, realpathCache), - ); - return !sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot)); - } - if ( - sourceOverlayDirs.some((overlayDir) => - isPathInsideOrEqual(pluginRootDir, overlayDir, realpathCache), - ) - ) { - return true; - } - if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) { - return false; - } - const relativePluginRoot = path.relative( - resolveComparablePath(legacyRoot, realpathCache), - resolveComparablePath(pluginRootDir, realpathCache), - ); - if (!isRelativePathInsideOrEqual(relativePluginRoot)) { - return false; - } - if (plugin.packageBuild?.bundledDist === false) { - return true; - } - if (sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot))) { - // Older index records lack packageBuild. Re-derive once so runtime loading - // and OpenClaw fingerprint the same source-only artifact. - return false; - } - // Discovery prefers a built plugin whenever the same child exists in the - // packaged root. Keep source-only bundled plugins, but invalidate stale - // source records once their built peer appears. - return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot)); -} - -function sourcePluginOptsOutOfBundledDist(pluginRootDir: string): boolean { - const packageJson = tryReadJsonSync(path.join(pluginRootDir, "package.json")); - return getPackageManifestMetadata(packageJson ?? undefined)?.build?.bundledDist === false; -} - -function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean { - const packageRoot = path.dirname(extensionsDir); - return ( - fs.existsSync(extensionsDir) && - fs.existsSync(path.join(packageRoot, ".git")) && - fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) && - fs.existsSync(path.join(packageRoot, "src")) - ); -} - -function hashExistingFile(filePath: string): string | null { - try { - return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); - } catch { - return null; - } -} - -function resolveRecordPackageJsonPath( - plugin: InstalledPluginIndexRecord, - realpathCache: Map, -): string | null { - const packageJsonPath = plugin.packageJson?.path; - if (!packageJsonPath) { - return null; - } - const rootDir = plugin.rootDir || path.dirname(plugin.manifestPath); - const resolved = path.resolve(rootDir, packageJsonPath); - const relative = path.relative(rootDir, resolved); - if (!isRelativePathInsideOrEqual(relative)) { - return null; - } - const realRelative = path.relative( - resolveComparablePath(rootDir, realpathCache), - resolveComparablePath(resolved, realpathCache), - ); - return isRelativePathInsideOrEqual(realRelative) ? resolved : null; -} - -function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean { - return index.diagnostics.some((diag) => { - const source = diag.source; - return ( - typeof diag.pluginId === "string" && - diag.pluginId.trim().length > 0 && - typeof source === "string" && - path.isAbsolute(source) && - !fs.existsSync(source) - ); - }); -} - -function hasStalePersistedPluginMetadata( - index: InstalledPluginIndex, - realpathCache: Map, -): boolean { - return index.plugins.some((plugin) => { - if (!hasOptionalMissingPluginManifestFile(plugin)) { - const manifestSignatureMatches = fileSignatureMatches( - plugin.manifestPath, - plugin.manifestFile, + if (!plugin.enabled && !fs.existsSync(plugin.rootDir)) { + const allowedRoots = [bundledRoot, ...overlays, ...(legacyRoot ? [legacyRoot] : [])]; + return !allowedRoots.some((root) => + isContainedPluginPath(root, plugin.rootDir, realpathCache), ); - if (manifestSignatureMatches !== true) { - const manifestHash = hashExistingFile(plugin.manifestPath); - if (manifestHash && manifestHash !== plugin.manifestHash) { - return true; - } + } + if (isRealPathInside(bundledRoot, plugin.rootDir, realpathCache)) { + if (!sourceCheckout) { + return false; } + const resolvedBundledRoot = safeRealpathSync(bundledRoot, realpathCache) ?? bundledRoot; + const resolvedPluginRoot = safeRealpathSync(plugin.rootDir, realpathCache) ?? plugin.rootDir; + const sourcePackage = tryReadJsonSync( + path.join( + legacyRoot, + path.relative(resolvedBundledRoot, resolvedPluginRoot), + "package.json", + ), + ); + return getPackageManifestMetadata(sourcePackage ?? undefined)?.build?.bundledDist === false; } - const packageJsonPath = resolveRecordPackageJsonPath(plugin, realpathCache); - if (!plugin.packageJson?.hash) { - return false; - } - if (!packageJsonPath) { - return true; - } - const packageJsonSignatureMatches = fileSignatureMatches( - packageJsonPath, - plugin.packageJson.fileSignature, + return ( + !overlays.some((root) => isRealPathInside(root, plugin.rootDir, realpathCache)) && + !( + plugin.packageBuild?.bundledDist === false && + legacyRoot && + isRealPathInside(legacyRoot, plugin.rootDir, realpathCache) + ) ); - if (packageJsonSignatureMatches === true && plugin.origin === "bundled") { - return false; - } - if (packageJsonSignatureMatches === false) { - return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash; - } - // Fast same-size rewrites can preserve observable stat fields on some filesystems. - const packageJsonHash = hashExistingFile(packageJsonPath); - return packageJsonHash !== plugin.packageJson.hash; }); } -function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) { - return loadInstalledPluginIndexInstallRecordsSync({ +function hasRecoveredInstallRecordsMissingFromPersistedIndex( + index: InstalledPluginIndex, + params: LoadPluginRegistryParams, + env: NodeJS.ProcessEnv, +): boolean { + const installRecords = loadInstalledPluginIndexInstallRecordsSync({ env, ...(params.stateDir ? { stateDir: params.stateDir } : {}), ...(params.filePath @@ -454,28 +341,32 @@ function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJ ? { filePath: params.pluginIndexFilePath } : {}), }); + const pluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId)); + return Object.keys(installRecords).some( + (pluginId) => !index.installRecords?.[pluginId] || !pluginIds.has(pluginId), + ); } -function hasRecoveredInstallRecordsMissingFromPersistedIndex( +function requiresDerivedRegistryValidation( index: InstalledPluginIndex, - installRecords: ReturnType, + params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv, + hasStalePluginFiles: () => boolean, ): boolean { - const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index); - const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId)); - return Object.entries(installRecords).some(([pluginId, record]) => { - if (persistedRecords[pluginId] && persistedPluginIds.has(pluginId)) { - return false; - } - const installPaths = [record.installPath, record.sourcePath].filter( - (candidate): candidate is string => - typeof candidate === "string" && candidate.trim().length > 0, - ); - if (installPaths.length === 0) { - return true; - } - return installPaths.some((installPath) => fs.existsSync(resolveUserPath(installPath, env))); - }); + return ( + params.candidates !== undefined || + params.discovery !== undefined || + params.diagnostics !== undefined || + params.installRecords !== undefined || + normalizePluginsConfig(params.config?.plugins).loadPaths.length > 0 || + hasMissingConfigPathActivationMetadata(index) || + index.diagnostics.some(({ pluginId, source }) => + Boolean(pluginId && source && path.isAbsolute(source) && !fs.existsSync(source)), + ) || + hasMismatchedPersistedBundledRoot(index, env) || + hasStalePluginFiles() || + hasRecoveredInstallRecordsMissingFromPersistedIndex(index, params, env) + ); } export function loadPluginRegistrySnapshotWithMetadata( @@ -494,96 +385,117 @@ export function loadPluginRegistrySnapshotWithMetadata( } const env = params.env ?? process.env; - const memoKey = resolvePluginRegistrySnapshotMemoKey(params, env); - const memo = findPluginRegistrySnapshotMemo(memoKey); - if (memo) { - return memo; - } - // Bound canonical paths to this registry build; lifecycle changes must - // never reuse security-sensitive symlink or plugin-root resolutions. - const realpathCache = new Map(); - const diagnostics: PluginRegistrySnapshotDiagnostic[] = []; const persistedReadsEnabled = params.preferPersisted !== false; - const pushStaleSourceDiagnostic = (message: string): void => { - diagnostics.push({ level: "warn", code: "persisted-registry-stale-source", message }); - }; - if (persistedReadsEnabled) { - const persistedIndex = readPersistedInstalledPluginIndexSync(params); - if (persistedIndex) { - if ( - params.config && - persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config) - ) { - diagnostics.push({ - level: "warn", - code: "persisted-registry-stale-policy", - message: - "Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - }); - } else if (hasMissingPersistedPluginSource(persistedIndex)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env, realpathCache)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if ( - hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env, realpathCache) - ) { - pushStaleSourceDiagnostic( - "Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasStalePersistedPluginDiagnostics(persistedIndex)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasMissingConfigPathActivationMetadata(persistedIndex)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasStalePersistedPluginMetadata(persistedIndex, realpathCache)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if ( - hasRecoveredInstallRecordsMissingFromPersistedIndex( - persistedIndex, - loadSnapshotInstallRecords(params, env), - env, - ) - ) { - pushStaleSourceDiagnostic( - "Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else { - const persistedResult: PluginRegistrySnapshotResult = { - snapshot: persistedIndex, - source: "persisted", - diagnostics, - }; - return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult); - } - } else { - diagnostics.push({ - level: "info", - code: "persisted-registry-missing", - message: "Persisted plugin registry is missing or invalid; using derived plugin index.", - }); - } + if (!persistedReadsEnabled) { + const derived = loadInstalledPluginIndexWithDiscovery({ + ...params, + installRecords: params.installRecords ?? {}, + }); + return { + snapshot: derived.index, + source: "derived", + diagnostics: [], + discovery: derived.discovery, + manifestRegistry: derived.manifestRegistry, + }; + } + + const diagnostics: PluginRegistrySnapshotDiagnostic[] = []; + const persistedIndex = readPersistedInstalledPluginIndexSync(params); + let stalePluginFiles: boolean | undefined; + const hasStalePluginFiles = () => + (stalePluginFiles ??= persistedIndex ? hasStalePersistedPluginFiles(persistedIndex) : false); + if (!persistedIndex) { + diagnostics.push({ + level: "info", + code: "persisted-registry-missing", + message: "Persisted plugin registry is missing or invalid; using derived plugin index.", + }); + } else if ( + params.config && + persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config) + ) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-policy", + message: + "Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", + }); + } else if (!requiresDerivedRegistryValidation(persistedIndex, params, env, hasStalePluginFiles)) { + return { + snapshot: persistedIndex, + source: "persisted", + diagnostics, + }; } const derived = loadInstalledPluginIndexWithDiscovery({ ...params, - installRecords: persistedReadsEnabled ? params.installRecords : (params.installRecords ?? {}), + ...(params.filePath && !params.pluginIndexFilePath + ? { pluginIndexFilePath: params.filePath } + : {}), }); - return rememberPluginRegistrySnapshotMemo(memoKey, { + const comparePackageJsonPath = + params.candidates !== undefined || params.discovery !== undefined || hasStalePluginFiles(); + const excludedMissingDisabledPlugins = new Map(); + if ( + persistedIndex && + params.candidates === undefined && + params.discovery === undefined && + params.installRecords === undefined && + !hasStalePluginFiles() && + !hasMismatchedPersistedBundledRoot(persistedIndex, env) + ) { + const derivedPluginIds = new Set(derived.index.plugins.map((plugin) => plugin.pluginId)); + for (const plugin of persistedIndex.plugins) { + if (!plugin.enabled && !derivedPluginIds.has(plugin.pluginId)) { + excludedMissingDisabledPlugins.set(plugin.pluginId, plugin.rootDir); + } + } + } + const contentMatches = + persistedIndex && + diagnostics.length === 0 && + isDeepStrictEqual( + resolvePluginRegistryContent( + persistedIndex, + comparePackageJsonPath, + excludedMissingDisabledPlugins, + ), + resolvePluginRegistryContent( + derived.index, + comparePackageJsonPath, + excludedMissingDisabledPlugins, + ), + ); + if (persistedIndex && contentMatches) { + const packageMetadataMatches = isDeepStrictEqual( + resolvePluginRegistryContent(persistedIndex, true), + resolvePluginRegistryContent(derived.index, true), + ); + return { + snapshot: persistedIndex, + source: "persisted", + diagnostics, + discovery: derived.discovery, + ...(packageMetadataMatches ? { manifestRegistry: derived.manifestRegistry } : {}), + }; + } else if (persistedIndex && diagnostics.length === 0) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-source", + message: + "Persisted plugin registry no longer matches current plugin discovery or metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", + }); + } + + return { snapshot: derived.index, source: "derived", diagnostics, discovery: derived.discovery, manifestRegistry: derived.manifestRegistry, - }); + }; } function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot { @@ -595,6 +507,7 @@ export function loadPluginRegistrySnapshot( ): PluginRegistrySnapshot { return resolveSnapshot(params); } + export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined { return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId); } diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 5dc51b2f8bc1..7bfd7dcdcd66 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -4,10 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - closeOpenClawStateDatabaseForTest, - runOpenClawStateWriteTransaction, -} from "../state/openclaw-state-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import type { PluginCandidate } from "./discovery.js"; import { readPersistedInstalledPluginIndex, @@ -169,15 +166,6 @@ function createIndex( }; } -function createPersistableIndex(pluginId: string): InstalledPluginIndex { - const index = createIndex(pluginId); - const plugins = index.plugins.map((plugin) => Object.assign({}, plugin, { enabled: false })); - return { - ...index, - plugins, - }; -} - function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object") { throw new Error(`expected ${label}`); @@ -330,6 +318,29 @@ describe("plugin registry facade", () => { ).toEqual(["demo"]); }); + it("keeps missing disabled records inspectable from the persisted registry", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const config = { plugins: { entries: { demo: { enabled: false } } } }; + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + config, + env, + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex(persisted, { stateDir }); + fs.rmSync(rootDir, { recursive: true }); + + const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, config, env }); + + expect(result.source).toBe("persisted"); + expectPluginRecordFields(getPluginRecord({ index: result.snapshot, pluginId: "demo" }), { + pluginId: "demo", + enabled: false, + }); + }); + it("resolves contribution owners from a plugin lookup table without rereading manifests", () => { const rootDir = makeTempDir(); const candidate = createCandidate(rootDir); @@ -471,7 +482,7 @@ describe("plugin registry facade", () => { expect(normalizedConfig.allow).toEqual(["demo"]); }); - it("reads the persisted registry before deriving from discovered candidates", async () => { + it("treats explicit discovered candidates as authoritative", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); const persistedRootDir = makeTempDir(); @@ -509,13 +520,70 @@ describe("plugin registry facade", () => { env: hermeticEnv(), }); - expect(result.source).toBe("persisted"); - expect(result.diagnostics).toStrictEqual([]); + expect(result.source).toBe("derived"); + expectDiagnosticCodes(result.diagnostics, ["persisted-registry-stale-source"]); expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ - "persisted", + "demo", ]); }); + it("keeps content-equivalent timestamp changes on the persisted path", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + env, + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex( + { + ...persisted, + plugins: [ + { + ...expectDefined(persisted.plugins[0], "persisted plugin test invariant"), + syntheticAuthRefs: ["demo"], + }, + ...persisted.plugins.slice(1), + ], + }, + { stateDir }, + ); + const manifestPath = path.join(rootDir, "openclaw.plugin.json"); + const future = new Date(Date.now() + 1_000); + fs.utimesSync(manifestPath, future, future); + + const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); + + expect(result.source).toBe("persisted"); + expect(result.snapshot.plugins[0]?.syntheticAuthRefs).toEqual(["demo"]); + }); + + it("reads install records from a custom SQLite registry path", async () => { + const tempDir = makeTempDir(); + const rootDir = makeTempDir(); + const filePath = path.join(tempDir, "custom-registry.sqlite"); + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + env, + preferPersisted: false, + }); + persisted.installRecords = { + demo: { source: "npm", spec: "demo@1.0.0", installPath: rootDir }, + }; + await writePersistedInstalledPluginIndex(persisted, { filePath }); + + const result = loadPluginRegistrySnapshotWithMetadata({ filePath, env }); + + expect(result.source).toBe("persisted"); + expectInstallRecord(result.snapshot.installRecords, "demo", { + source: "npm", + spec: "demo@1.0.0", + installPath: rootDir, + }); + }); + it("falls back to the derived registry when persisted source paths are missing", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); @@ -819,7 +887,7 @@ describe("plugin registry facade", () => { expectSnapshotPluginIds(result.snapshot, ["demo"]); }); - it("reuses config-scoped derived registries within the process", () => { + it("derives config-scoped registries for cold callers", () => { const stateDir = makeTempDir(); const workspaceDir = makeTempDir(); const bundledRoot = makeTempDir(); @@ -853,7 +921,7 @@ describe("plugin registry facade", () => { expect(first.source).toBe("derived"); expect(second.source).toBe("derived"); expect(manifestReadsAfterFirst).toBeGreaterThan(0); - expect(manifestReadsAfterSecond).toBe(manifestReadsAfterFirst); + expect(manifestReadsAfterSecond).toBeGreaterThan(manifestReadsAfterFirst); }); it("reloads profile extensions after the metadata lifecycle is cleared", () => { @@ -881,7 +949,7 @@ describe("plugin registry facade", () => { expectSnapshotPluginIds(second.snapshot, ["first", "second"]); }); - it("keys the process registry memo by resolved host contract version", () => { + it("derives the resolved host contract version", () => { const stateDir = makeTempDir(); const bundledRoot = makeTempDir(); const rootDir = path.join(bundledRoot, "demo"); @@ -907,56 +975,6 @@ describe("plugin registry facade", () => { expect(second.snapshot.hostContractVersion).toBe("2026.4.26"); }); - it("clears the process registry memo after persisted registry writes", async () => { - const stateDir = makeTempDir(); - const env = hermeticEnv(); - await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir }); - - const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - await writePersistedInstalledPluginIndex(createPersistableIndex("second"), { stateDir }); - const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - - expect(first.source).toBe("persisted"); - expect(second.source).toBe("persisted"); - expectSnapshotPluginIds(first.snapshot, ["first"]); - expectSnapshotPluginIds(second.snapshot, ["second"]); - }); - - it("reloads externally changed persisted state after the metadata lifecycle is cleared", async () => { - const stateDir = makeTempDir(); - const env = hermeticEnv(); - await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir }); - const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - const external = createPersistableIndex("second-external"); - runOpenClawStateWriteTransaction( - ({ db }) => { - db.prepare( - ` - UPDATE installed_plugin_index - SET plugins_json = ?, - install_records_json = ?, - diagnostics_json = ?, - updated_at_ms = ? - WHERE index_key = 'installed-plugin-index' - `, - ).run( - JSON.stringify(external.plugins), - JSON.stringify(external.installRecords), - JSON.stringify(external.diagnostics), - Date.now(), - ); - }, - { env: { ...env, OPENCLAW_STATE_DIR: stateDir } }, - ); - clearPluginMetadataLifecycleCaches(); - const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - - expect(first.source).toBe("persisted"); - expect(second.source).toBe("persisted"); - expectSnapshotPluginIds(first.snapshot, ["first"]); - expectSnapshotPluginIds(second.snapshot, ["second-external"]); - }); - it("derives a fresh registry without persisted install records when caller disables persisted reads", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); From 9a120f364fd008f10be2b912248b5479ac0eca69 Mon Sep 17 00:00:00 2001 From: RileyJJY <0668000974@xydigit.com> Date: Sun, 2 Aug 2026 02:55:52 +0800 Subject: [PATCH 38/53] fix(irc): strip markdown from outbound text (#112961) --- extensions/irc/src/send.test.ts | 73 +++++++++++++++++++++++++++++++++ extensions/irc/src/send.ts | 9 ++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 09bb36edc79c..e18cf5522dd7 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -10,11 +10,13 @@ const hoisted = vi.hoisted(() => { const loadConfig = vi.fn(); const resolveMarkdownTableMode = vi.fn(() => "preserve"); const convertMarkdownTables = vi.fn((text: string) => text); + const stripMarkdown = vi.fn((text: string) => text); const record = vi.fn(); return { loadConfig, resolveMarkdownTableMode, convertMarkdownTables, + stripMarkdown, record, normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()), connectIrcClient: vi.fn(), @@ -47,6 +49,14 @@ vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => { string, unknown >; + return original; +}); + +vi.mock("openclaw/plugin-sdk/markdown-table-runtime", async () => { + const original = (await vi.importActual("openclaw/plugin-sdk/markdown-table-runtime")) as Record< + string, + unknown + >; return { ...original, resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, @@ -61,6 +71,7 @@ vi.mock("openclaw/plugin-sdk/text-chunking", async () => { return { ...original, convertMarkdownTables: hoisted.convertMarkdownTables, + stripMarkdown: hoisted.stripMarkdown, }; }); @@ -71,6 +82,7 @@ function resetHoistedMocks() { hoisted.loadConfig.mockReset(); hoisted.resolveMarkdownTableMode.mockReset().mockReturnValue("preserve"); hoisted.convertMarkdownTables.mockReset().mockImplementation((text: string) => text); + hoisted.stripMarkdown.mockReset().mockImplementation((text: string) => text); hoisted.record.mockReset(); hoisted.normalizeIrcMessagingTarget .mockReset() @@ -85,6 +97,7 @@ afterAll(() => { vi.doUnmock("./connect-options.js"); vi.doUnmock("./protocol.js"); vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime"); + vi.doUnmock("openclaw/plugin-sdk/markdown-table-runtime"); vi.doUnmock("openclaw/plugin-sdk/text-chunking"); vi.resetModules(); }); @@ -159,6 +172,39 @@ describe("sendMessageIrc cfg threading", () => { }); }); + it("strips markdown after table conversion before sending to IRC", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + hoisted.resolveMarkdownTableMode.mockReturnValue("bullets"); + hoisted.convertMarkdownTables.mockReturnValue("**Status**\n- [docs](https://example.com)"); + hoisted.stripMarkdown.mockReturnValue("Status\n- docs (https://example.com)"); + + await sendMessageIrc("#room", " | a |\n| - |\n| **docs** | ", { + cfg: providedCfg, + client, + }); + + expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith( + "| a |\n| - |\n| **docs** |", + "bullets", + ); + expect(hoisted.stripMarkdown).toHaveBeenCalledWith("**Status**\n- [docs](https://example.com)"); + expect(client.sendPrivmsg).toHaveBeenCalledWith( + "#room", + "Status\n- docs (https://example.com)", + ); + }); + it("fails hard when cfg is omitted", async () => { const client = { isReady: vi.fn(() => true), @@ -254,6 +300,33 @@ describe("sendMessageIrc cfg threading", () => { }); }); + it("rejects stripped-empty replies before adding reply metadata", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + hoisted.stripMarkdown.mockReturnValue(""); + + await expect( + sendMessageIrc("#room", "#", { + cfg: providedCfg, + client, + replyTo: "irc-parent-1", + }), + ).rejects.toThrow("Message must be non-empty for IRC sends"); + + expect(client.sendPrivmsg).not.toHaveBeenCalled(); + expect(hoisted.record).not.toHaveBeenCalled(); + }); + it("declares message adapter durable text, media, and reply with receipt proofs", async () => { const providedCfg = { channels: { diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index 2776c6d0be05..4bc2efc069b8 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; -import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking"; +import { convertMarkdownTables, stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; import { resolveIrcAccount } from "./accounts.js"; import type { IrcClient } from "./client.js"; import { connectIrcClient } from "./client.js"; @@ -78,12 +78,11 @@ export async function sendMessageIrc( channel: "irc", accountId: account.accountId, }); - const prepared = convertMarkdownTables(text.trim(), tableMode); - const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared; - - if (!payload.trim()) { + const prepared = stripMarkdown(convertMarkdownTables(text.trim(), tableMode)); + if (!prepared.trim()) { throw new Error("Message must be non-empty for IRC sends"); } + const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared; const client = opts.client; if (client?.isReady()) { From b0ec8bbdfae5f29e5e5b31a55645d189f5df69c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:55:59 -0700 Subject: [PATCH 39/53] fix(cli): explain filtered plugin policy without unsafe recovery (#117556) Co-authored-by: Peter Steinberger --- src/cli/plugins-list-command.test.ts | 151 +++++++++++++++++++++++++++ src/cli/plugins-list-command.ts | 14 ++- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/cli/plugins-list-command.test.ts b/src/cli/plugins-list-command.test.ts index 40b8e7bdf0f2..dd59ef998be9 100644 --- a/src/cli/plugins-list-command.test.ts +++ b/src/cli/plugins-list-command.test.ts @@ -1,5 +1,6 @@ // Plugins list command tests cover plugin list command execution and output. import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OutputRuntimeEnv } from "../runtime.js"; function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv { @@ -14,6 +15,65 @@ function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv { }; } +type SnapshotPlugin = { + id: string; + enabled: boolean; + commands?: string[]; + agentHarnessIds?: string[]; +}; + +function mockPluginListSnapshot(plugins: SnapshotPlugin[], config: OpenClawConfig = {}): void { + vi.doMock("../config/config.js", () => ({ + getRuntimeConfig: () => config, + })); + vi.doMock("../plugins/status-snapshot.js", () => ({ + buildPluginRegistrySnapshotReport: () => ({ + workspaceDir: "/workspace", + registrySource: "config", + registryDiagnostics: [], + plugins, + diagnostics: [], + }), + })); +} + +function mockHumanListModules(importedModules: string[] = []): void { + vi.doMock("../plugins/source-display.js", () => { + importedModules.push("source-display"); + return { + formatPluginSourceForTable: vi.fn(), + resolvePluginSourceRoots: vi.fn(), + }; + }); + vi.doMock("../../packages/terminal-core/src/table.js", () => { + importedModules.push("table"); + return { + getTerminalTableWidth: vi.fn(), + renderTable: vi.fn(), + }; + }); + vi.doMock("../../packages/terminal-core/src/theme.js", () => { + importedModules.push("theme"); + return { + theme: { + muted: (value: string) => value, + }, + }; + }); + vi.doMock("./command-format.js", () => { + importedModules.push("command-format"); + return { + formatCliCommand: (value: string) => `formatted(${value})`, + }; + }); + vi.doMock("./plugins-list-format.js", () => { + importedModules.push("plugins-list-format"); + return { + formatPluginLine: vi.fn(), + }; + }); +} + describe("runPluginsListCommand", () => { afterEach(() => { vi.doUnmock("../config/config.js"); @@ -22,6 +82,8 @@ describe("runPluginsListCommand", () => { vi.doUnmock("../plugins/source-display.js"); vi.doUnmock("../terminal/table.js"); vi.doUnmock("../terminal/theme.js"); + vi.doUnmock("../../packages/terminal-core/src/table.js"); + vi.doUnmock("../../packages/terminal-core/src/theme.js"); vi.doUnmock("./command-format.js"); vi.doUnmock("./plugins-list-format.js"); vi.resetModules(); @@ -114,4 +176,93 @@ describe("runPluginsListCommand", () => { }, ]); }); + + it.each([ + { label: "normal", options: { enabled: true } }, + { label: "verbose", options: { enabled: true, verbose: true } }, + ])( + "explains an empty enabled-only $label list when plugins are installed", + async ({ options }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand(options, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }, + ); + + it.each([ + { label: "normal", options: { enabled: true } }, + { label: "verbose", options: { enabled: true, verbose: true } }, + ])("explains a globally disabled $label plugin inventory", async ({ options }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], { + plugins: { enabled: false }, + }); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand(options, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Plugins are globally disabled. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }); + + it.each([ + { label: "denylist", config: { plugins: { deny: ["disabled-plugin"] } } }, + { + label: "allowlist", + config: { plugins: { allow: ["allowed-plugin"] } }, + }, + ])("does not suggest a blocked mutation for a $label", async ({ config }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], config); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }); + + it("keeps install guidance when an enabled-only list has no installed plugins", async () => { + mockPluginListSnapshot([]); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No plugins found. Run formatted(openclaw plugins install ) to add one, or formatted(openclaw plugins list --json) to inspect raw discovery state.", + ]); + }); + + it("keeps empty enabled-only JSON lazy when every installed plugin is disabled", async () => { + const importedHumanModules: string[] = []; + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]); + mockHumanListModules(importedHumanModules); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true, json: true }, createJsonRuntime(writes)); + + expect(importedHumanModules).toEqual([]); + expect(writes).toEqual([ + { + workspaceDir: "/workspace", + registry: { source: "config", diagnostics: [] }, + plugins: [], + diagnostics: [], + }, + ]); + }); }); diff --git a/src/cli/plugins-list-command.ts b/src/cli/plugins-list-command.ts index 8ab4c35fd85c..9cbbf05101b1 100644 --- a/src/cli/plugins-list-command.ts +++ b/src/cli/plugins-list-command.ts @@ -76,11 +76,15 @@ export async function runPluginsListCommand( } = await loadHumanListModules(); if (list.length === 0) { - runtime.log( - theme.muted( - `No plugins found. Run ${formatCliCommand("openclaw plugins install ")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`, - ), - ); + const message = + opts.enabled && report.plugins.length > 0 + ? `${ + cfg.plugins?.enabled === false + ? "No enabled plugins found. Plugins are globally disabled." + : "No enabled plugins found." + } Run ${formatCliCommand("openclaw plugins list")} to inspect installed plugins.` + : `No plugins found. Run ${formatCliCommand("openclaw plugins install ")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`; + runtime.log(theme.muted(message)); return; } From 1e9a1405206d31a68c40c5517f1974eb3bef93cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:57:20 -0700 Subject: [PATCH 40/53] fix(skills): preserve profile in ClawHub command hints (#117555) Co-authored-by: Peter Steinberger --- src/cli/skills-cli.format.ts | 3 +- src/cli/skills-cli.test.ts | 62 +++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index b48479e9de19..b1fe75918b90 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -36,7 +36,8 @@ function appendClawHubHint(output: string, json?: boolean): string { if (json) { return output; } - return `${output}\n\nTip: use \`openclaw skills search\`, \`openclaw skills install\`, and \`openclaw skills update\` for ClawHub-backed skills.`; + const command = formatCliCommand("openclaw skills"); + return `${output}\n\nTip: use \`${command} search\`, \`${command} install\`, and \`${command} update\` for ClawHub-backed skills.`; } function formatSkillStatus(skill: SkillStatusEntry): string { diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 4744477c5c45..a52f6b24c439 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -1,5 +1,5 @@ // Skills CLI tests cover skill listing, install, and command output behavior. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { SkillStatusEntry, SkillStatusReport } from "../skills/discovery/status.js"; import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; @@ -51,6 +51,66 @@ function createMockReport(skills: SkillStatusEntry[]): SkillStatusReport { } describe("skills-cli", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("ClawHub command hints", () => { + it.each([ + { + name: "named profile", + profile: "work", + container: "", + prefix: "openclaw --profile work", + }, + { + name: "managed container", + profile: "", + container: "demo", + prefix: "openclaw --container demo", + }, + { + name: "default profile", + profile: "default", + container: "", + prefix: "openclaw", + }, + ])("preserves the $name on every human skill surface", ({ profile, container, prefix }) => { + vi.stubEnv("OPENCLAW_PROFILE", profile); + vi.stubEnv("OPENCLAW_CONTAINER_HINT", container); + const report = createMockReport([]); + const outputs = [ + formatSkillsList(report, {}), + formatSkillInfo(report, "missing-skill", {}), + formatSkillsCheck(report, {}), + ]; + + for (const output of outputs) { + for (const action of ["search", "install", "update"]) { + expect(output).toContain(`${prefix} skills ${action}`); + } + } + }); + + it("keeps profile and container guidance out of machine-readable skill output", () => { + vi.stubEnv("OPENCLAW_PROFILE", "work"); + vi.stubEnv("OPENCLAW_CONTAINER_HINT", "demo"); + const report = createMockReport([]); + const outputs = [ + formatSkillsList(report, { json: true }), + formatSkillInfo(report, "missing-skill", { json: true }), + formatSkillsCheck(report, { json: true }), + ]; + + for (const output of outputs) { + expect(() => JSON.parse(output)).not.toThrow(); + expect(output).not.toContain("Tip:"); + expect(output).not.toContain("openclaw --profile"); + expect(output).not.toContain("openclaw --container"); + } + }); + }); + describe("formatSkillsList", () => { it("formats empty skills list", () => { const report = createMockReport([]); From 7563e40e47b737c355c48441a6909456f115a72d Mon Sep 17 00:00:00 2001 From: Javier Ailbirt Date: Sat, 1 Aug 2026 18:59:08 +0000 Subject: [PATCH 41/53] fix(googlechat): drop invalid thread resource names before send (#108324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply routing can hand sendGoogleChatMessage a `thread` that is not a valid `spaces/{space}/threads/{thread}` resource name — a bare id, a `spaces/{space}/messages/{message}` name, or a thread from a different (or wrongly-cased) space. The Chat API rejects the whole request with `400 INVALID_ARGUMENT`, so the reply is never delivered even though the agent already produced it (and did any side effects). Guard the send so the `thread` field and the messageReplyOption fallback are applied only when the thread is a well-formed name belonging to the target space; otherwise post to the space as a new thread. Revives the approach of #28153 (auto-closed as stale) and addresses the failure mode behind #64313. --- extensions/googlechat/src/api.ts | 20 +++++++-- extensions/googlechat/src/targets.test.ts | 52 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index bd73b05702f8..8bc04101c8e7 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -178,6 +178,19 @@ async function fetchBuffer( }); } +/** + * A Google Chat `thread` must be a `spaces/{space}/threads/{thread}` resource + * name that belongs to the target space. Reply routing sometimes yields other + * shapes — a bare id, a `spaces/{space}/messages/{message}` name, or a thread + * from a different (or wrongly-cased) space — and passing any of those makes the + * Chat API reject the whole send with `400 INVALID_ARGUMENT`. Accept only a + * well-formed, same-space thread name; callers drop the rest so the message + * still delivers to the space (as a new thread) instead of failing outright. + */ +function isUsableGoogleChatThreadName(thread: string, space: string): boolean { + return /^spaces\/[^/]+\/threads\/[^/]+$/.test(thread) && thread.startsWith(`${space}/threads/`); +} + export async function sendGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; space: string; @@ -186,6 +199,7 @@ export async function sendGoogleChatMessage(params: { cardsV2?: GoogleChatCardV2[]; }): Promise<{ messageName?: string; threadName?: string } | null> { const { account, space, text, thread, cardsV2 } = params; + const usableThread = thread && isUsableGoogleChatThreadName(thread, space) ? thread : undefined; if ( text && (!cardsV2 || cardsV2.length === 0) && @@ -200,11 +214,11 @@ export async function sendGoogleChatMessage(params: { if (cardsV2 && cardsV2.length > 0) { body.cardsV2 = cardsV2; } - if (thread) { - body.thread = { name: thread }; + if (usableThread) { + body.thread = { name: usableThread }; } const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`); - if (thread) { + if (usableThread) { urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); } const url = urlObj.toString(); diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index d9db2f68e865..a315bf0cba0c 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -568,6 +568,58 @@ describe("sendGoogleChatMessage", () => { expect(String(url)).not.toContain("messageReplyOption="); }); + it.each([ + ["a bare id", "113887189178345237288721356"], + ["a thread key without prefix", "pytxeqyhqck"], + ["a message resource name", "spaces/AAA/messages/1720896000000.000000"], + ["a space resource name", "spaces/AAA"], + ["a thread from a different space", "spaces/BBB/threads/xyz"], + ])( + "drops an invalid thread resource name (%s) and posts to the space", + async (_label, badThread) => { + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/126"); + + const result = await sendGoogleChatMessage({ + account, + space: "spaces/AAA", + text: "hello", + thread: badThread, + }); + + const url = mockCallArg(fetchMock); + const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined; + // Invalid thread must not be forwarded, and the reply option must be omitted + // so the Chat API accepts the send instead of returning 400 INVALID_ARGUMENT. + expect(String(url)).not.toContain("messageReplyOption="); + if (typeof init?.body !== "string") { + throw new Error("Expected Google Chat request body"); + } + const body = JSON.parse(init.body) as { thread?: unknown }; + expect(body.thread).toBeUndefined(); + expect(result).toEqual({ messageName: "spaces/AAA/messages/126" }); + }, + ); + + it("keeps a valid same-space thread resource name", async () => { + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/127", "spaces/AAA/threads/xyz"); + + await sendGoogleChatMessage({ + account, + space: "spaces/AAA", + text: "hello", + thread: "spaces/AAA/threads/xyz", + }); + + const url = mockCallArg(fetchMock); + const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined; + expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); + if (typeof init?.body !== "string") { + throw new Error("Expected Google Chat request body"); + } + const body = JSON.parse(init.body) as { thread?: { name?: unknown } }; + expect(body.thread?.name).toBe("spaces/AAA/threads/xyz"); + }); + it("sends cardsV2 with the text fallback", async () => { const fetchMock = stubSuccessfulSend("spaces/AAA/messages/125"); const cardsV2 = [ From 71b35c3e1db158b21983fde1e456dd6f0b6b3797 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:59:31 -0700 Subject: [PATCH 42/53] fix(openai): keep embedding identity stable across upgrades (#117557) Co-authored-by: Peter Steinberger --- .../openai/memory-embedding-adapter.test.ts | 111 +++++++++++++++++- extensions/openai/memory-embedding-adapter.ts | 22 +++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/extensions/openai/memory-embedding-adapter.test.ts b/extensions/openai/memory-embedding-adapter.test.ts index 2e0f40c8896e..2442e2b32149 100644 --- a/extensions/openai/memory-embedding-adapter.test.ts +++ b/extensions/openai/memory-embedding-adapter.test.ts @@ -1,6 +1,10 @@ // Openai tests cover memory embedding adapter plugin behavior. -import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + resolveRemoteEmbeddingBearerClient, + type MemoryEmbeddingProvider, +} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ createOpenAiEmbeddingProvider: vi.fn(), @@ -27,6 +31,10 @@ const provider: MemoryEmbeddingProvider = { }; describe("OpenAI memory embedding adapter", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + beforeEach(() => { mocks.createOpenAiEmbeddingProvider.mockReset(); mocks.runOpenAiEmbeddingBatches.mockClear(); @@ -43,6 +51,105 @@ describe("OpenAI memory embedding adapter", () => { }); }); + it("keeps native OpenAI embedding cache identity stable across OpenClaw versions", async () => { + const createForVersion = async (version: string) => { + vi.stubEnv("OPENCLAW_VERSION", version); + const client = await resolveRemoteEmbeddingBearerClient({ + provider: "openai", + defaultBaseUrl: "https://api.openai.com/v1", + options: { + config: { models: {} } as never, + model: "text-embedding-3-small", + remote: { apiKey: "fixture-secret" }, + }, + }); + mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({ + provider, + client: { ...client, model: "text-embedding-3-small" }, + }); + const result = await openAiMemoryEmbeddingProviderAdapter.create({ + config: {} as never, + provider: "openai", + model: "text-embedding-3-small", + fallback: "none", + }); + return { headers: client.headers, cacheKeyData: result.runtime?.cacheKeyData }; + }; + + const previous = await createForVersion("2026.7.1"); + const current = await createForVersion("2026.7.2"); + + expect(previous.headers).toMatchObject({ + Authorization: "Bearer fixture-secret", + version: "2026.7.1", + "User-Agent": "openclaw/2026.7.1", + }); + expect(current.headers).toMatchObject({ + Authorization: "Bearer fixture-secret", + version: "2026.7.2", + "User-Agent": "openclaw/2026.7.2", + }); + expect(current.cacheKeyData).toEqual(previous.cacheKeyData); + expect(hashText(JSON.stringify(current.cacheKeyData))).toBe( + hashText(JSON.stringify(previous.cacheKeyData)), + ); + expect(current.cacheKeyData).toMatchObject({ + provider: "openai", + baseUrl: "https://api.openai.com/v1", + model: "text-embedding-3-small", + headers: [ + ["Content-Type", "application/json"], + ["originator", "openclaw"], + ], + }); + expect(JSON.stringify(current.cacheKeyData)).not.toContain("fixture-secret"); + }); + + it("preserves custom endpoint tenant and version-like cache identity headers", async () => { + const createForTenant = async (tenant: string) => { + const client = await resolveRemoteEmbeddingBearerClient({ + provider: "bailian-embedding", + defaultBaseUrl: "https://embeddings.example/v1", + options: { + config: { models: {} } as never, + model: "text-embedding-v3", + remote: { + apiKey: "fixture-secret", + headers: { + "X-Tenant": tenant, + version: "tenant-api-v2", + "User-Agent": "tenant-client/2", + }, + }, + }, + }); + mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({ + provider, + client: { ...client, model: "text-embedding-v3" }, + }); + return await openAiMemoryEmbeddingProviderAdapter.create({ + config: {} as never, + provider: "bailian-embedding", + model: "text-embedding-v3", + fallback: "none", + }); + }; + + const first = await createForTenant("tenant-a"); + const second = await createForTenant("tenant-b"); + const headers = first.runtime?.cacheKeyData?.headers; + + expect(headers).toEqual( + expect.arrayContaining([ + ["X-Tenant", "tenant-a"], + ["version", "tenant-api-v2"], + ["User-Agent", "tenant-client/2"], + ]), + ); + expect(first.runtime?.cacheKeyData).not.toEqual(second.runtime?.cacheKeyData); + expect(JSON.stringify(first.runtime?.cacheKeyData)).not.toContain("fixture-secret"); + }); + it("sends document input_type in OpenAI batch embedding requests", async () => { const result = await openAiMemoryEmbeddingProviderAdapter.create({ config: {} as never, diff --git a/extensions/openai/memory-embedding-adapter.ts b/extensions/openai/memory-embedding-adapter.ts index 43b41e8fccf8..7046092bcebb 100644 --- a/extensions/openai/memory-embedding-adapter.ts +++ b/extensions/openai/memory-embedding-adapter.ts @@ -11,6 +11,23 @@ import { DEFAULT_OPENAI_EMBEDDING_MODEL, } from "./embedding-provider.js"; +function resolveEmbeddingCacheExcludedHeaders(providerId: string, baseUrl: string): string[] { + const excludedHeaders = ["authorization"]; + if (providerId !== "openai") { + return excludedHeaders; + } + try { + if (new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/, "") === "api.openai.com") { + // Native attribution changes on every upgrade; cache identity must describe embeddings, + // not the OpenClaw build that requested them. + excludedHeaders.push("version", "user-agent"); + } + } catch { + // Invalid URLs are handled by the embedding client; keep existing custom-header identity. + } + return excludedHeaders; +} + export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = { id: "openai", defaultModel: DEFAULT_OPENAI_EMBEDDING_MODEL, @@ -37,7 +54,10 @@ export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapte model: client.model, outputDimensionality: client.outputDimensionality, documentInputType: client.documentInputType ?? client.inputType, - headers: sanitizeEmbeddingCacheHeaders(client.headers, ["authorization"]), + headers: sanitizeEmbeddingCacheHeaders( + client.headers, + resolveEmbeddingCacheExcludedHeaders(resolvedProvider, client.baseUrl), + ), }, batchEmbed: async (batch) => { const inputType = client.documentInputType ?? client.inputType; From 7facf157e67d7902dd645f33026a253e006ed0e0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:59:32 +0800 Subject: [PATCH 43/53] test(openai): parse realtime websocket frames safely --- .../openai/realtime-audio-buffer-ownership.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/extensions/openai/realtime-audio-buffer-ownership.test.ts b/extensions/openai/realtime-audio-buffer-ownership.test.ts index 0f496a264222..dd26c050f524 100644 --- a/extensions/openai/realtime-audio-buffer-ownership.test.ts +++ b/extensions/openai/realtime-audio-buffer-ownership.test.ts @@ -1,12 +1,21 @@ import { once } from "node:events"; import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; import { describe, expect, it, vi } from "vitest"; -import WebSocket, { WebSocketServer } from "ws"; +import WebSocket, { type RawData, WebSocketServer } from "ws"; import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; type RealtimeProviderKind = "native" | "gpt-live"; +function parseWebSocketMessage(data: RawData): Record { + const bytes = Buffer.isBuffer(data) + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : Buffer.from(data); + return JSON.parse(bytes.toString("utf8")) as Record; +} + async function withRealtimeProvider( kind: RealtimeProviderKind, prepareAudio: (bridge: RealtimeVoiceBridge) => void, @@ -21,7 +30,7 @@ async function withRealtimeProvider( const received: Array> = []; server.once("connection", (socket) => { socket.on("message", (payload) => { - const event = JSON.parse(payload.toString()) as Record; + const event = parseWebSocketMessage(payload); received.push(event); if (event.type === "session.update") { socket.send( From ebf121af6ab5036beda157722467ef5065ba58c3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:06:19 +0800 Subject: [PATCH 44/53] perf(gateway): mark recovery before model preparation (#117544) * perf(gateway): keep recovery runtime off startup path * perf(gateway): mark recovery before model preparation * test(gateway): lock recovery marking failure order --- .../server-startup-post-attach.test.ts | 56 ++++++++++++++++++- src/gateway/server-startup-post-attach.ts | 33 +++++++---- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index fd11656a7f43..c7fb7ee20231 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -123,8 +123,11 @@ vi.mock("../agents/subagent-registry.js", () => ({ scheduleSubagentOrphanRecovery: hoisted.scheduleSubagentOrphanRecovery, })); -vi.mock("../agents/main-session-restart-recovery.js", () => ({ +vi.mock("../agents/main-session-restart-recovery-marking.js", () => ({ markStartupOrphanedMainSessionsForRecovery: hoisted.markStartupOrphanedMainSessionsForRecovery, +})); + +vi.mock("../agents/main-session-restart-recovery.js", () => ({ scheduleRestartAbortedMainSessionRecovery: hoisted.scheduleRestartAbortedMainSessionRecovery, })); @@ -1812,9 +1815,12 @@ describe("startGatewayPostAttachRuntime", () => { }); }); - it("marks startup main-session orphans before channel startup", async () => { + it("marks startup main-session orphans before model runtime and channel startup", async () => { const events: string[] = []; let releaseMarking: (() => void) | undefined; + const prewarmPrimaryModel = vi.fn(async () => { + events.push("model-runtime"); + }); const startChannels = vi.fn(async () => { events.push("channels"); }); @@ -1835,6 +1841,7 @@ describe("startGatewayPostAttachRuntime", () => { defaultWorkspaceDir: "/tmp/openclaw-workspace", deps: {} as never, startChannels, + prewarmPrimaryModel, log: { warn: vi.fn() }, logHooks: { info: vi.fn(), @@ -1858,11 +1865,54 @@ describe("startGatewayPostAttachRuntime", () => { releaseMarking(); await sidecars; - expect(events).toEqual(["main-session-mark:start", "main-session-mark:done", "channels"]); + expect(events).toEqual([ + "main-session-mark:start", + "main-session-mark:done", + "model-runtime", + "channels", + ]); + expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1); expect(startChannels).toHaveBeenCalledTimes(1); expect(hoisted.scheduleRestartAbortedMainSessionRecovery).not.toHaveBeenCalled(); }); + it("marks startup main-session orphans before propagating model runtime failure", async () => { + const modelRuntimeError = new Error("model runtime unavailable"); + const startChannels = vi.fn(async () => {}); + const prewarmPrimaryModel = vi.fn(async () => { + throw modelRuntimeError; + }); + hoisted.markStartupOrphanedMainSessionsForRecovery.mockResolvedValueOnce({ + marked: 1, + skipped: 0, + }); + + await expect( + startGatewaySidecars({ + cfg: { hooks: { internal: { enabled: false } } } as never, + pluginRegistry: createPostAttachParams().pluginRegistry, + defaultWorkspaceDir: "/tmp/openclaw-workspace", + deps: {} as never, + startChannels, + prewarmPrimaryModel, + log: { warn: vi.fn() }, + logHooks: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + logChannels: { + info: vi.fn(), + error: vi.fn(), + }, + }), + ).rejects.toBe(modelRuntimeError); + + expect(hoisted.markStartupOrphanedMainSessionsForRecovery).toHaveBeenCalledTimes(1); + expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1); + expect(startChannels).not.toHaveBeenCalled(); + }); + it("logs startup main-session marker failures and still starts channels", async () => { const log = { warn: vi.fn() }; const startChannels = vi.fn(async () => {}); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index d3071265911e..c8cbe4f16ce6 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -55,6 +55,10 @@ type GatewayMemoryStartupPolicy = const loadMainSessionRestartRecoveryModule = createLazyRuntimeModule( () => import("../agents/main-session-restart-recovery.js"), ); +// Startup only needs orphan marking; keep resume and delivery runtime out of the pre-channel path. +const loadMainSessionRestartRecoveryMarkingModule = createLazyRuntimeModule( + () => import("../agents/main-session-restart-recovery-marking.js"), +); const loadAgentDefaultsModule = createLazyRuntimeModule(() => import("../agents/defaults.js")); @@ -658,6 +662,24 @@ export async function startGatewaySidecars(params: { const skipChannels = isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS); + // These runs were orphaned by the previous Gateway lifecycle. Record that fact + // even if this process later fails model preparation and never starts channels. + await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => { + try { + const { markStartupOrphanedMainSessionsForRecovery } = await measureStartup( + params.startupTrace, + "sidecars.main-session-recovery-load", + loadMainSessionRestartRecoveryMarkingModule, + ); + await measureStartup(params.startupTrace, "sidecars.main-session-recovery-scan", () => + markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg }), + ); + } catch (err) { + params.log.warn( + `main-session startup orphan marking failed before channel startup: ${String(err)}`, + ); + } + }); // Agent RPC remains available when transports are disabled. Publish configured/static facts before // accepting work; live provider catalogs stay advisory and never enter the Gateway lifecycle. await measureStartup(params.startupTrace, "sidecars.model-runtime", () => @@ -671,17 +693,6 @@ export async function startGatewaySidecars(params: { params.prewarmPrimaryModel, ), ); - await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => { - try { - const { markStartupOrphanedMainSessionsForRecovery } = - await loadMainSessionRestartRecoveryModule(); - await markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg }); - } catch (err) { - params.log.warn( - `main-session startup orphan marking failed before channel startup: ${String(err)}`, - ); - } - }); await measureStartup(params.startupTrace, "sidecars.channels", async () => { if (!skipChannels) { try { From c5090b9cf503afebc01a20bc47ac9309d4b3a3e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:09:52 -0700 Subject: [PATCH 45/53] refactor(policy): centralize doctor health-check descriptors (#117578) --- .../policy/src/doctor/check-factory.test.ts | 111 ++++++++ extensions/policy/src/doctor/check-factory.ts | 26 ++ .../policy/src/doctor/scopes/channels.ts | 150 ++++------ extensions/policy/src/doctor/scopes/core.ts | 55 +--- .../policy/src/doctor/scopes/data-auth.ts | 145 +++------- .../src/doctor/scopes/exec-approvals.ts | 120 ++------ .../policy/src/doctor/scopes/gateway.ts | 174 +++--------- .../policy/src/doctor/scopes/model-network.ts | 75 ++--- .../policy/src/doctor/scopes/routing.ts | 64 ++--- .../policy/src/doctor/scopes/sandbox.ts | 152 +++------- extensions/policy/src/doctor/scopes/tools.ts | 262 +++++------------- 11 files changed, 454 insertions(+), 880 deletions(-) create mode 100644 extensions/policy/src/doctor/check-factory.test.ts create mode 100644 extensions/policy/src/doctor/check-factory.ts diff --git a/extensions/policy/src/doctor/check-factory.test.ts b/extensions/policy/src/doctor/check-factory.test.ts new file mode 100644 index 000000000000..e06db1d66d37 --- /dev/null +++ b/extensions/policy/src/doctor/check-factory.test.ts @@ -0,0 +1,111 @@ +import type { + HealthCheckContext, + HealthFinding, + HealthRepairContext, + HealthRepairResult, +} from "openclaw/plugin-sdk/health"; +import { describe, expect, it, vi } from "vitest"; +import { createPolicyScopedChecks } from "./check-factory.js"; +import { CHECK_IDS } from "./check-ids.js"; +import type { PolicyEvaluation } from "./types.js"; + +describe("policy scoped health checks", () => { + const evaluation = {} as PolicyEvaluation; + const context = {} as HealthCheckContext; + + it("preserves registration order, descriptions, metadata, and repair capability", () => { + const repair = vi.fn(async (): Promise => ({ changes: [] })); + const checks = createPolicyScopedChecks( + { + evaluatePolicy: vi.fn(async () => evaluation), + findingsForCheck: vi.fn(() => []), + }, + [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + [CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair], + ], + ); + + expect( + checks.map(({ id, description, kind, source }) => ({ id, description, kind, source })), + ).toEqual([ + { + id: CHECK_IDS.policyMissingFile, + description: "The policy file exists.", + kind: "plugin", + source: "policy", + }, + { + id: CHECK_IDS.policyDeniedChannelProvider, + description: "Channels satisfy policy.", + kind: "plugin", + source: "policy", + }, + ]); + expect(Object.hasOwn(checks[0]!, "repair")).toBe(false); + expect(Object.hasOwn(checks[1]!, "repair")).toBe(true); + expect(checks[1]).toMatchObject({ repair }); + }); + + it("awaits the policy evaluation before selecting findings for the same check", async () => { + const findings: HealthFinding[] = [ + { checkId: CHECK_IDS.policyMissingFile, severity: "error", message: "Missing policy." }, + ]; + let releaseEvaluation!: () => void; + const evaluationGate = new Promise((resolve) => { + releaseEvaluation = resolve; + }); + const evaluatePolicy = vi.fn(async (received: HealthCheckContext) => { + expect(received).toBe(context); + await evaluationGate; + return evaluation; + }); + const findingsForCheck = vi.fn(() => findings); + const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + ]); + + const result = check!.detect(context); + expect(evaluatePolicy).toHaveBeenCalledOnce(); + expect(findingsForCheck).not.toHaveBeenCalled(); + + releaseEvaluation(); + await expect(result).resolves.toBe(findings); + expect(findingsForCheck).toHaveBeenCalledExactlyOnceWith( + evaluation, + CHECK_IDS.policyMissingFile, + ); + }); + + it("propagates evaluation failures without selecting findings", async () => { + const failure = new Error("policy evaluation failed"); + const evaluatePolicy = vi.fn(async () => { + throw failure; + }); + const findingsForCheck = vi.fn(() => []); + const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + ]); + + await expect(check!.detect(context)).rejects.toBe(failure); + expect(findingsForCheck).not.toHaveBeenCalled(); + }); + + it("retains the original repair callback and its exact result promise", () => { + const repairContext = {} as HealthRepairContext; + const findings: HealthFinding[] = []; + const pendingRepair = Promise.resolve({ changes: ["repaired"] }); + const repair = vi.fn(() => pendingRepair); + const [check] = createPolicyScopedChecks( + { + evaluatePolicy: vi.fn(async () => evaluation), + findingsForCheck: vi.fn(() => []), + }, + [[CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair]], + ); + + expect(check).toMatchObject({ repair }); + expect(check!.repair!(repairContext, findings)).toBe(pendingRepair); + expect(repair).toHaveBeenCalledExactlyOnceWith(repairContext, findings); + }); +}); diff --git a/extensions/policy/src/doctor/check-factory.ts b/extensions/policy/src/doctor/check-factory.ts new file mode 100644 index 000000000000..2beca23a7cfa --- /dev/null +++ b/extensions/policy/src/doctor/check-factory.ts @@ -0,0 +1,26 @@ +import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import type { POLICY_CHECK_IDS } from "./check-ids.js"; +import type { PolicyDoctorCheckDeps } from "./types.js"; + +type PolicyDoctorCheckDefinition = readonly [ + id: (typeof POLICY_CHECK_IDS)[number], + description: string, + repair?: NonNullable, +]; + +export function createPolicyScopedChecks( + deps: Pick, + definitions: readonly PolicyDoctorCheckDefinition[], +): readonly HealthCheck[] { + const { evaluatePolicy, findingsForCheck } = deps; + return definitions.map(([id, description, repair]) => ({ + id, + kind: "plugin", + description, + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), id); + }, + ...(repair ? { repair } : {}), + })); +} diff --git a/extensions/policy/src/doctor/scopes/channels.ts b/extensions/policy/src/doctor/scopes/channels.ts index 7f0c72d6f081..05d6bed85fdc 100644 --- a/extensions/policy/src/doctor/scopes/channels.ts +++ b/extensions/policy/src/doctor/scopes/channels.ts @@ -1,6 +1,7 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; @@ -10,109 +11,66 @@ export function createPolicyChannelProviderChecks( const { channelIdsFromFindings, disableChannels, - evaluatePolicy, - findingsForCheck, workspaceRepairsDisabledResult, workspaceRepairsEnabled, } = deps; - const policyChannelsDeniedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedChannelProvider, - kind: "plugin", - description: "Configured channels satisfy policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedChannelProvider); - }, - async repair(ctx, findings) { - if (!workspaceRepairsEnabled(ctx)) { - return workspaceRepairsDisabledResult("channel config"); - } - const channelIds = channelIdsFromFindings(findings); - if (channelIds.length === 0) { + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyDeniedChannelProvider, + "Configured channels satisfy policy deny rules.", + async (ctx, findings) => { + if (!workspaceRepairsEnabled(ctx)) { + return workspaceRepairsDisabledResult("channel config"); + } + const channelIds = channelIdsFromFindings(findings); + if (channelIds.length === 0) { + return { + status: "skipped", + reason: "no channel findings matched a configurable channel", + changes: [], + }; + } + const next = disableChannels(ctx.cfg, channelIds); + if (next.changed.length === 0) { + return { + status: "skipped", + reason: "matching channels were already disabled or missing", + changes: [], + }; + } return { - status: "skipped", - reason: "no channel findings matched a configurable channel", - changes: [], + config: next.config, + changes: next.changed.map( + (id) => `Disabled channels.${id}.enabled for policy conformance.`, + ), }; - } - const next = disableChannels(ctx.cfg, channelIds); - if (next.changed.length === 0) { - return { - status: "skipped", - reason: "matching channels were already disabled or missing", - changes: [], - }; - } - return { - config: next.config, - changes: next.changed.map( - (id) => `Disabled channels.${id}.enabled for policy conformance.`, - ), - }; - }, - }; - - return [policyChannelsDeniedProviderCheck]; + }, + ], + ]); } export function createPolicyIngressChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyIngressDmPolicyUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressDmPolicyUnapproved, - kind: "plugin", - description: "Channel direct-message access policy matches ingress requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmPolicyUnapproved); - }, - }; - const policyIngressDmScopeUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressDmScopeUnapproved, - kind: "plugin", - description: "Direct-message sessions use the policy-required isolation scope.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmScopeUnapproved); - }, - }; - const policyIngressOpenGroupsDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressOpenGroupsDenied, - kind: "plugin", - description: "Channel group access does not use open group policy when denied.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressOpenGroupsDenied); - }, - async repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied); - }, - }; - const policyIngressGroupMentionRequiredCheck: HealthCheck = { - id: CHECK_IDS.policyIngressGroupMentionRequired, - kind: "plugin", - description: "Channel group access keeps mention gates enabled when required.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyIngressGroupMentionRequired, - ); - }, - async repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyIngressGroupMentionRequired, - ); - }, - }; - - return [ - policyIngressDmPolicyUnapprovedCheck, - policyIngressDmScopeUnapprovedCheck, - policyIngressOpenGroupsDeniedCheck, - policyIngressGroupMentionRequiredCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyIngressDmPolicyUnapproved, + "Channel direct-message access policy matches ingress requirements.", + ], + [ + CHECK_IDS.policyIngressDmScopeUnapproved, + "Direct-message sessions use the policy-required isolation scope.", + ], + [ + CHECK_IDS.policyIngressOpenGroupsDenied, + "Channel group access does not use open group policy when denied.", + async (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied), + ], + [ + CHECK_IDS.policyIngressGroupMentionRequired, + "Channel group access keeps mention gates enabled when required.", + async (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressGroupMentionRequired), + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/core.ts b/extensions/policy/src/doctor/scopes/core.ts index 7255cc6d0c21..21d93305ad16 100644 --- a/extensions/policy/src/doctor/scopes/core.ts +++ b/extensions/policy/src/doctor/scopes/core.ts @@ -1,52 +1,17 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyMissingFileCheck: HealthCheck = { - id: CHECK_IDS.policyMissingFile, - kind: "plugin", - description: "The enabled Policy plugin has a policy file to verify.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingFile); - }, - }; - const policyHashMismatchCheck: HealthCheck = { - id: CHECK_IDS.policyHashMismatch, - kind: "plugin", - description: "The policy file matches the configured expected hash.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyHashMismatch); - }, - }; - const policyAttestationMismatchCheck: HealthCheck = { - id: CHECK_IDS.policyAttestationMismatch, - kind: "plugin", - description: "The current policy check matches the accepted attestation.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAttestationMismatch); - }, - }; - const policyInvalidFileCheck: HealthCheck = { - id: CHECK_IDS.policyInvalidFile, - kind: "plugin", - description: "The enabled policy file parses before policy checks run.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyInvalidFile); - }, - }; - - return [ - policyMissingFileCheck, - policyInvalidFileCheck, - policyHashMismatchCheck, - policyAttestationMismatchCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyMissingFile, "The enabled Policy plugin has a policy file to verify."], + [CHECK_IDS.policyInvalidFile, "The enabled policy file parses before policy checks run."], + [CHECK_IDS.policyHashMismatch, "The policy file matches the configured expected hash."], + [ + CHECK_IDS.policyAttestationMismatch, + "The current policy check matches the accepted attestation.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/data-auth.ts b/extensions/policy/src/doctor/scopes/data-auth.ts index 866e6480f23a..9bb0fbf15248 100644 --- a/extensions/policy/src/doctor/scopes/data-auth.ts +++ b/extensions/policy/src/doctor/scopes/data-auth.ts @@ -1,118 +1,49 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingTelemetryContentCapture, - kind: "plugin", - description: "Telemetry content capture remains disabled when policy denies it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingTelemetryContentCapture, - ); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyDataHandlingTelemetryContentCapture, - ); - }, - }; - const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, - kind: "plugin", - description: "Session retention maintenance is enforced when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, - ); - }, - }; - const policyDataHandlingSessionTranscriptMemoryCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingSessionTranscriptMemory, - kind: "plugin", - description: "Session transcript memory indexing remains disabled when policy denies it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingSessionTranscriptMemory, - ); - }, - }; - const policySecretsUnmanagedProviderCheck: HealthCheck = { - id: CHECK_IDS.policySecretsUnmanagedProvider, - kind: "plugin", - description: + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyDataHandlingTelemetryContentCapture, + "Telemetry content capture remains disabled when policy denies it.", + (ctx, findings) => + repairPolicyAutomaticNarrower( + ctx, + findings, + CHECK_IDS.policyDataHandlingTelemetryContentCapture, + ), + ], + [ + CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, + "Session retention maintenance is enforced when policy requires it.", + ], + [ + CHECK_IDS.policyDataHandlingSessionTranscriptMemory, + "Session transcript memory indexing remains disabled when policy denies it.", + ], + [ + CHECK_IDS.policySecretsUnmanagedProvider, "OpenClaw config SecretRefs use configured secret providers when policy requires managed providers.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsUnmanagedProvider); - }, - }; - const policySecretsDeniedProviderSourceCheck: HealthCheck = { - id: CHECK_IDS.policySecretsDeniedProviderSource, - kind: "plugin", - description: + ], + [ + CHECK_IDS.policySecretsDeniedProviderSource, "OpenClaw config secret providers and SecretRefs do not use sources denied by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySecretsDeniedProviderSource, - ); - }, - }; - const policySecretsInsecureProviderCheck: HealthCheck = { - id: CHECK_IDS.policySecretsInsecureProvider, - kind: "plugin", - description: + ], + [ + CHECK_IDS.policySecretsInsecureProvider, "Configured secret providers do not opt into insecure posture unless policy allows it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsInsecureProvider); - }, - }; - const policyAuthProfileInvalidMetadataCheck: HealthCheck = { - id: CHECK_IDS.policyAuthProfileInvalidMetadata, - kind: "plugin", - description: "OpenClaw config auth profiles declare required provider and mode metadata.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyAuthProfileInvalidMetadata, - ); - }, - }; - const policyAuthProfileUnapprovedModeCheck: HealthCheck = { - id: CHECK_IDS.policyAuthProfileUnapprovedMode, - kind: "plugin", - description: "OpenClaw config auth profile modes stay within the policy allowlist.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAuthProfileUnapprovedMode); - }, - }; - - return [ - policyDataHandlingTelemetryContentCaptureCheck, - policyDataHandlingSessionRetentionNotEnforcedCheck, - policyDataHandlingSessionTranscriptMemoryCheck, - policySecretsUnmanagedProviderCheck, - policySecretsDeniedProviderSourceCheck, - policySecretsInsecureProviderCheck, - policyAuthProfileInvalidMetadataCheck, - policyAuthProfileUnapprovedModeCheck, - ]; + ], + [ + CHECK_IDS.policyAuthProfileInvalidMetadata, + "OpenClaw config auth profiles declare required provider and mode metadata.", + ], + [ + CHECK_IDS.policyAuthProfileUnapprovedMode, + "OpenClaw config auth profile modes stay within the policy allowlist.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/exec-approvals.ts b/extensions/policy/src/doctor/scopes/exec-approvals.ts index bf9f10d08f79..605502fa7860 100644 --- a/extensions/policy/src/doctor/scopes/exec-approvals.ts +++ b/extensions/policy/src/doctor/scopes/exec-approvals.ts @@ -1,100 +1,40 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyExecApprovalChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyExecApprovalsMissingCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsMissing, - kind: "plugin", - description: "Required exec approvals artifact is present for policy conformance.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing); - }, - }; - const policyExecApprovalsInvalidCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsInvalid, - kind: "plugin", - description: "Exec approvals artifact parses before policy checks run.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid); - }, - }; - const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, - kind: "plugin", - description: "Exec approval defaults use a policy-approved security mode.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, - ); - }, - }; - const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, - kind: "plugin", - description: "Per-agent exec approval settings use policy-approved security modes.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, - ); - }, - }; - const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, - kind: "plugin", - description: + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyExecApprovalsMissing, + "Required exec approvals artifact is present for policy conformance.", + ], + [ + CHECK_IDS.policyExecApprovalsInvalid, + "Exec approvals artifact parses before policy checks run.", + ], + [ + CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + "Exec approval defaults use a policy-approved security mode.", + ], + [ + CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + "Per-agent exec approval settings use policy-approved security modes.", + ], + [ + CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, "Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, - ); - }, - }; - const policyExecApprovalsAllowlistMissingCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAllowlistMissing, - kind: "plugin", - description: "Exec approval allowlists include every pattern required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAllowlistMissing, - ); - }, - }; - const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected, - kind: "plugin", - description: "Exec approval allowlists do not contain patterns outside policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAllowlistUnexpected, - ); - }, - }; - - return [ - policyExecApprovalsMissingCheck, - policyExecApprovalsInvalidCheck, - policyExecApprovalsDefaultSecurityUnapprovedCheck, - policyExecApprovalsAgentSecurityUnapprovedCheck, - policyExecApprovalsAutoAllowSkillsEnabledCheck, - policyExecApprovalsAllowlistMissingCheck, - policyExecApprovalsAllowlistUnexpectedCheck, - ]; + ], + [ + CHECK_IDS.policyExecApprovalsAllowlistMissing, + "Exec approval allowlists include every pattern required by policy.", + ], + [ + CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + "Exec approval allowlists do not contain patterns outside policy.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/gateway.ts b/extensions/policy/src/doctor/scopes/gateway.ts index 8418e9143b38..1916a212cef5 100644 --- a/extensions/policy/src/doctor/scopes/gateway.ts +++ b/extensions/policy/src/doctor/scopes/gateway.ts @@ -3,140 +3,58 @@ import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health"; import type { PolicyEvidence } from "../../policy-state.js"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import { previewPolicyReviewRequiredRepair } from "../review-required-repairs.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; import { readPolicyBoolean, readStringList } from "../utils.js"; export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyGatewayNonLoopbackBindCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayNonLoopbackBind, - kind: "plugin", - description: "Gateway bind posture matches policy exposure requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNonLoopbackBind); - }, - repair(ctx, findings) { - return previewPolicyReviewRequiredRepair( - ctx, - findings, - CHECK_IDS.policyGatewayNonLoopbackBind, - ); - }, - }; - const policyGatewayAuthDisabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayAuthDisabled, - kind: "plugin", - description: "Gateway authentication remains enabled when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayAuthDisabled); - }, - }; - const policyGatewayRateLimitMissingCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayRateLimitMissing, - kind: "plugin", - description: "Gateway authentication rate-limit posture is explicit when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRateLimitMissing); - }, - }; - const policyGatewayControlUiInsecureCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayControlUiInsecure, - kind: "plugin", - description: "Gateway Control UI insecure exposure toggles remain disabled by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure); - }, - }; - const policyGatewayTailscaleFunnelCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayTailscaleFunnel, - kind: "plugin", - description: "Gateway Tailscale Funnel exposure matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayTailscaleFunnel); - }, - }; - const policyGatewayRemoteEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayRemoteEnabled, - kind: "plugin", - description: "Remote gateway mode matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled); - }, - }; - const policyGatewayHttpEndpointEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayHttpEndpointEnabled, - kind: "plugin", - description: "Gateway HTTP API endpoints match policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyGatewayHttpEndpointEnabled, - ); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyGatewayHttpEndpointEnabled, - ); - }, - }; - const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, - kind: "plugin", - description: "Gateway HTTP URL-fetch inputs have allowlists when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, - ); - }, - }; - const policyGatewayNodeCommandDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayNodeCommandDenied, - kind: "plugin", - description: "Gateway node command allowlists match policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNodeCommandDenied); - }, - repair(ctx, findings) { - return previewPolicyReviewRequiredRepair( - ctx, - findings, - CHECK_IDS.policyGatewayNodeCommandDenied, - ); - }, - }; - - return [ - policyGatewayNonLoopbackBindCheck, - policyGatewayAuthDisabledCheck, - policyGatewayRateLimitMissingCheck, - policyGatewayControlUiInsecureCheck, - policyGatewayTailscaleFunnelCheck, - policyGatewayRemoteEnabledCheck, - policyGatewayHttpEndpointEnabledCheck, - policyGatewayHttpUrlFetchUnrestrictedCheck, - policyGatewayNodeCommandDeniedCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyGatewayNonLoopbackBind, + "Gateway bind posture matches policy exposure requirements.", + (ctx, findings) => + previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNonLoopbackBind), + ], + [ + CHECK_IDS.policyGatewayAuthDisabled, + "Gateway authentication remains enabled when required by policy.", + ], + [ + CHECK_IDS.policyGatewayRateLimitMissing, + "Gateway authentication rate-limit posture is explicit when required by policy.", + ], + [ + CHECK_IDS.policyGatewayControlUiInsecure, + "Gateway Control UI insecure exposure toggles remain disabled by policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure), + ], + [CHECK_IDS.policyGatewayTailscaleFunnel, "Gateway Tailscale Funnel exposure matches policy."], + [ + CHECK_IDS.policyGatewayRemoteEnabled, + "Remote gateway mode matches policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled), + ], + [ + CHECK_IDS.policyGatewayHttpEndpointEnabled, + "Gateway HTTP API endpoints match policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayHttpEndpointEnabled), + ], + [ + CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, + "Gateway HTTP URL-fetch inputs have allowlists when required by policy.", + ], + [ + CHECK_IDS.policyGatewayNodeCommandDenied, + "Gateway node command allowlists match policy.", + (ctx, findings) => + previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNodeCommandDenied), + ], + ]); } export function gatewayExposureFindings( diff --git a/extensions/policy/src/doctor/scopes/model-network.ts b/extensions/policy/src/doctor/scopes/model-network.ts index 6ec13e916da4..17ce92db5791 100644 --- a/extensions/policy/src/doctor/scopes/model-network.ts +++ b/extensions/policy/src/doctor/scopes/model-network.ts @@ -2,6 +2,7 @@ import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import type { PolicyEvidence } from "../../policy-state.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; import { readPolicyBoolean, readStringList } from "../utils.js"; @@ -9,61 +10,25 @@ import { readPolicyBoolean, readStringList } from "../utils.js"; export function createPolicyModelNetworkChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyMcpDeniedServerCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedMcpServer, - kind: "plugin", - description: "Configured MCP servers do not match policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedMcpServer); - }, - }; - const policyMcpUnapprovedServerCheck: HealthCheck = { - id: CHECK_IDS.policyUnapprovedMcpServer, - kind: "plugin", - description: "Configured MCP servers do not match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedMcpServer); - }, - }; - const policyModelsDeniedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedModelProvider, - kind: "plugin", - description: "Configured model providers do not match policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedModelProvider); - }, - }; - const policyModelsUnapprovedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyUnapprovedModelProvider, - kind: "plugin", - description: "Configured model providers do not match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedModelProvider); - }, - }; - const policyNetworkPrivateAccessCheck: HealthCheck = { - id: CHECK_IDS.policyPrivateNetworkAccess, - kind: "plugin", - description: "Network SSRF policy settings match private-network requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyPrivateNetworkAccess); - }, - }; - - return [ - policyMcpDeniedServerCheck, - policyMcpUnapprovedServerCheck, - policyModelsDeniedProviderCheck, - policyModelsUnapprovedProviderCheck, - policyNetworkPrivateAccessCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyDeniedMcpServer, "Configured MCP servers do not match policy deny rules."], + [ + CHECK_IDS.policyUnapprovedMcpServer, + "Configured MCP servers do not match policy allow rules.", + ], + [ + CHECK_IDS.policyDeniedModelProvider, + "Configured model providers do not match policy deny rules.", + ], + [ + CHECK_IDS.policyUnapprovedModelProvider, + "Configured model providers do not match policy allow rules.", + ], + [ + CHECK_IDS.policyPrivateNetworkAccess, + "Network SSRF policy settings match private-network requirements.", + ], + ]); } export function mcpServerFindings( diff --git a/extensions/policy/src/doctor/scopes/routing.ts b/extensions/policy/src/doctor/scopes/routing.ts index f40241ac2ce7..c7b3bc0801a1 100644 --- a/extensions/policy/src/doctor/scopes/routing.ts +++ b/extensions/policy/src/doctor/scopes/routing.ts @@ -1,51 +1,25 @@ import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyRoutingChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - return [ - { - id: CHECK_IDS.policyRoutingBindingsRequired, - kind: "plugin", - description: "Routing policy has at least one channel route binding when required.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingBindingsRequired); - }, - }, - { - id: CHECK_IDS.policyRoutingBindingChannelUnconfigured, - kind: "plugin", - description: "Route bindings name channels present in configuration.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyRoutingBindingChannelUnconfigured, - ); - }, - }, - { - id: CHECK_IDS.policyRoutingAgentMismatch, - kind: "plugin", - description: "Authored routing probes resolve to their expected agents.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingAgentMismatch); - }, - }, - { - id: CHECK_IDS.policyRoutingMatchKindMismatch, - kind: "plugin", - description: "Authored routing probes match at their expected specificity.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyRoutingMatchKindMismatch, - ); - }, - }, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyRoutingBindingsRequired, + "Routing policy has at least one channel route binding when required.", + ], + [ + CHECK_IDS.policyRoutingBindingChannelUnconfigured, + "Route bindings name channels present in configuration.", + ], + [ + CHECK_IDS.policyRoutingAgentMismatch, + "Authored routing probes resolve to their expected agents.", + ], + [ + CHECK_IDS.policyRoutingMatchKindMismatch, + "Authored routing probes match at their expected specificity.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/sandbox.ts b/extensions/policy/src/doctor/scopes/sandbox.ts index 5988614b6fca..9e526da5e73a 100644 --- a/extensions/policy/src/doctor/scopes/sandbox.ts +++ b/extensions/policy/src/doctor/scopes/sandbox.ts @@ -1,123 +1,43 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policySandboxModeUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxModeUnapproved, - kind: "plugin", - description: "Sandbox mode config satisfies policy requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved); - }, - }; - const policySandboxBackendUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxBackendUnapproved, - kind: "plugin", - description: "Sandbox backend config satisfies policy requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved); - }, - }; - const policySandboxContainerPostureUnobservableCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerPostureUnobservable, - kind: "plugin", - description: "Sandbox container posture policy only targets observable container backends.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerPostureUnobservable, - ); - }, - }; - const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerHostNetworkDenied, - kind: "plugin", - description: "Sandbox container config avoids host network mode.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerHostNetworkDenied, - ); - }, - }; - const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied, - kind: "plugin", - description: "Sandbox container config avoids joining another container network namespace.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerNamespaceJoinDenied, - ); - }, - }; - const policySandboxContainerMountModeRequiredCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerMountModeRequired, - kind: "plugin", - description: "Sandbox container mounts are read-only when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerMountModeRequired, - ); - }, - }; - const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerRuntimeSocketMount, - kind: "plugin", - description: "Sandbox container mounts avoid host container runtime sockets.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerRuntimeSocketMount, - ); - }, - }; - const policySandboxContainerUnconfinedProfileCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerUnconfinedProfile, - kind: "plugin", - description: "Sandbox container profile config avoids unconfined profiles.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerUnconfinedProfile, - ); - }, - }; - const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = { - id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, - kind: "plugin", - description: "Sandbox browser CDP config includes a source range when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, - ); - }, - }; - - return [ - policySandboxModeUnapprovedCheck, - policySandboxBackendUnapprovedCheck, - policySandboxContainerPostureUnobservableCheck, - policySandboxContainerHostNetworkDeniedCheck, - policySandboxContainerNamespaceJoinDeniedCheck, - policySandboxContainerMountModeRequiredCheck, - policySandboxContainerRuntimeSocketMountCheck, - policySandboxContainerUnconfinedProfileCheck, - policySandboxBrowserCdpSourceRangeMissingCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policySandboxModeUnapproved, "Sandbox mode config satisfies policy requirements."], + [ + CHECK_IDS.policySandboxBackendUnapproved, + "Sandbox backend config satisfies policy requirements.", + ], + [ + CHECK_IDS.policySandboxContainerPostureUnobservable, + "Sandbox container posture policy only targets observable container backends.", + ], + [ + CHECK_IDS.policySandboxContainerHostNetworkDenied, + "Sandbox container config avoids host network mode.", + ], + [ + CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + "Sandbox container config avoids joining another container network namespace.", + ], + [ + CHECK_IDS.policySandboxContainerMountModeRequired, + "Sandbox container mounts are read-only when policy requires it.", + ], + [ + CHECK_IDS.policySandboxContainerRuntimeSocketMount, + "Sandbox container mounts avoid host container runtime sockets.", + ], + [ + CHECK_IDS.policySandboxContainerUnconfinedProfile, + "Sandbox container profile config avoids unconfined profiles.", + ], + [ + CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, + "Sandbox browser CDP config includes a source range when policy requires it.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/tools.ts b/extensions/policy/src/doctor/scopes/tools.ts index 087d75f36d12..e977383e3757 100644 --- a/extensions/policy/src/doctor/scopes/tools.ts +++ b/extensions/policy/src/doctor/scopes/tools.ts @@ -1,211 +1,77 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyAgentsWorkspaceAccessDenied, - kind: "plugin", - description: "Agent sandbox workspace access matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyAgentsWorkspaceAccessDenied, - ); - }, - }; - const policyAgentsToolNotDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyAgentsToolNotDenied, - kind: "plugin", - description: "Agent workspace mutation/runtime tools are denied when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied); - }, - }; - const policyToolsProfileUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsProfileUnapproved, - kind: "plugin", - description: "Configured tool profiles match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsProfileUnapproved); - }, - }; - const policyToolsFsWorkspaceOnlyRequiredCheck: HealthCheck = { - id: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, - kind: "plugin", - description: "Filesystem tools use workspace-only posture when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, - ); - }, - }; - const policyToolsExecSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecSecurityUnapproved, - kind: "plugin", - description: "Exec tool security mode matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyToolsExecSecurityUnapproved, - ); - }, - }; - const policyToolsExecAskUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecAskUnapproved, - kind: "plugin", - description: "Exec tool ask mode matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecAskUnapproved); - }, - }; - const policyToolsExecHostUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecHostUnapproved, - kind: "plugin", - description: "Exec tool host routing matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecHostUnapproved); - }, - }; - const policyToolsElevatedEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyToolsElevatedEnabled, - kind: "plugin", - description: "Elevated tool mode remains disabled when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled); - }, - }; - const policyToolsAlsoAllowMissingCheck: HealthCheck = { - id: CHECK_IDS.policyToolsAlsoAllowMissing, - kind: "plugin", - description: "Configured tools.alsoAllow entries include policy expected lists.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing); - }, - }; - const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsAlsoAllowUnexpected, - kind: "plugin", - description: "Configured tools.alsoAllow entries match policy expected lists.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected); - }, - }; - const policyToolsRequiredDenyMissingCheck: HealthCheck = { - id: CHECK_IDS.policyToolsRequiredDenyMissing, - kind: "plugin", - description: "Configured tool deny lists include tools required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing); - }, - }; - - return [ - policyAgentsWorkspaceAccessDeniedCheck, - policyAgentsToolNotDeniedCheck, - policyToolsProfileUnapprovedCheck, - policyToolsFsWorkspaceOnlyRequiredCheck, - policyToolsExecSecurityUnapprovedCheck, - policyToolsExecAskUnapprovedCheck, - policyToolsExecHostUnapprovedCheck, - policyToolsElevatedEnabledCheck, - policyToolsAlsoAllowMissingCheck, - policyToolsAlsoAllowUnexpectedCheck, - policyToolsRequiredDenyMissingCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyAgentsWorkspaceAccessDenied, "Agent sandbox workspace access matches policy."], + [ + CHECK_IDS.policyAgentsToolNotDenied, + "Agent workspace mutation/runtime tools are denied when policy requires it.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied), + ], + [CHECK_IDS.policyToolsProfileUnapproved, "Configured tool profiles match policy allow rules."], + [ + CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, + "Filesystem tools use workspace-only posture when policy requires it.", + ], + [ + CHECK_IDS.policyToolsExecSecurityUnapproved, + "Exec tool security mode matches policy allow rules.", + ], + [CHECK_IDS.policyToolsExecAskUnapproved, "Exec tool ask mode matches policy allow rules."], + [CHECK_IDS.policyToolsExecHostUnapproved, "Exec tool host routing matches policy allow rules."], + [ + CHECK_IDS.policyToolsElevatedEnabled, + "Elevated tool mode remains disabled when policy requires it.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled), + ], + [ + CHECK_IDS.policyToolsAlsoAllowMissing, + "Configured tools.alsoAllow entries include policy expected lists.", + ], + [ + CHECK_IDS.policyToolsAlsoAllowUnexpected, + "Configured tools.alsoAllow entries match policy expected lists.", + ], + [ + CHECK_IDS.policyToolsRequiredDenyMissing, + "Configured tool deny lists include tools required by policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing), + ], + ]); } export function createPolicyToolMetadataChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyUnmigratedToolsFileCheck: HealthCheck = { - id: CHECK_IDS.policyUnmigratedToolsFile, - kind: "plugin", - description: "Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnmigratedToolsFile); - }, - }; - const policyToolsMissingRiskCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolRisk, - kind: "plugin", - description: "AGENTS.md tool policy entries declare explicit risk levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolRisk); - }, - }; - const policyToolsUnknownRiskCheck: HealthCheck = { - id: CHECK_IDS.policyUnknownToolRisk, - kind: "plugin", - description: "AGENTS.md tool policy entries use known risk levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolRisk); - }, - }; - const policyToolsMissingSensitivityCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolSensitivity, - kind: "plugin", - description: "AGENTS.md tool policy entries declare default artifact sensitivity.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolSensitivity); - }, - }; - const policyToolsUnknownSensitivityCheck: HealthCheck = { - id: CHECK_IDS.policyUnknownToolSensitivity, - kind: "plugin", - description: "AGENTS.md tool policy entries use known sensitivity levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolSensitivity); - }, - }; - const policyToolsMissingOwnerCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolOwner, - kind: "plugin", - description: "AGENTS.md tool policy entries declare an accountable owner.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolOwner); - }, - }; - - return [ - policyUnmigratedToolsFileCheck, - policyToolsMissingRiskCheck, - policyToolsUnknownRiskCheck, - policyToolsMissingSensitivityCheck, - policyToolsMissingOwnerCheck, - policyToolsUnknownSensitivityCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyUnmigratedToolsFile, + "Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.", + ], + [ + CHECK_IDS.policyMissingToolRisk, + "AGENTS.md tool policy entries declare explicit risk levels.", + ], + [CHECK_IDS.policyUnknownToolRisk, "AGENTS.md tool policy entries use known risk levels."], + [ + CHECK_IDS.policyMissingToolSensitivity, + "AGENTS.md tool policy entries declare default artifact sensitivity.", + ], + [ + CHECK_IDS.policyMissingToolOwner, + "AGENTS.md tool policy entries declare an accountable owner.", + ], + [ + CHECK_IDS.policyUnknownToolSensitivity, + "AGENTS.md tool policy entries use known sensitivity levels.", + ], + ]); } From bf99e43ee7d3416ef0893f4d0321f5e59f6ee564 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:14:23 -0700 Subject: [PATCH 46/53] refactor: consolidate full release child workflows (#117385) --- .github/workflows/full-release-validation.yml | 1243 +++++------------ .../package-acceptance-workflow.test.ts | 516 ++++++- .../plugin-prerelease-test-plan.test.ts | 21 +- test/scripts/release-no-push-workflow.test.ts | 3 +- 4 files changed, 873 insertions(+), 910 deletions(-) diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 25d7c07d63c2..5a811a13e53f 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -426,43 +426,111 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: ci TARGET_REF: ${{ inputs.ref }} TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | + run: &full_release_child_dispatch | set -euo pipefail + FAIL_FAST="${FAIL_FAST:-false}" + + gh_with_retry() { + local output status attempt + for attempt in 1 2 3 4 5 6; do + set +e + output="$(gh "$@" 2>&1)" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + printf '%s\n' "$output" + return 0 + fi + if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then + echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 + sleep $((attempt * 10)) + continue + fi + printf '%s\n' "$output" >&2 + return "$status" + done + printf '%s\n' "$output" >&2 + return "$status" + } + + fetch_child_run_json() { + gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + } + + fetch_child_jobs() { + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then + gh_with_retry run view "$run_id" --json jobs --jq '.jobs[]' + return + fi + gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' + } + + read_child_run_field() { + local field="$1" + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then + case "$field" in + head_sha) field=headSha ;; + html_url) field=url ;; + esac + gh_with_retry run view "$run_id" --json "$field" --jq ".${field} // \"\"" + return + fi + fetch_child_run_json | jq -r ".${field} // \"\"" + } + + release_check_blocking_job() { + if [[ "$RELEASE_PROFILE" == "beta" && "$1" == "Run package acceptance / Telegram package acceptance / "* ]]; then + return 1 + fi + case "$1" in + "resolve_target" | \ + "Prepare release package artifact" | \ + "install_smoke_release_checks / "* | \ + "Run package acceptance" | \ + "Run package acceptance / "*) + return 0 + ;; + esac + return 1 + } + + release_checks_advisory_only() { + local run_json="$1" + local verifier_conclusion name saw_advisory failed + verifier_conclusion="$( + jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | + tail -n 1 + )" + if [[ "$verifier_conclusion" != "success" ]]; then + return 1 + fi + saw_advisory=0 + failed=0 + while IFS= read -r name; do + [[ -z "${name// }" ]] && continue + if release_check_blocking_job "$name"; then + echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." + failed=1 + else + saw_advisory=1 + fi + done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") + [[ "$saw_advisory" == "1" && "$failed" == "0" ]] + } dispatch_and_wait() { local workflow="$1" local dispatch_run_name="$2" shift 2 + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json jobs_json child_head_sha encoded_workflow_ref current_workflow_sha - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" current_workflow_sha="$( gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha @@ -471,13 +539,13 @@ jobs: echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 return 1 fi + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. set +e dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" dispatch_status=$? set -e printf '%s\n' "$dispatch_output" - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 exit "$dispatch_status" @@ -504,7 +572,6 @@ jobs: fi sleep 5 done - if [[ -z "$run_id" ]]; then echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 @@ -512,48 +579,21 @@ jobs: if [[ "$dispatch_status" -ne 0 ]]; then echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 fi - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true + if [[ -n "${active_child_run_id:-}" ]]; then + echo "Cancelling child workflow ${active_child_workflow}: ${active_child_run_id}" >&2 + gh run cancel "$active_child_run_id" >/dev/null 2>&1 || true fi } + # EXIT traps run after function locals unwind; preserve only adopted child identity. + active_child_workflow="$workflow" + active_child_run_id="$run_id" trap cancel_child EXIT INT TERM - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" + child_head_sha="$(read_child_run_field head_sha)" if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." cancel_child @@ -561,9 +601,70 @@ jobs: exit 1 fi + fail_fast_failed_jobs() { + if [[ "$FAIL_FAST" != "true" ]]; then + return 0 + fi + local failed_jobs_json + if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + return 0 + fi + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then + failed_jobs_json="$( + gh_with_retry run view "$run_id" --json jobs \ + --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' + )" + elif ! failed_jobs_json="$( + fetch_child_jobs | + jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' + )"; then + echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." + return 0 + fi + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + # Advisory QA jobs are owned by the child's status-artifact verifier. + failed_jobs_json="$( + jq '[.[] | select( + ((.name | startswith("Run QA Lab parity lane (")) + or .name == "Run QA Lab parity report" + or (.name | startswith("Run QA Lab runtime-pair lane (")) + or .name == "Verify QA Lab runtime-pair lanes" + or .name == "Run QA Lab live Discord lane" + or .name == "Run QA Lab live WhatsApp lane" + or .name == "Run QA Lab live Slack lane") + | not)]' <<< "$failed_jobs_json" + )" + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + # Beta live-provider and Telegram package checks are advisory; repo E2E is blocking. + failed_jobs_json="$( + jq '[.[] | select( + (((.name | startswith("Run repo/live E2E validation / ")) + and ((.name | contains("Docker live")) + or (.name | contains("Live media suites")) + or (.name | contains("validate_live_provider_suites")) + or (.name | contains("validate_release_live_cache")) + or (.name | contains("prepare_live_test_image")))) + or (.name | startswith("Run package acceptance / Telegram package acceptance / "))) + | not)]' <<< "$failed_jobs_json" + )" + fi + fi + if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then + echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run." + else + echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." + fi + jq '.[] | {name, conclusion, url: (.url // .html_url)}' <<< "$failed_jobs_json" + cancel_child + trap - EXIT INT TERM + exit 1 + fi + } + poll_count=0 while true; do - status="$(fetch_child_run_json | jq -r '.status')" + status="$(read_child_run_field status)" if [[ "$status" == "completed" ]]; then break fi @@ -573,41 +674,204 @@ jobs: fi if (( poll_count % 10 == 0 )); then echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true + fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: (.url // .html_url)}' || true fi sleep 60 done trap - EXIT INT TERM - conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')" - url="$(fetch_child_run_json | jq -r '.html_url')" + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')" + run_json="$( + jq -s '.[0] + .[1]' \ + <(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \ + <(printf '%s\n' "$jobs_json") + )" + conclusion="$(jq -r '.conclusion' <<< "$run_json")" + url="$(jq -r '.url' <<< "$run_json")" + else + conclusion="$(read_child_run_field conclusion)" + url="$(read_child_run_field html_url)" + fi echo "${workflow} finished with ${conclusion}: ${url}" echo "url=${url}" >> "$GITHUB_OUTPUT" echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true - exit 1 + if [[ "$conclusion" == "success" ]]; then + return 0 fi + if [[ "$workflow" == "openclaw-performance.yml" && "$RELEASE_PROFILE" == "beta" ]]; then + echo "::warning::OpenClaw Performance ended with ${conclusion}; advisory for beta: ${url}" + return 0 + fi + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' <<< "$run_json" || true + if [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]] && release_checks_advisory_only "$run_json"; then + echo "::warning::${workflow} ended with ${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes." + return 0 + fi + else + if [[ "$workflow" == "openclaw-performance.yml" ]]; then + echo "::error::OpenClaw Performance ended with ${conclusion}: ${url}" + fi + fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: (.url // .html_url)}' || true + fi + exit 1 } - { - echo "### Normal CI" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" - dispatch_run_name="CI ${dispatch_id}" - args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") - if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then - args+=(-f historical_target_tag="$TARGET_REF") - elif [[ "$TARGET_CONTEXT_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then - args+=(-f historical_target_tag="$TARGET_CONTEXT_REF") - elif [[ "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then - args+=(-f release_candidate_ref="$TARGET_CONTEXT_REF") - fi - dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" + case "$CHILD_WORKFLOW_KIND" in + ci) + { + echo "### Normal CI" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" + dispatch_run_name="CI ${dispatch_id}" + args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") + if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + args+=(-f historical_target_tag="$TARGET_REF") + elif [[ "$TARGET_CONTEXT_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + args+=(-f historical_target_tag="$TARGET_CONTEXT_REF") + elif [[ "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then + args+=(-f release_candidate_ref="$TARGET_CONTEXT_REF") + fi + dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" + ;; + plugin-prerelease) + { + echo "### Plugin prerelease" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" + dispatch_run_name="Plugin Prerelease ${dispatch_id}" + args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id") + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi + dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" + ;; + release-checks) + { + echo "### Release/live/Docker/QA validation" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + echo "- Provider: \`${PROVIDER}\`" + echo "- Cross-OS mode: \`${MODE}\`" + echo "- Release profile: \`${RELEASE_PROFILE}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" + echo "- Rerun group: \`${RERUN_GROUP}\`" + if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then + echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" + fi + if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then + echo "- Cross-OS suite filter: \`${CROSS_OS_SUITE_FILTER}\`" + fi + if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then + echo "- Release package spec: \`${RELEASE_PACKAGE_SPEC}\`" + fi + if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then + echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`" + fi + if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then + echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`" + fi + } >> "$GITHUB_STEP_SUMMARY" + child_rerun_group="$RERUN_GROUP" + if [[ "$child_rerun_group" == "release-checks" ]]; then + child_rerun_group=all + fi + release_checks_target_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}" + args=( + -f ref="$release_checks_target_ref" + -f expected_sha="$TARGET_SHA" + -f provider="$PROVIDER" + -f mode="$MODE" + -f release_profile="$RELEASE_PROFILE" + -f run_release_soak="$RUN_RELEASE_SOAK" + -f fail_fast="$FAIL_FAST" + -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" + -f rerun_group="$child_rerun_group" + ) + if [[ -n "${TARGET_CONTEXT_REF// }" ]]; then + args+=(-f allow_frozen_target_scenario_omissions=true) + fi + if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then + args+=(-f live_suite_filter="$LIVE_SUITE_FILTER") + fi + if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then + args+=(-f cross_os_suite_filter="$CROSS_OS_SUITE_FILTER") + fi + if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then + args+=(-f release_package_spec="$RELEASE_PACKAGE_SPEC") + fi + if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then + args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC") + fi + if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then + args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") + fi + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" + dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" + ;; + npm-telegram) + args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") + if [[ -n "${SCENARIO// }" ]]; then + args+=(-f scenario="$SCENARIO") + fi + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" + dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}" + ;; + performance) + fail_on_regression=true + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + fail_on_regression=false + fi + { + echo "### Product performance" + echo + echo "- Target SHA: \`${TARGET_SHA}\`" + echo "- Profile: \`release\`" + echo "- Repeat: \`3\`" + echo "- Deep profile: \`false\`" + echo "- Live OpenAI candidate: \`false\`" + echo "- Regression gate: \`${fail_on_regression}\`" + echo "- Report publication: disabled (artifacts only)" + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + echo "- Release impact: advisory" + else + echo "- Release impact: blocking" + fi + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + dispatch_run_name="OpenClaw Performance ${dispatch_id}" + args=( + -f target_ref="$TARGET_SHA" + -f profile=release + -f repeat=3 + -f deep_profile=false + -f live_openai_candidate=false + -f fail_on_regression="$fail_on_regression" + -f publish_reports=false + -f dispatch_id="$dispatch_id" + ) + dispatch_and_wait openclaw-performance.yml "$dispatch_run_name" "${args[@]}" + ;; + *) + echo "::error::Unsupported full-release child workflow kind ${CHILD_WORKFLOW_KIND}." >&2 + exit 2 + ;; + esac plugin_prerelease: name: Run plugin prerelease validation @@ -624,184 +888,14 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: plugin-prerelease TARGET_REF: ${{ inputs.ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | - set -euo pipefail - - dispatch_and_wait() { - local workflow="$1" - local dispatch_run_name="$2" - shift 2 - - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - return 1 - fi - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(fetch_child_run_json | jq -r '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')" - url="$(fetch_child_run_json | jq -r '.html_url')" - echo "${workflow} finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true - exit 1 - fi - } - - { - echo "### Plugin prerelease" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" - dispatch_run_name="Plugin Prerelease ${dispatch_id}" - args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id") - if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then - args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") - fi - dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" + run: *full_release_child_dispatch release_checks: name: Run release/live/Docker/QA validation @@ -820,6 +914,7 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: release-checks TARGET_REF: ${{ inputs.ref }} TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} @@ -838,325 +933,7 @@ jobs: PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} - run: | - set -euo pipefail - - dispatch_and_wait() { - local workflow="$1" - local dispatch_run_name="$2" - shift 2 - - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - return 1 - fi - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - release_check_blocking_job() { - if [[ "$RELEASE_PROFILE" == "beta" && "$1" == "Run package acceptance / Telegram package acceptance / "* ]]; then - return 1 - fi - case "$1" in - "resolve_target" | \ - "Prepare release package artifact" | \ - "install_smoke_release_checks / "* | \ - "Run package acceptance" | \ - "Run package acceptance / "*) - return 0 - ;; - esac - return 1 - } - - release_checks_advisory_only() { - local run_json="$1" - local verifier_conclusion name saw_advisory failed - - verifier_conclusion="$( - jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | - tail -n 1 - )" - if [[ "$verifier_conclusion" != "success" ]]; then - return 1 - fi - - saw_advisory=0 - failed=0 - while IFS= read -r name; do - [[ -z "${name// }" ]] && continue - if release_check_blocking_job "$name"; then - echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." - failed=1 - else - saw_advisory=1 - fi - done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") - - [[ "$saw_advisory" == "1" && "$failed" == "0" ]] - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - return 0 - fi - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then - # These jobs are continue-on-error in the child workflow. Let its - # status-artifact verifier decide whether their evidence is usable. - failed_jobs_json="$( - jq '[.[] | select( - ((.name | startswith("Run QA Lab parity lane (")) - or .name == "Run QA Lab parity report" - or (.name | startswith("Run QA Lab runtime-pair lane (")) - or .name == "Verify QA Lab runtime-pair lanes" - or .name == "Run QA Lab live Discord lane" - or .name == "Run QA Lab live WhatsApp lane" - or .name == "Run QA Lab live Slack lane") - | not)]' <<< "$failed_jobs_json" - )" - fi - if [[ "$workflow" == "openclaw-release-checks.yml" && "$RELEASE_PROFILE" == "beta" ]]; then - # Beta treats live-provider suites as advisory (live_advisory in - # openclaw-live-and-e2e-checks-reusable.yml); their failures must - # not fail-fast-cancel the remaining release-check matrix. Repo - # E2E under the same caller stays blocking. - failed_jobs_json="$( - jq '[.[] | select( - (((.name | startswith("Run repo/live E2E validation / ")) - and ((.name | contains("Docker live")) - or (.name | contains("Live media suites")) - or (.name | contains("validate_live_provider_suites")) - or (.name | contains("validate_release_live_cache")) - or (.name | contains("prepare_live_test_image")))) - or (.name | startswith("Run package acceptance / Telegram package acceptance / "))) - | not)]' <<< "$failed_jobs_json" - )" - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(fetch_child_run_json | jq -r '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')" - run_json="$( - jq -s '.[0] + .[1]' \ - <(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \ - <(printf '%s\n' "$jobs_json") - )" - conclusion="$(jq -r '.conclusion' <<< "$run_json")" - url="$(jq -r '.url' <<< "$run_json")" - echo "${workflow} finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' <<< "$run_json" || true - if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - if release_checks_advisory_only "$run_json"; then - echo "::warning::${workflow} ended with ${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes." - return 0 - fi - fi - exit 1 - fi - } - - { - echo "### Release/live/Docker/QA validation" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - echo "- Provider: \`${PROVIDER}\`" - echo "- Cross-OS mode: \`${MODE}\`" - echo "- Release profile: \`${RELEASE_PROFILE}\`" - echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" - echo "- Rerun group: \`${RERUN_GROUP}\`" - if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then - echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" - fi - if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then - echo "- Cross-OS suite filter: \`${CROSS_OS_SUITE_FILTER}\`" - fi - if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then - echo "- Release package spec: \`${RELEASE_PACKAGE_SPEC}\`" - fi - if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then - echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`" - fi - if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then - echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`" - fi - } >> "$GITHUB_STEP_SUMMARY" - - child_rerun_group="$RERUN_GROUP" - if [[ "$child_rerun_group" == "release-checks" ]]; then - child_rerun_group=all - fi - - release_checks_target_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}" - - args=( - -f ref="$release_checks_target_ref" - -f expected_sha="$TARGET_SHA" - -f provider="$PROVIDER" - -f mode="$MODE" - -f release_profile="$RELEASE_PROFILE" - -f run_release_soak="$RUN_RELEASE_SOAK" - -f fail_fast="$FAIL_FAST" - -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" - -f rerun_group="$child_rerun_group" - ) - if [[ -n "${TARGET_CONTEXT_REF// }" ]]; then - args+=(-f allow_frozen_target_scenario_omissions=true) - fi - if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then - args+=(-f live_suite_filter="$LIVE_SUITE_FILTER") - fi - if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then - args+=(-f cross_os_suite_filter="$CROSS_OS_SUITE_FILTER") - fi - if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then - args+=(-f release_package_spec="$RELEASE_PACKAGE_SPEC") - fi - if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then - args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC") - fi - if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then - args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") - fi - if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then - args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") - fi - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" - dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" - args+=(-f dispatch_id="$dispatch_id") - dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" + run: *full_release_child_dispatch npm_telegram: name: Run package Telegram E2E @@ -1174,6 +951,7 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: npm-telegram CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} @@ -1181,156 +959,7 @@ jobs: PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }} SCENARIO: ${{ inputs.npm_telegram_scenario }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | - set -euo pipefail - - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - exit 1 - fi - - args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") - if [[ -n "${SCENARIO// }" ]]; then - args+=(-f scenario="$SCENARIO") - fi - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" - dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" - args+=(-f dispatch_id="$dispatch_id") - - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::npm-telegram-beta-e2e.yml dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/npm-telegram-beta-e2e.yml/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::npm-telegram-beta-e2e.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow npm-telegram-beta-e2e.yml: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::npm-telegram-beta-e2e.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - failed_jobs_json="$( - gh_with_retry run view "$run_id" --json jobs \ - --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )" - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run." - jq '.[] | {name, conclusion, url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - poll_count=0 - while true; do - status="$(gh_with_retry run view "$run_id" --json status --jq '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')" - url="$(gh_with_retry run view "$run_id" --json url --jq '.url')" - echo "npm-telegram-beta-e2e.yml finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true - exit 1 - fi + run: *full_release_child_dispatch performance: name: Run product performance evidence @@ -1349,170 +978,12 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: performance RELEASE_PROFILE: ${{ inputs.release_profile }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} - run: | - set -euo pipefail - - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - exit 1 - fi - - fail_on_regression=true - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - fail_on_regression=false - fi - - { - echo "### Product performance" - echo - echo "- Target SHA: \`${TARGET_SHA}\`" - echo "- Profile: \`release\`" - echo "- Repeat: \`3\`" - echo "- Deep profile: \`false\`" - echo "- Live OpenAI candidate: \`false\`" - echo "- Regression gate: \`${fail_on_regression}\`" - echo "- Report publication: disabled (artifacts only)" - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - echo "- Release impact: advisory" - else - echo "- Release impact: blocking" - fi - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - dispatch_run_name="OpenClaw Performance ${dispatch_id}" - - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run openclaw-performance.yml \ - --ref "$CHILD_WORKFLOW_REF" \ - -f target_ref="$TARGET_SHA" \ - -f profile=release \ - -f repeat=3 \ - -f deep_profile=false \ - -f live_openai_candidate=false \ - -f fail_on_regression="$fail_on_regression" \ - -f publish_reports=false \ - -f dispatch_id="$dispatch_id" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::openclaw-performance.yml dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/openclaw-performance.yml/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::openclaw-performance.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow openclaw-performance.yml: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::openclaw-performance.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(gh_with_retry run view "$run_id" --json status --jq '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 10 == 0 )); then - echo "Still waiting on openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')" - url="$(gh_with_retry run view "$run_id" --json url --jq '.url')" - echo "openclaw-performance.yml finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - echo "::warning::OpenClaw Performance ended with ${conclusion}; advisory for beta: ${url}" - exit 0 - fi - echo "::error::OpenClaw Performance ended with ${conclusion}: ${url}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true - exit 1 - fi - + run: *full_release_child_dispatch summary: name: Verify full validation needs: diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index c643fb6cb6a9..1afcb8f2cc7b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -42,6 +42,48 @@ const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml"; const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml"; const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml"; const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml"; +const FULL_RELEASE_CHILD_DISPATCHES = [ + { + jobName: "normal_ci", + kind: "ci", + nonceSuffix: "-ci", + runName: "CI", + stepName: "Dispatch and monitor CI", + workflow: "ci.yml", + }, + { + jobName: "plugin_prerelease", + kind: "plugin-prerelease", + nonceSuffix: "-plugin-prerelease", + runName: "Plugin Prerelease", + stepName: "Dispatch and monitor plugin prerelease", + workflow: "plugin-prerelease.yml", + }, + { + jobName: "release_checks", + kind: "release-checks", + nonceSuffix: "-release-checks", + runName: "OpenClaw Release Checks", + stepName: "Dispatch and monitor release checks", + workflow: "openclaw-release-checks.yml", + }, + { + jobName: "npm_telegram", + kind: "npm-telegram", + nonceSuffix: "-npm-telegram", + runName: "NPM Telegram Beta E2E", + stepName: "Dispatch and monitor npm Telegram E2E", + workflow: "npm-telegram-beta-e2e.yml", + }, + { + jobName: "performance", + kind: "performance", + nonceSuffix: "", + runName: "OpenClaw Performance", + stepName: "Dispatch and monitor OpenClaw Performance", + workflow: "openclaw-performance.yml", + }, +] as const; const REPO_ROOT = process.env.GITHUB_WORKSPACE ?? process.cwd(); const RELEASE_MAINTAINER_SKILL = resolve( REPO_ROOT, @@ -184,6 +226,192 @@ function expectTextToIncludeAll(text: string | undefined, snippets: string[]): v } } +function runFullReleaseChildDispatch( + child: (typeof FULL_RELEASE_CHILD_DISPATCHES)[number], + overrides: Record = {}, +) { + const step = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName), + child.stepName, + ); + const script = step.run; + if (!script) { + throw new Error(`Expected full release child dispatch script for ${child.jobName}`); + } + + const workdir = tempDirs.make("full-release-child-dispatch-"); + const ghPath = resolve(workdir, "gh"); + const sleepPath = resolve(workdir, "sleep"); + const callsPath = resolve(workdir, "gh-calls.jsonl"); + const statusPath = resolve(workdir, "status-polls"); + writeFileSync(callsPath, ""); + writeFileSync( + ghPath, + `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +const env = process.env; +fs.appendFileSync(env.MOCK_GH_CALLS, JSON.stringify({ + args, + childWorkflowRef: env.CHILD_WORKFLOW_REF, + dispatchRunName: env.DISPATCH_RUN_NAME, +}) + "\\n"); +const jobs = JSON.parse(env.MOCK_GH_JOBS); +const conclusion = env.MOCK_GH_CONCLUSION; +const url = "https://github.com/openclaw/openclaw/actions/runs/101"; +function nextStatus() { + const statuses = JSON.parse(env.MOCK_GH_STATUSES); + let index = 0; + try { index = Number(fs.readFileSync(env.MOCK_GH_STATUS_POLLS, "utf8")); } catch {} + fs.writeFileSync(env.MOCK_GH_STATUS_POLLS, String(index + 1)); + return statuses[Math.min(index, statuses.length - 1)]; +} +if (args[0] === "workflow" && args[1] === "run") { + if (env.MOCK_GH_DISPATCH_ERROR) { + console.error(env.MOCK_GH_DISPATCH_ERROR); + process.exit(1); + } + console.log("Created workflow_dispatch event."); +} else if (args[0] === "api" && args.some((value) => value.includes("/commits/"))) { + console.log(env.MOCK_GH_CURRENT_SHA); +} else if (args[0] === "api" && args.some((value) => value.includes("/actions/workflows/") && value.endsWith("/runs"))) { + console.log(env.MOCK_GH_MATCHES); +} else if (args[0] === "api" && args.some((value) => value.includes("/jobs?"))) { + if (env.MOCK_GH_JOBS_ERROR) { + console.error(env.MOCK_GH_JOBS_ERROR); + process.exit(1); + } + jobs.forEach((job) => console.log(JSON.stringify(job))); +} else if (args[0] === "api" && args.some((value) => value.includes("/actions/runs/"))) { + if (env.MOCK_GH_STATUS_ERROR && fs.existsSync(env.MOCK_GH_STATUS_POLLS)) { + console.error(env.MOCK_GH_STATUS_ERROR); + process.exit(1); + } + console.log(JSON.stringify({ + conclusion, + head_sha: env.MOCK_GH_CHILD_SHA, + html_url: url, + status: nextStatus(), + })); +} else if (args[0] === "run" && args[1] === "view") { + const field = args[args.indexOf("--json") + 1]; + if (field === "status" && env.MOCK_GH_STATUS_ERROR) { + console.error(env.MOCK_GH_STATUS_ERROR); + process.exit(1); + } + if (field === "jobs") { + if (env.MOCK_GH_JOBS_ERROR) { + console.error(env.MOCK_GH_JOBS_ERROR); + process.exit(1); + } + const query = args[args.indexOf("--jq") + 1]; + if (query.startsWith("[.jobs")) { + console.log(JSON.stringify(jobs.filter((job) => job.status === "completed" && job.conclusion !== "success" && job.conclusion !== "skipped"))); + } else { + jobs.forEach((job) => console.log(JSON.stringify(job))); + } + } else { + console.log({ + conclusion, + headSha: env.MOCK_GH_CHILD_SHA, + status: field === "status" ? nextStatus() : undefined, + url, + }[field]); + } +} else if (args[0] !== "run" || args[1] !== "cancel") { + console.error("Unexpected mock gh invocation: " + JSON.stringify(args)); + process.exit(2); +} +`, + ); + chmodSync(ghPath, 0o755); + writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n"); + chmodSync(sleepPath, 0o755); + + const parentSha = "a".repeat(40); + const defaultJobs = [ + { + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Verify release checks", + status: "completed", + url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + }, + ]; + const stepValues: Record = { + ALLOW_UNRELEASED_CHANGELOG: "false", + CANDIDATE_ARTIFACT_JSON: "", + CHILD_WORKFLOW_KIND: child.kind, + CHILD_WORKFLOW_REF: "main", + CODEX_PLUGIN_SPEC: "", + CROSS_OS_SUITE_FILTER: "", + FAIL_FAST: "false", + GH_TOKEN: "fixture-token", + LIVE_SUITE_FILTER: "", + MODE: "both", + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: "", + PACKAGE_SPEC: "openclaw@beta", + PARENT_WORKFLOW_SHA: parentSha, + PROVIDER: "openai", + PROVIDER_MODE: "mock-openai", + RELEASE_PACKAGE_SPEC: "", + RELEASE_PROFILE: "stable", + RERUN_GROUP: "all", + RUN_RELEASE_SOAK: "false", + SCENARIO: "", + TARGET_CONTEXT_REF: "", + TARGET_REF: "main", + TARGET_SHA: "b".repeat(40), + }; + const stepEnv = Object.fromEntries( + Object.keys(step.env ?? {}).map((name) => { + const value = stepValues[name]; + if (value === undefined) { + throw new Error(`Missing child dispatch fixture value for ${child.jobName}.${name}`); + } + return [name, value]; + }), + ); + const result = spawnSync("bash", ["-c", script], { + cwd: workdir, + encoding: "utf8", + env: { + ...stepEnv, + GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN: + readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).env + ?.GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ?? "HTTP 5[0-9][0-9]", + GITHUB_OUTPUT: resolve(workdir, "github-output"), + GITHUB_REPOSITORY: "openclaw/openclaw", + GITHUB_RUN_ATTEMPT: "2", + GITHUB_RUN_ID: "77", + GITHUB_STEP_SUMMARY: resolve(workdir, "github-summary"), + MOCK_GH_CALLS: callsPath, + MOCK_GH_CHILD_SHA: parentSha, + MOCK_GH_CONCLUSION: "success", + MOCK_GH_CURRENT_SHA: parentSha, + MOCK_GH_JOBS: JSON.stringify(defaultJobs), + MOCK_GH_MATCHES: "[101]", + MOCK_GH_STATUSES: '["completed"]', + MOCK_GH_STATUS_POLLS: statusPath, + PATH: `${workdir}:${process.env.PATH}`, + ...overrides, + }, + timeout: 10_000, + }); + const calls = readFileSync(callsPath, "utf8") + .split("\n") + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { + args: string[]; + childWorkflowRef: string; + dispatchRunName?: string; + }, + ); + return { calls, result }; +} + function runPackageAcceptanceSummary(params: { advisory?: boolean; dockerArtifactResult?: string; @@ -1205,10 +1433,10 @@ describe("package acceptance workflow", () => { it("requires full release child workflows to run at the parent workflow SHA", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const releaseChecksWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); - const performanceJob = workflow.slice( - workflow.indexOf(" performance:\n"), - workflow.indexOf("\n summary:"), - ); + const performanceJob = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "performance"), + "Dispatch and monitor OpenClaw Performance", + ).run; expect(workflow).toContain("TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}"); expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}"); @@ -1315,22 +1543,24 @@ describe("package acceptance workflow", () => { }); it("keeps child-job fail-fast polling best-effort", () => { - const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); - expect(workflow.match(/continuing with authoritative workflow conclusion\./gu)).toHaveLength(3); + for (const child of FULL_RELEASE_CHILD_DISPATCHES.slice(0, 3)) { + const dispatch = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName), + child.stepName, + ); + expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(child.kind); + expect(dispatch.run).toContain("continuing with authoritative workflow conclusion."); + } }); it("adopts exact full-release child runs without retrying ambiguous dispatch posts", () => { - const childDispatches = [ - ["normal_ci", "Dispatch and monitor CI"], - ["plugin_prerelease", "Dispatch and monitor plugin prerelease"], - ["release_checks", "Dispatch and monitor release checks"], - ["npm_telegram", "Dispatch and monitor npm Telegram E2E"], - ["performance", "Dispatch and monitor OpenClaw Performance"], - ] as const; - const dispatchScripts = childDispatches.map(([jobName, stepName]) => { - const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, jobName); - return workflowStep(job, stepName).run ?? ""; + const dispatchScripts = FULL_RELEASE_CHILD_DISPATCHES.map((child) => { + const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName); + const step = workflowStep(job, child.stepName); + expect(step.env?.CHILD_WORKFLOW_KIND).toBe(child.kind); + return step.run ?? ""; }); + expect(new Set(dispatchScripts).size).toBe(1); for (const script of dispatchScripts) { expect(script.match(/gh workflow run/gu)).toHaveLength(1); @@ -1411,7 +1641,7 @@ describe("package acceptance workflow", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const retryCalls = workflow.split("\n").filter((line) => line.includes("gh_with_retry ")); - expect(retryCalls).toHaveLength(37); + expect(retryCalls.length).toBeGreaterThan(0); for (const call of retryCalls) { expect(call).toMatch(/gh_with_retry (api|run view)/u); } @@ -1437,6 +1667,254 @@ describe("package acceptance workflow", () => { ); }); + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "rejects moved workflow refs before dispatching $jobName", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_CURRENT_SHA: "c".repeat(40), + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing dispatch."); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(0); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "adopts the one exact $jobName child after an ambiguous dispatch without reposting", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_DISPATCH_ERROR: "HTTP 500: Failed to run workflow dispatch", + }); + const dispatchCalls = calls.filter(({ args }) => args[0] === "workflow"); + const adoptionCall = calls.find(({ args }) => args.includes("-X")); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stderr).toContain("adopted exact run 101"); + expect(dispatchCalls).toHaveLength(1); + expect(dispatchCalls[0]?.args.slice(0, 5)).toEqual([ + "workflow", + "run", + child.workflow, + "--ref", + "main", + ]); + expect(adoptionCall).toMatchObject({ + childWorkflowRef: "main", + dispatchRunName: `${child.runName} full-release-validation-77-2${child.nonceSuffix}`, + }); + expect(adoptionCall?.args).toContain( + "[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]", + ); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "refuses duplicate exact adoption candidates for $jobName", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_MATCHES: "[101, 102]", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Multiple runs matched"); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(0); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "refuses to adopt or retry a non-transient $jobName dispatch failure", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_DISPATCH_ERROR: "HTTP 422: Validation Failed", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing adoption polling"); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1); + expect(calls.some(({ args }) => args.includes("-X"))).toBe(false); + expect(calls.some(({ args }) => args[0] === "run" && args[1] === "cancel")).toBe(false); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "cancels exactly the adopted $jobName child when its workflow SHA differs", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_CHILD_SHA: "c".repeat(40), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("expected parent workflow SHA"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([ + expect.objectContaining({ args: ["run", "cancel", "101"] }), + ]); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "cancels exactly the adopted $jobName child when monitoring fails unexpectedly", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_STATUS_ERROR: "HTTP 403: Resource not accessible by integration", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("HTTP 403"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([ + expect.objectContaining({ args: ["run", "cancel", "101"] }), + ]); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES.slice(0, 4))( + "cancels the exact $jobName child after its first blocking failed job", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + FAIL_FAST: "true", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Run package acceptance", + status: "completed", + url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + }, + ]), + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(1); + expect(result.stdout).toContain("has failed child jobs before the workflow completed"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(1); + }, + ); + + it("keeps CI fail-fast job lookups advisory but npm Telegram job lookups fail-closed", () => { + const overrides = { + FAIL_FAST: "true", + MOCK_GH_JOBS_ERROR: "HTTP 403: Resource not accessible by integration", + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + }; + const normalCi = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[0], overrides); + const npmTelegram = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[3], overrides); + + expect(normalCi.result.status, normalCi.result.stderr).toBe(0); + expect(normalCi.result.stdout).toContain("continuing with authoritative workflow conclusion."); + expect(npmTelegram.result.status).toBe(1); + expect( + npmTelegram.calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel"), + `${npmTelegram.result.stdout}\n${npmTelegram.result.stderr}\n${JSON.stringify(npmTelegram.calls)}`, + ).toHaveLength(1); + }); + + it.each([ + { expectedStatus: 0, jobName: "Run QA Lab parity lane (sqlite)" }, + { expectedStatus: 0, jobName: "Run QA Lab live Discord lane" }, + { expectedStatus: 0, jobName: "Run repo/live E2E validation / Docker live" }, + { + expectedStatus: 0, + jobName: "Run package acceptance / Telegram package acceptance / mock-openai", + }, + { expectedStatus: 1, jobName: "Run repo/live E2E validation / Repo E2E" }, + { expectedStatus: 1, jobName: "Run package acceptance / Verify package integrity" }, + ])("preserves beta fail-fast ownership for $jobName", ({ expectedStatus, jobName }) => { + const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], { + FAIL_FAST: "true", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: jobName, + status: "completed", + }, + ]), + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + RELEASE_PROFILE: "beta", + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength( + expectedStatus, + ); + }); + + it.each([ + { expectedStatus: 0, failOnRegression: "false", profile: "beta" }, + { expectedStatus: 1, failOnRegression: "true", profile: "stable" }, + ])( + "keeps failed product performance $profile release behavior unchanged", + ({ expectedStatus, failOnRegression, profile }) => { + const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[4], { + MOCK_GH_CONCLUSION: "failure", + RELEASE_PROFILE: profile, + }); + const dispatch = calls.find(({ args }) => args[0] === "workflow"); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + expect(dispatch?.args).toContain(`fail_on_regression=${failOnRegression}`); + if (profile === "beta") { + expect(result.stdout).toContain("advisory for beta"); + } + }, + ); + + it.each([ + { expectedStatus: 0, failingJob: "Run optional live-provider check" }, + { expectedStatus: 1, failingJob: "Run package acceptance" }, + ])("keeps Tideclaw alpha package-safety lanes blocking", ({ expectedStatus, failingJob }) => { + const { result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], { + CHILD_WORKFLOW_REF: "tideclaw/alpha/2026-08-01-0000Z", + MOCK_GH_CONCLUSION: "failure", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Verify release checks", + status: "completed", + }, + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/202", + name: failingJob, + status: "completed", + }, + ]), + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + if (expectedStatus === 0) { + expect(result.stdout).toContain("accepted Tideclaw alpha advisory lanes"); + } else { + expect(result.stdout).toContain("package-safety Tideclaw alpha release-check lane"); + } + }); + it("keeps exhaustive update migration as a separate manual package gate", () => { const workflow = readFileSync(UPDATE_MIGRATION_WORKFLOW, "utf8"); const packageWorkflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); @@ -3115,6 +3593,7 @@ describe("package artifact reuse", () => { expect(npmTelegramJob.if).toContain("inputs.rerun_group == 'npm-telegram'"); expect(npmTelegramJob.if).not.toContain("inputs.rerun_group == 'all'"); expect(dispatchStep.env).toEqual({ + CHILD_WORKFLOW_KIND: "npm-telegram", CHILD_WORKFLOW_REF: "${{ github.ref_name }}", FAIL_FAST: "${{ inputs.fail_fast }}", GH_TOKEN: "${{ github.token }}", @@ -3126,7 +3605,8 @@ describe("package artifact reuse", () => { }); expectTextToIncludeAll(dispatchStep.run, [ 'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram"', - 'dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)"', + 'dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"', + 'dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}"', ".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF", "The dispatch was not retried to avoid creating a duplicate child.", 'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then', diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 82c898c03778..8cf1934cca4d 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -446,7 +446,9 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { expect(releaseWorkflowSource).toContain('--arg targetContextRef "$TARGET_CONTEXT_REF"'); expect(releaseWorkflowSource).toContain("targetContextRef: $targetContextRef"); expect(normalCiScript).toContain('dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}"'); - expect(normalCiScript).not.toContain("full_release_validation=true"); + const normalCiDispatchCase = normalCiScript.match(/^\s*ci\)\n([\s\S]*?)^\s*;;$/mu)?.[1]; + expect(normalCiDispatchCase).toContain('dispatch_and_wait ci.yml "$dispatch_run_name"'); + expect(normalCiDispatchCase).not.toContain("full_release_validation=true"); expect(pluginPrereleaseScript).toContain( 'args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id")', ); @@ -676,10 +678,19 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { default: false, type: "boolean", }); - expect( - fullReleaseSource.match(/has failed child jobs before the workflow completed/gu)?.length, - ).toBeGreaterThanOrEqual(3); - expect(fullReleaseSource.match(/if \[\[ "\$FAIL_FAST" != "true" \]\]; then/gu)?.length).toBe(4); + for (const [jobName, kind] of [ + ["normal_ci", "ci"], + ["plugin_prerelease", "plugin-prerelease"], + ["release_checks", "release-checks"], + ["npm_telegram", "npm-telegram"], + ] as const) { + const dispatch: WorkflowStep = fullReleaseWorkflow.jobs[jobName].steps[0]; + expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(kind); + expect(dispatch.env?.FAIL_FAST).toBe("${{ inputs.fail_fast }}"); + expect(dispatch.run).toContain('if [[ "$FAIL_FAST" != "true" ]]; then'); + expect(dispatch.run).toContain("has failed child jobs before the workflow completed"); + } + expect(fullReleaseWorkflow.jobs.performance.steps[0].env).not.toHaveProperty("FAIL_FAST"); expect(fullReleaseSource).toContain('-f fail_fast="$FAIL_FAST"'); expect(fullReleaseSource).toContain( "npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run.", diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 0634754efbb4..e626f82727f9 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -366,7 +366,8 @@ describe("release validation no-push transport", () => { expect(fullText).toContain("dispatch_and_wait plugin-prerelease.yml"); expect(fullText).toContain("dispatch_and_wait openclaw-release-checks.yml"); - expect(fullText).toContain("gh workflow run openclaw-performance.yml"); + expect(fullText).toContain("dispatch_and_wait openclaw-performance.yml"); + expect(fullText).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"'); const preparePackage = job(release, "prepare_release_package"); const live = job(release, "live_repo_e2e_release_checks"); From 4d6a63b7ee0a7456d067616eb0ab7d02aed04b91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:18:11 -0700 Subject: [PATCH 47/53] fix(tasks): validate notification policy before persistence (#117554) Co-authored-by: Peter Steinberger --- src/tasks/task-registry-record-api.ts | 24 +++--- src/tasks/task-registry.store.test.ts | 108 +++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/tasks/task-registry-record-api.ts b/src/tasks/task-registry-record-api.ts index d881c145f1e1..ff979491b58e 100644 --- a/src/tasks/task-registry-record-api.ts +++ b/src/tasks/task-registry-record-api.ts @@ -40,16 +40,17 @@ import { tasks, tryPersistTaskUpsert, } from "./task-registry-state.js"; -import type { - JsonValue, - TaskDeliveryState, - TaskDeliveryStatus, - TaskNotifyPolicy, - TaskRecord, - TaskRuntime, - TaskScopeKind, - TaskStatus, - TaskTerminalOutcome, +import { + parseTaskNotifyPolicy, + type JsonValue, + type TaskDeliveryState, + type TaskDeliveryStatus, + type TaskNotifyPolicy, + type TaskRecord, + type TaskRuntime, + type TaskScopeKind, + type TaskStatus, + type TaskTerminalOutcome, } from "./task-registry.types.js"; import { resolveTaskCleanupAfter } from "./task-retention.js"; @@ -531,9 +532,10 @@ export function updateTaskNotifyPolicyById(params: { taskId: string; notifyPolicy: TaskNotifyPolicy; }): TaskRecord | null { + const notifyPolicy = parseTaskNotifyPolicy(params.notifyPolicy); ensureTaskRegistryReady(); return updateTask(params.taskId, { - notifyPolicy: params.notifyPolicy, + notifyPolicy, lastEventAt: Date.now(), }); } diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index d4db16617ee8..e58d4b9ddf01 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -42,7 +42,7 @@ import { loadTaskRegistryStateFromSqlite, saveTaskRegistryStateToSqlite, } from "./task-registry.store.sqlite.js"; -import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js"; +import type { TaskDeliveryState, TaskNotifyPolicy, TaskRecord } from "./task-registry.types.js"; import { parseOptionalTaskTerminalOutcome, parseTaskDeliveryStatus, @@ -355,6 +355,112 @@ describe("task-registry store runtime", () => { ); }); + it.each(["verbose", "", "state-change", "DONE_ONLY"])( + "rejects an invalid notification policy before it can poison a SQLite restart (%s)", + async (invalidPolicy) => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-invalid-notify-" }, + async () => { + resetTaskRegistryForTests(); + const created = createTaskRecord({ + runtime: "acp", + ownerKey: "agent:main:main", + scopeKind: "session", + childSessionKey: "agent:main:acp:notify-policy", + runId: "run-invalid-notify-policy", + task: "Keep the task registry readable", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "done_only", + }); + const database = openOpenClawStateDatabase(); + const db = getNodeSqliteKysely(database.db); + + let mutationError: string | null = null; + try { + updateTaskNotifyPolicyById({ + taskId: created.taskId, + notifyPolicy: invalidPolicy as TaskNotifyPolicy, + }); + } catch (error) { + mutationError = error instanceof Error ? error.message : String(error); + } + + const persisted = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("task_runs") + .select("notify_policy") + .where("task_id", "=", created.taskId), + ); + + let restoredPolicy: TaskNotifyPolicy | null = null; + let restoreError: string | null = null; + try { + reloadTaskRegistryFromStore(); + restoredPolicy = getTaskById(created.taskId)?.notifyPolicy ?? null; + } catch (error) { + restoreError = error instanceof Error ? error.message : String(error); + } + + try { + expect({ + mutationError, + persistedPolicy: persisted?.notify_policy, + restoredPolicy, + restoreError, + }).toEqual({ + mutationError: `Invalid persisted task notify policy: ${JSON.stringify(invalidPolicy)}`, + persistedPolicy: "done_only", + restoredPolicy: "done_only", + restoreError: null, + }); + } finally { + if (persisted?.notify_policy !== "done_only") { + executeSqliteQuerySync( + database.db, + db + .updateTable("task_runs") + .set({ notify_policy: "done_only" }) + .where("task_id", "=", created.taskId), + ); + } + resetTaskRegistryForTests({ persist: false }); + } + }, + ); + }, + ); + + it.each(["done_only", "state_changes", "silent"] as const)( + "persists valid notification policy %s across a fresh SQLite restart", + async (notifyPolicy) => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-valid-notify-" }, + async () => { + resetTaskRegistryForTests(); + const created = createTaskRecord({ + runtime: "acp", + ownerKey: "agent:main:main", + scopeKind: "session", + childSessionKey: "agent:main:acp:notify-policy", + runId: "run-valid-notify-policy", + task: "Preserve valid notification policies", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "done_only", + }); + + expect( + updateTaskNotifyPolicyById({ taskId: created.taskId, notifyPolicy })?.notifyPolicy, + ).toBe(notifyPolicy); + reloadTaskRegistryFromStore(); + expect(getTaskById(created.taskId)?.notifyPolicy).toBe(notifyPolicy); + }, + ); + }, + ); + it("rejects corrupt persisted task rows during sqlite restore", async () => { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-task-store-corrupt-" }, From c263c273c75008ed3d3e0788ffc8373726802ce4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:20:31 -0700 Subject: [PATCH 48/53] fix(status): honor explicit local RPC fallback timeouts (#117519) Co-authored-by: Peter Steinberger --- src/commands/status.scan.shared.test.ts | 140 +++++++++++++++++++----- src/commands/status.scan.shared.ts | 6 +- 2 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index 58ca7f554cf2..a2c8a50d17ba 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -1,8 +1,19 @@ // Status scan shared tests cover gateway probe snapshots, Tailscale URLs, and shared scan helpers. +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { parseStatusRouteArgs } from "../cli/program/route-args.js"; +import { + buildMinimalGatewayHelloOkPayload, + closeMinimalGatewayServer, + parseMinimalGatewayRequestFrame, + sendMinimalGatewayConnectChallenge, + sendMinimalGatewayResponse, +} from "../gateway/minimal-gateway.test-helpers.js"; import { buildTailscaleHttpsUrl, resolveGatewayProbeSnapshot, @@ -325,39 +336,118 @@ describe("resolveGatewayProbeSnapshot", () => { expect(gatewayCall.timeoutMs).toBe(2000); }); - it("does not raise an explicit local status RPC fallback timeout", async () => { + it.each([1, 50, 999, 1000, 2000, 8000])( + "does not raise an explicit local status RPC fallback timeout (%i ms)", + async (timeoutMs) => { + mocks.resolveGatewayProbeTarget.mockReturnValue({ + mode: "local", + gatewayMode: "local", + remoteUrlMissing: false, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.callGateway.mockResolvedValue({ sessions: 1 }); + + await resolveGatewayProbeSnapshot({ + cfg: {}, + opts: { timeoutMs }, + }); + + const probeCall = readProbeCall(); + expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs"); + expect(probeCall.timeoutMs).toBe(timeoutMs); + expect(readGatewayCall().timeoutMs).toBe(Math.min(2000, timeoutMs)); + }, + ); + + it("enforces an explicit CLI timeout against a real local fallback status RPC", async () => { + const gateway = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(gateway, "listening"); + const address = gateway.address() as AddressInfo; + const url = `ws://127.0.0.1:${address.port}`; + const observedMethods: string[] = []; + gateway.on("connection", (socket) => { + sendMinimalGatewayConnectChallenge(socket); + socket.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id || !frame.method) { + return; + } + const requestId = frame.id; + if (frame.method === "connect") { + sendMinimalGatewayResponse( + socket, + requestId, + buildMinimalGatewayHelloOkPayload({ + methods: ["system-presence", "status"], + auth: { role: "operator", scopes: ["operator.read"] }, + }), + ); + return; + } + observedMethods.push(frame.method); + if (frame.method === "status") { + const responseTimer = setTimeout(() => { + if (socket.readyState === socket.OPEN) { + sendMinimalGatewayResponse(socket, requestId, { sessions: 1 }); + } + }, 400); + responseTimer.unref(); + } + }); + }); + + mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({ + url, + urlSource: "local loopback", + message: `Gateway target: ${url}`, + }); mocks.resolveGatewayProbeTarget.mockReturnValue({ mode: "local", gatewayMode: "local", remoteUrlMissing: false, }); - mocks.probeGateway.mockResolvedValue({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, - error: "timeout", - close: null, - auth: { - role: null, - scopes: [], - capability: "unknown", - }, - health: null, - status: null, - presence: null, - configSnapshot: null, + mocks.probeGateway.mockImplementation(async (...args: unknown[]) => { + const { probeGateway } = + await vi.importActual("../gateway/probe.js"); + return await probeGateway(...(args as Parameters)); }); - mocks.callGateway.mockResolvedValue({ sessions: 1 }); - - await resolveGatewayProbeSnapshot({ - cfg: {}, - opts: { timeoutMs: 1000 }, + mocks.callGateway.mockImplementation(async (...args: unknown[]) => { + const { callGateway } = + await vi.importActual("../gateway/call.js"); + return await callGateway(...(args as Parameters)); }); + const parsed = parseStatusRouteArgs(["node", "openclaw", "status", "--timeout", "250"]); + expect(parsed?.timeoutMs).toBe(250); - const probeCall = readProbeCall(); - expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs"); - expect(probeCall.timeoutMs).toBe(1000); - expect(readGatewayCall().timeoutMs).toBe(1000); + try { + const result = await resolveGatewayProbeSnapshot({ + cfg: { gateway: { auth: { mode: "none" } } }, + opts: { timeoutMs: parsed?.timeoutMs }, + }); + + expect(readProbeCall().timeoutMs).toBe(250); + expect(readGatewayCall().timeoutMs).toBe(250); + expect(observedMethods).toEqual(["system-presence", "status"]); + expect(result.gatewayProbe?.ok).toBe(false); + expect(result.gatewayProbe?.error).toContain("timeout"); + } finally { + await closeMinimalGatewayServer(gateway); + } }); it("lets callGateway reuse paired-device auth for local status RPC fallback", async () => { diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index fa70f4abd7b4..eb61f71bd4d0 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -208,7 +208,11 @@ async function applyLocalStatusRpcFallback(params: { if (!shouldTryLocalStatusRpcFallback(params)) { return params.gatewayProbe; } - const boundedFallbackTimeoutMs = Math.min(2000, Math.max(1000, params.timeoutMs)); + // Explicit probe budgets are operator-owned; only implicit fallback defaults get a floor. + const boundedFallbackTimeoutMs = Math.min( + 2000, + params.timeoutMsExplicit ? params.timeoutMs : Math.max(1000, params.timeoutMs), + ); // The fallback uses the gateway status RPC because it can succeed after probe handshake ambiguity. const status = await loadGatewayCallModule() .then(({ callGateway }) => From 1a0d3b5c4023871e896a40b28b84542e00134e9a Mon Sep 17 00:00:00 2001 From: zengLingbiao Date: Sun, 2 Aug 2026 03:26:27 +0800 Subject: [PATCH 49/53] fix(feishu): cancel unread streaming-card error bodies before release (#117312) --- .../src/streaming-card.error-release.test.ts | 151 ++++++++++++++++++ extensions/feishu/src/streaming-card.ts | 12 ++ 2 files changed, 163 insertions(+) create mode 100644 extensions/feishu/src/streaming-card.error-release.test.ts diff --git a/extensions/feishu/src/streaming-card.error-release.test.ts b/extensions/feishu/src/streaming-card.error-release.test.ts new file mode 100644 index 000000000000..b540899387ca --- /dev/null +++ b/extensions/feishu/src/streaming-card.error-release.test.ts @@ -0,0 +1,151 @@ +// Feishu streaming card tests exercise error-path response body cancellation +// through a real guarded HTTP transport against a loopback server. +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const loopback = vi.hoisted(() => ({ + baseUrl: "", + releases: [] as Array<{ bodyIsNull: boolean; bodyUsed: boolean }>, + authStatus: 200, + createStatus: 200, + settingsStatus: 200, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: async (...args: Parameters) => { + const [params] = args; + const url = new URL(params.url); + const redirected = new URL(`${url.pathname}${url.search}`, loopback.baseUrl).toString(); + const guarded = await actual.fetchWithSsrFGuard({ + ...params, + policy: { allowPrivateNetwork: true }, + url: redirected, + }); + return { + ...guarded, + release: async () => { + loopback.releases.push({ + bodyIsNull: guarded.response.body === null, + bodyUsed: guarded.response.bodyUsed, + }); + await guarded.release(); + }, + }; + }, + }; +}); + +const { FeishuStreamingSession } = await import("./streaming-card.js"); + +function writeJson(res: import("node:http").ServerResponse, payload: unknown, status = 200): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +let server: Server; + +beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname.includes("/auth/")) { + if (loopback.authStatus === 200) { + writeJson(res, { code: 0, msg: "ok", tenant_access_token: "token", expire: 3600 }); + } else { + writeJson(res, { error: "tenant token rejected" }, loopback.authStatus); + } + return; + } + if (url.pathname.endsWith("/settings")) { + if (loopback.settingsStatus === 200) { + writeJson(res, { code: 0, msg: "ok" }); + } else { + writeJson(res, { error: "settings rejected" }, loopback.settingsStatus); + } + return; + } + if (loopback.createStatus === 200) { + writeJson(res, { code: 0, msg: "ok", data: { card_id: "card_1" } }); + } else { + writeJson(res, { error: "card create rejected" }, loopback.createStatus); + } + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + loopback.baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +}); + +beforeEach(() => { + loopback.releases = []; + loopback.authStatus = 200; + loopback.createStatus = 200; + loopback.settingsStatus = 200; +}); + +describe("feishu streaming card error-path body release", () => { + it("cancels the unread tenant-token error body before release", async () => { + loopback.authStatus = 500; + const session = new FeishuStreamingSession({} as never, { + appId: "app_error_token", + appSecret: "secret", + }); + + await expect(session.start("chat_id", "open_id")).rejects.toThrow( + "Token request failed with HTTP 500", + ); + + expect(loopback.releases).toEqual([{ bodyIsNull: false, bodyUsed: true }]); + }); + + it("cancels the unread create-card error body before release", async () => { + loopback.createStatus = 500; + const session = new FeishuStreamingSession({} as never, { + appId: "app_error_create", + appSecret: "secret", + }); + + await expect(session.start("chat_id", "open_id")).rejects.toThrow( + "Create card request failed with HTTP 500", + ); + + expect(loopback.releases).toEqual([ + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + ]); + }); + + it("cancels the unread close-settings error body before release", async () => { + const client = { + im: { + message: { + create: async () => ({ code: 0, data: { message_id: "msg_1" } }), + }, + }, + }; + const session = new FeishuStreamingSession(client as never, { + appId: "app_error_close", + appSecret: "secret", + }); + await session.start("chat_id", "open_id"); + loopback.settingsStatus = 500; + + await expect(session.close()).resolves.toBe(false); + + expect(loopback.releases).toEqual([ + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + ]); + }); +}); diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 086d805a56a9..88c00f972b92 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -128,12 +128,22 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] { return ["open.feishu.cn"]; } +function cancelUnreadResponseBody(response: Response): void { + // A rejected response leaves its body unread; start cancellation before the + // guarded dispatcher is released so the connection is not leaked. Do not + // await: debug capture can tee the stream and deadlock a waiter. + if (!response.bodyUsed) { + void response.body?.cancel().catch(() => undefined); + } +} + async function assertSuccessfulCardKitResponse( response: Response, auditContext: string, action: string, ): Promise { if (!response.ok) { + cancelUnreadResponseBody(response); throw new Error(`${action} failed with HTTP ${response.status}`); } const data = await readFeishuJsonResponse(response, auditContext); @@ -174,6 +184,7 @@ async function getToken(creds: Credentials, deps?: FeishuStreamingDeps): Promise }; try { if (!response.ok) { + cancelUnreadResponseBody(response); throw new Error(`Token request failed with HTTP ${response.status}`); } data = await readFeishuJsonResponse(response, "feishu.streaming-card.token"); @@ -328,6 +339,7 @@ export class FeishuStreamingSession { }; try { if (!createRes.ok) { + cancelUnreadResponseBody(createRes); throw new Error(`Create card request failed with HTTP ${createRes.status}`); } createData = await readFeishuJsonResponse(createRes, "feishu.streaming-card.create"); From 2a5a64b51f5c50974b7bfc35f14e46dc0faa8f0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:26:58 -0700 Subject: [PATCH 50/53] fix(tasks): sanitize every human task and flow detail (#117568) Co-authored-by: Peter Steinberger --- src/commands/flows.test.ts | 232 +++++++++++++++++++++++++++++++++++++ src/commands/flows.ts | 33 ++++-- src/commands/tasks.test.ts | 218 ++++++++++++++++++++++++++++++---- src/commands/tasks.ts | 55 +++++---- 4 files changed, 480 insertions(+), 58 deletions(-) diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index 83c41e15f5cc..ce4262dcb3b6 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { createRunningTaskRun as createRunningTaskRunOrNull } from "../tasks/task-executor.js"; import { createManagedTaskFlow as createManagedTaskFlowOrNull } from "../tasks/task-flow-registry.js"; import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js"; +import { markTaskLostById, markTaskTerminalById } from "../tasks/task-registry.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { resetTaskFlowRegistryForTests, @@ -282,6 +283,237 @@ describe("flows commands", () => { }); }); + it.each(["failed", "timed_out", "lost"] as const)( + "shows the persisted failure reason for linked %s tasks", + async (status) => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-failure-detail", + goal: "Inspect child task failures", + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: `agent:main:flow-child-${status}`, + runId: `run-flow-child-${status}`, + label: "Inspect linked child", + task: "Inspect linked child", + notifyPolicy: "silent", + startedAt: Date.now(), + progressSummary: "Outdated child progress", + }); + const error = `${status}: linked provider credentials need attention`; + const endedAt = Date.now(); + + if (status === "lost") { + markTaskLostById({ taskId: task.taskId, endedAt, error }); + } else { + markTaskTerminalById({ + taskId: task.taskId, + status, + endedAt, + error, + terminalSummary: "Generic child completion summary", + }); + } + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("Inspect linked child"); + expect(linkedTaskLine).toContain(error); + expect(linkedTaskLine).not.toContain("Outdated child progress"); + expect(linkedTaskLine).not.toContain("Generic child completion summary"); + + const jsonRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime); + expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({ + tasks: [expect.objectContaining({ status, error })], + }); + }); + }, + ); + + it("includes running progress and terminal completion summaries for linked tasks", async () => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-task-progress", + goal: "Inspect child task updates", + status: "running", + }); + const running = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-running", + runId: "run-flow-child-running", + label: "Inspect running child", + task: "Inspect running child", + notifyPolicy: "silent", + startedAt: Date.now(), + progressSummary: "Downloading provider metadata", + }); + const completed = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-completed", + runId: "run-flow-child-completed", + label: "Inspect completed child", + task: "Inspect completed child", + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: completed.taskId, + status: "succeeded", + endedAt: Date.now(), + terminalSummary: "Provider metadata refreshed", + }); + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + expect(lines.find((line) => line.startsWith(`- ${running.taskId} `))).toContain( + "Downloading provider metadata", + ); + expect(lines.find((line) => line.startsWith(`- ${completed.taskId} `))).toContain( + "Provider metadata refreshed", + ); + }); + }); + + it("sanitizes linked task failure reasons before terminal display", async () => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-task-safety", + goal: "Inspect unsafe child error", + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-safety", + runId: "run-flow-child-safety", + label: "Inspect child safely", + task: "Inspect child safely", + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: task.taskId, + status: "failed", + endedAt: Date.now(), + error: "Provider \u001b[31mrejected\nforged: yes", + }); + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("Provider rejected forged: yes"); + expect(linkedTaskLine).not.toContain("\u001b"); + expect(linkedTaskLine).not.toContain("\n"); + }); + }); + + it("sanitizes persisted linked task identifiers while preserving raw flow JSON", async () => { + await withTaskFlowCommandStateDir(async () => { + const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: `controller${unsafe}`, + goal: `goal${unsafe}`, + currentStep: `step${unsafe}`, + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: `agent:main:child${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: task.taskId, + status: "failed", + endedAt: Date.now(), + error: `error${unsafe}`, + }); + + const humanRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, humanRuntime); + + const lines = vi.mocked(humanRuntime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("label"); + expect(linkedTaskLine).toContain("error"); + for (const line of lines) { + expect(line).not.toContain("\u001b"); + expect(line).not.toContain("\u0007"); + expect(line).not.toContain("\n"); + } + + const jsonRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime); + expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({ + goal: `goal${unsafe}`, + currentStep: `step${unsafe}`, + tasks: [ + expect.objectContaining({ + childSessionKey: `agent:main:child${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + error: `error${unsafe}`, + }), + ], + }); + }); + }); + + it("sanitizes untrusted TaskFlow filters and lookup errors", async () => { + await withTaskFlowCommandStateDir(async () => { + const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + const filterRuntime = createRuntime(); + await flowsListCommand({ status: `running${unsafe}` }, filterRuntime); + + const lookupRuntime = createRuntime(); + await flowsShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime); + + const lines = [ + ...vi.mocked(filterRuntime.log).mock.calls.map(([line]) => String(line)), + ...vi.mocked(lookupRuntime.error).mock.calls.map(([line]) => String(line)), + ]; + expect(lines.some((line) => line.includes("Status filter: running"))).toBe(true); + expect(lines.some((line) => line.includes("TaskFlow not found: missing"))).toBe(true); + for (const line of lines) { + expect(line).not.toContain("\u001b"); + expect(line).not.toContain("\u0007"); + expect(line).not.toContain("\n"); + } + }); + }); + it("shows TaskFlows with Date-invalid timestamps without crashing", async () => { await withTaskFlowCommandStateDir(async () => { const flow = createManagedTaskFlow({ diff --git a/src/commands/flows.ts b/src/commands/flows.ts index ad85553ee992..56e51c992f00 100644 --- a/src/commands/flows.ts +++ b/src/commands/flows.ts @@ -17,6 +17,7 @@ import { listTaskFlowRecords, resolveTaskFlowForLookupToken, } from "../tasks/task-flow-runtime-internal.js"; +import { formatTaskStatusDetail } from "../tasks/task-status.js"; const ID_PAD = 10; const STATUS_PAD = 10; @@ -25,7 +26,7 @@ const REV_PAD = 6; const CTRL_PAD = 20; function formatFlowLookupMiss(lookup: string): string { - return `TaskFlow not found: ${lookup}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`; + return `TaskFlow not found: ${sanitizeTerminalText(lookup)}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`; } function truncate(value: string, maxChars: number) { @@ -47,11 +48,7 @@ function safeFlowDisplayText(value: string | undefined, maxChars?: number): stri } function shortToken(value: string | undefined, maxChars = ID_PAD): string { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return "n/a"; - } - return truncate(trimmed, maxChars); + return safeFlowDisplayText(normalizeOptionalString(value), maxChars); } function formatFlowTimestamp(value: number | undefined | null): string { @@ -178,7 +175,7 @@ export async function flowsListCommand( runtime.log(info(`TaskFlows: ${flows.length}`)); runtime.log(info(`TaskFlow pressure: ${formatFlowListSummary(flows)}`)); if (statusFilter) { - runtime.log(info(`Status filter: ${statusFilter}`)); + runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`)); } if (flows.length === 0) { runtime.log( @@ -234,7 +231,7 @@ export async function flowsShowCommand( `tasks: ${taskSummary.total} total · ${taskSummary.active} active · ${taskSummary.failures} issues`, ]; for (const line of lines) { - runtime.log(line); + runtime.log(sanitizeTerminalText(line)); } if (tasks.length === 0) { runtime.log("Linked tasks: none"); @@ -243,7 +240,13 @@ export async function flowsShowCommand( runtime.log("Linked tasks:"); for (const task of tasks) { const safeLabel = safeFlowDisplayText(task.label ?? task.task); - runtime.log(`- ${task.taskId} ${task.status} ${task.runId ?? "n/a"} ${safeLabel}`); + const detail = formatTaskStatusDetail(task); + const safeDetail = detail ? ` · ${safeFlowDisplayText(detail)}` : ""; + runtime.log( + sanitizeTerminalText( + `- ${task.taskId} ${task.status} ${safeFlowDisplayText(task.runId)} ${safeLabel}${safeDetail}`, + ), + ); } } @@ -260,15 +263,21 @@ export async function flowsCancelCommand(opts: { lookup: string }, runtime: Runt flowId: flow.flowId, }); if (!result.found) { - runtime.error(result.reason ?? formatFlowLookupMiss(opts.lookup)); + runtime.error(sanitizeTerminalText(result.reason ?? formatFlowLookupMiss(opts.lookup))); runtime.exit(1); return; } if (!result.cancelled) { - runtime.error(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`); + runtime.error( + sanitizeTerminalText(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`), + ); runtime.exit(1); return; } const updated = getTaskFlowById(flow.flowId) ?? result.flow ?? flow; - runtime.log(`Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`); + runtime.log( + sanitizeTerminalText( + `Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`, + ), + ); } diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 084f87c5c513..32cb5a917269 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -12,6 +12,8 @@ import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js"; import { createTaskRecord as createTaskRecordOrNull, getTaskById, + markTaskLostById, + markTaskTerminalById, reloadTaskRegistryFromStore, } from "../tasks/task-registry.js"; import * as taskRegistryMaintenance from "../tasks/task-registry.maintenance.js"; @@ -79,6 +81,28 @@ function jsonRoundTrip(value: T): T { return JSON.parse(serialized) as T; } +const UNSAFE_TASK_TERMINAL_TEXT = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + +function createInspectableTask(params: Partial[0]> = {}) { + return createTaskRecord({ + runtime: "cli", + ownerKey: "agent:main:main", + scopeKind: "session", + status: "running", + notifyPolicy: "silent", + task: "Inspect a background task", + ...params, + }); +} + +function expectSafeTaskOutput(runtime: RuntimeEnv, channel: "log" | "error" = "log") { + for (const [line] of vi.mocked(runtime[channel]).mock.calls) { + for (const control of ["\u001b", "\u0007", "\n", "\r"]) { + expect(String(line)).not.toContain(control); + } + } +} + const zeroTaskAuditCounts = { delivery_failed: 0, inconsistent_timestamps: 0, @@ -97,31 +121,28 @@ async function writeSessionEntries( } } +function resetTaskCommandRuntime() { + taskRegistryMaintenance.stopTaskRegistryMaintenance(); + taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); + resetConfigRuntimeState(); + resetDetachedTaskLifecycleRuntimeForTests(); + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); + closeOpenClawAgentDatabasesForTest(); +} + async function withTaskCommandStateDir( run: (state: OpenClawTestState) => Promise, ): Promise { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-tasks-command-" }, async (state) => { - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); try { await run(state); } finally { - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); } }, ); @@ -134,14 +155,7 @@ describe("tasks commands", () => { afterEach(() => { vi.useRealTimers(); - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); mocks.callGateway.mockReset(); }); @@ -390,6 +404,54 @@ describe("tasks commands", () => { }); }); + it.each(["gateway", "local"] as const)( + "sanitizes untrusted %s task cancellation output", + async (owner) => { + await withTaskCommandStateDir(async () => { + const unsafe = UNSAFE_TASK_TERMINAL_TEXT; + const gatewayOwned = owner === "gateway"; + const task = createInspectableTask({ + runtime: gatewayOwned ? "cron" : "cli", + ownerKey: gatewayOwned ? "" : "agent:main:main", + scopeKind: gatewayOwned ? "system" : "session", + runId: `run${unsafe}`, + }); + if (gatewayOwned) { + mocks.callGateway.mockResolvedValueOnce({ + found: true, + cancelled: true, + task: { + taskId: `${task.taskId}${unsafe}`, + runtime: `cron${unsafe}`, + runId: task.runId, + }, + }); + } + const runtime = createRuntime(); + await tasksCancelCommand({ lookup: task.taskId }, runtime); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining(`Cancelled ${task.taskId}`), + ); + expectSafeTaskOutput(runtime); + if (!gatewayOwned) { + expect(getTaskById(task.taskId)).toMatchObject({ + status: "cancelled", + runId: `run${unsafe}`, + }); + return; + } + mocks.callGateway.mockResolvedValueOnce({ + found: true, + cancelled: false, + reason: `gateway refused${unsafe}`, + }); + const failureRuntime = createRuntime(); + await tasksCancelCommand({ lookup: task.taskId }, failureRuntime); + expectSafeTaskOutput(failureRuntime, "error"); + }); + }, + ); + it("fails ACP task cancellation loudly when the live gateway is unavailable", async () => { await withTaskCommandStateDir(async () => { const task = createTaskRecord({ @@ -680,6 +742,110 @@ describe("tasks commands", () => { }); }); + it("sanitizes every persisted task surface while preserving raw task JSON", async () => { + await withTaskCommandStateDir(async () => { + const unsafe = UNSAFE_TASK_TERMINAL_TEXT; + const task = createInspectableTask({ + sourceId: `source${unsafe}`, + childSessionKey: `agent:main:child${unsafe}`, + parentTaskId: `parent${unsafe}`, + agentId: `worker${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + progressSummary: `progress${unsafe}`, + terminalSummary: `summary${unsafe}`, + }); + markTaskLostById({ taskId: task.taskId, endedAt: Date.now(), error: `error${unsafe}` }); + const showRuntime = createRuntime(); + const listRuntime = createRuntime(); + const auditRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId }, showRuntime); + await tasksListCommand({}, listRuntime); + await tasksAuditCommand({}, auditRuntime); + for (const runtime of [showRuntime, listRuntime, auditRuntime]) { + expectSafeTaskOutput(runtime); + } + const shown = vi + .mocked(showRuntime.log) + .mock.calls.map(([line]) => String(line)) + .join("|"); + for (const field of [ + "sourceId", + "childSessionKey", + "parentTaskId", + "agentId", + "runId", + "label", + "task", + "error", + "progressSummary", + "terminalSummary", + ]) { + expect(shown).toContain(`${field}:`); + } + expect(vi.mocked(listRuntime.log).mock.calls.flat().join("|")).toContain("error"); + expect(vi.mocked(auditRuntime.log).mock.calls.flat().join("|")).toContain("error"); + const jsonRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId, json: true }, jsonRuntime); + expect(readFirstJsonLog(jsonRuntime)).toEqual(jsonRoundTrip(getTaskById(task.taskId))); + expect(getTaskById(task.taskId)).toMatchObject({ + runId: `run${unsafe}`, + error: `error${unsafe}`, + }); + const filteredListRuntime = createRuntime(); + await tasksListCommand( + { runtime: `cron${unsafe}`, status: `running${unsafe}` }, + filteredListRuntime, + ); + const filteredAuditRuntime = createRuntime(); + await tasksAuditCommand( + { + severity: `warn${unsafe}` as TaskSystemAuditSeverity, + code: `lost${unsafe}` as TaskSystemAuditCode, + }, + filteredAuditRuntime, + ); + for (const runtime of [filteredListRuntime, filteredAuditRuntime]) { + expectSafeTaskOutput(runtime); + } + const lookupRuntime = createRuntime(); + await tasksShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime); + expectSafeTaskOutput(lookupRuntime, "error"); + }); + }); + + it.each(["failed", "timed_out", "lost"] as const)( + "shows the persisted failure reason for %s tasks in list summaries", + async (status) => { + await withTaskCommandStateDir(async () => { + const task = createInspectableTask({ + runId: `task-list-${status}`, + label: "Original task title", + progressSummary: "Outdated running progress", + terminalSummary: "Generic terminal summary", + }); + const error = `${status}: upstream credentials need attention`; + const terminal = { taskId: task.taskId, endedAt: Date.now(), error }; + if (status === "lost") { + markTaskLostById(terminal); + } else { + markTaskTerminalById({ + ...terminal, + status, + terminalSummary: "Generic terminal summary", + }); + } + const runtime = createRuntime(); + await tasksListCommand({}, runtime); + const output = vi.mocked(runtime.log).mock.calls.flat().join("|"); + expect(output).toContain(error); + expect(output).not.toContain("Outdated running progress"); + expect(output).not.toContain("Generic terminal summary"); + }); + }, + ); + it("keeps task list summaries within their UTF-16 column limit", async () => { await withTaskCommandStateDir(async () => { createTaskRecord({ @@ -691,6 +857,8 @@ describe("tasks commands", () => { task: "Inspect task summary", terminalSummary: `${"y".repeat(78)}🚀xx`, }); + createInspectableTask({ progressSummary: "Fetching provider credentials" }); + createInspectableTask({ status: "succeeded", label: "Human-readable task title" }); const runtime = createRuntime(); await tasksListCommand({}, runtime); @@ -701,6 +869,8 @@ describe("tasks commands", () => { .join("\n"); expect(output).toContain(`${"y".repeat(78)}…`); expect(output).not.toContain("🚀"); + expect(output).toContain("Fetching provider credentials"); + expect(output).toContain("Human-readable task title"); }); }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 97fe4bf0e565..2d3d10186b86 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -4,6 +4,7 @@ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; import { formatLookupMiss } from "../cli/error-format.js"; @@ -42,6 +43,7 @@ import { } from "../tasks/task-registry.reconcile.js"; import { summarizeTaskRecords } from "../tasks/task-registry.summary.js"; import type { TaskNotifyPolicy, TaskRecord } from "../tasks/task-registry.types.js"; +import { formatTaskStatusDetail } from "../tasks/task-status.js"; import { buildTaskSystemAuditJsonPayload, buildTaskSystemAuditFindings, @@ -62,7 +64,7 @@ const info = theme.info; function formatTaskLookupMiss(lookup: string): string { return formatLookupMiss({ noun: "Task", - value: lookup, + value: sanitizeTerminalText(lookup), listCommand: "openclaw tasks list", valueLabel: "task id", }); @@ -208,11 +210,11 @@ function truncate(value: string, maxChars: number) { } function shortToken(value: string | undefined, maxChars = ID_PAD): string { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { + const sanitized = sanitizeTerminalText(normalizeOptionalString(value) ?? "").trim(); + if (!sanitized) { return "n/a"; } - return truncate(trimmed, maxChars); + return truncate(sanitized, maxChars); } function formatTaskStatusCell(status: string, rich: boolean) { @@ -245,10 +247,9 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { const lines = [rich ? theme.heading(header) : header]; for (const task of tasks) { const summary = truncate( - normalizeOptionalString(task.terminalSummary) || - normalizeOptionalString(task.progressSummary) || - normalizeOptionalString(task.label) || - task.task.trim(), + sanitizeTerminalText( + formatTaskStatusDetail(task) || normalizeOptionalString(task.label) || task.task.trim(), + ), 80, ); const line = [ @@ -257,7 +258,7 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { formatTaskStatusCell(task.status, rich), task.deliveryStatus.padEnd(DELIVERY_PAD), shortToken(task.runId, RUN_PAD).padEnd(RUN_PAD), - truncate(normalizeOptionalString(task.childSessionKey) || "n/a", 36).padEnd(36), + shortToken(task.childSessionKey, 36).padEnd(36), summary, ].join(" "); lines.push(line.trimEnd()); @@ -318,7 +319,7 @@ function formatAuditRows(findings: TaskSystemAuditFinding[], rich: boolean) { shortToken(finding.token).padEnd(ID_PAD), status, formatAgeMs(finding.ageMs).padEnd(8), - truncate(finding.detail, 88), + truncate(sanitizeTerminalText(finding.detail), 88), ] .join(" ") .trimEnd(), @@ -372,10 +373,10 @@ export async function tasksListCommand( runtime.log(info(`Background tasks: ${tasks.length}`)); runtime.log(info(`Task pressure: ${formatTaskListSummary(tasks)}`)); if (runtimeFilter) { - runtime.log(info(`Runtime filter: ${runtimeFilter}`)); + runtime.log(info(`Runtime filter: ${sanitizeTerminalText(runtimeFilter)}`)); } if (statusFilter) { - runtime.log(info(`Status filter: ${statusFilter}`)); + runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`)); } if (tasks.length === 0) { runtime.log( @@ -432,7 +433,7 @@ export async function tasksShowCommand( ...(task.terminalSummary ? [`terminalSummary: ${task.terminalSummary}`] : []), ]; for (const line of lines) { - runtime.log(line); + runtime.log(sanitizeTerminalText(line)); } } @@ -456,7 +457,9 @@ export async function tasksNotifyCommand( runtime.exit(1); return; } - runtime.log(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`); + runtime.log( + sanitizeTerminalText(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`), + ); } /** Cancels a detached task run by lookup token. */ @@ -470,18 +473,24 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt const gatewayResult = await tryCancelGatewayOwnedTaskViaGateway(task); if (gatewayResult) { if (!gatewayResult.found) { - runtime.error(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup)); + runtime.error( + sanitizeTerminalText(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup)), + ); runtime.exit(1); return; } if (!gatewayResult.cancelled) { - runtime.error(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`); + runtime.error( + sanitizeTerminalText(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`), + ); runtime.exit(1); return; } const updated = gatewayResult.task; runtime.log( - `Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + sanitizeTerminalText( + `Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + ), ); return; } @@ -490,18 +499,20 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt taskId: task.taskId, }); if (!result.found) { - runtime.error(result.reason ?? formatTaskLookupMiss(opts.lookup)); + runtime.error(sanitizeTerminalText(result.reason ?? formatTaskLookupMiss(opts.lookup))); runtime.exit(1); return; } if (!result.cancelled) { - runtime.error(result.reason ?? `Could not cancel task: ${opts.lookup}`); + runtime.error(sanitizeTerminalText(result.reason ?? `Could not cancel task: ${opts.lookup}`)); runtime.exit(1); return; } const updated = getTaskById(task.taskId); runtime.log( - `Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + sanitizeTerminalText( + `Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + ), ); } @@ -549,10 +560,10 @@ export async function tasksAuditCommand( runtime.log(info(`Showing ${filteredFindings.length} matching findings.`)); } if (severityFilter) { - runtime.log(info(`Severity filter: ${severityFilter}`)); + runtime.log(info(`Severity filter: ${sanitizeTerminalText(severityFilter)}`)); } if (codeFilter) { - runtime.log(info(`Code filter: ${codeFilter}`)); + runtime.log(info(`Code filter: ${sanitizeTerminalText(codeFilter)}`)); } if (limit) { runtime.log(info(`Limit: ${limit}`)); From acf28495b1ae8b911c38a9980eea303709f7a64f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:29:21 +0800 Subject: [PATCH 51/53] fix(ci): repair plugin prerelease validation (#117562) --- scripts/lib/state-schema-inline-plugin.d.mts | 9 ++++ scripts/lib/state-schema-inline-plugin.mjs | 40 ++++++++++++++++++ .../capability-provider-runtime.test.ts | 4 +- .../migration-provider-runtime.test.ts | 2 +- ...s.runtime.consult-current-snapshot.test.ts | 4 +- .../web-fetch-providers.runtime.test.ts | 7 ++-- test/vitest/vitest.shared.config.ts | 2 + tsdown.config.ts | 42 +++---------------- 8 files changed, 65 insertions(+), 45 deletions(-) create mode 100644 scripts/lib/state-schema-inline-plugin.d.mts create mode 100644 scripts/lib/state-schema-inline-plugin.mjs diff --git a/scripts/lib/state-schema-inline-plugin.d.mts b/scripts/lib/state-schema-inline-plugin.d.mts new file mode 100644 index 000000000000..329eaf70ac1a --- /dev/null +++ b/scripts/lib/state-schema-inline-plugin.d.mts @@ -0,0 +1,9 @@ +export const STATE_SCHEMA_INLINE_PLUGIN_NAME: string; + +export function createStateSchemaInlinePlugin(rootDir?: string): { + name: string; + load( + this: { addWatchFile(id: string): void }, + id: string, + ): { code: string; moduleType: "js" } | null; +}; diff --git a/scripts/lib/state-schema-inline-plugin.mjs b/scripts/lib/state-schema-inline-plugin.mjs new file mode 100644 index 000000000000..fb48b55d8f0d --- /dev/null +++ b/scripts/lib/state-schema-inline-plugin.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas"; + +const STATE_SCHEMA_MODULES = [ + { + modulePath: "src/state/openclaw-state-schema.ts", + schemaPath: "src/state/openclaw-state-schema.sql", + exportName: "OPENCLAW_STATE_SCHEMA_SQL", + }, + { + modulePath: "src/state/openclaw-agent-schema.ts", + schemaPath: "src/state/openclaw-agent-schema.sql", + exportName: "OPENCLAW_AGENT_SCHEMA_SQL", + }, +]; + +/** Inline canonical schema bytes so bundled consumers need no SQL asset. */ +export function createStateSchemaInlinePlugin(rootDir = process.cwd()) { + const schemasByModulePath = new Map( + STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]), + ); + + return { + name: STATE_SCHEMA_INLINE_PLUGIN_NAME, + load(id) { + const schema = schemasByModulePath.get(path.resolve(id)); + if (!schema) { + return null; + } + const schemaPath = path.resolve(rootDir, schema.schemaPath); + this.addWatchFile(schemaPath); + return { + code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`, + moduleType: "js", + }; + }, + }; +} diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 7f9ddd94cba3..9aacbec76877 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -80,8 +80,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { }; }); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot, diff --git a/src/plugins/migration-provider-runtime.test.ts b/src/plugins/migration-provider-runtime.test.ts index ab91bc697aa0..fa710f244440 100644 --- a/src/plugins/migration-provider-runtime.test.ts +++ b/src/plugins/migration-provider-runtime.test.ts @@ -61,7 +61,7 @@ vi.mock("./active-runtime-registry.js", () => ({ }, })); -vi.mock("./plugin-registry.js", () => ({ +vi.mock("./plugin-registry-snapshot.js", () => ({ loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot, loadPluginRegistrySnapshotWithMetadata: mocks.loadPluginRegistrySnapshotWithMetadata, })); diff --git a/src/plugins/providers.runtime.consult-current-snapshot.test.ts b/src/plugins/providers.runtime.consult-current-snapshot.test.ts index da2fa927c1f0..b08058ccfe5c 100644 --- a/src/plugins/providers.runtime.consult-current-snapshot.test.ts +++ b/src/plugins/providers.runtime.consult-current-snapshot.test.ts @@ -16,8 +16,8 @@ import { resetPluginRuntimeStateForTest } from "./runtime.js"; const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn()); const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshotWithMetadata: (params: unknown) => diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index 5b010654b66d..9ded1713f780 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -104,9 +104,10 @@ function createRuntimeWebFetchProvider() { describe("resolvePluginWebFetchProviders", () => { beforeAll(async () => { - vi.doMock("./plugin-registry.js", async () => { - const actual = - await vi.importActual("./plugin-registry.js"); + vi.doMock("./plugin-registry-snapshot.js", async () => { + const actual = await vi.importActual( + "./plugin-registry-snapshot.js", + ); return { ...actual, loadPluginRegistrySnapshotWithMetadata: () => ({ diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 5da7b85e5c85..33d2fbe5e696 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import acpCorePackageJson from "../../packages/acp-core/package.json" with { type: "json" }; import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; import privateLocalOnlyPluginSdkSubpaths from "../../scripts/lib/plugin-sdk-private-local-only-subpaths.json" with { type: "json" }; +import { createStateSchemaInlinePlugin } from "../../scripts/lib/state-schema-inline-plugin.mjs"; import { detectVitestHostInfo as detectVitestHostInfoImpl, isCiLikeEnv, @@ -158,6 +159,7 @@ if (!isCI && localScheduling.throttledBySystem && shouldPrintVitestThrottle(proc export const sharedVitestConfig = { root: repoRoot, envDir: false as const, + plugins: [createStateSchemaInlinePlugin(repoRoot)], resolve: { alias: [ { diff --git a/tsdown.config.ts b/tsdown.config.ts index 1ca9275f7644..2dcafbe5956d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,10 @@ import { pluginSdkEntrypoints, productionPluginSdkEntrypoints, } from "./scripts/lib/plugin-sdk-entries.mjs"; +import { + createStateSchemaInlinePlugin, + STATE_SCHEMA_INLINE_PLUGIN_NAME, +} from "./scripts/lib/state-schema-inline-plugin.mjs"; import { TSDOWN_PACKAGE_CONFIG_GROUP, TSDOWN_UNIFIED_CONFIG_GROUP, @@ -46,43 +50,7 @@ const env = { const OUTPUT_SOURCE_MAPS = process.env.OUTPUT_SOURCE_MAPS === "1"; const RUN_NODE_SKIP_DTS_BUILD = process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD === "1"; const TSDOWN_DECLARATIONS = !RUN_NODE_SKIP_DTS_BUILD; -export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas"; - -const STATE_SCHEMA_MODULES = [ - { - modulePath: "src/state/openclaw-state-schema.ts", - schemaPath: "src/state/openclaw-state-schema.sql", - exportName: "OPENCLAW_STATE_SCHEMA_SQL", - }, - { - modulePath: "src/state/openclaw-agent-schema.ts", - schemaPath: "src/state/openclaw-agent-schema.sql", - exportName: "OPENCLAW_AGENT_SCHEMA_SQL", - }, -] as const; - -/** Inline canonical schema bytes so packaged database opens need no SQL asset. */ -export function createStateSchemaInlinePlugin(rootDir: string = process.cwd()) { - const schemasByModulePath = new Map( - STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]), - ); - - return { - name: STATE_SCHEMA_INLINE_PLUGIN_NAME, - load(this: { addWatchFile(id: string): void }, id: string) { - const schema = schemasByModulePath.get(path.resolve(id)); - if (!schema) { - return null; - } - const schemaPath = path.resolve(rootDir, schema.schemaPath); - this.addWatchFile(schemaPath); - return { - code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`, - moduleType: "js" as const, - }; - }, - }; -} +export { createStateSchemaInlinePlugin, STATE_SCHEMA_INLINE_PLUGIN_NAME }; const SUPPRESSED_EVAL_WARNING_PATHS = [ "@protobufjs/inquire/index.js", From 6531ca91f457a9fdcbc0005a59927ae68c763898 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:30:17 +0800 Subject: [PATCH 52/53] fix(perf): separate warm and first-device health probes (#117525) * fix(perf): separate warm and first-device health probes * fix(perf): configure first-device health probe * fix(perf): require connected health probes * fix(perf): preserve generic health benchmark state --- .github/workflows/openclaw-performance.yml | 3 +- scripts/bench-cli-startup.ts | 46 ++++++-- test/scripts/bench-cli-startup.test.ts | 38 +++++-- .../scripts/cli-startup-bench-spawner.test.ts | 103 ++++++++++++++++++ .../openclaw-performance-workflow.test.ts | 7 ++ 5 files changed, 178 insertions(+), 19 deletions(-) diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index 0185f411b367..66aac720f846 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -807,7 +807,8 @@ jobs: OPENCLAW_HOME="$gateway_home" OPENCLAW_STATE_DIR="$gateway_state" OPENCLAW_CONFIG_PATH="$gateway_config" OPENCLAW_GATEWAY_PORT="$gateway_port" \ node --import tsx scripts/bench-cli-startup.ts \ - --case gatewayHealthJson \ + --case gatewayHealthJsonConnected \ + --case gatewayHealthJsonFirstDevice \ --case configGetGatewayPort \ --runs "$source_runs" \ --warmup 1 \ diff --git a/scripts/bench-cli-startup.ts b/scripts/bench-cli-startup.ts index affb00029acd..349849d8b149 100644 --- a/scripts/bench-cli-startup.ts +++ b/scripts/bench-cli-startup.ts @@ -12,6 +12,7 @@ type CommandCase = { name: string; args: string[]; presets: readonly string[]; + stateScope?: "case" | "sample"; expectedExitCodes?: readonly number[]; expectedNonzeroOutputIncludes?: readonly string[]; firstOutputBudgetMs?: number; @@ -444,6 +445,19 @@ const COMMAND_CASES: readonly CommandCase[] = [ expectedExitCodes: [0, 1], expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'], }, + { + id: "gatewayHealthJsonConnected", + name: "gateway health --json (connected)", + args: ["gateway", "health", "--json"], + presets: [], + stateScope: "case", + }, + { + id: "gatewayHealthJsonFirstDevice", + name: "gateway health --json (first device)", + args: ["gateway", "health", "--json"], + presets: [], + }, { id: "configGetGatewayPort", name: "config get gateway.port", @@ -649,6 +663,8 @@ function buildConfigFixture(commandCase: CommandCase): Record | if ( commandCase.id !== "configGetGatewayPort" && commandCase.id !== "gatewayHealthJson" && + commandCase.id !== "gatewayHealthJsonConnected" && + commandCase.id !== "gatewayHealthJsonFirstDevice" && commandCase.id !== "health" && commandCase.id !== "healthJson" ) { @@ -717,8 +733,10 @@ async function runSample(params: { cpuProfDir?: string; heapProfDir?: string; rssHookPath: string; + runRoot?: string; }): Promise { - const runRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")); + const runRoot = params.runRoot ?? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")); + const ownsRunRoot = params.runRoot == null; const stateDir = path.join(runRoot, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); const configFixture = buildConfigFixture(params.commandCase); @@ -849,7 +867,9 @@ async function runSample(params: { }); }); } finally { - rmSync(runRoot, { recursive: true, force: true }); + if (ownsRunRoot) { + rmSync(runRoot, { recursive: true, force: true }); + } } } @@ -939,14 +959,24 @@ async function runCase(params: { }): Promise { const samples: Sample[] = []; const totalRuns = params.warmup + params.runs; - for (let i = 0; i < totalRuns; i += 1) { - const sample = await runSample(params); - if (i < params.warmup) { - continue; + const caseRunRoot = + params.commandCase.stateScope === "case" + ? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")) + : undefined; + try { + for (let i = 0; i < totalRuns; i += 1) { + const sample = await runSample({ ...params, runRoot: caseRunRoot }); + if (i < params.warmup) { + continue; + } + samples.push(sample); + } + return samples; + } finally { + if (caseRunRoot) { + rmSync(caseRunRoot, { recursive: true, force: true }); } - samples.push(sample); } - return samples; } function tailLines(value: string, maxLines: number): string { diff --git a/test/scripts/bench-cli-startup.test.ts b/test/scripts/bench-cli-startup.test.ts index a4792df914de..506d76812331 100644 --- a/test/scripts/bench-cli-startup.test.ts +++ b/test/scripts/bench-cli-startup.test.ts @@ -462,6 +462,18 @@ describe("bench-cli-startup", () => { args: ["gateway", "health", "--json"], presets: ["real"], }, + { + id: "gatewayHealthJsonConnected", + name: "gateway health --json (connected)", + args: ["gateway", "health", "--json"], + presets: [], + }, + { + id: "gatewayHealthJsonFirstDevice", + name: "gateway health --json (first device)", + args: ["gateway", "health", "--json"], + presets: [], + }, { id: "health", name: "health", args: ["health"], presets: ["startup", "real"] }, { id: "healthJson", @@ -485,16 +497,22 @@ describe("bench-cli-startup", () => { expect(testing.parseGatewayPortEnv("::1")).toBe(32123); expect(testing.parseGatewayPortEnv("[::1]")).toBe(32123); - expect( - withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () => - testing.buildConfigFixture({ - id: "gatewayHealthJson", - name: "gateway health --json", - args: ["gateway", "health", "--json"], - presets: ["real"], - }), - ), - ).toMatchObject({ gateway: { port: 45678 } }); + for (const id of [ + "gatewayHealthJson", + "gatewayHealthJsonConnected", + "gatewayHealthJsonFirstDevice", + ]) { + expect( + withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () => + testing.buildConfigFixture({ + id, + name: "gateway health --json", + args: ["gateway", "health", "--json"], + presets: [], + }), + ), + ).toMatchObject({ gateway: { port: 45678 } }); + } for (const invalid of ["45678abc", "127.0.0.1:45678abc"]) { expect(() => diff --git a/test/scripts/cli-startup-bench-spawner.test.ts b/test/scripts/cli-startup-bench-spawner.test.ts index 14c6f0f43d9b..50a84d997b61 100644 --- a/test/scripts/cli-startup-bench-spawner.test.ts +++ b/test/scripts/cli-startup-bench-spawner.test.ts @@ -34,6 +34,109 @@ describe("CLI startup benchmark script spawners", () => { ); }); + it("reuses warmed state for gateway health while isolating first-device samples", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-state-scope-test-")); + try { + const fixturePath = path.join(tmpDir, "record-home.mjs"); + const homeLogPath = path.join(tmpDir, "homes.log"); + fs.writeFileSync( + fixturePath, + [ + 'import { appendFileSync } from "node:fs";', + "appendFileSync(process.env.OPENCLAW_BENCH_HOME_LOG, `${process.env.HOME}\\n`);", + "console.log('{\"ok\":true}');", + "", + ].join("\n"), + ); + + const runCase = (caseId: string) => { + fs.rmSync(homeLogPath, { force: true }); + execFileSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/bench-cli-startup.ts", + "--entry", + fixturePath, + "--case", + caseId, + "--runs", + "2", + "--warmup", + "1", + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_BENCH_HOME_LOG: homeLogPath, + }, + stdio: "pipe", + }, + ); + return fs.readFileSync(homeLogPath, "utf8").trim().split("\n"); + }; + + const warmedHomes = runCase("gatewayHealthJsonConnected"); + expect(warmedHomes).toHaveLength(3); + expect(new Set(warmedHomes).size).toBe(1); + expect(warmedHomes.every((home) => !fs.existsSync(home))).toBe(true); + + for (const caseId of ["gatewayHealthJson", "gatewayHealthJsonFirstDevice"]) { + const sampleHomes = runCase(caseId); + expect(sampleHomes).toHaveLength(3); + expect(new Set(sampleHomes).size).toBe(3); + expect(sampleHomes.every((home) => !fs.existsSync(home))).toBe(true); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("requires connected gateway health probes to exit successfully", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-connected-test-")); + try { + const fixturePath = path.join(tmpDir, "transport-error.mjs"); + fs.writeFileSync( + fixturePath, + [ + 'console.log(\'{"ok":false,"gateway_transport_error":"closed"}\');', + "process.exitCode = 1;", + "", + ].join("\n"), + ); + + const runCase = (caseId: string) => + spawnSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/bench-cli-startup.ts", + "--entry", + fixturePath, + "--case", + caseId, + "--runs", + "1", + "--warmup", + "0", + ], + { cwd: process.cwd(), encoding: "utf8" }, + ); + + expect(runCase("gatewayHealthJson").status).toBe(0); + for (const caseId of ["gatewayHealthJsonConnected", "gatewayHealthJsonFirstDevice"]) { + const result = runCase(caseId); + expect(result.status).toBe(1); + expect(result.stderr).toContain(`${caseId} sample 1: exited with code 1`); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("does not require unrelated fixture cases for a narrowed preset", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-budget-test-")); try { diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 0f1129dde2f9..522df47e675e 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -254,6 +254,13 @@ describe("OpenClaw performance workflow", () => { expect(run.indexOf(probeCap)).toBeLessThan(run.indexOf(boundedProbe)); }); + it("measures warmed and first-device gateway health separately", () => { + const run = findStep("Run OpenClaw source performance probes", "source_performance").run ?? ""; + + expect(run).toContain("--case gatewayHealthJsonConnected \\"); + expect(run).toContain("--case gatewayHealthJsonFirstDevice \\"); + }); + it("isolates required publication in a fresh artifact-consuming job", () => { const workflow = readWorkflow(); const publisher = workflow.jobs?.publish; From 7c9794b5c65c9e28d6ae359f8924c86d82ab4abd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:30:49 -0700 Subject: [PATCH 53/53] fix(whatsapp): normalize future-proof QA poll and video note ingress (#117579) Co-authored-by: Peter Steinberger --- .../whatsapp/src/qa-driver.runtime.test.ts | 94 +++++++++++++++++++ extensions/whatsapp/src/qa-driver.runtime.ts | 21 ++--- 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/extensions/whatsapp/src/qa-driver.runtime.test.ts b/extensions/whatsapp/src/qa-driver.runtime.test.ts index f4395916ff73..45d0e2b08561 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.test.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.test.ts @@ -462,6 +462,100 @@ describe("startWhatsAppQaDriverSession", () => { await session.close(); }); + it.each([ + ...[ + { name: "captionless video note", message: { ptvMessage: {} } }, + { + name: "ephemeral captionless video note", + message: { ephemeralMessage: { message: { ptvMessage: {} } } }, + }, + { + name: "edited captionless video note", + message: { editedMessage: { message: { ptvMessage: {} } } }, + }, + ].map(({ name, message }) => ({ + name, + message, + expected: { kind: "media", mediaType: "video/mp4", text: "" }, + })), + ...[ + "pollCreationMessage", + "pollCreationMessageV2", + "pollCreationMessageV3", + "pollCreationMessageV5", + ].flatMap((pollKey) => { + const poll = { + [pollKey]: { + name: "Choose a time", + options: [{ optionName: "Morning" }, { optionName: "Afternoon" }], + }, + }; + const expected = { + kind: "poll", + poll: { question: "Choose a time", options: ["Morning", "Afternoon"] }, + }; + return [ + { name: pollKey, message: poll, expected }, + { + name: `ephemeral ${pollKey}`, + message: { ephemeralMessage: { message: poll } }, + expected, + }, + ]; + }), + ...["pollCreationMessageV3", "pollCreationMessageV5"].flatMap((pollKey) => { + const poll = { + [pollKey]: { + name: "Choose a time", + options: [{ optionName: "Morning" }, { optionName: "Afternoon" }], + }, + }; + const wrappedPoll = { pollCreationMessageV4: { message: poll } }; + const expected = { + kind: "poll", + poll: { question: "Choose a time", options: ["Morning", "Afternoon"] }, + }; + return [ + { name: `future-proof version-4 ${pollKey}`, message: wrappedPoll, expected }, + { + name: `ephemeral future-proof version-4 ${pollKey}`, + message: { ephemeralMessage: { message: wrappedPoll } }, + expected, + }, + { name: `edited ${pollKey}`, message: { editedMessage: { message: poll } }, expected }, + ]; + }), + ])("resolves live ingress waiters for $name", async ({ message, expected }) => { + const sock = createMockSocket(); + mocks.createWaSocket.mockResolvedValue(sock); + mocks.waitForWaConnection.mockResolvedValue(undefined); + mocks.jidToE164.mockReturnValue("+15551234567"); + + const session = await startWhatsAppQaDriverSession({ + authDir: "/tmp/openclaw-whatsapp-auth", + }); + + try { + const observed = session.waitForMessage({ + timeoutMs: 150, + match: (candidate) => candidate.kind === expected.kind, + }); + sock.ev.emit("messages.upsert", { + messages: [ + { + key: { fromMe: false, id: "observed-message", remoteJid: "12345@lid" }, + message, + } as WAMessage, + ], + }); + + await expect(observed).resolves.toMatchObject(expected); + expect(session.getObservedMessages()).toHaveLength(1); + } finally { + await session.close(); + } + }); + it("uses canonical WhatsApp media MIME defaults when Baileys omits MIME", async () => { const sock = createMockSocket(); mocks.createWaSocket.mockResolvedValue(sock); diff --git a/extensions/whatsapp/src/qa-driver.runtime.ts b/extensions/whatsapp/src/qa-driver.runtime.ts index dab09b25ad8b..43462d160449 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.ts @@ -1,5 +1,5 @@ // Whatsapp plugin module implements qa driver behavior. -import type { ConnectionState, proto, WAMessage } from "baileys"; +import { getContentType, type ConnectionState, type proto, type WAMessage } from "baileys"; import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound"; import { describeReplyContext, @@ -180,19 +180,10 @@ function findMessageSection( if (current.depth >= 4) { continue; } - for (const wrapperName of [ - "botInvokeMessage", - "documentWithCaptionMessage", - "ephemeralMessage", - "groupMentionedMessage", - "viewOnceMessage", - "viewOnceMessageV2", - "viewOnceMessageV2Extension", - ]) { - const wrapper = current.value[wrapperName]; - if (isRecord(wrapper) && isRecord(wrapper.message)) { - queue.push({ depth: current.depth + 1, value: wrapper.message }); - } + const contentType = getContentType(current.value as proto.IMessage); + const wrapper = contentType ? current.value[contentType] : undefined; + if (isRecord(wrapper) && isRecord(wrapper.message)) { + queue.push({ depth: current.depth + 1, value: wrapper.message }); } } return undefined; @@ -218,6 +209,7 @@ function readPoll(message: unknown): WhatsAppQaDriverObservedPoll | undefined { "pollCreationMessage", "pollCreationMessageV2", "pollCreationMessageV3", + "pollCreationMessageV5", ]); if (!poll) { return undefined; @@ -244,6 +236,7 @@ function readMedia(message: unknown): const mediaSections = [ "imageMessage", "videoMessage", + "ptvMessage", "audioMessage", "documentMessage", "stickerMessage",