From 42f1e8dd4177fbec711deeccc6c0daafa76ace74 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:25 +0800 Subject: [PATCH 01/59] fix(openai): terminalize preconnect lifecycle --- .../openai/realtime-voice-lifecycle.test.ts | 14 ++++++ extensions/openai/realtime-voice-lifecycle.ts | 43 ++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/extensions/openai/realtime-voice-lifecycle.test.ts b/extensions/openai/realtime-voice-lifecycle.test.ts index 85b044e94f2a..181077ee0ec9 100644 --- a/extensions/openai/realtime-voice-lifecycle.test.ts +++ b/extensions/openai/realtime-voice-lifecycle.test.ts @@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest"; import { OpenAIRealtimeVoiceLifecycle } from "./realtime-voice-lifecycle.js"; describe("OpenAIRealtimeVoiceLifecycle", () => { + it("terminalizes preconnect cancellation until an explicit fresh connection", () => { + const lifecycle = new OpenAIRealtimeVoiceLifecycle(); + + expect(lifecycle.phase()).toBe("idle"); + expect(lifecycle.cancel()).toBe(true); + expect(lifecycle.phase()).toBe("terminal"); + expect(lifecycle.cancel()).toBe(false); + + const connection = lifecycle.connect(); + expect(lifecycle.phase()).toBe("connecting"); + expect(lifecycle.ready(connection)).toBe(true); + expect(lifecycle.phase()).toBe("ready"); + }); + it("moves a connection from connecting to ready", () => { const lifecycle = new OpenAIRealtimeVoiceLifecycle(); const connection = lifecycle.connect(); diff --git a/extensions/openai/realtime-voice-lifecycle.ts b/extensions/openai/realtime-voice-lifecycle.ts index a436a5ac92af..5f136ca16587 100644 --- a/extensions/openai/realtime-voice-lifecycle.ts +++ b/extensions/openai/realtime-voice-lifecycle.ts @@ -1,4 +1,9 @@ -type OpenAIRealtimeVoiceLifecyclePhase = "connecting" | "ready" | "retry-wait" | "terminal"; +type OpenAIRealtimeVoiceLifecyclePhase = + | "idle" + | "connecting" + | "ready" + | "retry-wait" + | "terminal"; type OpenAIRealtimeVoiceTerminalOutcome = "completed" | "error"; @@ -7,20 +12,29 @@ export type OpenAIRealtimeVoiceConnection = Readonly<{ signal: AbortSignal; }>; -type OpenAIRealtimeVoiceLifecycleState = { +type OpenAIRealtimeVoiceIdleState = { + phase: "idle" | "terminal"; + terminalOutcome?: "completed"; +}; + +type OpenAIRealtimeVoiceConnectionState = { connection: OpenAIRealtimeVoiceConnection; controller: AbortController; - phase: OpenAIRealtimeVoiceLifecyclePhase; + phase: Exclude; retryAttempts: number; terminalOutcome?: OpenAIRealtimeVoiceTerminalOutcome; terminalNotified: boolean; }; export class OpenAIRealtimeVoiceLifecycle { - private state: OpenAIRealtimeVoiceLifecycleState | undefined; + private state: OpenAIRealtimeVoiceIdleState | OpenAIRealtimeVoiceConnectionState = { + phase: "idle", + }; connect(): OpenAIRealtimeVoiceConnection { - this.state?.controller.abort(new Error("OpenAI realtime voice connection replaced")); + if ("controller" in this.state) { + this.state.controller.abort(new Error("OpenAI realtime voice connection replaced")); + } const controller = new AbortController(); const connection = this.createConnection(controller); this.state = { @@ -72,9 +86,16 @@ export class OpenAIRealtimeVoiceLifecycle { cancel(): boolean { const state = this.state; - if (!state || state.terminalOutcome) { + if (state.phase === "terminal") { return false; } + if (state.phase === "idle") { + this.state = { + phase: "terminal", + terminalOutcome: "completed", + }; + return true; + } state.phase = "terminal"; state.terminalOutcome = "completed"; state.controller.abort(new Error("OpenAI realtime voice session canceled")); @@ -125,8 +146,8 @@ export class OpenAIRealtimeVoiceLifecycle { return this.state?.phase === "ready"; } - phase(): OpenAIRealtimeVoiceLifecyclePhase | undefined { - return this.state?.phase; + phase(): OpenAIRealtimeVoiceLifecyclePhase { + return this.state.phase; } terminalOutcome( @@ -141,7 +162,9 @@ export class OpenAIRealtimeVoiceLifecycle { private currentState( connection: OpenAIRealtimeVoiceConnection, - ): OpenAIRealtimeVoiceLifecycleState | undefined { - return this.state?.connection.id === connection.id ? this.state : undefined; + ): OpenAIRealtimeVoiceConnectionState | undefined { + return "connection" in this.state && this.state.connection.id === connection.id + ? this.state + : undefined; } } From 337a2b3fc7efb3770fe55fb35ec56fcca18569bc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:54 +0800 Subject: [PATCH 02/59] fix(openai): discard preconnect audio on close --- CHANGELOG.md | 1 + .../realtime-quicksilver-bridge.test.ts | 22 +++++++++++ .../openai/realtime-quicksilver-bridge.ts | 5 ++- .../openai/realtime-voice-provider.test.ts | 37 +++++++++++++++++++ extensions/openai/realtime-voice-provider.ts | 5 ++- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc8f587723b..3e4f39696aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **OpenAI realtime preconnect close:** discard queued Talk audio when a bridge closes before its first connection, keep repeated closes idempotent, and require an explicit fresh connect before audio can flow again. - **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. diff --git a/extensions/openai/realtime-quicksilver-bridge.test.ts b/extensions/openai/realtime-quicksilver-bridge.test.ts index 91ceca298e94..6bcb03df22a6 100644 --- a/extensions/openai/realtime-quicksilver-bridge.test.ts +++ b/extensions/openai/realtime-quicksilver-bridge.test.ts @@ -209,6 +209,28 @@ describe("OpenAIQuicksilverVoiceBridge", () => { harness.bridge.close(); }); + it("discards audio closed before the first connection and reconnects fresh", async () => { + const harness = createHarness(); + + harness.bridge.sendAudio(Buffer.from("queued-before-connect")); + harness.bridge.close(); + harness.bridge.close(); + harness.bridge.sendAudio(Buffer.from("sent-after-close")); + + expect(harness.connections).toHaveLength(0); + expect(harness.onClose).not.toHaveBeenCalled(); + + await harness.bridge.connect(); + + expect( + sentEvents(harness.socket).filter((event) => event.type === "input_audio.append"), + ).toHaveLength(0); + + harness.bridge.close(); + expect(harness.onClose).toHaveBeenCalledOnce(); + expect(harness.onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const sockets: FakeSocket[] = []; const bridge = new OpenAIQuicksilverVoiceBridge({ diff --git a/extensions/openai/realtime-quicksilver-bridge.ts b/extensions/openai/realtime-quicksilver-bridge.ts index e0dd6583d4c0..de8583cfcca1 100644 --- a/extensions/openai/realtime-quicksilver-bridge.ts +++ b/extensions/openai/realtime-quicksilver-bridge.ts @@ -361,10 +361,13 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } if (this.socket?.readyState === WEBSOCKET_OPEN) { this.sendEvent({ type: "session.close" }); } diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 701d9d32c2a1..5980daf8b2cd 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -1549,6 +1549,43 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { bridge.close(); }); + 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, + }); + + bridge.sendAudio(Buffer.from("queued-before-connect")); + bridge.close(); + bridge.close(); + bridge.sendAudio(Buffer.from("sent-after-close")); + + 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" }))); + await connecting; + + expect( + parseSent(socket).filter((event) => event.type === "input_audio_buffer.append"), + ).toHaveLength(0); + + bridge.close(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const provider = buildOpenAIRealtimeVoiceProvider(); const bridge = provider.createBridge({ diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 68f13eb388c7..c1e18f520ed4 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -734,10 +734,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } const ws = this.ws; this.ws = null; ws?.close(1000, "Bridge closed"); From b54e0049c5f4bb2850bdbcef5e79801eb90036fc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 15:56:09 +0800 Subject: [PATCH 03/59] fix(ui): bound realtime Talk PCM playback ownership --- ui/src/pages/chat/realtime-talk-audio.test.ts | 149 +++++++++++++++++- ui/src/pages/chat/realtime-talk-audio.ts | 50 +++++- 2 files changed, 190 insertions(+), 9 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-audio.test.ts b/ui/src/pages/chat/realtime-talk-audio.test.ts index a1e1ccad9e07..b0db4684e9b6 100644 --- a/ui/src/pages/chat/realtime-talk-audio.test.ts +++ b/ui/src/pages/chat/realtime-talk-audio.test.ts @@ -1,6 +1,52 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; -import { RealtimeTalkMediaStreamMeter } from "./realtime-talk-audio.ts"; +import { + bytesToBase64, + RealtimeTalkMediaStreamMeter, + RealtimeTalkPcmOutputQueue, +} from "./realtime-talk-audio.ts"; + +class MockAudioBufferSource { + buffer: unknown = null; + readonly connect = vi.fn(); + readonly start = vi.fn(); + readonly stop = vi.fn(); + private ended: (() => void) | null = null; + + addEventListener(type: string, handler: () => void): void { + if (type === "ended") { + this.ended = handler; + } + } + + emitEnded(): void { + this.ended?.(); + } +} + +class MockOutputAudioContext { + currentTime = 0; + readonly destination = {}; + readonly sources: MockAudioBufferSource[] = []; + + createBuffer(_channels: number, length: number, sampleRate: number) { + const channel = new Float32Array(length); + return { + duration: length / sampleRate, + getChannelData: () => channel, + }; + } + + createBufferSource(): MockAudioBufferSource { + const source = new MockAudioBufferSource(); + this.sources.push(source); + return source; + } +} + +function silentPcmBase64(sampleCount: number): string { + return bytesToBase64(new Uint8Array(sampleCount * 2)); +} describe("RealtimeTalkMediaStreamMeter", () => { afterEach(() => { @@ -66,3 +112,104 @@ describe("RealtimeTalkMediaStreamMeter", () => { expect(onLevel).toHaveBeenLastCalledWith(0); }); }); + +describe("RealtimeTalkPcmOutputQueue", () => { + it("preserves ordered playback while the AudioContext advances normally", () => { + const context = new MockOutputAudioContext(); + context.currentTime = 1; + const queue = new RealtimeTalkPcmOutputQueue(); + + expect(queue.play(silentPcmBase64(100), context as unknown as AudioContext, 100)).toBe( + "queued", + ); + context.currentTime = 1.5; + expect(queue.play(silentPcmBase64(50), context as unknown as AudioContext, 100)).toBe("queued"); + + expect(context.sources.map((source) => source.start.mock.calls[0]?.[0])).toEqual([1, 2]); + expect(queue.queuedUntil).toBe(2.5); + expect(queue.isPlaying).toBe(true); + }); + + it("bounds a frozen AudioContext by queued seconds before allocating another source", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + + expect(queue.play(silentPcmBase64(600), context as unknown as AudioContext, 100)).toBe( + "queued", + ); + expect(queue.play(silentPcmBase64(500), context as unknown as AudioContext, 100)).toBe( + "overflow", + ); + + expect(context.sources).toHaveLength(1); + expect(queue.queuedUntil).toBe(6); + }); + + it("rejects an oversized frame before base64 decoding", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + + expect(queue.play("!".repeat(3_000), context as unknown as AudioContext, 100)).toBe("overflow"); + expect(context.sources).toHaveLength(0); + }); + + it("hard-caps source ownership across ten thousand suspended-context chunks", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + let queued = 0; + let overflowed = 0; + + for (let index = 0; index < 10_000; index += 1) { + const result = queue.play(silentPcmBase64(1), context as unknown as AudioContext, 48_000); + if (result === "queued") { + queued += 1; + } else if (result === "overflow") { + overflowed += 1; + } + } + + expect(queued).toBe(320); + expect(overflowed).toBe(9_680); + expect(context.sources).toHaveLength(320); + }); + + it("releases source ownership on ended", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + const chunk = silentPcmBase64(1); + + for (let index = 0; index < 320; index += 1) { + expect(queue.play(chunk, context as unknown as AudioContext, 48_000)).toBe("queued"); + } + expect(queue.play(chunk, context as unknown as AudioContext, 48_000)).toBe("overflow"); + + context.sources[0]?.emitEnded(); + + expect(queue.play(chunk, context as unknown as AudioContext, 48_000)).toBe("queued"); + expect(context.sources).toHaveLength(321); + }); + + it("stops idempotently and isolates late ended events from replacement playback", () => { + const context = new MockOutputAudioContext(); + const queue = new RealtimeTalkPcmOutputQueue(); + const chunk = silentPcmBase64(100); + + expect(queue.play(chunk, context as unknown as AudioContext, 100)).toBe("queued"); + const oldSource = context.sources[0]; + context.currentTime = 0.25; + queue.stop(context as unknown as AudioContext); + queue.stop(context as unknown as AudioContext); + + expect(oldSource?.stop).toHaveBeenCalledOnce(); + expect(queue.isPlaying).toBe(false); + expect(queue.queuedUntil).toBe(0.25); + + expect(queue.play(chunk, context as unknown as AudioContext, 100)).toBe("queued"); + const replacementSource = context.sources[1]; + oldSource?.emitEnded(); + + expect(queue.isPlaying).toBe(true); + expect(queue.queuedUntil).toBe(1.25); + expect(replacementSource?.stop).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts index bdb7aef45d81..32590083661b 100644 --- a/ui/src/pages/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -214,6 +214,16 @@ function pcm16ToFloat(bytes: Uint8Array): Float32Array { return samples; } +function base64DecodedByteLength(value: string): number { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((value.length * 3) / 4) - padding); +} + +const REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS = 10; +const REALTIME_TALK_PCM_OUTPUT_MAX_SOURCES = 320; + +type RealtimeTalkPcmOutputQueuePlayResult = "queued" | "ignored" | "overflow"; + export class RealtimeTalkPcmOutputQueue { private playhead = 0; private readonly sources = new Set(); @@ -226,13 +236,34 @@ export class RealtimeTalkPcmOutputQueue { return this.sources.size > 0; } - play(base64: string, outputContext: AudioContext | null, outputSampleRateHz: number): void { + play( + base64: string, + outputContext: AudioContext | null, + outputSampleRateHz: number, + ): RealtimeTalkPcmOutputQueuePlayResult { if (!outputContext) { - return; + return "ignored"; + } + const startAt = Math.max(outputContext.currentTime, this.playhead); + const queuedSeconds = Math.max(0, startAt - outputContext.currentTime); + const remainingSeconds = REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS - queuedSeconds; + const decodedByteLength = base64DecodedByteLength(base64); + const sampleCount = Math.floor(decodedByteLength / 2); + if ( + this.sources.size >= REALTIME_TALK_PCM_OUTPUT_MAX_SOURCES || + remainingSeconds <= 0 || + sampleCount / outputSampleRateHz > remainingSeconds + ) { + return "overflow"; } const samples = pcm16ToFloat(base64ToBytes(base64)); if (samples.length === 0) { - return; + return "ignored"; + } + const duration = samples.length / outputSampleRateHz; + const nextPlayhead = startAt + duration; + if (nextPlayhead - outputContext.currentTime > REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS) { + return "overflow"; } const buffer = outputContext.createBuffer(1, samples.length, outputSampleRateHz); buffer.getChannelData(0).set(samples); @@ -241,18 +272,21 @@ export class RealtimeTalkPcmOutputQueue { source.addEventListener("ended", () => this.sources.delete(source)); source.buffer = buffer; source.connect(outputContext.destination); - const startAt = Math.max(outputContext.currentTime, this.playhead); source.start(startAt); - this.playhead = startAt + buffer.duration; + this.playhead = nextPlayhead; + return "queued"; } stop(outputContext: AudioContext | null): void { - for (const source of this.sources) { + // Release ownership first so synchronous or late `ended` events from stopped + // sources cannot affect audio queued by a replacement playback turn. + const sources = [...this.sources]; + this.sources.clear(); + this.playhead = outputContext?.currentTime ?? 0; + for (const source of sources) { try { source.stop(); } catch {} } - this.sources.clear(); - this.playhead = outputContext?.currentTime ?? 0; } } From a33ae0b05b1801e3f7bb755992504de9e5ac58b9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 15:56:12 +0800 Subject: [PATCH 04/59] fix(ui): cancel overflowing realtime Talk playback --- .../chat/realtime-talk-gateway-relay.test.ts | 94 +++++++++++++++++-- .../pages/chat/realtime-talk-gateway-relay.ts | 23 ++++- .../chat/realtime-talk-google-live.test.ts | 50 ++++++++++ .../pages/chat/realtime-talk-google-live.ts | 25 ++++- 4 files changed, 180 insertions(+), 12 deletions(-) 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 c9c206720cac..80f41d8833a1 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -26,9 +26,18 @@ const inputSinks: Array<{ disconnect: ReturnType; gain: { value: number }; }> = []; +const createdSources: MockAudioBufferSource[] = []; let getUserMedia: ReturnType; let audioCurrentTime = 0; +class MockAudioBufferSource { + buffer: unknown = null; + readonly addEventListener = vi.fn(); + readonly connect = vi.fn(); + readonly start = vi.fn(); + readonly stop = vi.fn(); +} + class MockAudioContext { get currentTime(): number { return audioCurrentTime; @@ -81,13 +90,9 @@ class MockAudioContext { } createBufferSource() { - return { - addEventListener: vi.fn(), - buffer: null, - connect: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - }; + const source = new MockAudioBufferSource(); + createdSources.push(source); + return source; } } @@ -162,6 +167,7 @@ describe("GatewayRelayRealtimeTalkTransport", () => { listeners.clear(); processors.length = 0; inputSinks.length = 0; + createdSources.length = 0; audioCurrentTime = 0; vi.stubGlobal("AudioContext", MockAudioContext); getUserMedia = vi.fn(async () => ({ @@ -181,6 +187,7 @@ describe("GatewayRelayRealtimeTalkTransport", () => { listeners.clear(); processors.length = 0; inputSinks.length = 0; + createdSources.length = 0; }); it("preserves audio processing while selecting the exact microphone", async () => { @@ -325,6 +332,79 @@ describe("GatewayRelayRealtimeTalkTransport", () => { transport.stop(); }); + it("cancels overflowing playback and ignores late audio until provider clear", async () => { + const client = createClient(); + const transport = createTransport({ client }); + + await transport.start(); + for (let index = 0; index < 321; index += 1) { + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: "AAAA", + }); + } + + await waitForFast(() => + expect(requestCallsFor(client, "talk.session.cancelOutput")).toEqual([ + [ + "talk.session.cancelOutput", + { + sessionId: "relay-1", + reason: "playback-overflow", + }, + ], + ]), + ); + expect(createdSources).toHaveLength(320); + expect(createdSources.every((source) => source.stop.mock.calls.length === 1)).toBe(true); + + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: "AAAA", + }); + expect(createdSources).toHaveLength(320); + + emitTalkEvent({ relaySessionId: "relay-1", type: "clear" }); + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: "AAAA", + }); + expect(createdSources).toHaveLength(321); + expect(createdSources.at(-1)?.start).toHaveBeenCalledOnce(); + + transport.stop(); + }); + + it("cancels provider output when the first audio chunk exceeds the time budget", async () => { + const client = createClient(); + const transport = createTransport({ client }); + + await transport.start(); + emitTalkEvent({ + relaySessionId: "relay-1", + type: "audio", + audioBase64: zeroPcmBase64(24000 * 11), + }); + + await waitForFast(() => + expect(requestCallsFor(client, "talk.session.cancelOutput")).toEqual([ + [ + "talk.session.cancelOutput", + { + sessionId: "relay-1", + reason: "playback-overflow", + }, + ], + ]), + ); + expect(createdSources).toHaveLength(0); + + transport.stop(); + }); + it("acknowledges provider marks only after the local playback queue drains", async () => { vi.useFakeTimers(); const client = createClient(); diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.ts index b71189dfedf1..b9b71775bc9c 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.ts @@ -42,6 +42,7 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport private readonly delayedToolResults = new Set(); private readonly markAckTimers = new Set(); private cancelRequestedForPlayback = false; + private playbackOverflowed = false; private pendingOutputCancellations = 0; private speechFramesDuringPlayback = 0; private lastRelayError: string | undefined; @@ -120,6 +121,7 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport this.abortConsults(); this.media?.getTracks().forEach((track) => track.stop()); this.media = null; + this.playbackOverflowed = false; this.stopOutput(); void this.inputContext?.close(); this.inputContext = null; @@ -196,13 +198,14 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport this.ctx.callbacks.onStatus?.("listening"); return; case "audio": - if (event.audioBase64) { + if (event.audioBase64 && !this.playbackOverflowed) { this.cancelRequestedForPlayback = false; this.speechFramesDuringPlayback = 0; this.playPcm16(event.audioBase64); } return; case "clear": + this.playbackOverflowed = false; this.stopOutput({ releaseDelayedToolResults: this.pendingOutputCancellations === 0 }); if (event.talkEvent?.type === "turn.cancelled") { this.abortConsults(); @@ -251,7 +254,15 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport } private playPcm16(base64: string): void { - this.outputQueue.play(base64, this.outputContext, this.session.audio.outputSampleRateHz); + const result = this.outputQueue.play( + base64, + this.outputContext, + this.session.audio.outputSampleRateHz, + ); + if (result === "overflow") { + this.playbackOverflowed = true; + this.cancelOutput("playback-overflow", false); + } } private stopOutput(options: { releaseDelayedToolResults?: boolean } = {}): void { @@ -493,7 +504,11 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport } private cancelOutputForBargeIn(): void { - if (!this.outputQueue.isPlaying || this.cancelRequestedForPlayback) { + this.cancelOutput("barge-in"); + } + + private cancelOutput(reason: string, requirePlayback = true): void { + if ((requirePlayback && !this.outputQueue.isPlaying) || this.cancelRequestedForPlayback) { return; } this.cancelRequestedForPlayback = true; @@ -505,7 +520,7 @@ export class GatewayRelayRealtimeTalkTransport implements RealtimeTalkTransport void this.ctx.client .request("talk.session.cancelOutput", { sessionId: this.session.relaySessionId, - reason: "barge-in", + reason, }) .then( () => { diff --git a/ui/src/pages/chat/realtime-talk-google-live.test.ts b/ui/src/pages/chat/realtime-talk-google-live.test.ts index 044a0339438a..f71a36e2ea5e 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.test.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.test.ts @@ -405,6 +405,56 @@ describe("GoogleLiveRealtimeTalkTransport", () => { expect(cancelledEvent?.payload).toStrictEqual({ reason: "provider-interrupted" }); }); + it("closes an overflowing playback response and ignores late provider audio", async () => { + const onStatus = vi.fn(); + const onTalkEvent = vi.fn(); + const transport = createTransport({ onStatus, onTalkEvent }); + await transport.start(); + const ws = latestWebSocket(); + + ws.emitMessage( + encodeJsonFrame({ + serverContent: { + modelTurn: { + parts: Array.from({ length: 321 }, () => ({ + inlineData: { data: "AAAA", mimeType: "audio/pcm;rate=24000" }, + })), + }, + }, + }), + ); + + await waitForFast(() => + expect(onStatus).toHaveBeenCalledWith( + "error", + "Realtime Talk playback exceeded the browser audio buffer limit", + ), + ); + expect(createdSources).toHaveLength(320); + expect(createdSources.every((source) => source.stop.mock.calls.length === 1)).toBe(true); + expect(ws.readyState).toBe(3); + expect( + onTalkEvent.mock.calls.some( + ([event]) => + event.type === "turn.cancelled" && + event.final === true && + event.payload?.reason === "playback-overflow", + ), + ).toBe(true); + + ws.emitMessage( + encodeJsonFrame({ + serverContent: { + modelTurn: { + parts: [{ inlineData: { data: "AAAA", mimeType: "audio/pcm;rate=24000" } }], + }, + }, + }), + ); + await flushMicrotasks(); + expect(createdSources).toHaveLength(320); + }); + it("emits common Talk events for Google Live transcript and audio frames", async () => { const onTranscript = vi.fn(); const onTalkEvent = vi.fn(); diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index 12b6ef7bac9b..2cb07fd784b3 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -369,7 +369,30 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { } private playPcm16(base64: string): void { - this.outputQueue.play(base64, this.outputContext, this.session.audio.outputSampleRateHz); + if (this.closed) { + return; + } + const result = this.outputQueue.play( + base64, + this.outputContext, + this.session.audio.outputSampleRateHz, + ); + if (result !== "overflow") { + return; + } + this.stopOutput(); + this.emitTalkEvent({ + type: "turn.cancelled", + final: true, + payload: { reason: "playback-overflow" }, + }); + this.ctx.callbacks.onStatus?.( + "error", + "Realtime Talk playback exceeded the browser audio buffer limit", + ); + // Google Live exposes server-driven interruption but no client response-cancel + // frame, so closing the session is the only deterministic provider-side stop. + this.stop(); } private stopOutput(): void { From 2838a2a0ea5c2437c915979ccc58c1ee44d21b51 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:58 +0800 Subject: [PATCH 05/59] fix(ui): reject oversized Talk audio before decoding --- ui/src/pages/chat/realtime-talk-audio.ts | 6 ++-- .../chat/realtime-talk-google-live.test.ts | 33 +++++++++++++++++++ .../pages/chat/realtime-talk-google-live.ts | 7 ++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts index 32590083661b..8b4511d69ecd 100644 --- a/ui/src/pages/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -9,7 +9,7 @@ export function bytesToBase64(bytes: Uint8Array): string { return btoa(binary); } -export function base64ToBytes(value: string): Uint8Array { +function base64ToBytes(value: string): Uint8Array { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { @@ -214,7 +214,7 @@ function pcm16ToFloat(bytes: Uint8Array): Float32Array { return samples; } -function base64DecodedByteLength(value: string): number { +export function estimateBase64DecodedByteLength(value: string): number { const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; return Math.max(0, Math.floor((value.length * 3) / 4) - padding); } @@ -247,7 +247,7 @@ export class RealtimeTalkPcmOutputQueue { const startAt = Math.max(outputContext.currentTime, this.playhead); const queuedSeconds = Math.max(0, startAt - outputContext.currentTime); const remainingSeconds = REALTIME_TALK_PCM_OUTPUT_MAX_QUEUED_SECONDS - queuedSeconds; - const decodedByteLength = base64DecodedByteLength(base64); + const decodedByteLength = estimateBase64DecodedByteLength(base64); const sampleCount = Math.floor(decodedByteLength / 2); if ( this.sources.size >= REALTIME_TALK_PCM_OUTPUT_MAX_SOURCES || diff --git a/ui/src/pages/chat/realtime-talk-google-live.test.ts b/ui/src/pages/chat/realtime-talk-google-live.test.ts index f71a36e2ea5e..8e122f11dbb6 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.test.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.test.ts @@ -455,6 +455,39 @@ describe("GoogleLiveRealtimeTalkTransport", () => { expect(createdSources).toHaveLength(320); }); + it("rejects an oversized first frame before decoding provider audio", async () => { + const onStatus = vi.fn(); + const transport = createTransport({ onStatus }); + await transport.start(); + const ws = latestWebSocket(); + + ws.emitMessage( + encodeJsonFrame({ + serverContent: { + modelTurn: { + parts: [ + { + inlineData: { + data: "!".repeat(700_000), + mimeType: "audio/pcm;rate=24000", + }, + }, + ], + }, + }, + }), + ); + + await waitForFast(() => + expect(onStatus).toHaveBeenCalledWith( + "error", + "Realtime Talk playback exceeded the browser audio buffer limit", + ), + ); + expect(createdSources).toHaveLength(0); + expect(ws.readyState).toBe(3); + }); + it("emits common Talk events for Google Live transcript and audio frames", async () => { const onTranscript = vi.fn(); const onTalkEvent = vi.fn(); diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index 2cb07fd784b3..34c6efd0dd6c 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -1,8 +1,8 @@ // Control UI chat module implements realtime talk google live behavior. import { REALTIME_VOICE_DESCRIBE_VIEW_TOOL_NAME } from "../../../../src/talk/describe-view-tool.js"; import { - base64ToBytes, bytesToBase64, + estimateBase64DecodedByteLength, floatToPcm16, RealtimeTalkMediaStreamMeter, RealtimeTalkPcmInputPump, @@ -340,11 +340,14 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { this.emitTalkEvent({ type: "output.audio.delta", payload: { - byteLength: base64ToBytes(part.inlineData.data).byteLength, + byteLength: estimateBase64DecodedByteLength(part.inlineData.data), mimeType: part.inlineData.mimeType, }, }); this.playPcm16(part.inlineData.data); + if (this.closed) { + return; + } } else if (!part.thought && typeof part.text === "string" && part.text.trim()) { this.ctx.callbacks.onTranscript?.({ role: "assistant", From 07a352ac95681f036853eaa32215d6f54aec4c70 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 17:10:40 +0800 Subject: [PATCH 06/59] fix(openai): narrow idle lifecycle cancellation --- extensions/openai/realtime-voice-lifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/openai/realtime-voice-lifecycle.ts b/extensions/openai/realtime-voice-lifecycle.ts index 5f136ca16587..1bfe25ef5556 100644 --- a/extensions/openai/realtime-voice-lifecycle.ts +++ b/extensions/openai/realtime-voice-lifecycle.ts @@ -89,7 +89,7 @@ export class OpenAIRealtimeVoiceLifecycle { if (state.phase === "terminal") { return false; } - if (state.phase === "idle") { + if (!("controller" in state)) { this.state = { phase: "terminal", terminalOutcome: "completed", From b5a4f6e836b13710288214447d157c50cbbae379 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 17:50:27 +0800 Subject: [PATCH 07/59] chore(openai): drop release-owned changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4f39696aec..7fc8f587723b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,6 @@ Docs: https://docs.openclaw.ai ### Fixes -- **OpenAI realtime preconnect close:** discard queued Talk audio when a bridge closes before its first connection, keep repeated closes idempotent, and require an explicit fresh connect before audio can flow again. - **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. From 44ce19821fbced4382d2956fa379dc61d87b3ced Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:09:17 +0800 Subject: [PATCH 08/59] fix(ci): retire Kova runtime deps coverage (#116760) --- .github/workflows/openclaw-performance.yml | 4 ++-- test/scripts/openclaw-performance-workflow.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index f533c7dde338..85acf3351cad 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -154,8 +154,8 @@ jobs: deep_profile: "false" live: "false" managed_service: "true" - include_filters: "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:bundled-runtime-deps,scenario:agent-cold-warm-message" - expected_release_entries: "fresh-install:fresh,fresh-install:onboarded-user,bundled-runtime-deps:missing-plugin-index,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins" + include_filters: "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:agent-cold-warm-message" + expected_release_entries: "fresh-install:fresh,fresh-install:onboarded-user,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins" - lane: mock-deep-profile title: Kova mock provider deep profile auth: mock diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 4134c584a91c..753fcd06801c 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -729,7 +729,7 @@ esac const expectedReleaseEntries = matrixEntries.map((entry) => entry.expected_release_entries); expect(includeFilters).toEqual([ - "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:bundled-runtime-deps,scenario:agent-cold-warm-message", + "scenario:fresh-install,scenario:gateway-performance,scenario:bundled-plugin-startup,scenario:agent-cold-warm-message", "scenario:fresh-install,scenario:gateway-performance,scenario:agent-cold-warm-message", "scenario:agent-cold-warm-message", ]); @@ -742,7 +742,7 @@ esac expect(runKova.run).toContain('--include "$INCLUDE_FILTERS"'); expect(runKova.run).not.toContain("for filter in $INCLUDE_FILTERS"); expect(expectedReleaseEntries).toEqual([ - "fresh-install:fresh,fresh-install:onboarded-user,bundled-runtime-deps:missing-plugin-index,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", + "fresh-install:fresh,fresh-install:onboarded-user,bundled-plugin-startup:fresh,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", "fresh-install:fresh,fresh-install:onboarded-user,agent-cold-warm-message:mock-openai-provider,gateway-performance:many-bundled-plugins", "agent-cold-warm-message:mock-openai-provider", ]); From b252df88494e0bf1b5ea882fa7c74c1f786874e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:09:57 -0700 Subject: [PATCH 09/59] docs(skill): isolate autonomous issue sweep worktrees --- .../openclaw-autonomous-issue-sweep/SKILL.md | 83 +++++++++++++------ 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md index 9ce683ef9957..6f2e2c3e4344 100644 --- a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md +++ b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md @@ -1,6 +1,6 @@ --- name: openclaw-autonomous-issue-sweep -description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest; find existing PRs, deeply investigate bugs, simplify or refactor, live-test, independently review, land verified fixes, close already-fixed issues, and add only meaningful new evidence." +description: "Orchestrate 64 autonomous OpenClaw issue workers newest-to-oldest with isolated issue worktrees and resource-bounded parallelism; investigate bugs, simplify or refactor, review, land verified fixes, close already-fixed issues, and add meaningful evidence." --- # OpenClaw Autonomous Issue Sweep @@ -17,9 +17,11 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. - Use full-history forks so every subagent inherits the orchestrator's model and **xhigh reasoning effort**. Never print, record, or disclose model identifiers; redact subprocess banners and diagnostics before reporting. -- Treat a request to run this workflow as authority to review, fix, refactor, - commit, push, create/update PRs, land eligible changes, comment, and close - issues individually. Do not ask for routine confirmation again. +- Treat a request to run this workflow as authority to create lightweight, + issue-scoped isolated Git worktrees and `codex/issue-` branches, review, + fix, refactor, commit, push, create/update PRs, land eligible changes, + comment, and close issues individually. Do not ask for separate worktree or + routine-operation confirmation again. - Never treat sweep authority as permission to publish releases, bump protocol or SQLite schema versions, weaken security, break shipped compatibility, change another owner's protected product surface, or execute untrusted code @@ -36,30 +38,52 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. ## Coordinate 64 workers safely 1. Assign one subagent to maintain the live open-issue queue in descending - `createdAt` order, one to coordinate landing/proof capacity, and the rest to - issue investigations. Coordinator agents also investigate when idle. + `createdAt` order, one to coordinate landing/proof capacity, and no more + than **3** to live issue closures or other GitHub mutations. Assign the + remaining slots to issue investigations; idle coordinators also investigate. 2. Claim issues from the newest unclaimed end only; replenish workers as they finish. Parallel completions may arrive out of order, but never knowingly start an older unclaimed issue ahead of a newer available issue. 3. Deduplicate by canonical root cause, not merely by issue number. Let one owner fix a shared defect and link related issues/PRs to that outcome. -4. Freeze the reviewed source SHA for each wave. Designate a single fetch owner; - pause shared-ref refreshes while repo-native PR prepare/merge runs. -5. Never switch a shared checkout branch or edit it while sibling agents use it. - Use an existing agent-owned checkout, a repo-native isolated PR worktree, or - an explicitly user-authorized new worktree. Otherwise serialize write - access; parallel read-only investigations may continue. -6. Sample checkout/temp-volume free disk, CPU/load, memory pressure, process - count, operator-gateway health, actual worker count, and Octopool capacity - before each wave and periodically thereafter. Throttle expensive work for - sustained pressure or low disk; never kill unrelated operator processes. -7. Serialize merge operations and each Testbox lease. A lease has one owner and - one active command; never reclaim, sync, or change its head during a run. +4. Freeze the reviewed source SHA for each wave. Serialize only shared Git/ref + mutations: fetches, branch/ref changes, `git worktree add`/remove, PR + preparation and merges, and main-targeted pushes. Give each mutation a brief + coordinator-owned exclusive slot; do not hold it across coding, proof, + reviews, remote waits, or other independent issue work. +5. Give every independent root-cause fix its own isolated, issue-scoped + lightweight worktree and `codex/issue-` branch. Create it from the + frozen SHA, for example: + + ```bash + git worktree add -b "codex/issue-$issue_id" \ + "$campaign_worktrees/issue-$issue_id" "$frozen_main_sha" + ``` + + Reuse a repo-native isolated PR worktree when repairing an existing PR; + duplicate issues sharing one root cause share its single owner/worktree. + Share Git objects; do not clone the repository or install dependencies per + worktree merely for isolation. Never edit, switch, reset, or otherwise + mutate the shared checkout while sibling workers are active. Once isolated + worktrees exist, independent issue owners edit, inspect, and verify in + parallel within their own checkout. +6. Keep all **64** inherited high-effort agents available, but distinguish idle + agents from active local tool users. Start with bounded waves of **4–8** + concurrently active code/test workers and continuously reduce or expand that + limit according to usable CPU/load, memory/swap pressure, checkout and temp + free disk, process count, operator-gateway health, and remote-pool capacity. + Reserve capacity for the operator; count heavyweight proof proportionally, + stop admitting new commands under sustained pressure, and resume in small + waves after recovery. Never kill unrelated operator processes. +7. Serialize merges and each Testbox lease, not independent worktree edits. A + lease has one owner and one active command; never reclaim, sync, or change + its head during a run. 8. Respect GitHub rate limits, active assignees, repository ownership, and existing contributor work. Do not auto-assign broad-discovery candidates. 9. Replace finished workers while the queue remains. Record actual active, - completed, failed, fixed, landed, closed, commented, and skipped counts; - never report launched or finished workers as still running. + parked, completed, failed, fixed, landed, verified-closed, queued-for-close, + commented, and skipped counts. Persist that campaign checkpoint for resumed + workers; never report launched, parked, or finished workers as still running. ## Conserve GitHub capacity and host resources @@ -79,10 +103,17 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. merge decisions, or a stale/contradictory cached result. Rate-limit and deduplicate worker requests instead of having 64 agents independently fetch the same issue, PR, author profile, or CI rollup. -- Keep disk, load, memory pressure, active lease IDs, provider trust class, - checkout ownership, and pool capacity in the orchestration ledger. Slow new - assignments, serialize builds/tests, clean only campaign-owned artifacts, - and offload heavy proof before resource pressure threatens the host. +- Keep disk, CPU/load, memory pressure, active lease IDs, provider trust class, + issue-worktree ownership, active local tool count, frozen heads, and pool + capacity in the orchestration ledger. Dynamically cap concurrent code/test + workers instead of serializing every independent fix. Pause or interrupt only + campaign-owned work under host pressure, preserve each issue's claim and + isolated checkout, then resume from that recorded state when capacity returns. + Offload heavy proof before resource pressure threatens the host. +- Worktree checkout and dependency use must respect free-disk headroom. Reuse + shared Git objects and existing trusted dependency installs where safe; route + dependency-missing or heavyweight proof to the selected remote box instead + of multiplying local installs across issue checkouts. - The parent may prewarm a trusted Crabbox/Testbox lease when a concrete heavy proof is imminent, then hand its verified lease ID and checkout ownership to one subagent at a time. Avoid speculative fleets, respect path-scoped lease @@ -235,6 +266,10 @@ moves:` item with real evidence or an explicit reason for skipping it. - Recheck live state immediately before every mutation; avoid redundant, speculative, noisy, or duplicate comments. Handle closures individually and follow repository limits on bulk operations. +- After verifying the canonical landed SHA and preserving contributor credit, + remove only that campaign-owned isolated worktree during a brief serialized + Git mutation slot. Delete its campaign-owned branch only when no unlanded + work depends on it; never prune unrelated worktrees, refs, or user files. ## Parent-thread reporting From f9207db3ca957d77efe293dddbeaeb8cb122ed40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:10:54 -0700 Subject: [PATCH 10/59] fix(bedrock): reject truncated streams and preserve audio results (#116743) Co-authored-by: Peter Steinberger --- .../amazon-bedrock/stream.runtime.test.ts | 123 ++++++++++++++++++ extensions/amazon-bedrock/stream.runtime.ts | 9 +- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts index 114bbd539c39..cf5499377ac8 100644 --- a/extensions/amazon-bedrock/stream.runtime.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -123,6 +123,74 @@ describe("Bedrock inbound image base64", () => { }); describe("Bedrock tool-result replay", () => { + it("replays unsupported audio attachments as their canonical text placeholder", () => { + const messages = testing.convertMessages( + { + messages: [ + { + role: "toolResult", + toolCallId: "call_audio", + toolName: "listen", + content: [{ type: "audio", mimeType: "audio/wav", data: "YXVkaW8=" }], + isError: false, + }, + ], + } as never, + bedrockModel({ input: ["text", "image"] }), + "none", + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: ConversationRole.USER, + content: [ + { + toolResult: { + toolUseId: "call_audio", + content: [{ text: "(see attached audio)" }], + }, + }, + ], + }); + }); + + it("preserves valid text and image attachments alongside unsupported audio", () => { + const messages = testing.convertMessages( + { + messages: [ + { + role: "toolResult", + toolCallId: "call_media", + toolName: "inspect", + content: [ + { type: "audio", mimeType: "audio/wav", data: "YXVkaW8=" }, + { type: "text", text: "actual tool output" }, + { type: "image", mimeType: "image/png", data: "aW1hZ2U=" }, + ], + isError: false, + }, + ], + } as never, + bedrockModel({ input: ["text", "image"] }), + "none", + ); + + expect(messages[0]).toMatchObject({ + role: ConversationRole.USER, + content: [ + { + toolResult: { + toolUseId: "call_media", + content: [ + { text: "actual tool output" }, + { image: { format: "png", source: { bytes: expect.any(Uint8Array) } } }, + ], + }, + }, + ], + }); + }); + it("drops payload-less image husks from consecutive tool results", () => { const messages = testing.convertMessages( { @@ -335,6 +403,61 @@ describe("Bedrock profile endpoint resolution", () => { }); describe("Bedrock stop reasons", () => { + it.each([ + { + name: "text", + events: [ + { contentBlockDelta: { contentBlockIndex: 0, delta: { text: "truncated response" } } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + ], + contentType: "text", + }, + { + name: "tool call", + events: [ + { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "call_lookup", name: "lookup" } }, + }, + }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"query":"partial"}' } }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + ], + contentType: "toolCall", + }, + ])( + "reports truncated $name streams without a terminal messageStop", + async ({ events, contentType }) => { + vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + stream: streamEvents([{ messageStart: { role: ConversationRole.ASSISTANT } }, ...events]), + } as never); + + const stream = streamBedrockForTest(bedrockModel({}), { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], + } as never); + const eventTypes: string[] = []; + for await (const event of stream) { + eventTypes.push(event.type); + } + const result = await stream.result(); + + expect(eventTypes.at(-1)).toBe("error"); + expect(eventTypes).not.toContain("done"); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("Bedrock stream ended before messageStop"); + expect(result.content).toEqual([expect.objectContaining({ type: contentType })]); + expect(result.content[0]).not.toHaveProperty("index"); + expect(result.content[0]).not.toHaveProperty("partialJson"); + }, + ); + it.each([ BedrockStopReason.CONTENT_FILTERED, BedrockStopReason.GUARDRAIL_INTERVENED, diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index 2137af2981c9..aa12bbc0ed7f 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -332,7 +332,7 @@ const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = } } - if (refusalBuffer && !sawMessageStop) { + if (!sawMessageStop) { throw new Error("Bedrock stream ended before messageStop"); } if (options.signal?.aborted) { @@ -812,7 +812,7 @@ function createBedrockToolResult(message: ToolResultMessage): ContentBlock.ToolR content.push({ text: sanitizeSurrogates(block.text) }); continue; } - if (describeToolResultMediaPlaceholder([block])) { + if (block.type === "image" && describeToolResultMediaPlaceholder([block])) { content.push({ image: createImageBlock(block.mimeType, block.data) }); } } @@ -820,7 +820,10 @@ function createBedrockToolResult(message: ToolResultMessage): ContentBlock.ToolR return { toolResult: { toolUseId: message.toolCallId, - content: content.length > 0 ? content : [{ text: "(no output)" }], + content: + content.length > 0 + ? content + : [{ text: describeToolResultMediaPlaceholder(message.content) ?? "(no output)" }], status: message.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, }, }; From 7ea4129227146118096fea4d0706504bef6ad6ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:12:48 -0700 Subject: [PATCH 11/59] fix(qa): authenticate summaries and bound native scenario commands (#116748) Co-authored-by: Peter Steinberger --- extensions/qa-lab/src/cli.runtime.test.ts | 122 +++++++++++++++--- extensions/qa-lab/src/cli.runtime.ts | 52 ++++---- .../telegram/cli.runtime.test.ts | 43 +++++- .../live-transports/telegram/cli.runtime.ts | 13 +- extensions/qa-lab/src/suite-summary.test.ts | 54 ++++++++ extensions/qa-lab/src/suite-summary.ts | 13 +- .../src/test-file-scenario-runner.test.ts | 76 ++++++++++- .../qa-lab/src/test-file-scenario-runner.ts | 4 +- 8 files changed, 320 insertions(+), 57 deletions(-) diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index bd0eac4708f4..f0a7392ef79d 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -314,12 +314,12 @@ describe("qa cli runtime", () => { watchUrl: "http://127.0.0.1:43124", }); runQaMultipass.mockResolvedValue({ - outputDir: "/tmp/multipass", - reportPath: "/tmp/multipass/qa-suite-report.md", - summaryPath: "/tmp/multipass/qa-suite-summary.json", - hostLogPath: "/tmp/multipass/multipass-host.log", - bootstrapLogPath: "/tmp/multipass/multipass-guest-bootstrap.log", - guestScriptPath: "/tmp/multipass/multipass-guest-run.sh", + outputDir: suiteArtifactsDir, + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + hostLogPath: path.join(suiteArtifactsDir, "multipass-host.log"), + bootstrapLogPath: path.join(suiteArtifactsDir, "multipass-guest-bootstrap.log"), + guestScriptPath: path.join(suiteArtifactsDir, "multipass-guest-run.sh"), vmName: "openclaw-qa-test", scenarioIds: ["channel-chat-baseline"], }); @@ -464,9 +464,7 @@ describe("qa cli runtime", () => { } }); - it("keeps direct-suite zero-work validation disabled with --allow-failures", async () => { - const priorExitCode = process.exitCode; - process.exitCode = undefined; + it("rejects direct-suite zero-work summaries even with --allow-failures", async () => { const optionalScenario = { name: "Runtime tool fixture — image_generate", status: "skip" as const, @@ -490,14 +488,108 @@ describe("qa cli runtime", () => { }), ); - try { - await runQaSuiteCommand({ repoRoot: "/tmp/openclaw-repo", allowFailures: true }); - expect(process.exitCode).toBeUndefined(); - } finally { - process.exitCode = priorExitCode; - } + await expect( + runQaSuiteCommand({ repoRoot: "/tmp/openclaw-repo", allowFailures: true }), + ).rejects.toThrow("did not include any executed scenarios"); }); + it.each([ + { runner: "host" as const, summary: "missing" as const, expected: "Could not read QA summary" }, + { + runner: "host" as const, + summary: "malformed" as const, + expected: "Could not parse QA summary", + }, + { + runner: "multipass" as const, + summary: "missing" as const, + expected: "Could not read QA summary", + }, + { + runner: "multipass" as const, + summary: "malformed" as const, + expected: "Could not parse QA summary", + }, + { + runner: "multipass" as const, + summary: "zero-work" as const, + expected: "did not include any executed scenarios", + }, + ...(["host", "flow", "multipass"] as const).flatMap((runner) => [ + { + runner, + summary: "required-skip" as const, + expected: "did not include any executed scenarios", + }, + { + runner, + summary: "blocked" as const, + expected: "did not include any executed scenarios", + }, + ]), + ])( + "rejects $summary $runner summaries even with --allow-failures", + async ({ runner, summary, expected }) => { + if (summary === "missing") { + await fs.rm(suiteSummaryPath); + } else if (summary === "malformed") { + await fs.writeFile(suiteSummaryPath, "{not-json", "utf8"); + } else if (summary === "zero-work") { + await fs.writeFile( + suiteSummaryPath, + JSON.stringify({ + counts: { total: 0, passed: 0, failed: 0, skipped: 0 }, + scenarios: [], + }), + "utf8", + ); + } else { + await fs.writeFile( + suiteSummaryPath, + JSON.stringify({ + counts: { + total: 1, + passed: 0, + failed: 0, + skipped: summary === "required-skip" ? 1 : 0, + }, + scenarios: [ + { + name: "Required channel scenario", + status: summary === "required-skip" ? "skip" : "blocked", + details: "Required transport unavailable", + }, + ], + }), + "utf8", + ); + } + if (runner === "host" || runner === "flow") { + runQaSuite.mockResolvedValueOnce( + runner === "flow" + ? flowSuiteRuntimeResult({ + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + }) + : unifiedSuiteRuntimeResult({ + outputDir: suiteArtifactsDir, + reportPath: suiteReportPath, + summaryPath: suiteSummaryPath, + evidencePath: suiteEvidencePath, + }), + ); + } + + await expect( + runQaSuiteCommand({ + repoRoot: "/tmp/openclaw-repo", + ...(runner === "multipass" ? { runner } : {}), + allowFailures: true, + }), + ).rejects.toThrow(expected); + }, + ); + it("rejects host-only resource options for Playwright scenarios", async () => { await expect( runQaSuiteCommand({ diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index 8168ca123e77..5ab0fe1b0a45 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -368,6 +368,7 @@ async function runQaParityPreflight(params: { process.stdout.write(`QA parity preflight summary: ${result.summaryPath}\n`); const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( result.summaryPath, + { requireExecutedScenario: params.allowFailures === true }, ); if (blockingScenarioCount > 0) { if (params.allowFailures === true) { @@ -978,19 +979,18 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { process.stdout.write(`QA Multipass summary: ${result.summaryPath}\n`); process.stdout.write(`QA Multipass host log: ${result.hostLogPath}\n`); process.stdout.write(`QA Multipass bootstrap log: ${result.bootstrapLogPath}\n`); - if (!allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - { - optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ - scenarioIds, - explicitScenarioSelection: opts.explicitScenarioSelection, - }), - }, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { + optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ + scenarioIds, + explicitScenarioSelection: opts.explicitScenarioSelection, + }), + requireExecutedScenario: allowFailures, + }, + ); + if (!allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } @@ -1051,19 +1051,18 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { process.stdout.write(`QA suite report: ${result.reportPath}\n`); process.stdout.write(`QA suite evidence: ${result.evidencePath}\n`); process.stdout.write(`QA suite summary: ${result.summaryPath}\n`); - if (!allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - { - optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ - scenarioIds, - explicitScenarioSelection: opts.explicitScenarioSelection, - }), - }, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { + optionalScenarioNames: resolveQaReportOnlyOptionalScenarioNames({ + scenarioIds, + explicitScenarioSelection: opts.explicitScenarioSelection, + }), + requireExecutedScenario: allowFailures, + }, + ); + if (!allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } @@ -1080,6 +1079,7 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) { scenarioIds, explicitScenarioSelection: opts.explicitScenarioSelection, }), + requireExecutedScenario: allowFailures, }, ); if (!allowFailures && blockingScenarioCount > 0) { diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts index 39bb142fb031..c5a71ff2257b 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.test.ts @@ -60,6 +60,8 @@ describe("Telegram live QA scenario gate", () => { summaryPath, JSON.stringify({ counts: { + total: 1, + passed: status === "pass" ? 1 : 0, failed: status === "fail" ? 1 : 0, skipped: status === "skip" || status === "skipped" ? 1 : 0, }, @@ -76,6 +78,7 @@ describe("Telegram live QA scenario gate", () => { delete process.env[SUT_COMMAND_ENV]; tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-qa-telegram-gate-")); summaryPath = path.join(tempRoot, "qa-suite-summary.json"); + writeSummary("pass"); mocks.resolveTelegramQaScenarioIds.mockReturnValue(["channel-canary"]); mocks.runQaFlowSuiteFromRuntime.mockResolvedValue({ reportPath: ".artifacts/qa-e2e/telegram/qa-suite-report.md", @@ -121,7 +124,8 @@ describe("Telegram live QA scenario gate", () => { expect(process.exitCode).toBeUndefined(); }); - it("does not read the summary when failures are explicitly allowed", async () => { + it("permits genuinely executed failed scenarios when failures are explicitly allowed", async () => { + writeSummary("fail"); await runQaTelegramSuite({ repoRoot: "/repo", providerMode: "mock-openai", @@ -131,6 +135,43 @@ describe("Telegram live QA scenario gate", () => { expect(process.exitCode).toBeUndefined(); }); + it.each([ + { summary: "missing", expected: "Could not read QA summary" }, + { summary: "malformed", expected: "Could not parse QA summary" }, + { summary: "zero-work", expected: "did not include any executed scenarios" }, + { summary: "required-skip", expected: "did not include any executed scenarios" }, + { summary: "blocked", expected: "did not include any executed scenarios" }, + ])( + "rejects $summary Telegram summaries even with --allow-failures", + async ({ summary, expected }) => { + if (summary === "missing") { + rmSync(summaryPath); + } else if (summary === "malformed") { + writeFileSync(summaryPath, "{not-json", "utf8"); + } else if (summary === "zero-work") { + writeFileSync( + summaryPath, + JSON.stringify({ + counts: { total: 0, passed: 0, failed: 0, skipped: 0 }, + scenarios: [], + }), + "utf8", + ); + } else { + writeSummary(summary === "required-skip" ? "skip" : "blocked"); + } + + await expect( + runQaTelegramSuite({ + repoRoot: "/repo", + providerMode: "mock-openai", + allowFailures: true, + }), + ).rejects.toThrow(expected); + expect(process.exitCode).toBeUndefined(); + }, + ); + it("lists only scenarios accepted by its flow runner", async () => { const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); mocks.listTelegramQaScenarios.mockReturnValue([ diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts index 99011ebab2bf..4754c1d7eed1 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts @@ -199,13 +199,12 @@ export async function runQaTelegramSuite(opts: TelegramQaSuiteOptions) { report: result.reportPath, summary: result.summaryPath, }); - if (!runOptions.allowFailures) { - const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( - result.summaryPath, - ); - if (blockingScenarioCount > 0) { - process.exitCode = 1; - } + const blockingScenarioCount = await readQaSuiteFailedOrSkippedScenarioCountFromFile( + result.summaryPath, + { requireExecutedScenario: runOptions.allowFailures === true }, + ); + if (!runOptions.allowFailures && blockingScenarioCount > 0) { + process.exitCode = 1; } return result; } diff --git a/extensions/qa-lab/src/suite-summary.test.ts b/extensions/qa-lab/src/suite-summary.test.ts index cb32550a119f..9abcbf5fc279 100644 --- a/extensions/qa-lab/src/suite-summary.test.ts +++ b/extensions/qa-lab/src/suite-summary.test.ts @@ -94,6 +94,60 @@ describe("qa suite summary helpers", () => { ).resolves.toBe(1); }); + it.each([ + { + name: "required skip", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 1 }, + scenarios: [{ name: "required scenario", status: "skip" }], + }, + }, + { + name: "required skipped", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 1 }, + scenarios: [{ name: "required scenario", status: "skipped" }], + }, + }, + { + name: "blocked scenario", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 0 }, + scenarios: [{ name: "required scenario", status: "blocked" }], + }, + }, + { + name: "blocked evidence", + summary: { + counts: { total: 1, passed: 0, failed: 0, skipped: 0 }, + entries: [{ result: { status: "blocked" } }], + }, + }, + ])("requires a completed scenario before tolerating $name", async ({ summary }) => { + await expect( + readSummary(summary, (summaryPath) => + readQaSuiteFailedOrSkippedScenarioCountFromFile(summaryPath, { + requireExecutedScenario: true, + }), + ), + ).rejects.toThrow("did not include any executed scenarios"); + }); + + it("still permits a genuinely executed failed scenario in failure-tolerant gates", async () => { + await expect( + readSummary( + { + counts: { total: 1, passed: 0, failed: 1, skipped: 0 }, + scenarios: [{ name: "required scenario", status: "fail" }], + }, + (summaryPath) => + readQaSuiteFailedOrSkippedScenarioCountFromFile(summaryPath, { + requireExecutedScenario: true, + }), + ), + ).resolves.toBe(1); + }); + it("rejects a suite containing only catalog-confirmed report-only skips", async () => { await expect( readSummary( diff --git a/extensions/qa-lab/src/suite-summary.ts b/extensions/qa-lab/src/suite-summary.ts index 22a873b93bcb..25c7e44d0a08 100644 --- a/extensions/qa-lab/src/suite-summary.ts +++ b/extensions/qa-lab/src/suite-summary.ts @@ -118,6 +118,7 @@ function assertQaSuiteSummaryHasExecutedScenarios( summaryPath: string, errorCode: "summary_failure_count_missing" | "summary_blocking_count_missing", optionalScenarioNames?: ReadonlySet, + requireExecutedScenario = false, ): void { if (!summary || typeof summary !== "object") { return; @@ -137,14 +138,15 @@ function assertQaSuiteSummaryHasExecutedScenarios( const entries = Array.isArray(payload.entries) ? (payload.entries as QaEvidenceEntryStatus[]) : undefined; - const hasExecutedScenario = + const hasCompletedScenario = scenarios?.some((scenario) => scenario.status === "pass" || scenario.status === "fail") === true || entries?.some((entry) => entry.result?.status === "pass" || entry.result?.status === "fail") === true || (passed ?? 0) > 0 || - (failed ?? 0) > 0 || - (total !== null && total > 0 && (skipped === null || total > skipped)); + (failed ?? 0) > 0; + const hasExecutedScenario = + hasCompletedScenario || (total !== null && total > 0 && (skipped === null || total > skipped)); const hasBlockingNonOptionalSkip = errorCode === "summary_blocking_count_missing" && scenarios?.some( @@ -169,6 +171,8 @@ function assertQaSuiteSummaryHasExecutedScenarios( if ( total === 0 || scenarios?.length === 0 || + // A tolerated blocking result cannot authenticate a campaign that never completed a scenario. + (requireExecutedScenario && !hasCompletedScenario) || (!hasExecutedScenario && !hasBlockingUnknownOrFailedScenario && !hasBlockingNonOptionalSkip && @@ -309,7 +313,7 @@ export async function readQaSuiteFailedScenarioCountFromFile(summaryPath: string export async function readQaSuiteFailedOrSkippedScenarioCountFromFile( summaryPath: string, - options?: { optionalScenarioNames?: ReadonlySet }, + options?: { optionalScenarioNames?: ReadonlySet; requireExecutedScenario?: boolean }, ): Promise { const payload = await readQaSuiteSummaryFile(summaryPath); assertQaSuiteSummaryHasExecutedScenarios( @@ -317,6 +321,7 @@ export async function readQaSuiteFailedOrSkippedScenarioCountFromFile( summaryPath, "summary_blocking_count_missing", options?.optionalScenarioNames, + options?.requireExecutedScenario, ); const blockingScenarioCount = readQaSuiteFailedOrSkippedScenarioCountFromSummary(payload); if (blockingScenarioCount !== null) { diff --git a/extensions/qa-lab/src/test-file-scenario-runner.test.ts b/extensions/qa-lab/src/test-file-scenario-runner.test.ts index c7bc85230128..2daa843720fc 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.test.ts @@ -258,7 +258,7 @@ describe("qa test file scenario runner", () => { "sends a chat turn through the GUI", ], ]); - expect(commands.map((command) => command.timeoutMs)).toEqual([undefined, undefined]); + expect(commands.map((command) => command.timeoutMs)).toEqual([1_800_000, 1_800_000]); const evidence = validateQaEvidenceSummaryJson( JSON.parse(await fs.readFile(result.evidencePath, "utf8")), ); @@ -366,7 +366,7 @@ describe("qa test file scenario runner", () => { )}`, ], ]); - expect(commands.map((command) => command.timeoutMs)).toEqual([undefined]); + expect(commands.map((command) => command.timeoutMs)).toEqual([1_800_000]); const evidence = validateQaEvidenceSummaryJson( JSON.parse(await fs.readFile(result.evidencePath, "utf8")), ); @@ -885,6 +885,78 @@ describe("qa test file scenario runner", () => { expect(commands.map((command) => command.timeoutMs)).toEqual([3 * 60 * 60_000]); }); + it.each([ + { executionKind: "vitest" as const, commandCount: 1 }, + { executionKind: "playwright" as const, commandCount: 2 }, + ])( + "applies the resolved command timeout to every $executionKind subprocess", + async ({ commandCount, executionKind }) => { + const repoRoot = await makeTempRepo(`qa-${executionKind}-command-timeout-`); + const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", `scenario-${executionKind}`); + const commands: QaScenarioCommandExecution[] = []; + + await runQaTestFileScenarios({ + repoRoot, + outputDir, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + scenarios: [ + makeTestFileScenario( + executionKind, + executionKind === "playwright" + ? "ui/src/e2e/chat-flow.e2e.test.ts" + : "extensions/qa-lab/src/coverage-report.test.ts", + ), + ], + commandTimeoutMs: 321, + runCommand: async (command) => { + commands.push(command); + await writeNativeVitestReport(command, { passed: 1 }); + return { exitCode: 0, stdout: "native pass\n", stderr: "" }; + }, + }); + + expect(commands).toHaveLength(commandCount); + expect(commands.map((command) => command.timeoutMs)).toEqual( + Array.from({ length: commandCount }, () => 321), + ); + }, + ); + + it.each(["vitest", "playwright"] as const)( + "terminates a hanging $executionKind subprocess with failure evidence", + async (executionKind) => { + const repoRoot = await makeTempRepo(`qa-${executionKind}-hung-command-`); + const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", `scenario-${executionKind}`); + const result = await runQaTestFileScenarios({ + repoRoot, + outputDir, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + scenarios: [ + makeTestFileScenario( + executionKind, + executionKind === "playwright" + ? "ui/src/e2e/chat-flow.e2e.test.ts" + : "extensions/qa-lab/src/coverage-report.test.ts", + ), + ], + commandTimeoutMs: 100, + runCommand: (execution) => + runQaScenarioCommandLifecycle({ + ...execution, + args: ["-e", "setInterval(() => {}, 1_000)"], + }), + }); + + expect(result.results[0]).toMatchObject({ + failureMessage: expect.stringContaining("timed out after 100ms"), + status: "fail", + }); + expect(result.evidence.entries[0]?.result.status).toBe("fail"); + }, + ); + describe.skipIf(process.platform === "win32")("script timeout process groups", () => { const commandTimeoutMs = 1_500; let descendantPid: number | undefined; diff --git a/extensions/qa-lab/src/test-file-scenario-runner.ts b/extensions/qa-lab/src/test-file-scenario-runner.ts index 152ba80291e4..5b6ae5264665 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.ts @@ -295,13 +295,13 @@ async function runScenarioCommandSteps(params: { const timeoutMs = params.scenario.execution.kind === "script" ? (params.scenario.execution.timeoutMs ?? params.commandTimeoutMs) - : undefined; + : params.commandTimeoutMs; const result = await params.runCommand({ command: step.command, args: step.args, cwd: params.repoRoot, env: params.env, - ...(timeoutMs === undefined ? {} : { timeoutMs }), + timeoutMs, }); if (result.stdout) { logChunks.push(result.stdout); From 454bf5ccd7eb7bae652bf043f2809359c27c9186 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:16:06 -0700 Subject: [PATCH 12/59] fix(cli): reject dangling config path escapes (#116738) Co-authored-by: Peter Steinberger --- src/cli/config-cli-path.ts | 5 +++-- src/cli/config-cli.test.ts | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/cli/config-cli-path.ts b/src/cli/config-cli-path.ts index 86fb16ee15e4..9af8986610c9 100644 --- a/src/cli/config-cli-path.ts +++ b/src/cli/config-cli-path.ts @@ -69,9 +69,10 @@ function parsePath(raw: string): PathSegment[] { const ch = trimmed[i]; if (ch === "\\") { const next = trimmed[i + 1]; - if (next) { - current += next; + if (next === undefined) { + throw new Error(`Invalid path (trailing escape): ${raw}`); } + current += next; i += 2; continue; } diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 8d00c3f9d0db..857f123b8812 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -3346,6 +3346,31 @@ describe("config cli", () => { args: ["config", "set", "gateway.[port]", "23456"], error: "Invalid path (empty segment): gateway.[port]", }, + { + name: "rejects a trailing escape for config get before reading another key", + args: ["config", "get", "gateway.port\\"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for config set before writing another key", + args: ["config", "set", "gateway.port\\", "23456"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for config unset before deleting another key", + args: ["config", "unset", "gateway.port\\"], + error: "Invalid path (trailing escape): gateway.port\\", + }, + { + name: "rejects a trailing escape for batch config set before writing another key", + args: [ + "config", + "set", + "--batch-json", + JSON.stringify([{ path: "gateway.port\\", value: 23456 }]), + ], + error: "Invalid path (trailing escape): gateway.port\\", + }, ])("$name", async ({ args, error, list }) => { if (list) { const resolved = { agents: { list } } as unknown as OpenClawConfig; @@ -3358,6 +3383,15 @@ describe("config cli", () => { expect(mockWriteConfigFile).not.toHaveBeenCalled(); }); + it.each(["gateway.port\\", "gateway.port\\ "])( + "rejects a trailing escape in shared config path %s", + (configPath) => { + expect(() => parseConfigSetPath(configPath)).toThrow( + `Invalid path (trailing escape): ${configPath}`, + ); + }, + ); + it.each([ "agents.list[0]id", "agents.list[0] id", @@ -3396,6 +3430,12 @@ describe("config cli", () => { ["agents.list[0].id", ["agents", "list", "0", "id"]], ["agents.list[0][1]", ["agents", "list", "0", "1"]], ["[0]", ["0"]], + [" gateway.port ", ["gateway", "port"]], + ["channels.discord.guilds.prod\\.guild", ["channels", "discord", "guilds", "prod.guild"]], + [ + "channels.discord.guilds.prod\\\\.channels", + ["channels", "discord", "guilds", "prod\\", "channels"], + ], ])("preserves valid bracket path %s", (configPath, expected) => { expect(parseConfigSetPath(configPath)).toEqual(expected); }); From 402bd4af01b7653d58e6b0364e143651df004e37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:17:24 -0700 Subject: [PATCH 13/59] fix(whatsapp): keep sends running when typing presence fails (#116739) Co-authored-by: Peter Steinberger --- extensions/whatsapp/src/send.test.ts | 34 ++++++++++++++++++++++++++++ extensions/whatsapp/src/send.ts | 10 +++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index a2746251a5f3..e625c381016b 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -142,6 +142,40 @@ describe("web outbound", () => { expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined); }); + it.each([ + { name: "text", mediaUrl: undefined }, + { name: "media", mediaUrl: "/tmp/pic.jpg" }, + ])("still sends $name when composing presence fails", async ({ mediaUrl }) => { + const mediaBuffer = Buffer.from("img"); + if (mediaUrl) { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: mediaBuffer, + contentType: "image/jpeg", + kind: "image", + }); + } + sendComposingTo.mockRejectedValueOnce(new Error("presence update unavailable")); + + await expect( + sendMessageWhatsApp("+1555", "hi", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + ...(mediaUrl ? { mediaUrl } : {}), + }), + ).resolves.toEqual({ + messageId: "msg123", + toJid: "1555@s.whatsapp.net", + }); + + expect(sendComposingTo).toHaveBeenCalledWith("+1555"); + expect(sendMessage).toHaveBeenCalledWith( + "+1555", + "hi", + mediaUrl ? mediaBuffer : undefined, + mediaUrl ? "image/jpeg" : undefined, + ); + }); + it("re-chunks after WhatsApp marker expansion", async () => { const onDeliveryResult = vi.fn(); await sendMessageWhatsApp("+1555", Array.from({ length: 8 }, () => "`x`").join(" "), { diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 660c448ea601..5ec2906e13bc 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -240,7 +240,15 @@ export async function sendMessageWhatsApp( logger.info({ jid: redactedJid, hasMedia }, "sending message"); if (!isWhatsAppNewsletterJid(jid)) { await active.assertSendReady?.(to); - await active.sendComposingTo(to); + try { + await active.sendComposingTo(to); + } catch (err) { + // Typing is optional; a failed chatstate update must not block the actual message. + logger.warn( + { err: String(err), jid: redactedJid }, + "failed to send composing presence; continuing message delivery", + ); + } } const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; From 58580fff2f9c5e23ac1a217286365530657ea599 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:18:51 +0800 Subject: [PATCH 14/59] fix(plugins): require explicit source external startup (#116759) --- src/plugins/channel-plugin-ids.test.ts | 77 ++++++++++++++++++++++ src/plugins/gateway-startup-plugin-plan.ts | 9 ++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 0381150bab4e..881c41805b54 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -211,6 +211,16 @@ function createManifestRegistryFixture(): PluginManifestRegistry { origin: "global", activation: { onStartup: true }, }, + { + id: "source-external-startup", + enabledByDefault: true, + activation: { onStartup: true }, + channels: ["source-external-channel"], + providers: ["source-external-provider"], + packageManifest: { + build: { bundledDist: false }, + }, + }, { id: "demo-config-startup", enabledByDefault: true, @@ -314,6 +324,7 @@ function createInstalledPluginRecordFixture( origin: record.origin, enabled: true, ...(record.enabledByDefault === true ? { enabledByDefault: true } : {}), + ...(record.packageManifest?.build ? { packageBuild: record.packageManifest.build } : {}), startup: { sidecar: record.activation?.onStartup === true, memory, @@ -1429,6 +1440,72 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("does not ambient-start source-discovered external plugins from onStartup alone", () => { + expectStartupPluginIds({ + config: createStartupConfig({ + noConfiguredChannels: true, + memorySlot: "none", + }), + expected: ["browser"], + }); + }); + + it.each([ + [ + "plugins.entries", + createStartupConfig({ + enabledPluginIds: ["source-external-startup"], + noConfiguredChannels: true, + memorySlot: "none", + }), + ["browser", "source-external-startup"], + ], + [ + "plugins.allow", + createStartupConfig({ + allowPluginIds: ["source-external-startup"], + noConfiguredChannels: true, + memorySlot: "none", + }), + ["source-external-startup"], + ], + ])( + "starts source-discovered external plugins explicitly selected through %s", + (_name, config, expected) => { + expectStartupPluginIds({ + config, + expected, + }); + }, + ); + + it.each([ + [ + "configured channel", + { + channels: { + "source-external-channel": { enabled: true }, + }, + plugins: { + slots: { memory: "none" }, + }, + } as OpenClawConfig, + ], + [ + "selected provider", + createStartupConfig({ + modelId: "source-external-provider/demo-model", + noConfiguredChannels: true, + memorySlot: "none", + }), + ], + ])("preserves %s activation for source-discovered external plugins", (_name, config) => { + expectStartupPluginIds({ + config, + expected: ["browser", "source-external-startup"], + }); + }); + it("loads explicit trusted policy plugins at startup", () => { expectStartupPluginIds({ config: createStartupConfig({ diff --git a/src/plugins/gateway-startup-plugin-plan.ts b/src/plugins/gateway-startup-plugin-plan.ts index 60fef360d28b..7bc0f462f7ff 100644 --- a/src/plugins/gateway-startup-plugin-plan.ts +++ b/src/plugins/gateway-startup-plugin-plan.ts @@ -418,9 +418,14 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { pluginIds.push(plugin.pluginId); continue; } + const isSourceExternalPlugin = + plugin.origin === "bundled" && plugin.packageBuild?.bundledDist === false; + // Source checkout discovery still uses the bundled root, but source-only + // packages are externally owned and must keep the external explicit-startup policy. + const startupPolicyOrigin = isSourceExternalPlugin ? "workspace" : plugin.origin; const activationState = resolveEffectivePluginActivationState({ id: plugin.pluginId, - origin: plugin.origin, + origin: startupPolicyOrigin, config: pluginsConfig, rootConfig: params.config, enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin, params.platform), @@ -430,7 +435,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { continue; } if ( - plugin.origin !== "bundled" + startupPolicyOrigin !== "bundled" ? activationState.explicitlyEnabled : activationState.source === "explicit" || activationState.source === "default" ) { From 873bcc2985e786ed7903bb83a932f22a2a484320 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:21:31 -0700 Subject: [PATCH 15/59] fix(irc): recognize punctuation in nickname mentions (#116758) Co-authored-by: Peter Steinberger --- extensions/irc/src/inbound.behavior.test.ts | 64 +++++++++++++++++++++ extensions/irc/src/inbound.ts | 26 ++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/extensions/irc/src/inbound.behavior.test.ts b/extensions/irc/src/inbound.behavior.test.ts index 7a6d4f8ca3e2..1c2c23fe5e49 100644 --- a/extensions/irc/src/inbound.behavior.test.ts +++ b/extensions/irc/src/inbound.behavior.test.ts @@ -334,6 +334,70 @@ describe("irc inbound behavior", () => { expect(ctx?.OriginatingTo).toBe("channel:#ops"); }); + it.each([ + { label: "ordinary nick", nick: "OpenClaw", text: "OpenClaw: hello", mentioned: true }, + { label: "ASCII case folding", nick: "OpenClaw", text: "openclaw: hello", mentioned: true }, + { label: "leading bracket", nick: "[Claw]", text: "[Claw]: hello", mentioned: true }, + { label: "trailing bracket", nick: "Claw]", text: "hello Claw],", mentioned: true }, + { label: "leading caret", nick: "^Claw", text: "^Claw, hello", mentioned: true }, + { label: "trailing hyphen", nick: "Claw-", text: "Claw-: hello", mentioned: true }, + { label: "escaped backslash", nick: "\\Claw", text: "\\Claw: hello", mentioned: true }, + { label: "embedded brackets", nick: "Claw[Ops]", text: "Claw[Ops]: hi", mentioned: true }, + { label: "RFC1459 opening bracket", nick: "[Claw", text: "{claw: hello", mentioned: true }, + { label: "RFC1459 opening brace", nick: "{Claw", text: "[claw: hello", mentioned: true }, + { label: "RFC1459 closing bracket", nick: "Claw]", text: "claw}: hello", mentioned: true }, + { label: "RFC1459 closing brace", nick: "Claw}", text: "claw]: hello", mentioned: true }, + { label: "RFC1459 backslash", nick: "\\Claw", text: "|claw: hello", mentioned: true }, + { label: "RFC1459 vertical bar", nick: "|Claw", text: "\\claw: hello", mentioned: true }, + { label: "RFC1459 caret", nick: "^Claw", text: "~claw: hello", mentioned: true }, + { label: "RFC1459 tilde", nick: "~Claw", text: "^claw: hello", mentioned: true }, + { label: "ordinary nick suffix", nick: "Claw", text: "Clawbot: hello", mentioned: false }, + { label: "ordinary nick prefix", nick: "Claw", text: "overClaw: hello", mentioned: false }, + { label: "IRC nick punctuation suffix", nick: "Claw", text: "Claw-bot: hi", mentioned: false }, + { label: "RFC1459 tilde nick suffix", nick: "Claw", text: "Claw~bot: hi", mentioned: false }, + { + label: "punctuated nick inside a longer nick", + nick: "[Claw]", + text: "prefix[Claw]: hello", + mentioned: false, + }, + ])( + "recognizes only complete IRC nickname mentions: $label", + async ({ nick, text, mentioned }) => { + const coreRuntime = createPluginRuntimeMock(); + const runtime = createRuntimeEnv(); + setIrcRuntime(coreRuntime as never); + + await handleIrcInbound({ + message: createMessage({ + target: "#ops", + isGroup: true, + text, + }), + account: createAccount({ + nick, + config: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groupAllowFrom: [], + groups: { + "#ops": { enabled: true, requireMention: true }, + }, + }, + }), + config: { channels: { irc: {} } } as CoreConfig, + runtime, + sendReply: vi.fn(async () => {}), + }); + + expect(coreRuntime.channel.inbound.dispatch).toHaveBeenCalledTimes(mentioned ? 1 : 0); + if (!mentioned) { + expect(runtime.log).toHaveBeenCalledWith("irc: drop channel #ops (missing-mention)"); + } + }, + ); + it("drops a spoofed sender for a host-less nick!user DM allowlist entry", async () => { const coreRuntime = createPluginRuntimeMock(); const runtime = createRuntimeEnv(); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 919630c59f6e..d15d01490293 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -81,6 +81,27 @@ const ircIngressIdentity = defineStableChannelIngressIdentity({ }); const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// IRC nicknames permit punctuation, so ASCII word boundaries lose valid leading/trailing chars. +const IRC_NICK_CHARACTER = String.raw`[A-Za-z0-9_\-\[\]\\\x60^{}|~]`; +const IRC_RFC1459_CASE_EQUIVALENTS = new Map([ + ["[", "{"], + ["{", "["], + ["]", "}"], + ["}", "]"], + ["\\", "|"], + ["|", "\\"], + ["^", "~"], + ["~", "^"], +]); + +function buildIrcNickMentionPattern(value: string): string { + return Array.from(value, (character) => { + const equivalent = IRC_RFC1459_CASE_EQUIVALENTS.get(character); + return equivalent + ? `[${escapeIrcRegexLiteral(character)}${escapeIrcRegexLiteral(equivalent)}]` + : escapeIrcRegexLiteral(character); + }).join(""); +} function isBareNick(value: string): boolean { return !value.includes("!") && !value.includes("@"); @@ -266,7 +287,10 @@ export async function handleIrcInbound(params: { const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig); const mentionNick = connectedNick?.trim() || account.nick; const explicitMentionRegex = mentionNick - ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i") + ? new RegExp( + `(? Date: Fri, 31 Jul 2026 18:27:14 +0800 Subject: [PATCH 16/59] fix(skills): allow autonomous sweep sync (#116755) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b6a2496a3a4e..c09f181ff90e 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,8 @@ USER.md !.agents/skills/graincrawl/** !.agents/skills/notcrawl/ !.agents/skills/notcrawl/** +!.agents/skills/openclaw-autonomous-issue-sweep/ +!.agents/skills/openclaw-autonomous-issue-sweep/** !.agents/skills/openclaw-changelog-update/ !.agents/skills/openclaw-changelog-update/** !.agents/skills/openclaw-ci-limits/ From 27fa5a8950f14dd4d1b8242fe062ddb3867c1155 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 03:36:55 -0700 Subject: [PATCH 17/59] docs(skill): require independent proof before issue closure --- .../openclaw-autonomous-issue-sweep/SKILL.md | 79 ++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md index 6f2e2c3e4344..6f1b9a595781 100644 --- a/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md +++ b/.agents/skills/openclaw-autonomous-issue-sweep/SKILL.md @@ -17,6 +17,10 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. - Use full-history forks so every subagent inherits the orchestrator's model and **xhigh reasoning effort**. Never print, record, or disclose model identifiers; redact subprocess banners and diagnostics before reporting. +- Begin every full-history child assignment with its explicit role and agent + identity, require inherited **xhigh reasoning effort**, and forbid + `create_goal`, visualizations, `spawn_agent`, or nested agents. Children + return evidence to the orchestrator; never downgrade their model or effort. - Treat a request to run this workflow as authority to create lightweight, issue-scoped isolated Git worktrees and `codex/issue-` branches, review, fix, refactor, commit, push, create/update PRs, land eligible changes, @@ -67,6 +71,7 @@ subagents. Keep parent-thread updates to concise progress and clickable URLs. mutate the shared checkout while sibling workers are active. Once isolated worktrees exist, independent issue owners edit, inspect, and verify in parallel within their own checkout. + 6. Keep all **64** inherited high-effort agents available, but distinguish idle agents from active local tool users. Start with bounded waves of **4–8** concurrently active code/test workers and continuously reduce or expand that @@ -185,6 +190,74 @@ Choose outcomes in this order: - Do not edit `CHANGELOG.md`; capture user impact, issue/PR references, and human credit in the PR body or commit message. +## Hard issue-closure gate + +An issue stays open unless every step below passes. Similar wording, adjacent +tests, merged PR dates, contributor suggestions, and confident review summaries +are not closure proof. + +1. Write down the reporter's exact **primary symptom**, desired user-visible + outcome, every separately affected surface, reported version/build SHA, and + all proposed alternatives. An optional mitigation or diagnostic suggestion + does not replace the reported primary outcome. +2. Personally trace both shipped and current behavior end to end: entry point, + canonical owner, caller, callee, dependency contract, sibling surfaces, and + existing tests. Reproduce the exact reported failure on the affected build + and prove the same user action succeeds on current `main`. Use a runnable + product or boundary-level regression; a nearby unit test, revised error text, + or an unexecuted source inspection is insufficient. +3. Prove Git ancestry rather than inferring it from dates: + + ```bash + git merge-base --is-ancestor "$fix_sha" "$current_main_sha" + git merge-base --is-ancestor "$fix_sha" "$reported_build_or_tag_sha" + git tag --contains "$fix_sha" + ``` + + The fix must be an ancestor of current `main`. Compare it against **each** + affected exact build/tag, account for diverged release branches, and identify + the first containing release when known. A merge before a release date does + not prove inclusion in that release. If the fix was already in an affected + build, assume the report still reproduces until a later causal fix is proved. + +4. Classify the candidate honestly: root-cause repair, mitigation, diagnostic + improvement, unsupported contract, workaround, or product decision. Never + close because a suggested fallback landed if the primary action still fails, + any reported surface remains broken, an owner hold exists, or documented + behavior requires an unresolved maintainer/security/product decision. +5. Require a **different, independent subagent with inherited xhigh reasoning** + to challenge the investigator's closure packet. The challenger personally + verifies the primary outcome, every affected surface, runtime owner and + contract, release ancestry, and before/after proof. The investigator cannot + self-approve; only a separate authorized closure coordinator may grant the + mutation after both reviewers agree. Any disagreement means **leave open**. +6. Immediately recheck live GitHub state, labels/owner holds, current `main`, + and exact proof. Do not close on stale state, an incomplete source map, an + indirect main-only test, changed wording without changed behavior, or any + unresolved facet. In **one sentence**, the closure comment must state the + exact fixed behavior, fix SHA/PR, first containing version when known, and + before/after evidence. +7. If a closure is challenged or an incorrectly closed issue is reopened, + **pause all closure mutations**. Audit earlier closures, correct the public + record, reopen proven mistakes, and resume only after explicit root + authorization. Continue safe investigation and verified code-fix work. + +Required evidence map: + +```text +Primary symptom -> expected outcome -> every reported surface -> affected build/tag +Entry -> caller -> canonical owner -> callee -> dependency -> sibling -> boundary proof +Fix SHA -> current-main ancestry -> each affected-build ancestry -> containing release +Affected-build failure -> current-main success -> independent challenge -> coordinator grant +``` + +Reject example: a remote command fails because its explicit working directory +does not exist on the target host. A merged change that only replaces a vague +spawn error with an accurate invalid-directory diagnostic is useful, but the +command still fails. If the primary expected outcome is successful execution, +leave the issue open; changing that explicit-directory contract may need an +owner decision. + ## Verify behavior and obtain two independent reviews For every non-trivial production change: @@ -253,9 +326,9 @@ moves:` item with real evidence or an explicit reason for skipping it. - Keep owner/security/auth/config/public-SDK/protocol/persistent-state/product decisions outside autonomous landing when the relevant guide requires owner judgment. Continue with the next issue instead of blocking the whole sweep. -- Close a fixed issue only after live rechecking its open state and matching - the original symptoms to current-main proof. Cite the merged PR/commit and - ask the reporter to reopen if it still reproduces on the current version. +- Close a fixed issue only after the complete **Hard issue-closure gate**, + independent challenger sign-off, coordinator grant, and fresh live recheck. + Cite the exact causal PR/commit and first containing release when known. - Never close merely because a repro is difficult, the report is inconvenient, the behavior might be intentional, or the PR is stale. Product-decision and won't-implement closures require maintainer judgment. From 9b736a42c69b7d80f7cb3a1fe1950630b68011dd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:27:39 +0800 Subject: [PATCH 18/59] feat(models): expose tool support to clients --- .../Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ .../src/schema/agents-models-skills.ts | 1 + src/gateway/server-methods/models-list-result.ts | 5 ++++- src/gateway/server.models-voicewake-misc.test.ts | 10 ++++++++++ ui/src/api/types.ts | 1 + 5 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5b7fc1d99b87..025a6e632b97 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -11956,6 +11956,7 @@ public struct ModelChoice: Codable, Sendable { public let available: Bool? public let contextwindow: Int? public let reasoning: Bool? + public let supportstools: Bool? public let agentruntime: [String: AnyCodable]? public let apikeysupported: Bool? public let input: [AnyCodable]? @@ -11968,6 +11969,7 @@ public struct ModelChoice: Codable, Sendable { available: Bool? = nil, contextwindow: Int? = nil, reasoning: Bool? = nil, + supportstools: Bool? = nil, agentruntime: [String: AnyCodable]? = nil, apikeysupported: Bool? = nil, input: [AnyCodable]? = nil) @@ -11979,6 +11981,7 @@ public struct ModelChoice: Codable, Sendable { self.available = available self.contextwindow = contextwindow self.reasoning = reasoning + self.supportstools = supportstools self.agentruntime = agentruntime self.apikeysupported = apikeysupported self.input = input @@ -11992,6 +11995,7 @@ public struct ModelChoice: Codable, Sendable { case available case contextwindow = "contextWindow" case reasoning + case supportstools = "supportsTools" case agentruntime = "agentRuntime" case apikeysupported = "apiKeySupported" case input diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index f5e2f5a9e45c..46a03dc47173 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -37,6 +37,7 @@ export const ModelChoiceSchema = closedObject({ available: Type.Optional(Type.Boolean()), contextWindow: Type.Optional(Type.Integer({ minimum: 1 })), reasoning: Type.Optional(Type.Boolean()), + supportsTools: Type.Optional(Type.Boolean()), agentRuntime: Type.Optional(GatewayAgentRuntimeSchema), apiKeySupported: Type.Optional(Type.Boolean()), input: Type.Optional( diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 9b5941d76c9d..ecd75339299b 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -60,7 +60,7 @@ type ModelsListView = ModelCatalogBrowseView; type ModelsListEntry = Pick< ModelCatalogEntry, "alias" | "contextWindow" | "id" | "input" | "name" | "provider" | "reasoning" -> & { available?: boolean }; +> & { available?: boolean; supportsTools?: boolean }; type ModelsListEntryWithCapabilities = ModelsListEntry & { agentRuntime?: GatewayAgentRuntime; apiKeySupported?: boolean; @@ -96,6 +96,9 @@ function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { ...(entry.alias ? { alias: entry.alias } : {}), ...(contextWindow ? { contextWindow } : {}), ...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}), + ...(typeof entry.compat?.supportsTools === "boolean" + ? { supportsTools: entry.compat.supportsTools } + : {}), }; } diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index f6396a97f59b..90aed1058314 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -92,6 +92,7 @@ type ModelCatalogRpcEntry = { contextWindow?: number; input?: string[]; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: GatewayAgentRuntime; }; @@ -179,6 +180,7 @@ type ConfiguredProviderModelFixture = { name: string; alias: string; contextWindow: number; + supportsTools?: boolean; }; const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) => ({ @@ -200,6 +202,9 @@ const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) = id: params.modelId, name: params.name, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined + ? {} + : { compat: { supportsTools: params.supportsTools } }), }, ], }, @@ -213,6 +218,7 @@ const expectedConfiguredProviderModel = (params: ConfiguredProviderModelFixture) alias: params.alias, provider: params.provider, contextWindow: params.contextWindow, + ...(params.supportsTools === undefined ? {} : { supportsTools: params.supportsTools }), }); describe("gateway server models + voicewake", () => { @@ -362,6 +368,9 @@ describe("gateway server models + voicewake", () => { if (expected.contextWindow !== undefined) { expect(models[0]?.contextWindow).toBe(expected.contextWindow); } + if (expected.supportsTools !== undefined) { + expect(models[0]?.supportsTools).toBe(expected.supportsTools); + } }; test( @@ -757,6 +766,7 @@ describe("gateway server models + voicewake", () => { name: "Kimi K2.5 (Configured)", alias: "Kimi K2.5 (NVIDIA)", contextWindow: 32_000, + supportsTools: false, }, }, { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 6e64deaf133f..a958e9d65392 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -935,6 +935,7 @@ export type ModelCatalogEntry = { available?: boolean; contextWindow?: number; reasoning?: boolean; + supportsTools?: boolean; agentRuntime?: import("../../../packages/gateway-protocol/src/schema.js").GatewayAgentRuntime; input?: Array<"text" | "image" | "document">; apiKeySupported?: boolean; From 5b7f514c940c2e3bb84aded0350862ea3d0eac54 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:28:02 +0800 Subject: [PATCH 19/59] fix(chat): explain chat-only model limits --- .../run/attempt-system-prompt-prepare.ts | 14 +- .../attempt.spawn-workspace.test-support.ts | 3 +- .../embedded-agent-runner/run/attempt.ts | 1 + src/agents/model-tool-support.test.ts | 12 +- src/agents/model-tool-support.ts | 8 + ui/src/e2e/chat-only-model.e2e.test.ts | 168 ++++++++++++++++++ ui/src/i18n/locales/en.ts | 3 + ui/src/pages/chat/chat-view.test.ts | 39 ++++ .../chat/components/chat-model-controls.ts | 56 ++++-- ui/src/styles/chat/layout.css | 21 +++ ui/src/test-helpers/control-ui-e2e.ts | 2 + 11 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 ui/src/e2e/chat-only-model.e2e.test.ts diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts index af866fe7ac50..b08aea516277 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts @@ -25,6 +25,7 @@ import { resolveOpenClawReferencePaths } from "../../docs-path.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { prepareAgentMemoryPrompt } from "../../memory-prompt-prepare.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; +import { buildModelToolsUnavailablePrompt } from "../../model-tool-support.js"; import { buildProjectMemoryWriteInstruction, prepareProjectMemoryBootstrap, @@ -65,6 +66,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { getProviderRuntimeHandle: () => ProviderRuntimePluginHandle; isRawModelRun: boolean; markStage: (name: string) => void; + modelToolsEnabled: boolean; proactiveSubagentOrchestration: boolean; sandbox?: SandboxContext; sandboxSessionKey: string; @@ -273,6 +275,14 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { const projectMemoryWriteInstruction = buildProjectMemoryWriteInstruction( attempt.preparedModelRuntime?.projectKey, ); + const extraSystemPrompt = + [ + attempt.extraSystemPrompt, + projectMemoryWriteInstruction, + buildModelToolsUnavailablePrompt(params.modelToolsEnabled), + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n") || undefined; const attemptSystemPrompt = buildAttemptSystemPrompt({ isRawModelRun: params.isRawModelRun, @@ -287,9 +297,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { workspaceDir: params.effectiveWorkspace, defaultThinkLevel: attempt.thinkLevel, reasoningLevel: attempt.reasoningLevel ?? "off", - extraSystemPrompt: projectMemoryWriteInstruction - ? [attempt.extraSystemPrompt, projectMemoryWriteInstruction].filter(Boolean).join("\n\n") - : attempt.extraSystemPrompt, + extraSystemPrompt, ownerNumbers: attempt.ownerNumbers, reasoningTagHint, heartbeatPrompt, diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index c30d400f03e9..06f4e1a7c4cc 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -744,7 +744,8 @@ vi.mock("../../model-auth.js", () => ({ resolveModelAuthMode: () => undefined, })); -vi.mock("../../model-tool-support.js", () => ({ +vi.mock("../../model-tool-support.js", async (importOriginal) => ({ + ...(await importOriginal()), supportsModelTools: (...args: unknown[]) => hoisted.supportsModelToolsMock(...args), })); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 1155d41528bd..96d512902376 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -292,6 +292,7 @@ export async function runEmbeddedAttempt( getProviderRuntimeHandle, isRawModelRun, markStage: (name) => prepStages.mark(name), + modelToolsEnabled: toolsEnabled, proactiveSubagentOrchestration, sandbox: sandbox ?? undefined, sandboxSessionKey, diff --git a/src/agents/model-tool-support.test.ts b/src/agents/model-tool-support.test.ts index d7f7b2db6e7b..1e3c39f4b5c0 100644 --- a/src/agents/model-tool-support.test.ts +++ b/src/agents/model-tool-support.test.ts @@ -1,6 +1,6 @@ // Documents model tool-support compatibility defaults. import { describe, expect, it } from "vitest"; -import { supportsModelTools } from "./model-tool-support.js"; +import { buildModelToolsUnavailablePrompt, supportsModelTools } from "./model-tool-support.js"; describe("supportsModelTools", () => { it("defaults to true when the model has no compat override", () => { @@ -15,3 +15,13 @@ describe("supportsModelTools", () => { expect(supportsModelTools({ compat: { supportsTools: false } } as never)).toBe(false); }); }); + +describe("buildModelToolsUnavailablePrompt", () => { + it("tells chat-only models not to invent tool-backed work", () => { + expect(buildModelToolsUnavailablePrompt(true)).toBeUndefined(); + expect(buildModelToolsUnavailablePrompt(false)).toContain( + "Do not claim that you ran commands, read or wrote files, browsed the web, generated media", + ); + expect(buildModelToolsUnavailablePrompt(false)).toContain("switch to a tool-capable model"); + }); +}); diff --git a/src/agents/model-tool-support.ts b/src/agents/model-tool-support.ts index 742907d8cf38..2f0b57da3e2a 100644 --- a/src/agents/model-tool-support.ts +++ b/src/agents/model-tool-support.ts @@ -4,6 +4,9 @@ * Provider catalogs can opt a model out via `compat.supportsTools === false`; * absent metadata remains permissive for older catalog entries. */ +const MODEL_TOOLS_UNAVAILABLE_PROMPT = + "## Tool availability\n\nThis model cannot use tools in this run. Do not claim that you ran commands, read or wrote files, browsed the web, generated media, or performed any other tool-backed action. If a request requires tools, say they are unavailable in this chat and ask the user to switch to a tool-capable model."; + /** Returns whether a catalog model should be offered tool calls. */ export function supportsModelTools(model: { compat?: unknown }): boolean { const compat = @@ -12,3 +15,8 @@ export function supportsModelTools(model: { compat?: unknown }): boolean { : undefined; return compat?.supportsTools !== false; } + +/** Builds the bounded honesty guard for models that explicitly disable tools. */ +export function buildModelToolsUnavailablePrompt(modelToolsEnabled: boolean): string | undefined { + return modelToolsEnabled ? undefined : MODEL_TOOLS_UNAVAILABLE_PROMPT; +} diff --git a/ui/src/e2e/chat-only-model.e2e.test.ts b/ui/src/e2e/chat-only-model.e2e.test.ts new file mode 100644 index 000000000000..8e45decdaff6 --- /dev/null +++ b/ui/src/e2e/chat-only-model.e2e.test.ts @@ -0,0 +1,168 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); +const sessionKey = "agent:main:main"; +const proofDir = + process.env.OPENCLAW_CAPTURE_UI_PROOF === "1" + ? path.join(process.cwd(), ".artifacts", "control-ui-e2e", "chat-only-model") + : null; + +const models = [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + contextWindow: 200_000, + supportsTools: true, + }, +]; + +function sessionsList(model: string, modelProvider: string) { + return { + count: 1, + defaults: { + contextTokens: 32_768, + model: "qwen3-8b", + modelProvider: "lmstudio", + thinkingDefault: "off", + thinkingLevels: [{ id: "off", label: "off" }], + }, + path: "", + sessions: [ + { + contextTokens: 32_768, + displayName: "Local chat", + hasActiveRun: false, + key: sessionKey, + kind: "direct", + label: "Local chat", + model, + modelProvider, + status: "done", + totalTokens: 0, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; +} + +suite.define(() => { + it("explains chat-only models and keeps model switching as the recovery path", async () => { + if (proofDir) { + await mkdir(proofDir, { recursive: true }); + } + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + agentModel: "lmstudio/qwen3-8b", + models, + sessionKey, + methodResponses: { + "sessions.list": sessionsList("qwen3-8b", "lmstudio"), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + + const main = page.getByRole("main"); + const composer = main.locator(".agent-chat__composer-shell"); + const picker = composer.locator('[data-chat-model-select="true"]'); + const badge = picker.locator(".chat-controls__model-capability-badge"); + + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + await expect.poll(async () => (await badge.textContent())?.trim()).toBe("Chat only"); + await expect.poll(() => picker.getAttribute("aria-label")).toContain("Chat only"); + + if (proofDir) { + await composer.screenshot({ + animations: "disabled", + path: path.join(proofDir, "01-desktop-chat-only-composer.png"), + }); + } + + await picker.click(); + const localOption = composer.locator('[data-chat-model-option="lmstudio/qwen3-8b"]'); + const openAiOption = composer.locator('[data-chat-model-option="openai/gpt-5.5"]'); + await expect + .poll(async () => (await localOption.textContent())?.replace(/\s+/g, " ").trim()) + .toContain("32.8k context · Chat only"); + await expect + .poll(async () => (await openAiOption.textContent())?.includes("Chat only")) + .toBe(false); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "02-desktop-model-picker.png"), + }); + } + + await composer.locator('[data-chat-model-provider="openai"]').click(); + await openAiOption.click(); + const patch = await gateway.waitForRequest("sessions.patch"); + expect(patch.params).toMatchObject({ key: sessionKey, model: "openai/gpt-5.5" }); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("available"); + await expect.poll(() => badge.count()).toBe(0); + + const pickerDetails = composer.locator("details.chat-controls__model"); + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + await composer.locator('[data-chat-model-provider="lmstudio"]').click(); + await localOption.click(); + await expect.poll(() => picker.getAttribute("data-chat-model-tools")).toBe("unavailable"); + if (await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open)) { + await picker.click(); + } + await page.setViewportSize({ height: 844, width: 390 }); + await expect.poll(() => picker.isVisible()).toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "03-mobile-chat-only-model.png"), + }); + } + + if (!(await pickerDetails.evaluate((element: HTMLDetailsElement) => element.open))) { + await picker.click(); + } + const menu = composer.locator(".chat-controls__inline-select-menu--combined"); + await expect + .poll(async () => { + const box = await menu.boundingBox(); + return box !== null && box.x >= 0 && box.x + box.width <= 390; + }) + .toBe(true); + + if (proofDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "04-mobile-model-picker.png"), + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 544ff72a38ca..117a25cfc816 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4902,6 +4902,9 @@ export const en: TranslationMap = { fastHelp: "Fast responses finish sooner and can use more of your usage limits.", speedUnsupported: "Speed control is not supported for this model.", contextWindow: "{count} context", + chatOnly: "Chat only", + chatOnlyHelp: + "This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.", providerModels: "{provider} models", resetReasoning: "Reset to default ({level})", useDefaultReasoning: "Use default reasoning ({level})", diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 17fd51fc7b3b..4374dadd83ea 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -5352,6 +5352,45 @@ describe("chat model controls", () => { expect(modelOption?.closest("openclaw-tooltip")).toBeNull(); }); + it("marks chat-only models in the active control and picker", () => { + const { state } = createChatHeaderState({ + model: "qwen3-8b", + modelProvider: "lmstudio", + models: [ + { + id: "qwen3-8b", + name: "Qwen3 8B", + provider: "lmstudio", + contextWindow: 32_768, + supportsTools: false, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + supportsTools: true, + }, + ], + }); + const container = renderModelControls(state); + const trigger = getChatModelSelect(container); + + expect(trigger.dataset.chatModelTools).toBe("unavailable"); + expect( + trigger.querySelector(".chat-controls__model-capability-badge")?.textContent?.trim(), + ).toBe("Chat only"); + expect(trigger.getAttribute("aria-label")).toContain("Chat only"); + expect( + container + .querySelector('[data-chat-model-option="lmstudio/qwen3-8b"]') + ?.querySelector(".chat-controls__model-option-meta") + ?.textContent?.trim(), + ).toBe("32.8k context · Chat only"); + expect( + container.querySelector('[data-chat-model-option="openai/gpt-5.5"]')?.textContent, + ).not.toContain("Chat only"); + }); + it("shows canonical OpenAI model names instead of command aliases", () => { const { state } = createChatHeaderState({ model: "gpt-5.5", diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 406eda8e8160..838ca4164a76 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -60,6 +60,7 @@ type ChatModelProviderOption = ChatModelSelectOption & { contextWindow?: number; isDefault: boolean; provider: string; + supportsTools?: boolean; }; const CHAT_MODEL_PROVIDER_GROUP_ALIASES: Readonly> = { @@ -210,6 +211,9 @@ export function renderChatModelControls(props: ChatModelControlsProps) { return { commitValue: isDefault ? "" : option.value, ...(catalogEntry?.contextWindow ? { contextWindow: catalogEntry.contextWindow } : {}), + ...(typeof catalogEntry?.supportsTools === "boolean" + ? { supportsTools: catalogEntry.supportsTools } + : {}), isDefault, value: option.value, label: resolveChatModelPickerLabel(option.value, option.label, props.modelCatalog), @@ -403,8 +407,21 @@ function renderChatModelReasoningSelect(params: { } = params; const triggerModel = formatCombinedPickerModelLabel(triggerModelLabel); const triggerThinking = formatCombinedPickerThinkingLabel(triggerThinkingLabel); - const triggerTitle = `${triggerModel} · ${triggerThinking}`; - const triggerLabel = triggerTitle; + const defaultModelOption = modelOptions.find((option) => option.isDefault); + const activeModelOption = + selectedModelValue === "" + ? defaultModelOption + : modelOptions.find((option) => option.value === selectedModelValue); + const selectedModelOption = activeModelOption ?? modelOptions[0]; + const modelToolsUnavailable = activeModelOption?.supportsTools === false; + const triggerTitle = [ + triggerModel, + triggerThinking, + modelToolsUnavailable ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); + const triggerLabel = `${triggerModel} · ${triggerThinking}`; const sliderStops = thinkingOptions.filter((option) => option.value !== ""); const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue); const hasThinkingOverride = selectedThinkingValue !== ""; @@ -530,7 +547,6 @@ function renderChatModelReasoningSelect(params: { providerGroups.set(option.provider, [option]); } } - const defaultModelOption = modelOptions.find((option) => option.isDefault); const orderedProviderGroups = [...providerGroups]; const defaultProviderIndex = orderedProviderGroups.findIndex( ([provider]) => provider === defaultModelOption?.provider, @@ -541,21 +557,22 @@ function renderChatModelReasoningSelect(params: { orderedProviderGroups.unshift(defaultProviderGroup); } } - const selectedModelOption = - (selectedModelValue === "" - ? defaultModelOption - : modelOptions.find((option) => option.value === selectedModelValue)) ?? modelOptions[0]; const selectedProvider = selectedModelOption?.provider ?? orderedProviderGroups[0]?.[0] ?? "other"; const renderModelOption = (entry: ChatModelProviderOption) => { const selected = entry.value === selectedModelValue || (entry.isDefault && selectedModelValue === ""); const modelLabel = formatCombinedPickerModelOptionLabel(entry); - const contextLabel = entry.contextWindow - ? t("chat.modelControls.contextWindow", { - count: formatCompactTokenCount(entry.contextWindow), - }) - : ""; + const modelMeta = [ + entry.contextWindow + ? t("chat.modelControls.contextWindow", { + count: formatCompactTokenCount(entry.contextWindow), + }) + : "", + entry.supportsTools === false ? t("chat.modelControls.chatOnly") : "", + ] + .filter(Boolean) + .join(" · "); return html`
+ ` + : nothing} +
+ `; +} diff --git a/ui/src/e2e/model-alias-display.e2e.test.ts b/ui/src/e2e/model-alias-display.e2e.test.ts index 95a176352487..01f106a4a082 100644 --- a/ui/src/e2e/model-alias-display.e2e.test.ts +++ b/ui/src/e2e/model-alias-display.e2e.test.ts @@ -143,8 +143,9 @@ suite.define(() => { expect(response?.status()).toBe(200); await gateway.waitForRequest("agents.list"); await gateway.waitForRequest("config.get"); - const modelRequest = await gateway.waitForRequest("models.list"); - expect(modelRequest.params).toEqual({ view: "configured" }); + const modelRequest = await gateway.waitForRequest("chat.metadata"); + expect(modelRequest.params).toEqual({ agentId: "main" }); + expect(await gateway.getRequests("models.list")).toHaveLength(0); const select = page.locator("select.settings-select").first(); await select.waitFor({ state: "visible", timeout: 10_000 }); diff --git a/ui/src/pages/agents/agents-page.test.ts b/ui/src/pages/agents/agents-page.test.ts index 9c9a4e167f02..fc2bb748024d 100644 --- a/ui/src/pages/agents/agents-page.test.ts +++ b/ui/src/pages/agents/agents-page.test.ts @@ -5,12 +5,13 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { AgentsFilesListResult, AgentsListResult, + CronJob, ModelCatalogEntry, ToolsEffectiveResult, } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import type { AgentsPanel } from "../../lib/agents/panels.ts"; -import * as chatModels from "../chat/models.ts"; +import { loadCronJobsPage, type CronState } from "../../lib/cron/index.ts"; import type { AgentsRouteData } from "./route.ts"; import "./agents-page.ts"; @@ -30,7 +31,9 @@ type TestAgentsPage = HTMLElement & { toolsEffectiveLoading: boolean; toolsEffectiveResult: ToolsEffectiveResult | null; chatModelCatalog: ModelCatalogEntry[]; + chatModelCatalogError: string | null; chatModelCatalogRequest: unknown; + cron: CronState; requestGeneration: number; routeDataInitialized: boolean; subscriptions: { @@ -42,6 +45,9 @@ type TestAgentsPage = HTMLElement & { applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot, sourceChanged: boolean) => void; ensureAgentIdentities: () => void; loadActivePanelData: () => void; + refreshCron: () => Promise; + requestUpdate: () => void; + runCronTask: (task: (cronState: CronState) => Promise) => Promise; loadEffectiveToolsForAgent: (agentId: string) => void; loadAgentFiles: (agentId: string, force?: boolean) => Promise; }; @@ -82,6 +88,21 @@ function files(agentId: string, workspace: string): AgentsFilesListResult { return { agentId, workspace, files: [] }; } +function cronJob(id: string, agentId?: string): CronJob { + return { + id, + ...(agentId ? { agentId } : {}), + name: `Scheduled job ${id}`, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + } as CronJob; +} + const agentsList: AgentsListResult = { defaultId: "main", mainKey: "main", @@ -139,7 +160,7 @@ function pageContext( } describe("AgentsPage gateway lifecycle", () => { - it("loads the configured model catalog once for the overview model picker", async () => { + it("loads the selected agent's configured model catalog once for the overview model picker", async () => { const models = [ { id: "claude-opus-4-8", @@ -160,7 +181,71 @@ describe("AgentsPage gateway lifecycle", () => { await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); expect(request).toHaveBeenCalledOnce(); - expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }); + expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }); + }); + + it("caches separate configured model catalogs for the default and worker agents", async () => { + const defaultModels = [ + { id: "default-model", name: "Default account model", provider: "openai" }, + ]; + const workerModels = [ + { id: "worker-model", name: "Worker private model", provider: "anthropic" }, + ]; + const request = vi.fn(async (_method: string, params?: { agentId?: string }) => ({ + models: params?.agentId === "worker" ? workerModels : defaultModels, + })); + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "overview" } as AgentsRouteData; + page.client = { request } as unknown as GatewayBrowserClient; + page.connected = true; + page.agentsSelectedId = "main"; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(defaultModels)); + + page.agentsSelectedId = "worker"; + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(workerModels)); + + page.agentsSelectedId = "main"; + page.loadActivePanelData(); + expect(page.chatModelCatalog).toEqual(defaultModels); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(1, "chat.metadata", { agentId: "main" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "worker" }); + }); + + it("rejects a stale default-agent catalog after switching to a worker agent", async () => { + const defaultModels = [ + { id: "default-model", name: "Default account model", provider: "openai" }, + ]; + const workerModels = [ + { id: "worker-model", name: "Worker private model", provider: "anthropic" }, + ]; + const defaultResult = deferred<{ models: ModelCatalogEntry[] }>(); + const request = vi.fn((_method: string, params?: { agentId?: string }) => + params?.agentId === "worker" + ? Promise.resolve({ models: workerModels }) + : defaultResult.promise, + ); + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "overview" } as AgentsRouteData; + page.client = { request } as unknown as GatewayBrowserClient; + page.connected = true; + page.agentsSelectedId = "main"; + + page.loadActivePanelData(); + page.agentsSelectedId = "worker"; + page.loadActivePanelData(); + + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(workerModels)); + defaultResult.resolve({ models: defaultModels }); + await defaultResult.promise; + await Promise.resolve(); + + expect(page.chatModelCatalog).toEqual(workerModels); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "worker" }); }); it("rejects an old-client model catalog after the Gateway client changes", async () => { @@ -215,7 +300,7 @@ describe("AgentsPage gateway lifecycle", () => { expect(page.chatModelCatalog).toEqual(nextModels); expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); }); it("refreshes a settled model catalog after a same-client reconnect", async () => { @@ -242,34 +327,238 @@ describe("AgentsPage gateway lifecycle", () => { await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(nextModels)); expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" }); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); }); - it("handles a model catalog failure and retries without a stale request", async () => { + it("surfaces a rejected agent-scoped metadata RPC and retries without marking an empty catalog loaded", async () => { const models = [{ id: "new", name: "Opus 4.8", alias: "opus", provider: "anthropic" }]; - const loadModels = vi - .spyOn(chatModels, "loadModels") + const request = vi + .fn() .mockRejectedValueOnce(new Error("model catalog unavailable")) - .mockResolvedValueOnce(models); + .mockResolvedValueOnce({ models }); const page = document.createElement("openclaw-agents-page") as TestAgentsPage; page.routeData = { panel: "overview" } as AgentsRouteData; - page.client = { request: vi.fn() } as unknown as GatewayBrowserClient; + page.client = { request } as unknown as GatewayBrowserClient; page.connected = true; page.agentsSelectedId = "main"; - try { - page.loadActivePanelData(); - await vi.waitFor(() => expect(page.chatModelCatalogRequest).toBeNull()); - expect(page.chatModelCatalog).toEqual([]); + page.loadActivePanelData(); + await vi.waitFor(() => { + expect(page.chatModelCatalogError).toBe("model catalog unavailable"); + expect(page.chatModelCatalogRequest).toBeNull(); + }); + expect(page.chatModelCatalog).toEqual([]); - page.loadActivePanelData(); - await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.chatModelCatalog).toEqual(models)); - expect(loadModels).toHaveBeenCalledTimes(2); - expect(loadModels).toHaveBeenLastCalledWith(page.client, { refresh: true }); - } finally { - loadModels.mockRestore(); - } + expect(page.chatModelCatalogError).toBeNull(); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(2, "chat.metadata", { agentId: "main" }); + }); + + it("requests the selected agent's implicit default cron job before the first 50 unrelated jobs", async () => { + const unrelatedJobs = Array.from({ length: 50 }, (_, index) => + cronJob(`other-${index}`, "other"), + ); + const globalNextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const implicitDefaultJob = { + ...cronJob("default-job"), + state: { nextRunAtMs: scopedNextWakeAtMs }, + }; + const request = vi.fn(async (method: string, params?: { agentId?: string; limit?: number }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }; + } + if (method === "cron.list") { + const scoped = params?.agentId === "main"; + return { + jobs: scoped ? [implicitDefaultJob] : unrelatedJobs, + total: scoped ? 1 : 51, + offset: 0, + hasMore: !scoped, + }; + } + throw new Error(`Unexpected gateway method: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + + await vi.waitFor(() => { + expect(page.cron.cronJobs).toEqual([implicitDefaultJob]); + expect(page.cron.cronScopedTotal).toBe(1); + expect(page.cron.cronScopedNextWakeAtMs).toBe(scopedNextWakeAtMs); + }); + expect(page.cron.cronStatus).toEqual({ + enabled: true, + jobs: 51, + nextWakeAtMs: globalNextWakeAtMs, + }); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 50, offset: 0 }), + ); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 1, enabled: "enabled" }), + ); + }); + + it("loads the selected agent's remaining cron jobs after preserving the first-page total", async () => { + const jobs = Array.from({ length: 50 }, (_, index) => cronJob(`main-${index}`, "main")); + const lastJob = cronJob("main-50", "main"); + const request = vi.fn( + async (method: string, params?: { agentId?: string; limit?: number; offset?: number }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 80, nextWakeAtMs: null }; + } + if (params?.limit === 1) { + return { jobs: [jobs[0]], total: 51 }; + } + if (params?.offset === 50) { + return { jobs: [lastJob], total: 51, offset: 50, nextOffset: null, hasMore: false }; + } + return { jobs, total: 51, offset: 0, nextOffset: 50, hasMore: true }; + }, + ); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + + await vi.waitFor(() => { + expect(page.cron.cronJobs).toHaveLength(50); + expect(page.cron.cronJobsTotal).toBe(51); + expect(page.cron.cronScopedTotal).toBe(51); + }); + expect(page.cron.cronJobsHasMore).toBe(true); + + await page.runCronTask((cronState) => + loadCronJobsPage(cronState, { append: true, tableFilters: true }), + ); + + expect(page.cron.cronJobs).toHaveLength(51); + expect(page.cron.cronJobs.at(-1)).toEqual(lastJob); + expect(page.cron.cronJobsTotal).toBe(51); + expect(page.cron.cronScopedTotal).toBe(51); + expect(page.cron.cronJobsHasMore).toBe(false); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "main", limit: 50, offset: 50 }), + ); + }); + + it("reloads cron jobs when the selected agent changes", async () => { + const request = vi.fn(async (method: string, params?: { agentId?: string }) => { + if (method === "cron.status") { + return { enabled: true, jobs: 2, nextWakeAtMs: null }; + } + return { jobs: [cronJob(`${params?.agentId}-job`, params?.agentId)], total: 1 }; + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.cron.cronJobs[0]?.id).toBe("main-job")); + + page.agentsSelectedId = "other"; + page.loadActivePanelData(); + expect(page.cron.cronJobs).toEqual([]); + + await vi.waitFor(() => expect(page.cron.cronJobs[0]?.id).toBe("other-job")); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "other" }), + ); + }); + + it("keeps an in-flight scoped cron request attached to a same-client gateway snapshot", async () => { + const job = cronJob("same-client-job", "main"); + const pendingJobs = deferred<{ jobs: CronJob[]; total: number }>(); + const request = vi.fn((method: string, params?: { limit?: number }) => { + if (method === "cron.status") { + return Promise.resolve({ enabled: true, jobs: 1, nextWakeAtMs: null }); + } + if (params?.limit === 50) { + return pendingJobs.promise; + } + return Promise.resolve({ jobs: [job], total: 1 }); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "cron" } as AgentsRouteData; + page.client = client; + page.connected = true; + page.agentsSelectedId = "main"; + page.cron = { ...page.cron, client, connected: true }; + + page.loadActivePanelData(); + await vi.waitFor(() => expect(page.cron.cronLoading).toBe(true)); + const inFlightState = page.cron; + + page.applyGatewaySnapshot(snapshot(client), false); + expect(page.cron).toBe(inFlightState); + + pendingJobs.resolve({ jobs: [job], total: 1 }); + await vi.waitFor(() => { + expect(page.cron.cronJobs).toEqual([job]); + expect(page.cron.cronLoading).toBe(false); + }); + }); + + it("immediately publishes cron loading and ignores a second refresh while the first is pending", async () => { + const job = cronJob("double-refresh-job", "main"); + const pendingJobs = deferred<{ jobs: CronJob[]; total: number }>(); + const request = vi.fn((method: string, params?: { limit?: number }) => { + if (method === "cron.status") { + return Promise.resolve({ enabled: true, jobs: 1, nextWakeAtMs: null }); + } + if (params?.limit === 50) { + return pendingJobs.promise; + } + return Promise.resolve({ jobs: [job], total: 1 }); + }); + const client = { request } as unknown as GatewayBrowserClient; + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.client = client; + page.connected = true; + page.cron = { ...page.cron, client, connected: true, cronAgentId: "main" }; + const requestUpdate = vi.spyOn(page, "requestUpdate"); + + const firstRefresh = page.refreshCron(); + expect(page.cron.cronLoading).toBe(true); + expect(requestUpdate).toHaveBeenCalled(); + + await page.refreshCron(); + expect( + request.mock.calls.filter( + ([method, params]) => method === "cron.list" && params?.limit === 50, + ), + ).toHaveLength(1); + + pendingJobs.resolve({ jobs: [job], total: 1 }); + await firstRefresh; + + expect(page.cron.cronLoading).toBe(false); + expect(page.cron.cronJobs).toEqual([job]); }); it("preserves matching initial route data, then resets it on provider replacement", () => { diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 8061ff625e87..706d7ed7b7bb 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -36,14 +36,15 @@ import { currentConfigObject, findAgentConfigEntryIndex } from "../../lib/config import { createInitialCronState, loadCronJobsPage, + loadCronScopeStats, loadCronStatus, runCronJob, + type CronState, } from "../../lib/cron/index.ts"; import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { normalizeStringEntries } from "../../lib/string-coerce.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import { loadModels } from "../chat/models.ts"; import { loadAgentFileContent, saveAgentFile } from "./files.ts"; import { resetIdentityDraft, @@ -91,6 +92,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { @state() toolsEffectiveError: string | null = null; @state() toolsEffectiveResult: ToolsEffectiveResult | null = null; @state() chatModelCatalog: ModelCatalogEntry[] = []; + @state() chatModelCatalogError: string | null = null; @state() agentFilesLoading = false; @state() agentFilesError: string | null = null; @state() agentFilesList: AgentsFilesListResult | null = null; @@ -121,10 +123,12 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private hasBoundSessions = false; private sessionsSource: ApplicationContext["sessions"] | null = null; private chatModelCatalogClient: GatewayBrowserClient | null = null; - private chatModelCatalogRefreshRequired = false; + private chatModelCatalogAgentId: string | null = null; + private readonly chatModelCatalogByAgentId = new Map(); private chatModelCatalogRequest: { client: GatewayBrowserClient; generation: number; + agentId: string; } | null = null; private normalizedLocation = ""; private readonly subscriptions = new SubscriptionsController(this) @@ -290,12 +294,13 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.syncGatewayState(snapshot); if (forceReset || (!initialBind && clientChanged)) { this.resetForClientChange(); - this.chatModelCatalogRefreshRequired = forceReset && !clientChanged; } else if (!initialBind && connectionChanged) { this.invalidateTransientRequests(); this.chatModelCatalog = []; this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = true; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogByAgentId.clear(); + this.chatModelCatalogError = null; } this.ensureInitialData(); } @@ -303,11 +308,11 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private syncGatewayState(snapshot: ApplicationGatewaySnapshot) { this.client = snapshot.client; this.connected = snapshot.phase === "connected"; - this.cron = { - ...this.cron, - client: snapshot.client, - connected: snapshot.phase === "connected", - }; + if (this.cron.client !== this.client || this.cron.connected !== this.connected) { + // In-flight cron loaders mutate their captured state; same-client + // snapshots must retain it or loading never clears in the visible state. + this.cron = { ...this.cron, client: this.client, connected: this.connected }; + } } private syncAgentState(agents = this.context.agents) { @@ -360,12 +365,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.agentsSelectedId = null; this.chatModelCatalog = []; this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = false; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogByAgentId.clear(); + this.chatModelCatalogError = null; this.resetSelectionState(); - this.cron = createInitialCronState({ - client: this.client, - connected: this.connected, - }); } private resetForAgentsSourceChange() { @@ -553,39 +556,68 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { void this.context.channels.refresh(false); return; } - if (this.agentsPanel === "cron" && !this.cron.cronLoading && !this.cron.cronStatus) { - void this.refreshCron(); + if (this.agentsPanel === "cron") { + if (this.cron.cronAgentId !== agentId) { + this.cron = createInitialCronState({ + client: this.client, + connected: this.connected, + }); + this.cron.cronAgentId = agentId; + } + if (!this.cron.cronLoading && !this.cron.cronStatus) { + void this.refreshCron(); + } } } private ensureModelCatalog() { const client = this.client; - if (!client || !this.connected || this.chatModelCatalogClient === client) { + const agentId = this.resolveSelectedAgentId(); + if (!client || !this.connected || !agentId) { return; } + if (this.chatModelCatalogClient === client) { + const cached = this.chatModelCatalogByAgentId.get(agentId); + if (cached) { + this.chatModelCatalog = cached; + this.chatModelCatalogAgentId = agentId; + this.chatModelCatalogError = null; + return; + } + } const generation = this.requestGeneration; const previousRequest = this.chatModelCatalogRequest; - if (previousRequest?.client === client && previousRequest.generation === generation) { + if ( + previousRequest?.client === client && + previousRequest.generation === generation && + previousRequest.agentId === agentId + ) { return; } - const request = { client, generation }; + if (this.chatModelCatalogAgentId !== agentId) { + this.chatModelCatalog = []; + } + const request = { client, generation, agentId }; this.chatModelCatalogRequest = request; - const refresh = this.chatModelCatalogRefreshRequired || previousRequest?.client === client; - this.chatModelCatalogRefreshRequired = false; - // A direct overview has no chat metadata. Refresh after reconnect so neither - // an in-flight request nor a settled cache can restore stale Gateway models. - void loadModels(client, refresh ? { refresh: true } : undefined) - .then((models) => { - if (this.isCurrentRequest(client, generation)) { + this.chatModelCatalogError = null; + // Only chat metadata projects the selected agent's private provider/auth + // scope; models.list always resolves against the default agent. + void client + .request<{ models?: ModelCatalogEntry[] }>("chat.metadata", { agentId }) + .then((result) => { + if (this.isCurrentRequest(client, generation, agentId)) { + const models = result.models ?? []; this.chatModelCatalog = models; this.chatModelCatalogClient = client; + this.chatModelCatalogAgentId = agentId; + this.chatModelCatalogByAgentId.set(agentId, models); + this.chatModelCatalogError = null; } }) - .catch(() => { - if (this.isCurrentRequest(client, generation)) { - this.chatModelCatalog = []; - this.chatModelCatalogClient = null; - this.chatModelCatalogRefreshRequired = true; + .catch((error: unknown) => { + if (this.isCurrentRequest(client, generation, agentId)) { + this.chatModelCatalogAgentId = null; + this.chatModelCatalogError = error instanceof Error ? error.message : String(error); } }) .finally(() => { @@ -644,15 +676,28 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private async refreshCron() { const cronState = this.cron; - if (!cronState.connected || !cronState.client) { + if (!cronState.connected || !cronState.client || cronState.cronLoading) { return; } await Promise.all([ - loadCronStatus(cronState), - loadCronJobsPage(cronState, { tableFilters: true }), + this.runCronTask((current) => loadCronStatus(current)), + this.runCronTask((current) => loadCronScopeStats(current)), + this.runCronTask((current) => loadCronJobsPage(current, { tableFilters: true })), ]); - if (this.cron === cronState) { - this.cron = { ...cronState, cronJobs: [...cronState.cronJobs] }; + } + + private async runCronTask(task: (cronState: CronState) => Promise): Promise { + const cronState = this.cron; + try { + const result = task(cronState); + if (this.cron === cronState) { + this.requestUpdate(); + } + return await result; + } finally { + if (this.cron === cronState) { + this.requestUpdate(); + } } } @@ -680,6 +725,9 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { private resetSelectionState() { this.requestGeneration += 1; + this.chatModelCatalog = []; + this.chatModelCatalogAgentId = null; + this.chatModelCatalogError = null; this.agentFilesList = null; this.agentFilesError = null; this.agentFileActive = null; @@ -699,6 +747,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { this.toolsCatalogLoading = false; this.toolsCatalogLoadingAgentId = null; resetToolsEffectiveState(this); + this.cron = createInitialCronState({ + client: this.client, + connected: this.connected, + }); } private findAgentIndex(agentId: string) { @@ -794,9 +846,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { if (!this.cron.cronJobs.some((entry) => entry.id === jobId)) { return; } - void runCronJob(this.cron, jobId, "force").finally(() => { - this.cron = { ...this.cron, cronJobs: [...this.cron.cronJobs] }; - }); + void this.runCronTask((cronState) => runCronJob(cronState, jobId, "force")); } override render() { @@ -836,6 +886,11 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { cron: { status: this.cron.cronStatus, jobs: this.cron.cronJobs, + jobsTotal: this.cron.cronJobsTotal, + jobsHasMore: this.cron.cronJobsHasMore, + jobsLoadingMore: this.cron.cronJobsLoadingMore, + scopedTotal: this.cron.cronScopedTotal, + scopedNextWakeAtMs: this.cron.cronScopedNextWakeAtMs, loading: this.cron.cronLoading, error: this.cron.cronError, }, @@ -874,6 +929,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { runtimeSessionKey: this.sessionKey, runtimeSessionMatchesSelectedAgent: selectedAgentId === this.chatAgentId(), modelCatalog: this.chatModelCatalog, + modelCatalogError: this.chatModelCatalogError, pinnedAgentIds: this.context.navigation.snapshot.pinnedAgentIds, onTogglePinnedAgent: (agentId) => togglePinnedAgent(this.context.navigation, agentId), onRefresh: () => this.refreshAgents(), @@ -947,6 +1003,10 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { onOpenMemorySettings: () => this.context.navigate("memory"), onOpenAgentDefaults: () => this.context.navigate("ai-agents"), onCronRefresh: () => void this.refreshCron(), + onCronLoadMore: () => + void this.runCronTask((cronState) => + loadCronJobsPage(cronState, { append: true, tableFilters: true }), + ), onCronRunNow: (jobId) => this.runCronJobNow(jobId), onSkillsFilterChange: (next) => (this.skillsFilter = next), onSkillsRefresh: () => { @@ -994,6 +1054,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { stageAgentPrimaryModel(this.context.runtimeConfig, agentId, modelId); void refreshVisibleToolsEffectiveForCurrentSession(this); }, + onModelCatalogRetry: () => this.ensureModelCatalog(), onModelFallbacksChange: (agentId, fallbacks) => stageAgentModelFallbacks(this.context.runtimeConfig, agentId, fallbacks), onSetDefault: (agentId) => { diff --git a/ui/src/pages/agents/agents-view.test-helpers.ts b/ui/src/pages/agents/agents-view.test-helpers.ts new file mode 100644 index 000000000000..efaa1c020413 --- /dev/null +++ b/ui/src/pages/agents/agents-view.test-helpers.ts @@ -0,0 +1,114 @@ +import type { renderAgents } from "./view.ts"; + +type AgentsViewProps = Parameters[0]; + +export function createAgentViewTestProps( + overrides: Partial = {}, +): AgentsViewProps { + return { + basePath: "", + authToken: null, + loading: false, + error: null, + agentsList: { + defaultId: "alpha", + mainKey: "main", + scope: "workspace", + agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never], + }, + selectedAgentId: "beta", + activePanel: "overview", + config: { + form: null, + loading: false, + saving: false, + dirty: false, + }, + channels: { + snapshot: null, + loading: false, + error: null, + lastSuccess: null, + }, + cron: { + status: null, + jobs: [], + jobsTotal: 0, + jobsHasMore: false, + jobsLoadingMore: false, + scopedTotal: null, + scopedNextWakeAtMs: null, + loading: false, + error: null, + }, + agentFiles: { + list: null, + loading: false, + error: null, + active: null, + contents: {}, + drafts: {}, + saving: false, + }, + agentIdentityLoading: false, + agentIdentityError: null, + agentIdentityById: {}, + identityDraft: { name: null, emoji: null, avatar: null }, + identitySaving: false, + identityError: null, + agentSkills: { + report: null, + loading: false, + error: null, + agentId: null, + filter: "", + }, + toolsCatalog: { + loading: false, + error: null, + result: null, + }, + toolsEffective: { + loading: false, + error: null, + result: null, + }, + runtimeSessionKey: "main", + runtimeSessionMatchesSelectedAgent: false, + modelCatalog: [], + modelCatalogError: null, + pinnedAgentIds: [], + onRefresh: () => undefined, + onSelectAgent: () => undefined, + onCreateAgent: () => undefined, + onSelectPanel: () => undefined, + onLoadFiles: () => undefined, + onSelectFile: () => undefined, + onFileDraftChange: () => undefined, + onFileReset: () => undefined, + onFileSave: () => undefined, + onToolsProfileChange: () => undefined, + onToolsOverridesChange: () => undefined, + onConfigReload: () => undefined, + onConfigSave: () => undefined, + onModelChange: () => undefined, + onModelFallbacksChange: () => undefined, + onModelCatalogRetry: () => undefined, + onChannelsRefresh: () => undefined, + onCronRefresh: () => undefined, + onCronLoadMore: () => undefined, + onCronRunNow: () => undefined, + onSkillsFilterChange: () => undefined, + onSkillsRefresh: () => undefined, + onAgentSkillToggle: () => undefined, + onAgentSkillsClear: () => undefined, + onAgentSkillsDisableAll: () => undefined, + onSetDefault: () => undefined, + onIdentityFieldChange: () => undefined, + onIdentityAvatarSelect: () => undefined, + onIdentitySave: () => undefined, + onTogglePinnedAgent: () => undefined, + onOpenAgentDefaults: () => undefined, + ...overrides, + }; +} diff --git a/ui/src/pages/agents/panels-overview.ts b/ui/src/pages/agents/panels-overview.ts index 16dfb4cef6d4..04f7c7db1589 100644 --- a/ui/src/pages/agents/panels-overview.ts +++ b/ui/src/pages/agents/panels-overview.ts @@ -6,6 +6,7 @@ import type { AgentsListResult, ModelCatalogEntry, } from "../../api/types.ts"; +import { renderPanelRefreshStatus } from "../../components/panel-refresh-status.ts"; import { renderSettingsRow, renderSettingsSection } from "../../components/settings-ui.ts"; import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; @@ -46,6 +47,7 @@ export function renderAgentOverview(params: { configSaving: boolean; configDirty: boolean; modelCatalog: ModelCatalogEntry[]; + modelCatalogError: string | null; onConfigReload: () => void; onConfigSave: () => void; onIdentityFieldChange: (field: "name" | "emoji", value: string) => void; @@ -53,6 +55,7 @@ export function renderAgentOverview(params: { onIdentitySave: () => void; onModelChange: (agentId: string, modelId: string | null) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onModelCatalogRetry: () => void; onSelectPanel: (panel: AgentsPanel) => void; }) { const { @@ -275,6 +278,14 @@ export function renderAgentOverview(params: { `, }, html` + ${renderPanelRefreshStatus({ + status: { + error: params.modelCatalogError, + hasLoaded: params.modelCatalog.length > 0, + stale: Boolean(params.modelCatalogError && params.modelCatalog.length > 0), + }, + onRetry: params.onModelCatalogRetry, + })} ${renderSettingsRow({ title: isDefault ? t("agents.overview.primaryModelDefault") diff --git a/ui/src/pages/agents/panels-status-files.ts b/ui/src/pages/agents/panels-status-files.ts index c280a263bc4c..51d8aabc98ac 100644 --- a/ui/src/pages/agents/panels-status-files.ts +++ b/ui/src/pages/agents/panels-status-files.ts @@ -12,6 +12,7 @@ import type { CronJob, CronStatus, } from "../../api/types.ts"; +import { renderCronJobsPagination } from "../../components/cron-jobs-pagination.ts"; import { renderHubTabs } from "../../components/hub-tabs.ts"; import { icons } from "../../components/icons.ts"; import "../../components/modal-dialog.ts"; @@ -296,14 +297,19 @@ export function renderAgentCron(params: { context: AgentContext; agentId: string; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsLoadingMore: boolean; status: CronStatus | null; + scopedTotal: number | null; + scopedNextWakeAtMs: number | null; loading: boolean; error: string | null; onRefresh: () => void; + onLoadMore: () => void; onRunNow: (jobId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; }) { - const jobs = params.jobs.filter((job) => job.agentId === params.agentId); return html` ${renderAgentContextSection( params.context, @@ -334,11 +340,13 @@ export function renderAgentCron(params: { })} ${renderSettingsRow({ title: t("agents.cronPanel.jobs"), - control: renderSettingsValue(params.status?.jobs ?? t("common.na")), + control: renderSettingsValue(params.scopedTotal ?? t("common.na")), })} ${renderSettingsRow({ title: t("agents.cronPanel.nextWake"), - control: renderSettingsValue(formatNextRun(params.status?.nextWakeAtMs ?? null)), + control: renderSettingsValue( + formatNextRun(params.status?.enabled === false ? null : params.scopedNextWakeAtMs), + ), })} `, )} @@ -347,34 +355,44 @@ export function renderAgentCron(params: { title: t("agents.cronPanel.agentJobsTitle"), description: t("agents.cronPanel.agentJobsSubtitle"), }, - jobs.length === 0 + params.jobs.length === 0 ? renderSettingsEmpty(t("agents.cronPanel.noJobs")) - : jobs.map((job) => { - const metaParts = [ - job.description, - formatCronSchedule(job), - job.sessionTarget, - formatCronState(job), - formatCronPayload(job), - ].filter(Boolean); - return renderSettingsRow({ - title: job.name, - description: metaParts.join(" · "), - control: html` - ${renderSettingsStatus({ - kind: job.enabled ? "ok" : "warn", - label: job.enabled ? t("common.enabled") : t("common.disabled"), - })} - - `, - }); - }), + : html` + ${params.jobs.map((job) => { + const metaParts = [ + job.description, + formatCronSchedule(job), + job.sessionTarget, + formatCronState(job), + formatCronPayload(job), + ].filter(Boolean); + return renderSettingsRow({ + title: job.name, + description: metaParts.join(" · "), + control: html` + ${renderSettingsStatus({ + kind: job.enabled ? "ok" : "warn", + label: job.enabled ? t("common.enabled") : t("common.disabled"), + })} + + `, + }); + })} + ${renderCronJobsPagination({ + jobsShown: params.jobs.length, + jobsTotal: params.jobsTotal, + hasMore: params.jobsHasMore, + loading: params.loading, + loadingMore: params.jobsLoadingMore, + onLoadMore: params.onLoadMore, + })} + `, )} `; } diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 61c41c1e294b..3b077c10d303 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -1,14 +1,16 @@ // Control UI tests cover agents behavior. import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { ChannelAccountSnapshot } from "../../api/types.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ChannelAccountSnapshot, CronJob } from "../../api/types.ts"; import { i18n, t } from "../../i18n/index.ts"; +import { createInitialCronState, loadCronJobsPage } from "../../lib/cron/index.ts"; +import { formatNextRun } from "../../lib/presenter.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; +import { createAgentViewTestProps as createProps } from "./agents-view.test-helpers.ts"; import { renderAgentChannels, renderAgentFiles } from "./panels-status-files.ts"; import { renderAgents } from "./view.ts"; -type AgentsProps = Parameters[0]; - function createSkill() { return { name: "Repo Skill", @@ -40,6 +42,21 @@ function createSkill() { }; } +function createCronJob(id: string, overrides: Partial = {}): CronJob { + return { + id, + name: `Scheduled job ${id}`, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + ...overrides, + } as CronJob; +} + function directText(element: Element | null | undefined): string | undefined { return Array.from(element?.childNodes ?? []) .filter((node) => node.nodeType === Node.TEXT_NODE) @@ -58,107 +75,6 @@ function expectAgentTab(container: Element, text: string): HTMLElement & { disab return button; } -function createProps(overrides: Partial = {}): AgentsProps { - return { - basePath: "", - authToken: null, - loading: false, - error: null, - agentsList: { - defaultId: "alpha", - mainKey: "main", - scope: "workspace", - agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never], - }, - selectedAgentId: "beta", - activePanel: "overview", - config: { - form: null, - loading: false, - saving: false, - dirty: false, - }, - channels: { - snapshot: null, - loading: false, - error: null, - lastSuccess: null, - }, - cron: { - status: null, - jobs: [], - loading: false, - error: null, - }, - agentFiles: { - list: null, - loading: false, - error: null, - active: null, - contents: {}, - drafts: {}, - saving: false, - }, - agentIdentityLoading: false, - agentIdentityError: null, - agentIdentityById: {}, - identityDraft: { name: null, emoji: null, avatar: null }, - identitySaving: false, - identityError: null, - agentSkills: { - report: null, - loading: false, - error: null, - agentId: null, - filter: "", - }, - toolsCatalog: { - loading: false, - error: null, - result: null, - }, - toolsEffective: { - loading: false, - error: null, - result: null, - }, - runtimeSessionKey: "main", - runtimeSessionMatchesSelectedAgent: false, - modelCatalog: [], - pinnedAgentIds: [], - onRefresh: () => undefined, - onSelectAgent: () => undefined, - onCreateAgent: () => undefined, - onSelectPanel: () => undefined, - onLoadFiles: () => undefined, - onSelectFile: () => undefined, - onFileDraftChange: () => undefined, - onFileReset: () => undefined, - onFileSave: () => undefined, - onToolsProfileChange: () => undefined, - onToolsOverridesChange: () => undefined, - onConfigReload: () => undefined, - onConfigSave: () => undefined, - onModelChange: () => undefined, - onModelFallbacksChange: () => undefined, - onChannelsRefresh: () => undefined, - onCronRefresh: () => undefined, - onCronRunNow: () => undefined, - onSkillsFilterChange: () => undefined, - onSkillsRefresh: () => undefined, - onAgentSkillToggle: () => undefined, - onAgentSkillsClear: () => undefined, - onAgentSkillsDisableAll: () => undefined, - onSetDefault: () => undefined, - onIdentityFieldChange: () => undefined, - onIdentityAvatarSelect: () => undefined, - onIdentitySave: () => undefined, - onTogglePinnedAgent: () => undefined, - onOpenAgentDefaults: () => undefined, - ...overrides, - }; -} - describe("renderAgents", () => { it("opens global Agent defaults before the per-agent tabs", () => { const container = document.createElement("div"); @@ -205,6 +121,152 @@ describe("renderAgents", () => { ).toBe("Fetched Beta"); }); + it("shows a model-catalog failure and lets the operator retry", () => { + const container = document.createElement("div"); + const onModelCatalogRetry = vi.fn(); + render( + renderAgents( + createProps({ modelCatalogError: "model catalog unavailable", onModelCatalogRetry }), + ), + container, + ); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("model catalog unavailable"); + const retry = Array.from(alert?.querySelectorAll("button") ?? []).find( + (button) => button.textContent?.trim() === t("common.retry"), + ); + retry?.click(); + + expect(onModelCatalogRetry).toHaveBeenCalledOnce(); + }); + + it("renders and counts a server-scoped default-agent cron job without an explicit agentId", () => { + const job = createCronJob("implicit-default-job", { + name: "Implicit default-agent reminder", + }); + const globalNextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const container = document.createElement("div"); + render( + renderAgents( + createProps({ + activePanel: "cron", + selectedAgentId: "alpha", + cron: { + status: { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }, + jobs: [job], + jobsTotal: 1, + jobsHasMore: false, + jobsLoadingMore: false, + scopedTotal: 1, + scopedNextWakeAtMs, + loading: false, + error: null, + }, + }), + ), + container, + ); + + expect(container.textContent).toContain("Implicit default-agent reminder"); + expect( + expectAgentTab(container, t("agents.tabs.cronJobs")).querySelector(".hub-tab__badge--count") + ?.textContent, + ).toContain("1"); + + const schedulerRows = [...container.querySelectorAll(".settings-row")]; + const jobsRow = schedulerRows.find( + (row) => + row.querySelector(".settings-row__title")?.textContent === t("agents.cronPanel.jobs"), + ); + const nextWakeRow = schedulerRows.find( + (row) => + row.querySelector(".settings-row__title")?.textContent === t("agents.cronPanel.nextWake"), + ); + expect(jobsRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe("1"); + expect(nextWakeRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe( + formatNextRun(scopedNextWakeAtMs), + ); + expect(nextWakeRow?.textContent).not.toContain(formatNextRun(globalNextWakeAtMs)); + }); + + it("loads and renders the selected agent's 51st cron job when Load more is clicked", async () => { + const jobs = Array.from({ length: 50 }, (_, index) => + createCronJob(`main-${index}`, { agentId: "alpha" }), + ); + const lastJob = createCronJob("main-50", { + agentId: "alpha", + name: "Fifty-first agent reminder", + }); + const request = vi.fn(async () => ({ + jobs: [lastJob], + total: 51, + offset: 50, + nextOffset: null, + hasMore: false, + })); + const client = { request } as unknown as GatewayBrowserClient; + const cronState = { + ...createInitialCronState({ client, connected: true }), + cronAgentId: "alpha", + cronJobs: jobs, + cronJobsTotal: 51, + cronJobsHasMore: true, + cronJobsNextOffset: 50, + }; + const container = document.createElement("div"); + const renderCurrentPage = (): void => { + render( + renderAgents( + createProps({ + activePanel: "cron", + selectedAgentId: "alpha", + cron: { + status: { enabled: true, jobs: 80, nextWakeAtMs: null }, + jobs: cronState.cronJobs, + jobsTotal: cronState.cronJobsTotal, + jobsHasMore: cronState.cronJobsHasMore, + jobsLoadingMore: cronState.cronJobsLoadingMore, + scopedTotal: 51, + scopedNextWakeAtMs: null, + loading: cronState.cronLoading, + error: cronState.cronError, + }, + onCronLoadMore: () => { + const nextPage = loadCronJobsPage(cronState, { + append: true, + tableFilters: true, + }); + renderCurrentPage(); + void nextPage.then(renderCurrentPage); + }, + }), + ), + container, + ); + }; + renderCurrentPage(); + + expect( + expectAgentTab(container, t("agents.tabs.cronJobs")).querySelector(".hub-tab__badge--count") + ?.textContent, + ).toContain("51"); + expect(container.textContent).not.toContain(lastJob.name); + + const loadMore = container.querySelector(".cron-load-more"); + expect(loadMore?.textContent?.trim()).toBe(t("cron.list.loadMore")); + loadMore?.click(); + expect(container.querySelector(".cron-load-more")?.disabled).toBe(true); + + await vi.waitFor(() => expect(container.textContent).toContain(lastJob.name)); + expect(request).toHaveBeenCalledWith( + "cron.list", + expect.objectContaining({ agentId: "alpha", limit: 50, offset: 50 }), + ); + expect(container.querySelector(".cron-load-more")).toBeNull(); + }); + it("renders Memory after Automations and scopes the panel to the selected agent", () => { const container = document.createElement("div"); render(renderAgents(createProps({ activePanel: "memory" })), container); diff --git a/ui/src/pages/agents/view.ts b/ui/src/pages/agents/view.ts index d8c170679eb1..e5874d440320 100644 --- a/ui/src/pages/agents/view.ts +++ b/ui/src/pages/agents/view.ts @@ -53,6 +53,11 @@ type ChannelsState = { type CronState = { status: CronStatus | null; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsLoadingMore: boolean; + scopedTotal: number | null; + scopedNextWakeAtMs: number | null; loading: boolean; error: string | null; }; @@ -111,6 +116,7 @@ type AgentsProps = { runtimeSessionKey: string; runtimeSessionMatchesSelectedAgent: boolean; modelCatalog: ModelCatalogEntry[]; + modelCatalogError: string | null; pinnedAgentIds: readonly string[]; onTogglePinnedAgent: (agentId: string) => void; onRefresh: () => void; @@ -131,11 +137,13 @@ type AgentsProps = { onIdentitySave: () => void; onModelChange: (agentId: string, modelId: string | null) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onModelCatalogRetry: () => void; onChannelsRefresh: () => void; onOpenMemoryImport?: () => void; onOpenMemorySettings?: () => void; onOpenAgentDefaults: () => void; onCronRefresh: () => void; + onCronLoadMore: () => void; onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; onSkillsRefresh: () => void; @@ -166,9 +174,7 @@ export function renderAgents(props: AgentsProps) { const channelEntryCount = props.channels.snapshot ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length : null; - const cronJobCount = selectedId - ? props.cron.jobs.filter((j) => j.agentId === selectedId).length - : null; + const cronJobCount = selectedId ? props.cron.jobsTotal : null; const tabCounts: Record = { files: props.agentFiles.list?.files?.length ?? null, skills: selectedSkillCount, @@ -279,6 +285,7 @@ export function renderAgents(props: AgentsProps) { configSaving: props.config.saving, configDirty: props.config.dirty, modelCatalog: props.modelCatalog, + modelCatalogError: props.modelCatalogError, onConfigReload: props.onConfigReload, onConfigSave: props.onConfigSave, onIdentityFieldChange: props.onIdentityFieldChange, @@ -286,6 +293,7 @@ export function renderAgents(props: AgentsProps) { onIdentitySave: props.onIdentitySave, onModelChange: props.onModelChange, onModelFallbacksChange: props.onModelFallbacksChange, + onModelCatalogRetry: props.onModelCatalogRetry, onSelectPanel: props.onSelectPanel, }), ) @@ -378,10 +386,16 @@ export function renderAgents(props: AgentsProps) { ), agentId: selectedAgent.id, jobs: props.cron.jobs, + jobsTotal: props.cron.jobsTotal, + jobsHasMore: props.cron.jobsHasMore, + jobsLoadingMore: props.cron.jobsLoadingMore, status: props.cron.status, + scopedTotal: props.cron.scopedTotal, + scopedNextWakeAtMs: props.cron.scopedNextWakeAtMs, loading: props.cron.loading, error: props.cron.error, onRefresh: props.onCronRefresh, + onLoadMore: props.onCronLoadMore, onRunNow: props.onCronRunNow, onSelectPanel: props.onSelectPanel, }) diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index bcd66e568bec..cd32fb7ffbfb 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -14,6 +14,7 @@ import type { CronJobsSortBy, CronSortDir, } from "../../api/types.ts"; +import { renderCronJobsPagination } from "../../components/cron-jobs-pagination.ts"; import { icon, icons } from "../../components/icons.ts"; import { highlightCodeHtml } from "../../components/markdown-code-blocks.ts"; import { @@ -653,25 +654,14 @@ function renderJobsTable(props: CronProps, hasAnyJobsFilters: boolean) { (job) => job.id, (job) => renderJobRow(job, props), )} - + ${renderCronJobsPagination({ + jobsShown: props.jobs.length, + jobsTotal: props.jobsTotal, + hasMore: props.jobsHasMore, + loading: props.loading, + loadingMore: props.jobsLoadingMore, + onLoadMore: props.onLoadMoreJobs, + })} `; } diff --git a/ui/src/styles/cron-jobs-pagination.css b/ui/src/styles/cron-jobs-pagination.css new file mode 100644 index 000000000000..859043c4307a --- /dev/null +++ b/ui/src/styles/cron-jobs-pagination.css @@ -0,0 +1,13 @@ +.cron-table__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-size: var(--control-ui-text-sm); +} + +.cron-load-more { + align-self: flex-start; +} diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index 300771733509..45ec15c153a5 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -425,16 +425,6 @@ height: 12px; } -.cron-table__footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); - font-size: var(--control-ui-text-sm); -} - .cron-empty-state { padding: var(--space-6) var(--space-4); display: grid; @@ -455,10 +445,6 @@ line-height: 1.45; } -.cron-load-more { - align-self: flex-start; -} - /* ── Detail view ── */ .cron-back-row { From 754fddbc798f1d7791f73cebe67069cdac3033b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:50:24 -0700 Subject: [PATCH 44/59] fix(anthropic-vertex): correct multi-region endpoints (#116757) Co-authored-by: Peter Steinberger --- extensions/anthropic-vertex/index.test.ts | 17 +++++++-- .../anthropic-vertex/provider-catalog.ts | 4 ++- extensions/anthropic-vertex/region.test.ts | 6 ++++ .../anthropic-vertex/stream-runtime.test.ts | 18 ++++++++++ .../anthropic-payload-policy.test.ts | 36 +++++++++++++++++++ .../transports/anthropic-payload-policy.ts | 2 ++ 6 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/ai/src/transports/anthropic-payload-policy.test.ts diff --git a/extensions/anthropic-vertex/index.test.ts b/extensions/anthropic-vertex/index.test.ts index 56d97a821ee5..b124d77751fc 100644 --- a/extensions/anthropic-vertex/index.test.ts +++ b/extensions/anthropic-vertex/index.test.ts @@ -102,6 +102,19 @@ describe("anthropic-vertex provider plugin", () => { expect(result.provider.models[4]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" }); }); + it.each([ + { region: "global", baseUrl: "https://aiplatform.googleapis.com" }, + { region: "us", baseUrl: "https://aiplatform.us.rep.googleapis.com" }, + { region: "eu", baseUrl: "https://aiplatform.eu.rep.googleapis.com" }, + { region: "us-east5", baseUrl: "https://us-east5-aiplatform.googleapis.com" }, + ])("publishes the SDK endpoint for the $region location", ({ region, baseUrl }) => { + expect( + buildAnthropicVertexProvider({ + env: { GOOGLE_CLOUD_LOCATION: region }, + }).baseUrl, + ).toBe(baseUrl); + }); + it.each(["global", "us", "eu"])("publishes Opus 5 for the %s endpoint", (region) => { const provider = buildAnthropicVertexProvider({ env: { GOOGLE_CLOUD_LOCATION: region }, @@ -194,7 +207,7 @@ describe("anthropic-vertex provider plugin", () => { name: "Claude Sonnet 5", api: "anthropic-messages", provider: "anthropic-vertex", - baseUrl: "https://us-aiplatform.googleapis.com", + baseUrl: "https://aiplatform.us.rep.googleapis.com", reasoning: true, input: ["text", "image"], contextWindow: 1_000_000, @@ -235,7 +248,7 @@ describe("anthropic-vertex provider plugin", () => { name: "Claude Opus 5", api: "anthropic-messages", provider: "anthropic-vertex", - baseUrl: "https://us-aiplatform.googleapis.com", + baseUrl: "https://aiplatform.us.rep.googleapis.com", reasoning: false, input: ["text"], cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, diff --git a/extensions/anthropic-vertex/provider-catalog.ts b/extensions/anthropic-vertex/provider-catalog.ts index e83408662305..da9bf0e506b7 100644 --- a/extensions/anthropic-vertex/provider-catalog.ts +++ b/extensions/anthropic-vertex/provider-catalog.ts @@ -238,7 +238,9 @@ export function buildAnthropicVertexProvider(params?: { const baseUrl = normalizeLowercaseStringOrEmpty(region) === "global" ? "https://aiplatform.googleapis.com" - : `https://${region}-aiplatform.googleapis.com`; + : region === "us" || region === "eu" + ? `https://aiplatform.${region}.rep.googleapis.com` + : `https://${region}-aiplatform.googleapis.com`; return { baseUrl, diff --git a/extensions/anthropic-vertex/region.test.ts b/extensions/anthropic-vertex/region.test.ts index 49a2ced78f94..30b8686039f1 100644 --- a/extensions/anthropic-vertex/region.test.ts +++ b/extensions/anthropic-vertex/region.test.ts @@ -25,6 +25,12 @@ describe("anthropic vertex region helpers", () => { ).toBe("europe-west4"); }); + it.each(["us", "eu"])("parses the %s multi-region Vertex endpoint", (region) => { + expect( + resolveAnthropicVertexRegionFromBaseUrl(`https://aiplatform.${region}.rep.googleapis.com`), + ).toBe(region); + }); + it("treats the global Vertex endpoint as global", () => { expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe( "global", diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index 00a56299ae8e..2cd043fba24a 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -589,6 +589,24 @@ describe("createAnthropicVertexStreamFn", () => { }); describe("createAnthropicVertexStreamFnForModel", () => { + it.each(["us", "eu"])("preserves the %s multi-region SDK endpoint", (region) => { + const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFnForModel( + { baseUrl: `https://aiplatform.${region}.rep.googleapis.com` }, + { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv, + deps, + ); + + void streamFn(makeModel({ id: "claude-sonnet-5", maxTokens: 128_000 }), { messages: [] }, {}); + + expect(anthropicVertexCtorMock).toHaveBeenCalledWith({ + googleAuth: googleAuthClient, + projectId: "vertex-project", + region, + baseURL: `https://aiplatform.${region}.rep.googleapis.com/v1`, + }); + }); + it("derives project and region from the model and env", () => { const { deps, anthropicVertexCtorMock, googleAuthClient } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFnForModel( diff --git a/packages/ai/src/transports/anthropic-payload-policy.test.ts b/packages/ai/src/transports/anthropic-payload-policy.test.ts new file mode 100644 index 000000000000..8603d1edbfbd --- /dev/null +++ b/packages/ai/src/transports/anthropic-payload-policy.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAnthropicEphemeralCacheControl } from "./anthropic-payload-policy.js"; + +describe("resolveAnthropicEphemeralCacheControl", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + "https://aiplatform.googleapis.com", + "https://us-east5-aiplatform.googleapis.com", + "https://aiplatform.us.rep.googleapis.com", + "https://aiplatform.eu.rep.googleapis.com", + ])("preserves env-configured long retention for the official %s endpoint", (baseUrl) => { + vi.stubEnv("OPENCLAW_CACHE_RETENTION", "long"); + + expect(resolveAnthropicEphemeralCacheControl(baseUrl, undefined)).toEqual({ + type: "ephemeral", + ttl: "1h", + }); + }); + + it("keeps env-configured long retention restricted for custom proxy endpoints", () => { + vi.stubEnv("OPENCLAW_CACHE_RETENTION", "long"); + + expect( + resolveAnthropicEphemeralCacheControl("https://proxy.example.test/vertex", undefined), + ).toEqual({ type: "ephemeral" }); + }); + + it("preserves explicitly configured long retention for custom proxy endpoints", () => { + expect( + resolveAnthropicEphemeralCacheControl("https://proxy.example.test/vertex", "long"), + ).toEqual({ type: "ephemeral", ttl: "1h" }); + }); +}); diff --git a/packages/ai/src/transports/anthropic-payload-policy.ts b/packages/ai/src/transports/anthropic-payload-policy.ts index 265b1dfa1633..f17a1f4c67b0 100644 --- a/packages/ai/src/transports/anthropic-payload-policy.ts +++ b/packages/ai/src/transports/anthropic-payload-policy.ts @@ -56,6 +56,8 @@ function isLongTtlEligibleEndpoint(baseUrl: string | undefined): boolean { return ( hostname === "api.anthropic.com" || hostname === "aiplatform.googleapis.com" || + hostname === "aiplatform.us.rep.googleapis.com" || + hostname === "aiplatform.eu.rep.googleapis.com" || hostname.endsWith("-aiplatform.googleapis.com") ); } From 5f5a871f39c41f40b336d96767fee72d4cbeb381 Mon Sep 17 00:00:00 2001 From: Penchan <5032148+p3nchan@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:51:12 +0800 Subject: [PATCH 45/59] fix(outbound): strip echoed inbound metadata before delivery (#50520) Co-authored-by: Penchan --- src/infra/outbound/payloads.test.ts | 38 +++++++++++++++++++++++++++++ src/infra/outbound/payloads.ts | 3 ++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index de13be4efd26..c2f608d88a97 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -2,6 +2,7 @@ // interactive blocks, mirror text, and suppressed relay status payloads. import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { describe, expect, it } from "vitest"; +import { markInboundContextLabel } from "../../auto-reply/reply/inbound-context-marker.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { @@ -53,6 +54,43 @@ describe("normalizeReplyPayloadsForDelivery", () => { ]); }); + it("strips leading echoed inbound metadata before parsing reply directives", () => { + const text = [ + markInboundContextLabel("Location:"), + "```json", + '{"latitude":51.5072,"longitude":-0.1276}', + "```", + "", + markInboundContextLabel("Plugin context:"), + "```json", + '{"source":"example","payload":{"mode":"test"}}', + "```", + "", + "[[reply_to: 123]] Visible reply", + ].join("\n"); + + expect(normalizeReplyPayloadsForDelivery([{ text }])).toMatchObject([ + { + text: "Visible reply", + replyToId: "123", + replyToTag: true, + }, + ]); + }); + + it("preserves marked metadata examples after visible reply text", () => { + const text = [ + "Here is the metadata format:", + "", + markInboundContextLabel("Location:"), + "```json", + '{"latitude":51.5072,"longitude":-0.1276}', + "```", + ].join("\n"); + + expect(normalizeReplyPayloadsForDelivery([{ text }])).toMatchObject([{ text }]); + }); + it("strips unsupported citation control markers from reply payload text", () => { const payloads: ReplyPayload[] = [{ text: "v2026.5.20 release note citeturn2view0" }]; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 04a21b6d1379..889eca89ec66 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -10,6 +10,7 @@ import { isRenderablePayload, shouldSuppressReasoningPayload, } from "../../auto-reply/reply/reply-payloads.js"; +import { stripLeadingInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -231,7 +232,7 @@ function createOutboundPayloadPlanEntry( if (shouldSuppressReasoningPayload(payload)) { return null; } - const parsed = parseReplyDirectives(payload.text ?? "", { + const parsed = parseReplyDirectives(stripLeadingInboundMetadata(payload.text ?? ""), { extractMarkdownImages: context.extractMarkdownImages, }); const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls; From eb55c8ea8fcb69f9357c7c27c9eaaab71a2c0800 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 19:52:33 +0800 Subject: [PATCH 46/59] feat(plugins): externalize Voyage embeddings (#116785) * feat(voyage): externalize embedding provider * fix(voyage): drop stale bundled description --- docs/plugins/plugin-inventory.md | 8 ++--- docs/plugins/reference/voyage.md | 2 +- extensions/voyage/README.md | 14 ++++++++ extensions/voyage/index.ts | 2 +- extensions/voyage/package.json | 26 ++++++++++++-- package.json | 1 + .../official-external-provider-catalog.json | 36 +++++++++++++++++++ src/cli/plugins-location-bridges.test.ts | 1 + .../official-external-plugin-catalog.test.ts | 28 +++++++++++++++ .../bundled-plugin-build-entries.test.ts | 8 +++++ 10 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 extensions/voyage/README.md diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 2492b9334048..e8a1162c8435 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description. ## Core npm package -65 plugins +64 plugins - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -169,8 +169,6 @@ Each entry lists the package, distribution route, and description. - **[volcengine](/plugins/reference/volcengine)** (`@openclaw/volcengine-provider`) - included in OpenClaw. Adds Volcengine, Volcengine Plan model provider support to OpenClaw. -- **[voyage](/plugins/reference/voyage)** (`@openclaw/voyage-provider`) - included in OpenClaw. Adds memory embedding provider support. - - **[vydra](/plugins/reference/vydra)** (`@openclaw/vydra-provider`) - included in OpenClaw. Adds Vydra model provider support to OpenClaw. - **[web-readability](/plugins/reference/web-readability)** (`@openclaw/web-readability-plugin`) - included in OpenClaw. Extract readable article content from local HTML web fetch responses. @@ -185,7 +183,7 @@ Each entry lists the package, distribution route, and description. ## Official external packages -80 plugins +81 plugins - **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management. @@ -337,6 +335,8 @@ Each entry lists the package, distribution route, and description. - **[voice-call](/plugins/reference/voice-call)** (`@openclaw/voice-call`) - npm; ClawHub. OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls. +- **[voyage](/plugins/reference/voyage)** (`@openclaw/voyage-provider`) - npm; ClawHub: `clawhub:@openclaw/voyage-provider`. Adds memory embedding provider support. + - **[whatsapp](/plugins/reference/whatsapp)** (`@openclaw/whatsapp`) - ClawHub: `clawhub:@openclaw/whatsapp`; npm. OpenClaw WhatsApp channel plugin for WhatsApp Web chats. - **[zai](/plugins/reference/zai)** (`@openclaw/zai-provider`) - npm; ClawHub: `clawhub:@openclaw/zai-provider`. Adds Z.AI model provider support to OpenClaw. diff --git a/docs/plugins/reference/voyage.md b/docs/plugins/reference/voyage.md index c4952f1d67fd..c4220ee48600 100644 --- a/docs/plugins/reference/voyage.md +++ b/docs/plugins/reference/voyage.md @@ -12,7 +12,7 @@ Adds memory embedding provider support. ## Distribution - Package: `@openclaw/voyage-provider` -- Install route: included in OpenClaw +- Install route: npm; ClawHub: `clawhub:@openclaw/voyage-provider` ## Surface diff --git a/extensions/voyage/README.md b/extensions/voyage/README.md new file mode 100644 index 000000000000..37b074cb890e --- /dev/null +++ b/extensions/voyage/README.md @@ -0,0 +1,14 @@ +# OpenClaw Voyage Provider + +Official OpenClaw memory embedding provider plugin for Voyage AI. + +Install from OpenClaw: + +```bash +openclaw plugins install @openclaw/voyage-provider +openclaw gateway restart +``` + +Set `VOYAGE_API_KEY`, then configure memory search with `provider: "voyage"`. +See for setup and +configuration. diff --git a/extensions/voyage/index.ts b/extensions/voyage/index.ts index 8fadd002c3b1..bfdb7048e033 100644 --- a/extensions/voyage/index.ts +++ b/extensions/voyage/index.ts @@ -5,7 +5,7 @@ import { voyageMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter export default definePluginEntry({ id: "voyage", name: "Voyage Embeddings", - description: "Bundled Voyage memory embedding provider plugin", + description: "Voyage memory embedding provider plugin", register(api) { api.registerMemoryEmbeddingProvider(voyageMemoryEmbeddingProviderAdapter); }, diff --git a/extensions/voyage/package.json b/extensions/voyage/package.json index 20569589e823..e7db3033e9ed 100644 --- a/extensions/voyage/package.json +++ b/extensions/voyage/package.json @@ -1,8 +1,11 @@ { "name": "@openclaw/voyage-provider", "version": "2026.7.2", - "private": true, - "description": "OpenClaw Voyage embedding provider plugin", + "description": "OpenClaw Voyage embedding provider plugin.", + "repository": { + "type": "git", + "url": "https://github.com/openclaw/openclaw" + }, "type": "module", "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" @@ -10,6 +13,23 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/voyage-provider", + "npmSpec": "@openclaw/voyage-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + }, + "compat": { + "pluginApi": ">=2026.7.2" + }, + "build": { + "openclawVersion": "2026.7.2", + "bundledDist": false + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } } } diff --git a/package.json b/package.json index e53ba0b2a464..28b0fed71bc7 100644 --- a/package.json +++ b/package.json @@ -316,6 +316,7 @@ "!dist/extensions/venice/**", "!dist/extensions/vercel-ai-gateway/**", "!dist/extensions/voice-call/**", + "!dist/extensions/voyage/**", "!dist/extensions/whatsapp/**", "!dist/extensions/zai/**", "!dist/extensions/zalo/**", diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index 882145a98922..9388b8164643 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -1708,6 +1708,42 @@ } } }, + { + "name": "@openclaw/voyage-provider", + "description": "OpenClaw Voyage embedding provider plugin.", + "source": "official", + "kind": "provider", + "openclaw": { + "plugin": { + "id": "voyage", + "label": "Voyage" + }, + "providers": [ + { + "id": "voyage", + "name": "Voyage", + "docs": "/reference/memory-config", + "categories": [ + "cloud" + ], + "envVars": [ + "VOYAGE_API_KEY" + ] + } + ], + "contracts": { + "memoryEmbeddingProviders": [ + "voyage" + ] + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/voyage-provider", + "npmSpec": "@openclaw/voyage-provider", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + } + } + }, { "name": "@openclaw/stepfun-provider", "description": "OpenClaw StepFun provider plugin.", diff --git a/src/cli/plugins-location-bridges.test.ts b/src/cli/plugins-location-bridges.test.ts index 106bc2d3cfb9..f6c1a75a78bb 100644 --- a/src/cli/plugins-location-bridges.test.ts +++ b/src/cli/plugins-location-bridges.test.ts @@ -168,6 +168,7 @@ describe("listPersistedBundledPluginLocationBridges", () => { ["duckduckgo", "@openclaw/duckduckgo-plugin", false], ["synthetic", "@openclaw/synthetic-provider", true], ["teams-meetings", "@openclaw/teams-meetings", true], + ["voyage", "@openclaw/voyage-provider", true], ["zoom-meetings", "@openclaw/zoom-meetings", true], ] as const)( "externalizes the shipped bundled %s plugin using official install metadata", diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index f19de92439d3..c4a04dd73614 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -2000,6 +2000,26 @@ describe("official external plugin catalog", () => { ]); }); + it("lists Voyage as an official external memory embedding provider", () => { + const voyage = expectCatalogEntry("voyage"); + const manifest = getOfficialExternalPluginCatalogManifest(voyage); + + expect(resolveOfficialExternalPluginId(voyage)).toBe("voyage"); + expect(resolveOfficialExternalPluginInstall(voyage)).toEqual({ + clawhubSpec: "clawhub:@openclaw/voyage-provider", + npmSpec: "@openclaw/voyage-provider", + defaultChoice: "npm", + minHostVersion: ">=2026.7.2", + }); + expect(manifest?.contracts?.memoryEmbeddingProviders).toEqual(["voyage"]); + expect(manifest?.providers).toEqual([ + expect.objectContaining({ + id: "voyage", + envVars: ["VOYAGE_API_KEY"], + }), + ]); + }); + it.each([ ["teams-meetings", "@openclaw/teams-meetings", "teams_meetings", "teams"], ["zoom-meetings", "@openclaw/zoom-meetings", "zoom_meetings", "zoom"], @@ -2067,6 +2087,12 @@ describe("official external plugin catalog", () => { providerIds: new Set(["groq", "moonshot", "zai"]), }), ).toEqual(["groq", "moonshot", "zai"]); + expect( + resolveOfficialExternalProviderContractPluginIds({ + contract: "memoryEmbeddingProviders", + providerIds: new Set(["voyage"]), + }), + ).toEqual(["voyage"]); }); it("maps env-only web-fetch credentials to external plugin owners", () => { @@ -2116,6 +2142,7 @@ describe("official external plugin catalog", () => { TOKENPLAN_API_KEY: "tokenplan-key", VENICE_API_KEY: "venice-key", AI_GATEWAY_API_KEY: "gateway-key", + VOYAGE_API_KEY: "voyage-key", ZAI_API_KEY: "zai-key", }), ).toEqual([ @@ -2138,6 +2165,7 @@ describe("official external plugin catalog", () => { "tencent", "venice", "vercel-ai-gateway", + "voyage", "zai", ]); expect(resolveOfficialExternalProviderPluginIdsForEnv({ GROQ_API_KEY: " " })).toEqual([]); diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index 84df1bcc921c..d8f337a6d754 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -375,6 +375,14 @@ describe("bundled plugin build entries", () => { expect(artifacts).not.toContain("dist/extensions/duckduckgo/package.json"); }); + it("excludes the externalized Voyage provider from bundled artifacts", () => { + const artifacts = listBundledPluginPackArtifacts(); + + expect(artifacts).not.toContain("dist/extensions/voyage/index.js"); + expect(artifacts).not.toContain("dist/extensions/voyage/openclaw.plugin.json"); + expect(artifacts).not.toContain("dist/extensions/voyage/package.json"); + }); + it("keeps bundled channel secret contracts on packed top-level sidecars", () => { const artifacts = listBundledPluginPackArtifacts(); const excludedPackageDirs = collectRootPackageExcludedExtensionDirs(); From 4ec46e0ff1ed8f31460dfbe8eb79118553e92f50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:54:03 -0700 Subject: [PATCH 47/59] fix(qa-lab): support groups and isolate thread timelines (#116803) Co-authored-by: Peter Steinberger --- extensions/qa-lab/web/src/app.browser.test.ts | 69 +++++++- extensions/qa-lab/web/src/app.ts | 13 +- .../qa-lab/web/src/ui-render-content.ts | 36 +++- extensions/qa-lab/web/src/ui-render.test.ts | 166 ++++++++++++++++++ extensions/qa-lab/web/src/ui-types.ts | 4 +- 5 files changed, 271 insertions(+), 17 deletions(-) diff --git a/extensions/qa-lab/web/src/app.browser.test.ts b/extensions/qa-lab/web/src/app.browser.test.ts index 47ed732933c4..0ab4dd5c950d 100644 --- a/extensions/qa-lab/web/src/app.browser.test.ts +++ b/extensions/qa-lab/web/src/app.browser.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Bootstrap, RunnerSelection } from "./ui-types.js"; +import type { Bootstrap, RunnerSelection, Snapshot } from "./ui-types.js"; const httpMock = vi.hoisted(() => { class QaLabHttpError extends Error { @@ -103,14 +103,17 @@ function createBootstrap(selection: RunnerSelection): Bootstrap { }; } -async function mountRunner(selection: RunnerSelection) { +async function mountRunner( + selection: RunnerSelection, + snapshot: Snapshot = { conversations: [], events: [], messages: [], threads: [] }, +) { let bootstrap = createBootstrap(selection); httpMock.getJson.mockImplementation(async (url: string) => { if (url === "/api/bootstrap") { return bootstrap; } if (url === "/api/state") { - return { conversations: [], events: [], messages: [], threads: [] }; + return snapshot; } if (url === "/api/report") { return { report: null }; @@ -195,6 +198,66 @@ afterEach(() => { }); describe("QA Lab runner browser interactions", () => { + it("sends group conversation messages from the interactive chat composer", async () => { + const root = await mountRunner( + { + alternateModel: "mock-openai/gpt-5.6-luna-alt", + channel: null, + channelDriver: "qa-channel", + evidenceMode: "full", + fastMode: false, + primaryModel: "mock-openai/gpt-5.6-luna", + profile: "all", + providerMode: "mock-openai", + runtimePair: null, + runtimePairLane: null, + scenarioIds: ["dm-chat-baseline"], + }, + { + conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + events: [], + messages: [], + threads: [ + { + accountId: "default", + conversationId: "qa-room", + id: "owned-thread", + title: "Owned thread", + }, + ], + }, + ); + httpMock.postJson.mockResolvedValue({ message: { id: "group-message" } }); + + root.querySelector("[data-thread-select='owned-thread']")?.click(); + selectValue(root, "#conversation-kind", "group"); + const conversationInput = root.querySelector("#conversation-id"); + if (!conversationInput) { + throw new Error("missing group conversation input"); + } + conversationInput.value = "qa-group"; + conversationInput.dispatchEvent(new Event("input", { bubbles: true })); + const composer = root.querySelector("#composer-text"); + if (!composer) { + throw new Error("missing group message composer"); + } + composer.value = "hello group"; + composer.dispatchEvent(new Event("input", { bubbles: true })); + root.querySelector("[data-action='send']")?.click(); + + await vi.waitFor(() => expect(httpMock.postJson).toHaveBeenCalledTimes(1)); + expect(httpMock.postJson).toHaveBeenCalledWith( + "/api/inbound/message", + expect.objectContaining({ + accountId: "default", + conversation: { id: "qa-group", kind: "group", title: "qa-group" }, + text: "hello group", + }), + ); + const submittedPayload = httpMock.postJson.mock.calls[0]?.[1] as Record; + expect(submittedPayload).not.toHaveProperty("threadId"); + }); + it("keeps scenario rows from collapsing inside the scrolling list", async () => { const root = await mountRunner({ alternateModel: "mock-openai/gpt-5.6-luna-alt", diff --git a/extensions/qa-lab/web/src/app.ts b/extensions/qa-lab/web/src/app.ts index c89a0382a019..0ff300627144 100644 --- a/extensions/qa-lab/web/src/app.ts +++ b/extensions/qa-lab/web/src/app.ts @@ -579,23 +579,29 @@ export async function createQaLabApp(root: HTMLDivElement) { state.selectedConversationKey, ); const accountId = selectedConversation?.accountId ?? "default"; + const selectedThreadId = + selectedConversation?.id === conversationId && + selectedConversation.kind === state.composer.conversationKind + ? state.selectedThreadId + : null; await postJson("/api/inbound/message", { accountId, conversation: { id: conversationId, kind: state.composer.conversationKind, - ...(state.composer.conversationKind === "channel" ? { title: conversationId } : {}), + ...(state.composer.conversationKind !== "direct" ? { title: conversationId } : {}), }, senderId: state.composer.senderId.trim() || "alice", senderName: state.composer.senderName.trim() || undefined, text, - ...(state.selectedThreadId ? { threadId: state.selectedThreadId } : {}), + ...(selectedThreadId ? { threadId: selectedThreadId } : {}), }); state.selectedConversationKey = conversationSelectionKey({ accountId, id: conversationId, kind: state.composer.conversationKind, }); + state.selectedThreadId = selectedThreadId; state.composer.text = ""; chatScrollLocked = true; await refresh(); @@ -1737,8 +1743,9 @@ export async function createQaLabApp(root: HTMLDivElement) { /* Composer form */ root.querySelector("#conversation-kind")?.addEventListener("change", (e) => { + const selectedKind = (e.currentTarget as HTMLSelectElement).value; state.composer.conversationKind = - (e.currentTarget as HTMLSelectElement).value === "channel" ? "channel" : "direct"; + selectedKind === "channel" || selectedKind === "group" ? selectedKind : "direct"; }); root.querySelector("#conversation-id")?.addEventListener("input", (e) => { state.composer.conversationId = (e.currentTarget as HTMLInputElement).value; diff --git a/extensions/qa-lab/web/src/ui-render-content.ts b/extensions/qa-lab/web/src/ui-render-content.ts index 27807ed69faf..8f4d83b02dd3 100644 --- a/extensions/qa-lab/web/src/ui-render-content.ts +++ b/extensions/qa-lab/web/src/ui-render-content.ts @@ -69,6 +69,11 @@ function deriveSelectedThread(state: UiState): string | null { function filteredMessages(state: UiState) { const messages = state.snapshot?.messages ?? []; + const selectedConversationThreadIds = new Set( + (state.snapshot?.threads ?? []) + .filter((thread) => threadConversationSelectionKey(thread) === state.selectedConversationKey) + .map((thread) => thread.id), + ); return messages.filter((message) => { if ( state.selectedConversationKey && @@ -76,10 +81,12 @@ function filteredMessages(state: UiState) { ) { return false; } - if (state.selectedThreadId && message.threadId !== state.selectedThreadId) { - return false; + if (state.selectedThreadId) { + return message.threadId === state.selectedThreadId; } - return true; + // External thread ids have no sidebar record, even when the conversation + // also owns navigable threads, so keep their messages in the root view. + return !message.threadId || !selectedConversationThreadIds.has(message.threadId); }); } @@ -88,18 +95,28 @@ function formatConversationLabel( conversations: Conversation[], ): string { const label = conversation.title || conversation.id; - const hasAccountCollision = conversations.some( + const sidebarCollisions = conversations.filter( (candidate) => - candidate.accountId !== conversation.accountId && - candidate.kind === conversation.kind && - candidate.id === conversation.id, + candidate !== conversation && + candidate.id === conversation.id && + (candidate.kind === "direct") === (conversation.kind === "direct"), ); - return hasAccountCollision ? `${label} (${conversation.accountId})` : label; + const hasAccountCollision = sidebarCollisions.some( + (candidate) => candidate.accountId !== conversation.accountId, + ); + const hasKindCollision = sidebarCollisions.some( + (candidate) => candidate.kind !== conversation.kind, + ); + const disambiguators = [ + ...(hasKindCollision ? [conversation.kind] : []), + ...(hasAccountCollision ? [conversation.accountId] : []), + ]; + return disambiguators.length > 0 ? `${label} (${disambiguators.join(", ")})` : label; } export function renderChatView(state: UiState): string { const conversations = state.snapshot?.conversations ?? []; - const channels = conversations.filter((c) => c.kind === "channel"); + const channels = conversations.filter((c) => c.kind === "channel" || c.kind === "group"); const dms = conversations.filter((c) => c.kind === "direct"); const threads = (state.snapshot?.threads ?? []).filter( (thread) => @@ -205,6 +222,7 @@ export function renderChatView(state: UiState): string { as diff --git a/extensions/qa-lab/web/src/ui-render.test.ts b/extensions/qa-lab/web/src/ui-render.test.ts index 8052fb327677..ca85a0b2dde8 100644 --- a/extensions/qa-lab/web/src/ui-render.test.ts +++ b/extensions/qa-lab/web/src/ui-render.test.ts @@ -158,6 +158,172 @@ describe("QA Lab UI evidence render", () => { expect(html).toContain( `data-conversation-key="${selectedConversationKey.replaceAll('"', """)}"`, ); + + const crossAccountKindHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + snapshot: { + conversations: [ + { accountId: "account-a", id: "shared", kind: "group" }, + { accountId: "account-b", id: "shared", kind: "channel" }, + ], + events: [], + messages: [], + threads: [], + }, + }), + ); + expect(crossAccountKindHtml).toContain("shared (group, account-a)"); + expect(crossAccountKindHtml).toContain("shared (channel, account-b)"); + }); + + it("shows group conversations in the sidebar and composer without leaking same-id rooms", () => { + const selectedConversationKey = JSON.stringify(["account-a", "group", "shared"]); + const html = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + composer: { + conversationId: "shared", + conversationKind: "group", + senderId: "alice", + senderName: "Alice", + text: "", + }, + snapshot: { + conversations: [ + { accountId: "account-a", id: "shared", kind: "group" }, + { accountId: "account-b", id: "shared", kind: "group" }, + { accountId: "account-a", id: "shared", kind: "channel" }, + { accountId: "account-a", id: "shared", kind: "direct" }, + ], + events: [], + messages: [ + { + accountId: "account-a", + conversation: { id: "shared", kind: "group" }, + direction: "inbound", + id: "selected-group-message", + reactions: [], + senderId: "alice", + text: "selected group message", + timestamp: 1, + }, + { + accountId: "account-b", + conversation: { id: "shared", kind: "group" }, + direction: "inbound", + id: "foreign-group-message", + reactions: [], + senderId: "bob", + text: "foreign group message", + timestamp: 2, + }, + { + accountId: "account-a", + conversation: { id: "shared", kind: "channel" }, + direction: "outbound", + id: "same-id-channel-message", + reactions: [], + senderId: "openclaw", + text: "same-id channel message", + timestamp: 3, + }, + ], + threads: [], + }, + }), + ); + + expect(html).toContain("shared (group, account-a)"); + expect(html).toContain("shared (group, account-b)"); + expect(html).toContain("shared (channel, account-a)"); + expect(html).toContain("selected group message"); + expect(html).not.toContain("foreign group message"); + expect(html).not.toContain("same-id channel message"); + expect(html).toContain(''); + expect(html).toContain( + `data-conversation-key="${selectedConversationKey.replaceAll('"', """)}"`, + ); + }); + + it("keeps thread replies out of the root timeline when thread navigation exists", () => { + const selectedConversationKey = JSON.stringify(["default", "channel", "qa-room"]); + const snapshot: NonNullable = { + conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + events: [], + messages: [ + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "root-message", + reactions: [], + senderId: "openclaw", + text: "root timeline message", + timestamp: 1, + }, + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "thread-message", + reactions: [], + senderId: "openclaw", + text: "thread-only reply", + threadId: "owned-thread", + timestamp: 2, + }, + { + accountId: "default", + conversation: { id: "qa-room", kind: "channel" }, + direction: "outbound", + id: "external-thread-message", + reactions: [], + senderId: "openclaw", + text: "externally observed thread reply", + threadId: "external-thread", + timestamp: 3, + }, + ], + threads: [ + { + accountId: "default", + conversationId: "qa-room", + id: "owned-thread", + title: "Owned thread", + }, + ], + }; + + const rootHtml = renderQaLabUi( + evidenceState({ activeTab: "chat", selectedConversationKey, snapshot }), + ); + expect(rootHtml).toContain("Main timeline"); + expect(rootHtml).toContain("root timeline message"); + expect(rootHtml).not.toContain("thread-only reply"); + expect(rootHtml).toContain("externally observed thread reply"); + + const threadHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + selectedThreadId: "owned-thread", + snapshot, + }), + ); + expect(threadHtml).not.toContain("root timeline message"); + expect(threadHtml).toContain("thread-only reply"); + expect(threadHtml).not.toContain("externally observed thread reply"); + + const externalThreadHtml = renderQaLabUi( + evidenceState({ + activeTab: "chat", + selectedConversationKey, + snapshot: { ...snapshot, threads: [] }, + }), + ); + expect(externalThreadHtml).toContain("thread-only reply"); }); it("renders capture startup commands without personal home paths", () => { diff --git a/extensions/qa-lab/web/src/ui-types.ts b/extensions/qa-lab/web/src/ui-types.ts index 04ee7759af51..5c86096abecf 100644 --- a/extensions/qa-lab/web/src/ui-types.ts +++ b/extensions/qa-lab/web/src/ui-types.ts @@ -18,7 +18,7 @@ import type { export type Conversation = { accountId: string; id: string; - kind: "direct" | "channel"; + kind: "direct" | "channel" | "group"; title?: string; }; @@ -371,7 +371,7 @@ export type UiState = { runnerDraftDirty: boolean; runnerPlanOverride: RunnerResolvedPlan | null; composer: { - conversationKind: "direct" | "channel"; + conversationKind: "direct" | "channel" | "group"; conversationId: string; senderId: string; senderName: string; From 4d7d710c483a7ba50aa512a069a07fbcede11392 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:56:07 -0700 Subject: [PATCH 48/59] fix(qa-channel): enforce message and thread lifecycle ownership (#116801) Co-authored-by: Peter Steinberger --- extensions/qa-channel/src/channel-actions.ts | 3 + extensions/qa-channel/src/channel.test.ts | 115 +++++++++++++++++++ extensions/qa-lab/src/bus-queries.ts | 2 +- extensions/qa-lab/src/bus-state.test.ts | 104 +++++++++++++++++ extensions/qa-lab/src/bus-state.ts | 35 +++++- extensions/qa-lab/src/self-check.test.ts | 9 +- 6 files changed, 263 insertions(+), 5 deletions(-) diff --git a/extensions/qa-channel/src/channel-actions.ts b/extensions/qa-channel/src/channel-actions.ts index 7761d0f2c957..16ace743ce75 100644 --- a/extensions/qa-channel/src/channel-actions.ts +++ b/extensions/qa-channel/src/channel-actions.ts @@ -163,6 +163,9 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = { // QA evidence must not validate a host target while the bus acts on a // foreign immutable message owner. assertQaMessageMatchesTarget(message, target); + if (message.deleted) { + throw new Error(`qa-channel message was deleted: ${message.id}`); + } return message; }; diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 0220a9a0ad81..4fce75457e4e 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -706,6 +706,121 @@ describe("qa-channel plugin", () => { } }); + it("keeps deleted messages out of channel actions and makes reactions idempotent", async () => { + installQaChannelTestRegistry(); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + + try { + const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl }); + const handleAction = requireQaActionHandler(); + const live = state.addOutboundMessage({ to: "channel:qa-room", text: "needle live" }); + const deleted = state.addOutboundMessage({ to: "channel:qa-room", text: "needle deleted" }); + const actionContext = { + channel: "qa-channel" as const, + cfg, + accountId: "default", + }; + const reactionParams = { + to: "channel:qa-room", + messageId: deleted.id, + emoji: "eyes", + }; + + await handleAction({ ...actionContext, action: "react", params: reactionParams }); + const cursorAfterReaction = state.getSnapshot().cursor; + await handleAction({ ...actionContext, action: "react", params: reactionParams }); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction); + expect(state.readMessage({ messageId: deleted.id }).reactions).toHaveLength(1); + + await handleAction({ + ...actionContext, + action: "delete", + params: { to: "channel:qa-room", messageId: deleted.id }, + }); + + for (const action of ["read", "reactions", "react", "edit", "delete"] as const) { + await expect( + handleAction({ + ...actionContext, + action, + params: { + to: "channel:qa-room", + messageId: deleted.id, + ...(action === "react" ? { emoji: "eyes" } : {}), + ...(action === "edit" ? { text: "edited after deletion" } : {}), + }, + }), + ).rejects.toThrow("qa-channel message was deleted"); + } + + const result = await handleAction({ + ...actionContext, + action: "search", + params: { query: "needle", channelId: "qa-room" }, + }); + const payload = extractToolPayload(result) as { messages: Array<{ id: string }> }; + expect(payload.messages.map((message) => message.id)).toEqual([live.id]); + expect(state.readMessage({ messageId: deleted.id }).deleted).toBe(true); + } finally { + await bus.stop(); + } + }); + + it("rejects thread replies outside the owning account and conversation", async () => { + installQaChannelTestRegistry(); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + + try { + const cfg = { + channels: { + "qa-channel": { + baseUrl: bus.baseUrl, + accounts: { other: { baseUrl: bus.baseUrl } }, + }, + }, + }; + const handleAction = requireQaActionHandler(); + const thread = state.createThread({ conversationId: "qa-room", title: "Owned thread" }); + + for (const attempt of [ + { accountId: "other", channelId: "qa-room" }, + { accountId: "default", channelId: "other-room" }, + ]) { + await expect( + handleAction({ + channel: "qa-channel", + action: "thread-reply", + cfg, + accountId: attempt.accountId, + params: { + channelId: attempt.channelId, + threadId: thread.id, + text: "foreign reply", + }, + }), + ).rejects.toThrow("qa-bus thread not found in selected account and conversation"); + } + expect(state.getSnapshot().messages).toEqual([]); + expect(state.getSnapshot().conversations).toEqual([ + { accountId: "default", id: "qa-room", kind: "channel" }, + ]); + + const result = await handleAction({ + channel: "qa-channel", + action: "thread-reply", + cfg, + accountId: "default", + params: { channelId: "qa-room", threadId: thread.id, text: "owned reply" }, + }); + const payload = extractToolPayload(result) as { message: { threadId: string } }; + expect(payload.message.threadId).toBe(thread.id); + } finally { + await bus.stop(); + } + }); + it("binds message-id actions and searches to the selected account and conversation", async () => { installQaChannelTestRegistry(); const state = createQaBusState(); diff --git a/extensions/qa-lab/src/bus-queries.ts b/extensions/qa-lab/src/bus-queries.ts index 28a787c80996..28d3e978edf2 100644 --- a/extensions/qa-lab/src/bus-queries.ts +++ b/extensions/qa-lab/src/bus-queries.ts @@ -115,7 +115,7 @@ export function searchQaBusMessages(params: { const limit = Math.max(1, Math.min(params.input.limit ?? 20, 100)); const query = normalizeOptionalLowercaseString(params.input.query); return Array.from(params.messages.values()) - .filter((message) => message.accountId === accountId) + .filter((message) => message.accountId === accountId && !message.deleted) .filter((message) => params.input.conversationId !== undefined ? message.conversation.id === params.input.conversationId diff --git a/extensions/qa-lab/src/bus-state.test.ts b/extensions/qa-lab/src/bus-state.test.ts index 425f8573b5fc..5ae1b2bd5c41 100644 --- a/extensions/qa-lab/src/bus-state.test.ts +++ b/extensions/qa-lab/src/bus-state.test.ts @@ -113,6 +113,110 @@ describe("qa-bus state", () => { expect(typeof snapshot.messages[0]?.reactions[0]?.timestamp).toBe("number"); }); + it("keeps deleted messages inspectable but removes them from mutations and search", () => { + const state = createQaBusState(); + const live = state.addOutboundMessage({ to: "channel:qa-room", text: "needle live" }); + const deleted = state.addOutboundMessage({ to: "channel:qa-room", text: "needle deleted" }); + + state.deleteMessage({ messageId: deleted.id }); + const cursorAfterDelete = state.getSnapshot().cursor; + + expect(state.readMessage({ messageId: deleted.id }).deleted).toBe(true); + expect(state.getSnapshot().messages.map((message) => message.id)).toEqual([ + live.id, + deleted.id, + ]); + expect(state.searchMessages({ query: "needle", limit: 1 })).toEqual([ + expect.objectContaining({ id: live.id }), + ]); + + expect(() => + state.editMessage({ messageId: deleted.id, text: "edited after deletion" }), + ).toThrow("qa-bus message was deleted"); + expect(() => state.reactToMessage({ messageId: deleted.id, emoji: "eyes" })).toThrow( + "qa-bus message was deleted", + ); + expect(() => state.deleteMessage({ messageId: deleted.id })).toThrow( + "qa-bus message was deleted", + ); + expect(state.getSnapshot().cursor).toBe(cursorAfterDelete); + }); + + it("adds each sender and emoji reaction at most once", () => { + const state = createQaBusState(); + const message = state.addOutboundMessage({ to: "channel:qa-room", text: "react once" }); + + state.reactToMessage({ messageId: message.id, emoji: "eyes", senderId: " alice " }); + const cursorAfterReaction = state.getSnapshot().cursor; + + const repeated = state.reactToMessage({ + messageId: message.id, + emoji: "eyes", + senderId: "alice", + }); + expect(repeated.reactions).toHaveLength(1); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction); + + state.reactToMessage({ messageId: message.id, emoji: "eyes", senderId: "bob" }); + state.reactToMessage({ messageId: message.id, emoji: "wave", senderId: "alice" }); + expect(state.readMessage({ messageId: message.id }).reactions).toEqual([ + expect.objectContaining({ emoji: "eyes", senderId: "alice" }), + expect.objectContaining({ emoji: "eyes", senderId: "bob" }), + expect.objectContaining({ emoji: "wave", senderId: "alice" }), + ]); + expect(state.getSnapshot().cursor).toBe(cursorAfterReaction + 2); + }); + + it("keeps owned threads scoped to their account, channel, and conversation", () => { + const state = createQaBusState(); + const thread = state.createThread({ + accountId: "account-a", + conversationId: "qa-room", + title: "Owned thread", + }); + const originalSnapshot = state.getSnapshot(); + + expect(() => + state.addOutboundMessage({ + accountId: "account-b", + to: `thread:qa-room/${thread.id}`, + text: "cross-account reply", + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + expect(() => + state.addOutboundMessage({ + accountId: "account-a", + to: `thread:other-room/${thread.id}`, + text: "wrong-room reply", + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + for (const kind of ["direct", "group"] as const) { + expect(() => + state.addInboundMessage({ + accountId: "account-a", + conversation: { id: "qa-room", kind }, + senderId: "alice", + text: "wrong-kind reply", + threadId: thread.id, + }), + ).toThrow("qa-bus thread not found in selected account and conversation"); + } + expect(state.getSnapshot()).toEqual(originalSnapshot); + + const reply = state.addOutboundMessage({ + accountId: "account-a", + to: `thread:qa-room/${thread.id}`, + text: "owned reply", + }); + const external = state.addOutboundMessage({ + accountId: "account-b", + to: "thread:other-room/external-thread", + text: "externally observed reply", + }); + expect(reply.threadId).toBe(thread.id); + expect(external.threadId).toBe("external-thread"); + }); + it("rejects cross-account message reads and mutations", () => { const state = createQaBusState(); const message = state.addOutboundMessage({ diff --git a/extensions/qa-lab/src/bus-state.ts b/extensions/qa-lab/src/bus-state.ts index ffcb227197ca..f28fe1eed097 100644 --- a/extensions/qa-lab/src/bus-state.ts +++ b/extensions/qa-lab/src/bus-state.ts @@ -122,6 +122,16 @@ export function createQaBusState() { return created; }; + const requireActiveMessageForAccount = ( + input: Pick, + ): QaBusMessage => { + const message = requireQaBusMessageForAccount({ messages, input }); + if (message.deleted) { + throw new Error(`qa-bus message was deleted: ${input.messageId}`); + } + return message; + }; + const createMessage = (params: { direction: QaBusMessage["direction"]; accountId: string; @@ -137,6 +147,17 @@ export function createQaBusState() { nativeCommand?: QaBusInboundMessageInput["nativeCommand"]; toolCalls?: QaBusToolCall[]; }): QaBusMessage => { + const thread = params.threadId ? threads.get(params.threadId) : undefined; + if ( + thread && + (thread.accountId !== params.accountId || + thread.conversationId !== params.conversation.id || + params.conversation.kind !== "channel") + ) { + // Unknown ids can represent externally observed threads; owned records + // must never cross account, conversation, or channel-kind boundaries. + throw new Error("qa-bus thread not found in selected account and conversation"); + } const storedConversation = ensureConversation(params.accountId, params.conversation); const toolCalls = sanitizeQaBusToolCalls(params.toolCalls); const message: QaBusMessage = { @@ -257,12 +278,20 @@ export function createQaBusState() { }, reactToMessage(input: QaBusReactToMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); const reaction = { emoji: input.emoji, senderId: input.senderId?.trim() || DEFAULT_BOT_ID, timestamp: input.timestamp ?? Date.now(), }; + if ( + message.reactions.some( + (existing) => + existing.emoji === reaction.emoji && existing.senderId === reaction.senderId, + ) + ) { + return cloneMessage(message); + } message.reactions.push(reaction); pushEvent({ kind: "reaction-added", @@ -275,7 +304,7 @@ export function createQaBusState() { }, editMessage(input: QaBusEditMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); message.text = input.text; message.editedAt = input.timestamp ?? Date.now(); pushEvent({ @@ -287,7 +316,7 @@ export function createQaBusState() { }, deleteMessage(input: QaBusDeleteMessageInput) { const accountId = normalizeAccountId(input.accountId); - const message = requireQaBusMessageForAccount({ messages, input }); + const message = requireActiveMessageForAccount(input); message.deleted = true; pushEvent({ kind: "message-deleted", diff --git a/extensions/qa-lab/src/self-check.test.ts b/extensions/qa-lab/src/self-check.test.ts index 0e1121bf2924..1aff1be8e461 100644 --- a/extensions/qa-lab/src/self-check.test.ts +++ b/extensions/qa-lab/src/self-check.test.ts @@ -125,6 +125,13 @@ describe("createQaSelfCheckScenario", () => { "thread:qa-room/thread-1", "thread:qa-room/thread-1", ]); - expect(state.searchMessages({ query: "inside thread" }).at(-1)?.deleted).toBe(true); + const deletedMessage = state.getSnapshot().messages.find((message) => message.deleted); + if (!deletedMessage) { + throw new Error("self-check did not preserve its deleted message tombstone"); + } + expect(state.readMessage({ messageId: deletedMessage.id }).deleted).toBe(true); + expect( + state.searchMessages({ query: "inside thread" }).map((message) => message.id), + ).not.toContain(deletedMessage.id); }); }); From c52bc53745257557b3384eb96ca21a74934ab6b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:57:29 -0700 Subject: [PATCH 49/59] fix(plugins): clean up partial service startup once (#116804) Co-authored-by: Peter Steinberger --- src/plugins/services.test.ts | 95 ++++++++++++++++++++++++++++++++++++ src/plugins/services.ts | 44 ++++++++++------- 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index 7162464fd877..4e40b5e0ad7c 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -169,6 +169,72 @@ describe("startPluginServices", () => { expectServiceLifecycleState({ starts, stops, contexts, config }); }); + it("rolls back partially started services before starting their siblings", async () => { + const acquired = new Set(); + const received = vi.fn(); + const siblingStart = vi.fn(); + const rollback = vi.fn((ctx: OpenClawPluginServiceContext) => { + acquired.delete("failed-service"); + ctx.gatewayEvents?.emit("rolled-back", {}, { scope: "operator.read" }); + }); + const broadcastPluginEvent = vi.fn(); + + const handle = await startPluginServices({ + registry: createRegistry([ + { + id: "failed-service", + start: (ctx) => { + acquired.add("failed-service"); + ctx.gatewayEvents?.onSessionsChanged(received); + throw new Error("start failed after acquiring resources"); + }, + stop: rollback, + }, + { id: "sibling-service", start: siblingStart }, + ]), + config: createServiceConfig(), + broadcastPluginEvent, + }); + + expect(rollback).toHaveBeenCalledOnce(); + expect(acquired.size).toBe(0); + expect(siblingStart).toHaveBeenCalledOnce(); + expect(broadcastPluginEvent).toHaveBeenCalledWith( + "plugin.plugin:test.rolled-back", + {}, + "operator.read", + ); + + queuePluginSessionsChanged({ sessionKey: "agent:main:main" }); + await Promise.resolve(); + expect(received).not.toHaveBeenCalled(); + + await handle.stop(); + expect(rollback).toHaveBeenCalledOnce(); + }); + + it("runs concurrent and repeated shutdowns through one cleanup operation", async () => { + let releaseStop: (() => void) | undefined; + const stopping = new Promise((resolve) => { + releaseStop = resolve; + }); + const stop = vi.fn(() => stopping); + const handle = await startTrackingServices({ + services: [{ id: "service", start: () => {}, stop }], + }); + + const firstStop = handle.stop(); + const secondStop = handle.stop(); + releaseStop?.(); + await Promise.all([firstStop, secondStop]); + + expect(firstStop).toBe(secondStop); + expect(stop).toHaveBeenCalledOnce(); + + await handle.stop(); + expect(stop).toHaveBeenCalledOnce(); + }); + it("binds gateway events to the owning plugin namespace and scope", async () => { const broadcastPluginEvent = vi.fn(); await startPluginServices({ @@ -445,6 +511,35 @@ describe("startPluginServices", () => { expect(stopThrows).toHaveBeenCalledOnce(); }); + it("continues starting siblings when rollback also fails", async () => { + const rollback = vi.fn(() => { + throw new Error("rollback failed"); + }); + const siblingStart = vi.fn(); + + const handle = await startTrackingServices({ + services: [ + { + id: "failed-service", + start: () => { + throw new Error("start failed"); + }, + stop: rollback, + }, + { id: "sibling-service", start: siblingStart }, + ], + }); + + expect(rollback).toHaveBeenCalledOnce(); + expect(siblingStart).toHaveBeenCalledOnce(); + expect(mockedLogger.warn).toHaveBeenCalledWith( + "plugin service stop failed (failed-service): Error: rollback failed", + ); + + await handle.stop(); + expect(rollback).toHaveBeenCalledOnce(); + }); + it("emits per-service startup trace spans and summary", async () => { const measured: string[] = []; const details: Array<{ diff --git a/src/plugins/services.ts b/src/plugins/services.ts index fdbc65cf57a9..ead9ac142b04 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -174,6 +174,17 @@ export async function startPluginServices(params: { stop?: () => void | Promise; revokeGatewayEvents: () => void; }> = []; + const stopService = async (entry: (typeof running)[number]) => { + try { + if (entry.stop) { + await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.()); + } + } catch (err) { + log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); + } finally { + entry.revokeGatewayEvents(); + } + }; let failedCount = 0; for (const entry of params.registry.services) { const service = entry.service; @@ -189,6 +200,11 @@ export async function startPluginServices(params: { service: entry, gatewayEvents: scopedGatewayEvents.gatewayEvents, }); + const runningService = { + id: service.id, + stop: service.stop ? () => service.stop?.(serviceContext) : undefined, + revokeGatewayEvents: scopedGatewayEvents.revoke, + }; try { const startService = () => withPluginHttpRouteRegistry(params.registry, () => service.start(serviceContext)); @@ -197,18 +213,15 @@ export async function startPluginServices(params: { } else { await startService(); } - running.push({ - id: service.id, - stop: service.stop ? () => service.stop?.(serviceContext) : undefined, - revokeGatewayEvents: scopedGatewayEvents.revoke, - }); + running.push(runningService); } catch (err) { - scopedGatewayEvents.revoke(); failedCount += 1; const error = err as Error; log.error( `plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}`, ); + // A failed start can already own resources; revoke events only after its cleanup runs. + await stopService(runningService); } } params.startupTrace?.detail?.("sidecars.plugin-services.summary", [ @@ -217,19 +230,14 @@ export async function startPluginServices(params: { ["failedCount", failedCount], ]); + let stopPromise: Promise | undefined; return { - stop: async () => { - for (const entry of running.toReversed()) { - try { - if (entry.stop) { - await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.()); - } - } catch (err) { - log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); - } finally { - entry.revokeGatewayEvents(); + stop: () => + // Store the shared promise before plugin cleanup runs so shutdown cannot start twice. + (stopPromise ??= Promise.resolve().then(async () => { + for (const entry of running.toReversed()) { + await stopService(entry); } - } - }, + })), }; } From 708c4d68dc50a64b688df10aac55fd5cdd82a318 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 04:59:06 -0700 Subject: [PATCH 50/59] fix(ui): preserve chat attachments and run ownership (#116752) Co-authored-by: Peter Steinberger --- ui/src/pages/chat/chat-gateway.test.ts | 105 ++++++++++++++++- ui/src/pages/chat/chat-gateway.ts | 14 ++- ui/src/pages/chat/chat-send-submit.test.ts | 128 +++++++++++++++++++++ ui/src/pages/chat/chat-send-submit.ts | 6 +- ui/src/pages/chat/run-lifecycle.test.ts | 47 ++++++++ ui/src/pages/chat/run-lifecycle.ts | 4 + 6 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 ui/src/pages/chat/chat-send-submit.test.ts diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 7e527fa19ab0..5e8c9fb8800f 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -853,6 +853,107 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStreamSegments).toEqual([]); }); + it.each([ + { + name: "provider timeout", + event: { + state: "error", + errorKind: "timeout", + errorMessage: "agent provider timeout", + }, + projectionStatus: "timeout", + sessionStatus: "timeout", + errorSummary: "Error: agent provider timeout", + }, + { + name: "provider failure", + event: { + state: "error", + errorMessage: "agent provider failure", + }, + projectionStatus: "error", + sessionStatus: "failed", + errorSummary: "Error: agent provider failure", + }, + { + name: "operator cancellation", + event: { state: "aborted" }, + projectionStatus: "aborted", + sessionStatus: "killed", + errorSummary: null, + }, + ] as const)( + "projects the canonical $name status onto the selected session", + ({ event, projectionStatus, sessionStatus, errorSummary }) => { + vi.useFakeTimers(); + try { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Partial assistant reply", + chatStreamStartedAt: 100, + }) as ChatState & { + chatRunStatus?: { phase: string; runId: string | null; sessionKey: string } | null; + lastLocalTerminalReconcile?: { sessionStatus: string } | null; + sessionsResult?: { + ts: number; + path: string; + count: number; + defaults: Record; + sessions: Array>; + }; + }; + state.sessionsResult = { + ts: 0, + path: "", + count: 1, + defaults: {}, + sessions: [ + { + key: "main", + kind: "direct", + updatedAt: 1, + hasActiveRun: true, + activeRunIds: ["run-1"], + status: "running", + startedAt: 100, + }, + ], + }; + + expect( + handleChatGatewayEvent(state, { + runId: "run-1", + sessionKey: "main", + ...event, + }), + ).toBe(event.state); + + expect( + getChatSessionProjection(state, state.chatMessages, { sessionKey: "main" }).runs["run-1"] + ?.status, + ).toBe(projectionStatus); + expect(state.sessionsResult.sessions[0]).toMatchObject({ + activeRunIds: [], + hasActiveRun: false, + status: sessionStatus, + }); + expect(state.lastLocalTerminalReconcile?.sessionStatus).toBe(sessionStatus); + expect(state.chatRunStatus).toMatchObject({ + phase: "interrupted", + runId: "run-1", + sessionKey: "main", + }); + expect(state.chatRunError?.summary ?? null).toBe(errorSummary); + expect(state.chatRunId).toBeNull(); + expect(state.chatStream).toBeNull(); + expect(state.chatStreamStartedAt).toBeNull(); + } finally { + vi.useRealTimers(); + } + }, + ); + it("reconciles cached run and indicator state on terminal events", () => { vi.useFakeTimers(); try { @@ -1950,7 +2051,7 @@ describe("handleChatGatewayEvent", () => { }, ); - it("does not let a completed run's late error interrupt a newer response", () => { + it("does not label a newer response with a completed run's late error", () => { const state = createState({ sessionKey: "main", chatRunId: "run-completed" }); expect( @@ -1982,7 +2083,7 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStream).toBe("Newer response"); expect(state.chatMessages).toHaveLength(1); expectTextChatMessage(state.chatMessages[0], "assistant", "Delivered once."); - expect(state.chatRunError).toEqual({ summary: "Error: late provider failure" }); + expect(state.chatRunError).toBeNull(); }); it("upgrades an empty final to one authoritative assistant reply", () => { diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index 0fa41cfc08ff..5589e7b8dfb6 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -273,11 +273,12 @@ function handleChatEvent( } if (payload.state === "error") { if ( + (!state.chatRunId || state.chatRunId === payload.runId) && payload.errorMessage?.trim() && projectedRun.currentRun?.errorMessage !== previousTerminalRun.errorMessage ) { - // A completed transcript is immutable; retain provider guidance without - // adopting its old run or interrupting a newer in-flight response. + // Completed-run diagnostics belong to an idle composer or that same run; + // publishing them over a newer response falsely marks the new run failed. setChatRunError(state, resolveGatewayErrorText(payload, null)); } return "error"; @@ -329,7 +330,7 @@ function handleChatEvent( }); const reconcileTerminalRun = ( outcome: "done" | "interrupted", - sessionStatus: "done" | "failed" | "killed", + sessionStatus: "done" | "failed" | "killed" | "timeout", ) => reconcileChatRunLifecycle(state as unknown as Parameters[0], { outcome, @@ -459,7 +460,12 @@ function handleChatEvent( state.chatMessages = materializeVisibleStream({ includeCurrent: true }); } } - reconcileTerminalRun("interrupted", "failed"); + // The shared Gateway projection owns timeout classification; preserve it + // when publishing selected-session and sidebar terminal status. + reconcileTerminalRun( + "interrupted", + projectedRun?.currentRun?.status === "timeout" ? "timeout" : "failed", + ); setChatRunError( state, resolveGatewayErrorText(payload, projectedErrorMessage ? visiblePayloadMessage : null), diff --git a/ui/src/pages/chat/chat-send-submit.test.ts b/ui/src/pages/chat/chat-send-submit.test.ts new file mode 100644 index 000000000000..da1c2dd6938a --- /dev/null +++ b/ui/src/pages/chat/chat-send-submit.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; +import { createSessionCapability } from "../../lib/sessions/index.ts"; +import { + getChatAttachmentDataUrl, + registerChatAttachmentPayload, + releaseChatAttachmentPayloads, +} from "./attachment-payload-store.ts"; +import type { ChatHost } from "./chat-send-contract.ts"; +import { handleSendChat } from "./chat-send-submit.ts"; + +const attachmentsToRelease: ChatAttachment[] = []; +const attachmentDataUrl = "data:application/pdf;base64,JVBERi0xLjQK"; + +afterEach(() => { + releaseChatAttachmentPayloads(attachmentsToRelease); + attachmentsToRelease.length = 0; +}); + +function createStagedAttachment(id: string): ChatAttachment { + const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" }); + const attachment = registerChatAttachmentPayload({ + attachment: { + id, + mimeType: "application/pdf", + fileName: "brief.pdf", + sizeBytes: file.size, + }, + dataUrl: attachmentDataUrl, + file, + }); + attachmentsToRelease.push(attachment); + return attachment; +} + +function createImmediateCommandHost( + command: string, + attachment: ChatAttachment, + overrides: Partial = {}, +): ChatHost { + const host = { + sessions: createSessionCapability({ + snapshot: { client: null, phase: "reconnecting", hello: null }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }), + client: null, + connected: true, + sessionKey: "agent:main", + chatLoading: false, + chatMessage: command, + chatMessages: [], + chatLocalInputHistoryBySession: {}, + chatInputHistorySessionKey: null, + chatInputHistoryItems: null, + chatInputHistoryIndex: -1, + chatDraftBeforeHistory: null, + chatAttachments: [attachment], + chatQueue: [], + chatRunId: null, + chatSending: false, + chatStream: null, + chatModelCatalog: [], + hello: null, + refreshSessionsAfterChat: new Map(), + ...overrides, + } satisfies Partial; + return host as ChatHost; +} + +describe("handleSendChat immediate local commands", () => { + it.each(["/export-session", "/export"])( + "preserves staged attachments while %s exports the chat", + async (command) => { + const attachment = createStagedAttachment("export-att"); + const exportCurrentChat = vi.fn(); + const host = createImmediateCommandHost(command, attachment, { exportCurrentChat }); + + await handleSendChat(host); + + expect(exportCurrentChat).toHaveBeenCalledOnce(); + expect(host.chatMessage).toBe(""); + expect(host.chatAttachments).toEqual([attachment]); + expect(getChatAttachmentDataUrl(attachment)).toBe(attachmentDataUrl); + expect(host.chatQueue).toStrictEqual([]); + }, + ); + + it("does not duplicate staged attachments into both old and new session composers", async () => { + const attachment = createStagedAttachment("new-session-att"); + const attachmentsBySession = new Map(); + const host = createImmediateCommandHost("/new", attachment); + host.createChatSession = vi.fn(async () => { + const previousSessionKey = host.sessionKey; + const nextSessionKey = "agent:main:new"; + // Session creation captures the next composer before route switching + // decides whether the old session's attachment needs a memory fallback. + const createdSessionAttachments = [...host.chatAttachments]; + attachmentsBySession.set(previousSessionKey, [...host.chatAttachments]); + host.sessionKey = nextSessionKey; + host.chatAttachments = createdSessionAttachments; + attachmentsBySession.set(nextSessionKey, [...host.chatAttachments]); + return true; + }); + + await handleSendChat(host); + + expect(host.createChatSession).toHaveBeenCalledOnce(); + expect(attachmentsBySession.get("agent:main")).toStrictEqual([]); + expect(attachmentsBySession.get("agent:main:new")).toStrictEqual([]); + expect(host.chatAttachments).toStrictEqual([]); + }); + + it("restores staged attachments when creating a new session is cancelled", async () => { + const attachment = createStagedAttachment("cancelled-new-session-att"); + const createChatSession = vi.fn(async () => false); + const host = createImmediateCommandHost("/new", attachment, { createChatSession }); + + await handleSendChat(host); + + expect(createChatSession).toHaveBeenCalledOnce(); + expect(host.chatMessage).toBe("/new"); + expect(host.chatAttachments).toHaveLength(1); + expect(host.chatAttachments[0]).toMatchObject(attachment); + expect(getChatAttachmentDataUrl(host.chatAttachments[0]!)).toBe(attachmentDataUrl); + }); +}); diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index d1de90a6f902..055d44fb8449 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -380,7 +380,11 @@ export async function handleSendChat( ).previousDraft; } else { host.chatMessage = ""; - host.chatAttachments = []; + // Export leaves the composer in its current session; /new must clear + // attachments before its handoff can capture them under both routes. + if (parsed.command.key !== "export-session") { + host.chatAttachments = []; + } resetChatInputHistoryNavigation(host); } } diff --git a/ui/src/pages/chat/run-lifecycle.test.ts b/ui/src/pages/chat/run-lifecycle.test.ts index 565fe579c133..fb39efc4411d 100644 --- a/ui/src/pages/chat/run-lifecycle.test.ts +++ b/ui/src/pages/chat/run-lifecycle.test.ts @@ -6,6 +6,7 @@ import type { SessionsListResult } from "../../api/types.ts"; import { isSessionRunActive } from "../../lib/session-run-state.ts"; import { CHAT_RUN_STATUS_TOAST_DURATION_MS, + handleAbortChat, hasAbortableSessionRun, reconcileChatRunFromCurrentSessionRow, reconcileChatRunFromSessionRow, @@ -62,6 +63,52 @@ function makeAbortHost(over: Partial = {}): AbortHost { }; } +describe("handleAbortChat", () => { + it("shows reconnect guidance when an offline session run has no browser run identity", async () => { + const request = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + const host = makeAbortHost({ + client, + connected: false, + chatMessage: "keep this draft", + sessionsResult: makeSessionsResult([ + { key: "agent:main", hasActiveRun: true, status: "running" }, + ]), + }); + + expect(hasAbortableSessionRun(host)).toBe(true); + await handleAbortChat(host, { preserveDraft: true }); + + expect(host.chatError).toBe("Not connected. Try again after reconnecting."); + expect(host.lastError).toBe(host.chatError); + expect(host.chatMessage).toBe("keep this draft"); + expect(host.pendingAbort).toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + }); + + it("keeps offline exact-run stops safely queued for reconnect", async () => { + const request = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + const host = makeAbortHost({ + client, + connected: false, + chatRunId: "run-main", + chatMessage: "keep this draft", + }); + + await handleAbortChat(host, { preserveDraft: true }); + + expect(host.pendingAbort).toEqual({ + sourceClient: client, + sessionKey: "agent:main", + runId: "run-main", + }); + expect(host.chatMessage).toBe("keep this draft"); + expect(host.chatError ?? null).toBeNull(); + expect(request).not.toHaveBeenCalled(); + }); +}); + describe("replayPendingChatAbort", () => { it("dispatches a queued exact browser run stop through chat.abort", async () => { const request = vi.fn(async () => ({ aborted: true })); diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index 6f13c3acec5f..c2761e8168e5 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -1,5 +1,6 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow, SessionRunStatus, SessionsListResult } from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; import { isSessionRunActive } from "../../lib/session-run-state.ts"; import { reconcileSessionRunTerminal, @@ -260,6 +261,9 @@ export async function handleAbortChat(host: ChatAbortHost, opts?: ChatAbortOptio : null; const pendingAbort = disconnectedIntent?.runId ? disconnectedIntent : null; if (!host.connected && !pendingAbort) { + // Session-only stops cannot be replayed safely against a later run. + // Explain the blocked action instead of leaving the visible Stop inert. + setChatError(host, t("chat.questions.disconnected")); return; } if (!opts?.preserveDraft) { From 9812380f10021e24d3a0b2258af4bd7132c22916 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:00:21 -0700 Subject: [PATCH 51/59] fix(ui): coalesce profile identity refresh (#116764) * fix(ui): coalesce profile identity refresh * style(ui): format profile refresh e2e --- ui/src/e2e/profile-page.e2e.test.ts | 55 ++++++++++ ui/src/pages/profile/profile-page.test.ts | 119 ++++++++++++++++++++++ ui/src/pages/profile/profile-page.ts | 30 ++++-- 3 files changed, 193 insertions(+), 11 deletions(-) diff --git a/ui/src/e2e/profile-page.e2e.test.ts b/ui/src/e2e/profile-page.e2e.test.ts index 91d4874114f5..6c1f2d609ed0 100644 --- a/ui/src/e2e/profile-page.e2e.test.ts +++ b/ui/src/e2e/profile-page.e2e.test.ts @@ -365,4 +365,59 @@ describeControlUiE2e("Control UI profile page mocked Gateway E2E", () => { await context.close(); } }); + + it("keeps identity refresh single-flight and retries after a failed request", async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["users.self"], + presenceUsers: testPresenceUsers, + methodResponses: { + "users.self": { profile: testProfile }, + }, + }); + + try { + const response = await page.goto(`${server.baseUrl}settings/profile`); + expect(response?.status()).toBe(200); + + const refresh = page.locator(".profile-refresh"); + await gateway.waitForRequest("users.self"); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(1); + await expect.poll(() => refresh.isDisabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refreshing…" [disabled]'); + + await refresh.evaluate((element) => { + const button = element as HTMLButtonElement; + button.click(); + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(1); + + await gateway.rejectDeferred("users.self", { message: "identity unavailable" }); + await page.getByText("identity unavailable", { exact: true }).waitFor({ timeout: 10_000 }); + await expect.poll(() => refresh.isEnabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refresh"'); + + await gateway.deferNext("users.self"); + await refresh.click(); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(2); + await expect.poll(() => refresh.isDisabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refreshing…" [disabled]'); + + await refresh.evaluate((element) => { + (element as HTMLButtonElement).dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await expect.poll(async () => (await gateway.getRequests("users.self")).length).toBe(2); + + await gateway.resolveDeferred("users.self", { profile: testProfile }); + const displayName = page.locator('.identity-name-control input[type="text"]'); + await displayName.waitFor({ timeout: 10_000 }); + await expect(displayName.inputValue()).resolves.toBe(testProfile.displayName); + await expect.poll(() => refresh.isEnabled()).toBe(true); + expect(await refresh.ariaSnapshot()).toContain('button "Refresh"'); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/pages/profile/profile-page.test.ts b/ui/src/pages/profile/profile-page.test.ts index cbfe73032e84..7ad89fcc60b2 100644 --- a/ui/src/pages/profile/profile-page.test.ts +++ b/ui/src/pages/profile/profile-page.test.ts @@ -317,6 +317,125 @@ it("retries the identity bootstrap when users.self returns no profile", async () ); }); +it("keeps identity refresh single-flight and allows retry after settlement", async () => { + const profile: UserProfile = { + id: "profile-1", + displayName: "Ada", + avatarMime: null, + mergedInto: null, + createdAt: 1, + updatedAt: 2, + emails: ["ada@example.test"], + hasAvatar: false, + }; + let rejectIdentity: ((reason: Error) => void) | undefined; + const firstIdentity = new Promise((_resolve, reject) => { + rejectIdentity = reject; + }); + const request = vi.fn(async (method: string) => { + if (method !== "users.self") { + throw new Error(`unexpected method: ${method}`); + } + if (request.mock.calls.length === 1) { + return await firstIdentity; + } + return { profile }; + }); + const harness = createConnectedContext(request as GatewayBrowserClient["request"], { + id: profile.id, + email: profile.emails[0], + name: profile.displayName ?? undefined, + }); + const provider = createApplicationContextProvider(harness.context); + const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement; + provider.append(page); + document.body.append(provider); + + await waitForFast(() => + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(1), + ); + await page.updateComplete; + const refresh = page.querySelector(".profile-refresh")!; + expect(refresh.disabled).toBe(true); + expect(refresh.textContent?.trim()).toBe(t("common.refreshing")); + + const pageWithIdentity = page as unknown as { loadIdentity: () => Promise }; + await Promise.all([pageWithIdentity.loadIdentity(), pageWithIdentity.loadIdentity()]); + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(1); + + rejectIdentity?.(new Error("identity unavailable")); + await waitForFast(() => expect(refresh.disabled).toBe(false)); + expect(refresh.textContent?.trim()).toBe(t("common.refresh")); + expect(page.textContent).toContain("identity unavailable"); + + refresh.click(); + await waitForFast(() => + expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(2), + ); + await waitForFast(() => + expect(page.querySelector(".identity-name-control input")?.value).toBe("Ada"), + ); +}); + +it("replaces an in-flight identity request after a same-client reconnect", async () => { + const staleProfile: UserProfile = { + id: "profile-1", + displayName: "Stale identity", + avatarMime: null, + mergedInto: null, + createdAt: 1, + updatedAt: 2, + emails: ["ada@example.test"], + hasAvatar: false, + }; + const freshProfile = { ...staleProfile, displayName: "Fresh identity", updatedAt: 3 }; + let resolveStale: ((value: { profile: UserProfile }) => void) | undefined; + let resolveFresh: ((value: { profile: UserProfile }) => void) | undefined; + const staleRequest = new Promise<{ profile: UserProfile }>((resolve) => { + resolveStale = resolve; + }); + const freshRequest = new Promise<{ profile: UserProfile }>((resolve) => { + resolveFresh = resolve; + }); + const request = vi.fn(async (method: string) => { + if (method !== "users.self") { + throw new Error(`unexpected method: ${method}`); + } + return await (request.mock.calls.length === 1 ? staleRequest : freshRequest); + }); + const harness = createConnectedContext(request as GatewayBrowserClient["request"], { + id: staleProfile.id, + email: staleProfile.emails[0], + name: staleProfile.displayName ?? undefined, + }); + const provider = createApplicationContextProvider(harness.context); + const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement; + provider.append(page); + document.body.append(provider); + + await waitForFast(() => expect(request).toHaveBeenCalledTimes(1)); + harness.emitConnected(false); + await page.updateComplete; + harness.emitConnected(true); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(2)); + + resolveFresh?.({ profile: freshProfile }); + await waitForFast(() => + expect(page.querySelector(".identity-name-control input")?.value).toBe( + "Fresh identity", + ), + ); + resolveStale?.({ profile: staleProfile }); + await staleRequest; + await Promise.resolve(); + await page.updateComplete; + + expect(page.querySelector(".identity-name-control input")?.value).toBe( + "Fresh identity", + ); + expect(request).toHaveBeenCalledTimes(2); +}); + it("bootstraps and refreshes the connected user's profile through users.self", async () => { let profile: UserProfile = { id: "profile-1", diff --git a/ui/src/pages/profile/profile-page.ts b/ui/src/pages/profile/profile-page.ts index 8694f834b163..6577ebb46c7f 100644 --- a/ui/src/pages/profile/profile-page.ts +++ b/ui/src/pages/profile/profile-page.ts @@ -85,19 +85,21 @@ export class ProfilePage extends OpenClawLightDomElement { private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) { const clientChanged = snapshot.client !== this.client; - const nextSelfUser = - snapshot.phase === "connected" - ? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser }) - : null; + const nextConnected = snapshot.phase === "connected"; + const connectionChanged = nextConnected !== this.connected; + const nextSelfUser = nextConnected + ? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser }) + : null; const selfProfileChanged = nextSelfUser?.id !== this.selfUser?.id; + const identitySourceChanged = clientChanged || connectionChanged || selfProfileChanged; this.client = snapshot.client; - this.connected = snapshot.phase === "connected"; + this.connected = nextConnected; this.selfUser = nextSelfUser; // connected/client are plain fields; an unidentified (token-auth) connect or // disconnect changes no @state, so the render branch must be invalidated // explicitly or the page sticks on the stale offline/connected view. this.requestUpdate(); - if (clientChanged || selfProfileChanged) { + if (identitySourceChanged) { this.identityRequestId += 1; this.ownProfile = null; this.displayName = ""; @@ -105,10 +107,10 @@ export class ProfilePage extends OpenClawLightDomElement { this.identityBusy = null; this.identityError = null; } - if (snapshot.phase !== "connected" || !snapshot.client) { + if (!nextConnected || !snapshot.client) { return; } - if (nextSelfUser && (clientChanged || selfProfileChanged)) { + if (nextSelfUser && identitySourceChanged) { void this.loadIdentity(); } void this.context.agents.ensureList().then((list) => { @@ -120,7 +122,9 @@ export class ProfilePage extends OpenClawLightDomElement { private async loadIdentity() { const client = this.client; - if (!client || !this.connected) { + // One active request owns the generation; reconnects clear loading before + // starting their replacement so stale responses cannot win out of order. + if (!client || !this.connected || this.identityLoading) { return; } const requestId = ++this.identityRequestId; @@ -300,7 +304,7 @@ export class ProfilePage extends OpenClawLightDomElement { } private refreshManually() { - if (this.selfUser && !this.identityBusy) { + if (this.selfUser && !this.identityBusy && !this.identityLoading) { void this.loadIdentity(); } } @@ -380,7 +384,11 @@ export class ProfilePage extends OpenClawLightDomElement { ${this.selfUser - ? html`` : nothing} From 28744126fcaacd76a55467f3f79fb1079dd8c7fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:01:46 -0700 Subject: [PATCH 52/59] fix(ui): restore mobile navigation and accessible usage filters (#116751) Co-authored-by: Peter Steinberger --- .../sessions-hub-header.browser.test.ts | 41 ++++++++++++++++--- ui/src/pages/usage/metrics.test.ts | 28 +++++++++++++ ui/src/pages/usage/metrics.ts | 7 +++- ui/src/styles/hub-tabs.css | 14 +++---- ui/src/styles/layout.mobile.css | 5 --- ui/src/styles/usage.css | 8 ++++ 6 files changed, 81 insertions(+), 22 deletions(-) diff --git a/ui/src/components/sessions-hub-header.browser.test.ts b/ui/src/components/sessions-hub-header.browser.test.ts index 125449e517ef..7bffa070c03b 100644 --- a/ui/src/components/sessions-hub-header.browser.test.ts +++ b/ui/src/components/sessions-hub-header.browser.test.ts @@ -1,5 +1,5 @@ import { html, render } from "lit"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; import "../styles.css"; import { renderSessionsHubHeader } from "./sessions-hub-header.ts"; @@ -11,7 +11,11 @@ async function useViewport(width: number, height = 800) { await page.viewport(width, height); } -async function mount(active: "sessions" | "worktrees", withActions: boolean) { +async function mount( + active: "sessions" | "worktrees", + withActions: boolean, + onSelect: (tab: "sessions" | "worktrees") => void = () => undefined, +) { const container = document.createElement("div"); container.style.width = "calc(100vw - 32px)"; container.style.maxWidth = "1120px"; @@ -21,7 +25,7 @@ async function mount(active: "sessions" | "worktrees", withActions: boolean) { active, title: "Threads", actions: withActions ? html`
Agent selector
` : undefined, - onSelect: () => undefined, + onSelect, }), container, ); @@ -79,10 +83,35 @@ describe.skipIf(!hasBrowserLayout)("Sessions hub header browser layout", () => { }, ); - it("keeps the page header hidden on mobile", async () => { + it("keeps session navigation and operational headers available on mobile", async () => { await useViewport(414, 800); - const sessions = await mount("sessions", true); + const onSelect = vi.fn(); + const sessions = await mount("sessions", true, onSelect); const header = sessions.querySelector(".hub-page-header"); - expect(getComputedStyle(header!).display).toBe("none"); + const tabs = sessions.querySelector(".sessions-hub-tabs"); + const actions = sessions.querySelector(".hub-page-header__actions"); + expect(getComputedStyle(header!).display).toBe("grid"); + expect(tabs?.getBoundingClientRect().width).toBeGreaterThan(0); + expect(actions?.getBoundingClientRect().width).toBeGreaterThan(0); + + const worktreesTab = sessions.querySelector("#sessions-tab-worktrees"); + expect(worktreesTab?.getBoundingClientRect().width).toBeGreaterThan(0); + worktreesTab?.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 })); + expect(onSelect).toHaveBeenCalledWith("worktrees"); + + const operationalHeader = document.createElement("section"); + operationalHeader.className = "content-header"; + operationalHeader.innerHTML = ''; + document.body.append(operationalHeader); + expect(getComputedStyle(operationalHeader).display).toBe("flex"); + expect( + operationalHeader.querySelector("button")?.getBoundingClientRect().width, + ).toBeGreaterThan(0); + + const chatContent = document.createElement("main"); + chatContent.className = "content content--chat"; + chatContent.innerHTML = '
'; + document.body.append(chatContent); + expect(getComputedStyle(chatContent.querySelector(".content-header")!).display).toBe("none"); }); }); diff --git a/ui/src/pages/usage/metrics.test.ts b/ui/src/pages/usage/metrics.test.ts index 9131cec172f4..4b2f106e2f32 100644 --- a/ui/src/pages/usage/metrics.test.ts +++ b/ui/src/pages/usage/metrics.test.ts @@ -358,6 +358,34 @@ describe("usage mosaic token buckets", () => { expect(container.querySelector(".usage-mosaic-total")?.textContent).toContain("10.0K"); }); + it("renders named, focusable hour toggles and preserves shift selection", () => { + const session = makeSessionWithTokenBuckets([ + { date: "2026-02-01", quarterIndex: 40, totalTokens: 10_000 }, + ]); + const onSelectHour = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + render(renderUsageMosaic([session], "utc", [10], onSelectHour), container); + + const cells = container.querySelectorAll(".usage-hour-cell"); + const selectedHour = cells[10]; + const unselectedHour = cells[11]; + expect(selectedHour).toBeInstanceOf(HTMLButtonElement); + expect(selectedHour?.type).toBe("button"); + expect(selectedHour?.getAttribute("aria-label")).toBe("10:00 · 10.0K tokens"); + expect(selectedHour?.getAttribute("aria-pressed")).toBe("true"); + expect(unselectedHour?.getAttribute("aria-pressed")).toBe("false"); + + selectedHour?.focus(); + expect(document.activeElement).toBe(selectedHour); + selectedHour?.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true })); + expect(onSelectHour).toHaveBeenCalledWith(10, true); + unselectedHour?.click(); + expect(onSelectHour).toHaveBeenCalledWith(11, false); + + container.remove(); + }); + it("renders precise UTC buckets in their local hour", () => { vi.spyOn(Date.prototype, "getHours").mockImplementation(function (this: Date) { return (this.getUTCHours() + 8) % 24; diff --git a/ui/src/pages/usage/metrics.ts b/ui/src/pages/usage/metrics.ts index 7ddd6cd67722..ce124d05e6d1 100644 --- a/ui/src/pages/usage/metrics.ts +++ b/ui/src/pages/usage/metrics.ts @@ -425,12 +425,15 @@ function renderUsageMosaic( : "color-mix(in srgb, var(--accent) 24%, transparent)"; const selected = selectedHours.includes(hour); return html` -
onSelectHour(hour, e.shiftKey)} - >
+ > `; })} diff --git a/ui/src/styles/hub-tabs.css b/ui/src/styles/hub-tabs.css index ab1cdef194c5..9a5501c8042d 100644 --- a/ui/src/styles/hub-tabs.css +++ b/ui/src/styles/hub-tabs.css @@ -120,11 +120,7 @@ wa-tab.hub-tab:focus-visible::part(base) { } @media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { - .content-header.sessions-hub-header { - display: none; - } - - .content-header.hub-page-header:not(.sessions-hub-header) { + .content-header.hub-page-header { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: @@ -136,20 +132,20 @@ wa-tab.hub-tab:focus-visible::part(base) { max-height: none; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__title { + .hub-page-header .hub-page-header__title { grid-area: intro; justify-self: stretch; } - .hub-page-header:not(.sessions-hub-header) .page-title { + .hub-page-header .page-title { display: none; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__tabs { + .hub-page-header .hub-page-header__tabs { grid-area: tabs; } - .hub-page-header:not(.sessions-hub-header) .hub-page-header__actions { + .hub-page-header .hub-page-header__actions { grid-area: actions; justify-self: center; } diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 61b515ca92b7..23cf82a4c37f 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -338,11 +338,6 @@ html.openclaw-native-macos body .shell--mobile-nav .topnav-shell__actions { font-size: 12px; } - /* Content */ - .content-header { - display: none; - } - /* Hide the entire content-header on mobile chat — controls are in mobile gear menu */ .content--chat .content-header { display: none; diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index a36a74de7fa3..4fb478307f2d 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -1158,6 +1158,9 @@ details.usage-filter-select summary::-webkit-details-marker, .usage-hour-cell { min-height: 46px; + padding: 0; + cursor: pointer; + appearance: none; transition: transform 0.18s var(--ease-out), border-color 0.18s var(--ease-out), @@ -1169,6 +1172,11 @@ details.usage-filter-select summary::-webkit-details-marker, box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); } +.usage-hour-cell:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); + outline-offset: 2px; +} + .usage-hour-cell.selected { border-color: color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); From f061b82d116f4291755f5c4abb36066834b7fccf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:05:16 -0700 Subject: [PATCH 53/59] fix(matrix): preserve message-tool room thread routing (#116802) Co-authored-by: Peter Steinberger --- .../src/channel.message-adapter.test.ts | 74 ++++++++ .../matrix/src/channel.threading.test.ts | 159 ++++++++++++++++++ extensions/matrix/src/channel.ts | 31 +++- extensions/matrix/src/session-route.test.ts | 30 ++++ extensions/matrix/src/session-route.ts | 2 + 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 extensions/matrix/src/channel.threading.test.ts diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index 53798b5ae83b..3541cb8445dd 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("./matrix/send.js", () => ({ + editMessageMatrix: vi.fn(), + reactMatrixMessage: vi.fn(), + resolveMatrixRoomId: vi.fn(), sendMessageMatrix: mocks.sendMessageMatrix, sendPollMatrix: vi.fn(), sendTypingMatrix: vi.fn(), @@ -65,6 +68,77 @@ describe("matrix channel message adapter", () => { expect(matrixPlugin.meta.markdownCapable).toBe(true); }); + it.each([ + { + name: "the current room with reply quoting disabled", + to: "room:!room:example", + replyToMode: "off" as const, + expectedThreadId: "$thread", + }, + { + name: "an equivalent room target prefix", + to: "matrix:channel:!room:example", + replyToMode: "all" as const, + expectedThreadId: "$thread", + }, + { + name: "a different room", + to: "room:!another:example", + replyToMode: "all" as const, + expectedThreadId: undefined, + }, + { + name: "a direct user target without proven room identity", + to: "user:@alice:example", + replyToMode: "all" as const, + expectedThreadId: undefined, + }, + ])("routes a native Matrix message action in $name", async (testCase) => { + const threading = matrixPlugin.threading; + const handleAction = matrixPlugin.actions?.handleAction; + if (!threading?.resolveAutoThreadId || !handleAction) { + throw new Error("Expected Matrix threaded message action adapters"); + } + const toolContext = { + currentChannelProvider: "matrix" as const, + currentChannelId: "room:!room:example", + currentThreadTs: "$thread", + currentMessageId: "$reply", + replyToMode: testCase.replyToMode, + hasRepliedRef: { value: true }, + }; + const threadId = threading.resolveAutoThreadId({ + cfg, + accountId: "default", + to: testCase.to, + toolContext, + replyToId: "$explicit-reply", + }); + + await handleAction({ + cfg, + channel: "matrix", + action: "send", + accountId: "default", + toolContext, + params: { + to: testCase.to, + message: "threaded native action", + replyTo: "$explicit-reply", + ...(threadId ? { threadId } : {}), + }, + }); + + expect(mocks.sendMessageMatrix).toHaveBeenCalledOnce(); + expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe(testCase.to); + expect(lastMatrixSendOptions()).toMatchObject({ + cfg, + accountId: "default", + replyToId: "$explicit-reply", + threadId: testCase.expectedThreadId, + }); + }); + beforeEach(() => { mocks.sendMessageMatrix.mockReset(); mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" }); diff --git a/extensions/matrix/src/channel.threading.test.ts b/extensions/matrix/src/channel.threading.test.ts new file mode 100644 index 000000000000..8e665d2372bf --- /dev/null +++ b/extensions/matrix/src/channel.threading.test.ts @@ -0,0 +1,159 @@ +// Matrix threading tests keep room-affinity coverage isolated from account/env fixtures. +import { describe, expect, it } from "vitest"; +import { matrixPlugin } from "./channel.js"; +import type { CoreConfig } from "./types.js"; + +function requireMatrixAutoThreadIdResolver() { + const resolveAutoThreadId = matrixPlugin.threading?.resolveAutoThreadId; + if (!resolveAutoThreadId) { + throw new Error("expected Matrix automatic thread resolver"); + } + return resolveAutoThreadId; +} + +function requireMatrixToolContextTargetMatcher() { + const matchesToolContextTarget = matrixPlugin.threading?.matchesToolContextTarget; + if (!matchesToolContextTarget) { + throw new Error("expected Matrix tool context target matcher"); + } + return matchesToolContextTarget; +} + +describe("matrix message-tool threading", () => { + it.each([ + { + name: "the exact current room", + currentChannelId: "room:!room:example.org", + target: "room:!room:example.org", + expected: true, + }, + { + name: "an equivalent Matrix room prefix", + currentChannelId: "matrix:room:!room:example.org", + target: "channel:!room:example.org", + expected: true, + }, + { + name: "a raw current room id", + currentChannelId: "!room:example.org", + target: "matrix:room:!room:example.org", + expected: true, + }, + { + name: "a different room", + currentChannelId: "room:!room:example.org", + target: "room:!another:example.org", + expected: false, + }, + { + name: "a room alias without verified room resolution", + currentChannelId: "room:!room:example.org", + target: "#room:example.org", + expected: false, + }, + { + name: "a direct user target without verified room identity", + currentChannelId: "room:!dm:example.org", + target: "user:@alice:example.org", + expected: false, + }, + { + name: "a room id with different case", + currentChannelId: "room:!Room:example.org", + target: "room:!room:example.org", + expected: false, + }, + { + name: "two user targets rather than a room", + currentChannelId: "user:@alice:example.org", + target: "user:@alice:example.org", + expected: false, + }, + ])("only matches $name by canonical Matrix room identity", (testCase) => { + const toolContext = { + currentChannelId: testCase.currentChannelId, + currentThreadTs: "$thread", + replyToMode: "off" as const, + }; + + expect( + requireMatrixToolContextTargetMatcher()({ + target: testCase.target, + toolContext, + }), + ).toBe(testCase.expected); + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: testCase.target, + toolContext, + }), + ).toBe(testCase.expected ? "$thread" : undefined); + }); + + it.each(["off", "first", "all", "batched"] as const)( + "preserves an existing Matrix room thread when replyToMode is %s", + (replyToMode) => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + replyToId: "$reply", + toolContext: { + currentChannelId: "matrix:room:!room:example.org", + currentThreadTs: "$thread", + replyToMode, + hasRepliedRef: { value: true }, + }, + }), + ).toBe("$thread"); + }, + ); + + it("does not infer a Matrix room thread without an existing thread root", () => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + toolContext: { currentChannelId: "room:!room:example.org" }, + }), + ).toBeUndefined(); + }); + + it("does not inherit Matrix room threads from another channel provider", () => { + const toolContext = { + currentChannelProvider: "slack" as const, + currentChannelId: "room:!room:example.org", + currentThreadTs: "$thread", + }; + + expect( + requireMatrixToolContextTargetMatcher()({ + target: "room:!room:example.org", + toolContext, + }), + ).toBe(false); + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "room:!room:example.org", + toolContext, + }), + ).toBeUndefined(); + }); + + it("does not infer Matrix DM room identity from a matching user messaging target", () => { + expect( + requireMatrixAutoThreadIdResolver()({ + cfg: {} as CoreConfig, + to: "user:@alice:example.org", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "room:!dm:example.org", + currentMessagingTarget: "user:@alice:example.org", + currentThreadTs: "$thread", + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 4722fc91afd6..546bef41bd5f 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -4,7 +4,10 @@ import { adaptScopedAccountAccessor, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; +import type { + ChannelDoctorAdapter, + ChannelThreadingToolContext, +} from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { createChannelMessageAdapterFromOutbound, @@ -339,6 +342,24 @@ function resolveMatrixDeliveryTarget(params: { return null; } +function matchesMatrixToolContextRoom(params: { + target: string; + toolContext: ChannelThreadingToolContext; +}): boolean { + const { toolContext } = params; + if (toolContext.currentChannelProvider && toolContext.currentChannelProvider !== "matrix") { + return false; + } + const currentTarget = toolContext.currentChannelId + ? resolveMatrixTargetIdentity(toolContext.currentChannelId) + : null; + const target = resolveMatrixTargetIdentity(params.target); + // A Matrix user target can select a different DM room; only verified room IDs may share threads. + return ( + currentTarget?.kind === "room" && target?.kind === "room" && currentTarget.id === target.id + ); +} + const matrixChannelOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkTextForOutbound, @@ -665,6 +686,14 @@ export const matrixPlugin: ChannelPlugin = ), }, threading: { + matchesToolContextTarget: matchesMatrixToolContextRoom, + resolveAutoThreadId: ({ to, toolContext }) => { + const threadId = normalizeOptionalString(toolContext?.currentThreadTs); + if (!threadId || !toolContext) { + return undefined; + } + return matchesMatrixToolContextRoom({ target: to, toolContext }) ? threadId : undefined; + }, resolveReplyToMode: createScopedAccountReplyToModeResolver< ReturnType >({ diff --git a/extensions/matrix/src/session-route.test.ts b/extensions/matrix/src/session-route.test.ts index 7c5292ffb587..6738c43a332c 100644 --- a/extensions/matrix/src/session-route.test.ts +++ b/extensions/matrix/src/session-route.test.ts @@ -296,6 +296,36 @@ describe("resolveMatrixOutboundSessionRoute", () => { expect(channelRoute.threadId).toBe("$RootEvent:Example.Org"); }); + it.each([ + { + name: "uses the Matrix thread root when replying to a child event", + threadId: "$ThreadRoot:Example.Org", + replyToId: "$ReplyChild:Example.Org", + expectedThreadId: "$ThreadRoot:Example.Org", + }, + { + name: "keeps reply-only session routing when no Matrix thread exists", + threadId: undefined, + replyToId: "$ReplyChild:Example.Org", + expectedThreadId: "$ReplyChild:Example.Org", + }, + ])("$name", ({ threadId, replyToId, expectedThreadId }) => { + const route = expectRoute( + resolveMatrixOutboundSessionRoute({ + cfg: {}, + agentId: "main", + target: "room:!ops:example.org", + threadId, + replyToId, + }), + ); + + expect(route.threadId).toBe(expectedThreadId); + expect(route.sessionKey).toBe( + `agent:main:matrix:channel:!ops:example.org:thread:${expectedThreadId}`, + ); + }); + it("does not claim room aliases as canonical inbound session ids", () => { const route = resolveMatrixOutboundSessionRoute({ cfg: {}, diff --git a/extensions/matrix/src/session-route.ts b/extensions/matrix/src/session-route.ts index c4006262f8ad..50269d116017 100644 --- a/extensions/matrix/src/session-route.ts +++ b/extensions/matrix/src/session-route.ts @@ -121,6 +121,8 @@ export function resolveMatrixOutboundSessionRoute(params: ChannelOutboundSession replyToId: params.replyToId, threadId: params.threadId, currentSessionKey: params.currentSessionKey, + // Matrix m.thread identifies the session; m.in_reply_to may name a different child event. + precedence: ["threadId", "replyToId", "currentSession"], normalizeThreadId: (threadId) => threadId, canRecoverCurrentThread: ({ route }) => route.peer.kind !== "direct" || (params.cfg.session?.dmScope ?? "main") !== "main", From c2b0def1375e20c7776f34850f55ee1b961ff860 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:08:10 -0700 Subject: [PATCH 54/59] fix(ollama): stream native tool call lifecycle (#116809) Co-authored-by: Peter Steinberger --- extensions/ollama/src/stream-runtime.test.ts | 247 ++++++++++++++++++- extensions/ollama/src/stream.test.ts | 4 +- extensions/ollama/src/stream.ts | 94 +++++-- 3 files changed, 312 insertions(+), 33 deletions(-) diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 01926cd5444c..737546be70d1 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1747,7 +1747,7 @@ describe("createOllamaStreamFn streaming events", () => { ); }); - it("emits only done for tool-call-only responses (no text content)", async () => { + it("streams the complete lifecycle for tool-call-only responses", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', @@ -1757,12 +1757,36 @@ describe("createOllamaStreamFn streaming events", () => { const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }); const events = await collectStreamEvents(stream); - // No text content means no start/text_start/text_delta/text_end events const types = events.map((e) => e.type); - expect(types).toEqual(["done"]); - const doneEvent = requireEntry(events, 0, "tool-call-only done event"); + expect(types).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[1]).toMatchObject({ + type: "toolcall_start", + contentIndex: 0, + partial: { content: [{ type: "toolCall", name: "bash", arguments: {} }] }, + }); + expect(events[2]).toMatchObject({ + type: "toolcall_delta", + contentIndex: 0, + delta: '{"command":"ls"}', + }); + expect(events[3]).toMatchObject({ + type: "toolcall_end", + contentIndex: 0, + toolCall: { name: "bash", arguments: { command: "ls" } }, + }); + const doneEvent = requireEntry(events, 4, "tool-call-only done event"); if (doneEvent.type === "done") { expect(doneEvent.reason).toBe("toolUse"); + expect(doneEvent.message.content[0]).toMatchObject({ + type: "toolCall", + id: events[3]?.type === "toolcall_end" ? events[3].toolCall.id : undefined, + }); } }, ); @@ -1839,7 +1863,21 @@ describe("createOllamaStreamFn streaming events", () => { const events = await collectStreamEvents(stream); const types = events.map((e) => e.type); - expect(types).toEqual(["start", "text_start", "text_delta", "text_end", "done"]); + expect(types).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[5]).toMatchObject({ + type: "toolcall_delta", + contentIndex: 1, + delta: '{"command":"ls"}', + }); const doneEvent = events.at(-1); if (doneEvent?.type === "done") { expect(doneEvent.reason).toBe("toolUse"); @@ -1848,6 +1886,104 @@ describe("createOllamaStreamFn streaming events", () => { ); }); + it("streams multiple native calls with stable provider ids across chunks", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"id":"call-read","function":{"name":"read","arguments":{"path":"/tmp/a"}}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"id":"call-bash","function":{"name":"bash","arguments":"{\\"command\\":\\"ls\\"}"}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + const toolCallEnds = events.filter((event) => event.type === "toolcall_end"); + expect(toolCallEnds).toMatchObject([ + { + contentIndex: 0, + toolCall: { id: "call-read", name: "read", arguments: { path: "/tmp/a" } }, + }, + { + contentIndex: 1, + toolCall: { id: "call-bash", name: "bash", arguments: { command: "ls" } }, + }, + ]); + expect(events.filter((event) => event.type === "toolcall_delta")).toMatchObject([ + { contentIndex: 0, delta: '{"path":"/tmp/a"}' }, + { contentIndex: 1, delta: '{"command":"ls"}' }, + ]); + expect(events.filter((event) => event.type === "toolcall_start")).toMatchObject([ + { partial: { content: [{ arguments: {} }] } }, + { + partial: { + content: [{ arguments: { path: "/tmp/a" } }, { arguments: {} }], + }, + }, + ]); + const done = events.at(-1); + if (done?.type !== "done") { + throw new Error("missing terminal Ollama message"); + } + expect(done.message.content).toMatchObject([ + { type: "toolCall", id: "call-read" }, + { type: "toolCall", id: "call-bash" }, + ]); + }, + ); + }); + + it("does not stream non-executable calls from a token-limited final chunk", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":true,"done_reason":"length"}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual(["done"]); + expect(events[0]).toMatchObject({ + type: "done", + reason: "length", + message: { content: [], stopReason: "length" }, + }); + }, + ); + }); + + it("never exposes an intermediate native call invalidated by a later length terminal", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"done_reason":"length"}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }), + ); + + expect(events.map((event) => event.type)).toEqual(["done"]); + expect(events[0]).toMatchObject({ + type: "done", + reason: "length", + message: { content: [], stopReason: "length" }, + }); + }, + ); + }); + it("emits text_end as soon as Ollama switches from text to tool calls", async () => { const controlledFetch = createControlledNdjsonFetch(); fetchWithSsrFGuardMock.mockImplementation(controlledFetch.fetchImpl); @@ -1899,6 +2035,20 @@ describe("createOllamaStreamFn streaming events", () => { ); controlledFetch.close(); + const toolCallStartEvent = await nextEventWithin(iterator); + const toolCallDeltaEvent = await nextEventWithin(iterator); + const toolCallEndEvent = await nextEventWithin(iterator); + expect(toolCallStartEvent).not.toBe("timeout"); + expect(toolCallDeltaEvent).not.toBe("timeout"); + expect(toolCallEndEvent).not.toBe("timeout"); + expectIteratorEvent(toolCallStartEvent, { type: "toolcall_start", done: false }); + expectIteratorEvent(toolCallDeltaEvent, { + type: "toolcall_delta", + delta: '{"command":"ls"}', + done: false, + }); + expectIteratorEvent(toolCallEndEvent, { type: "toolcall_end", done: false }); + const doneEvent = await nextEventWithin(iterator); expect(doneEvent).not.toBe("timeout"); if (doneEvent !== "timeout" && doneEvent.done === false) { @@ -2343,7 +2493,14 @@ describe("createOllamaStreamFn streaming events", () => { }); const events = await collectStreamEvents(stream); - expect(events.map((e) => e.type)).toEqual(["done"]); + expect(events.map((event) => event.type)).toEqual([ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(JSON.stringify(events)).not.toContain("I should think privately"); const doneEvent = events.at(-1); expect(doneEvent?.type).toBe("done"); if (doneEvent?.type === "done") { @@ -2360,6 +2517,84 @@ describe("createOllamaStreamFn streaming events", () => { }, ); }); + + it("flushes buffered visible Kimi text before streaming its native tool call", async () => { + await withMockNdjsonFetch( + [ + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"Visible answer"},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { id: "kimi-k2.6:cloud", provider: "ollama" }, + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(events[2]).toMatchObject({ type: "text_delta", delta: "Visible answer" }); + expect(events[4]).toMatchObject({ type: "toolcall_start", contentIndex: 1 }); + expect(events[6]).toMatchObject({ type: "toolcall_end", contentIndex: 1 }); + expect(events.at(-1)).toMatchObject({ + type: "done", + message: { + content: [ + { type: "text", text: "Visible answer" }, + { type: "toolCall", name: "bash" }, + ], + }, + }); + }, + ); + }); + + it("does not reveal buffered Kimi reasoning for an empty tool-call chunk", async () => { + const hiddenPrefix = + "I should think privately and not leak this planning text in the answer. " + + "I need to keep deciding what to say next."; + await withMockNdjsonFetch( + [ + JSON.stringify({ + model: "kimi-k2.6:cloud", + created_at: "t", + message: { role: "assistant", content: hiddenPrefix }, + done: false, + }), + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[]},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":" ️ Visible answer"},"done":false}', + '{"model":"kimi-k2.6:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true}', + ], + async () => { + const events = await collectStreamEvents( + await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { id: "kimi-k2.6:cloud", provider: "ollama" }, + }), + ); + + expect(events.map((event) => event.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "done", + ]); + expect(events[2]).toMatchObject({ type: "text_delta", delta: "Visible answer" }); + expect(JSON.stringify(events)).not.toContain("I should think privately"); + }, + ); + }); }); describe("createOllamaStreamFn", () => { diff --git a/extensions/ollama/src/stream.test.ts b/extensions/ollama/src/stream.test.ts index e8aa0ec4532b..ebe011d5393f 100644 --- a/extensions/ollama/src/stream.test.ts +++ b/extensions/ollama/src/stream.test.ts @@ -325,9 +325,7 @@ describe("createOllamaStreamFn thinking events", () => { }; expect(done.reason).toBe("length"); expect(done.message?.stopReason).toBe("length"); - expect(done.message?.content).toEqual([ - expect.objectContaining({ type: "toolCall", name: "read" }), - ]); + expect(done.message?.content).toEqual([]); }); it("uses generic stream timeout for Ollama request timeout", async () => { diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 01ef98b8f216..508bc7edd9a3 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -1262,6 +1262,7 @@ function createRawOllamaStreamFn( let accumulatedThinking = ""; let suppressedThinking = ""; const accumulatedToolCalls: OllamaToolCall[] = []; + const streamedToolCalls: ToolCall[] = []; let finalResponse: OllamaChatResponse | undefined; let pendingFinalVisibleContent: string | undefined; const modelInfo = { @@ -1291,9 +1292,24 @@ function createRawOllamaStreamFn( if (accumulatedVisibleContent) { parts.push({ type: "text", text: accumulatedVisibleContent }); } + parts.push(...streamedToolCalls); return parts; }; + const ensureStreamStarted = () => { + if (streamStarted) { + return; + } + streamStarted = true; + const emptyPartial = buildStreamAssistantMessage({ + model: modelInfo, + content: [], + stopReason: "stop", + usage: buildUsageWithNoCost({}), + }); + stream.push({ type: "start", partial: emptyPartial }); + }; + const closeThinkingBlock = () => { if (!thinkingStarted || thinkingEnded) { return; @@ -1345,16 +1361,7 @@ function createRawOllamaStreamFn( closeThinkingBlock(); } - if (!streamStarted) { - streamStarted = true; - const emptyPartial = buildStreamAssistantMessage({ - model: modelInfo, - content: [], - stopReason: "stop", - usage: buildUsageWithNoCost({}), - }); - stream.push({ type: "start", partial: emptyPartial }); - } + ensureStreamStarted(); if (!textBlockStarted) { textBlockStarted = true; const partial = buildStreamAssistantMessage({ @@ -1392,16 +1399,7 @@ function createRawOllamaStreamFn( refreshTimeout?.(); const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning; if (thinkingDelta && shouldEmitThinking) { - if (!streamStarted) { - streamStarted = true; - const emptyPartial = buildStreamAssistantMessage({ - model: modelInfo, - content: [], - stopReason: "stop", - usage: buildUsageWithNoCost({}), - }); - stream.push({ type: "start", partial: emptyPartial }); - } + ensureStreamStarted(); if (!thinkingStarted) { thinkingStarted = true; const partial = buildStreamAssistantMessage({ @@ -1435,10 +1433,18 @@ function createRawOllamaStreamFn( accumulatedRawContent += rawDelta; flushVisibleText(resolveVisibleContent(false)); } - if (chunk.message?.tool_calls) { + if (chunk.message?.tool_calls?.length) { + // Kimi holds short visible prefixes until a terminal boundary; + // settle them now so later tool indices cannot overwrite text. + flushVisibleText(resolveVisibleContent(true)); closeThinkingBlock(); closeTextBlock(); - accumulatedToolCalls.push(...chunk.message.tool_calls); + for (const rawToolCall of chunk.message.tool_calls) { + // Ollama can report a length stop in a later chunk, so no call + // becomes executable until its authoritative terminal arrives. + const id = readOllamaToolCallId(rawToolCall.id) ?? `ollama_call_${randomUUID()}`; + accumulatedToolCalls.push({ ...rawToolCall, id }); + } } if (chunk.done) { pendingFinalVisibleContent = resolveVisibleContent(true); @@ -1473,7 +1479,11 @@ function createRawOllamaStreamFn( if (accumulatedThinking) { finalResponse.message.thinking = accumulatedThinking; } - if (accumulatedToolCalls.length > 0) { + if (finalResponse.done_reason === "length") { + // All consumers inspect terminal content, not only lifecycle events; + // a token-limit stop must never retain an executable-looking call. + delete finalResponse.message.tool_calls; + } else if (accumulatedToolCalls.length > 0) { finalResponse.message.tool_calls = accumulatedToolCalls; } @@ -1491,9 +1501,45 @@ function createRawOllamaStreamFn( closeThinkingBlock(); closeTextBlock(); + const reason = resolveOllamaStopReason(finalResponse); + if (reason === "toolUse") { + for (const completedToolCall of assistantMessage.content) { + if (completedToolCall.type !== "toolCall") { + continue; + } + ensureStreamStarted(); + const placeholder: ToolCall = { ...completedToolCall, arguments: {} }; + streamedToolCalls.push(placeholder); + const contentIndex = buildCurrentContent().length - 1; + const partial = () => + buildStreamAssistantMessage({ + model: modelInfo, + content: buildCurrentContent(), + stopReason: "stop", + usage: buildUsageWithNoCost({}), + }); + stream.push({ type: "toolcall_start", contentIndex, partial: partial() }); + // Replace the placeholder instead of mutating it: queued start + // snapshots must not see arguments before their delta arrives. + streamedToolCalls[streamedToolCalls.length - 1] = completedToolCall; + stream.push({ + type: "toolcall_delta", + contentIndex, + delta: JSON.stringify(completedToolCall.arguments), + partial: partial(), + }); + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: completedToolCall, + partial: partial(), + }); + } + } + stream.push({ type: "done", - reason: resolveOllamaStopReason(finalResponse), + reason, message: assistantMessage, }); } finally { From 410c1633283d728e87e3a392964e41c03c615ccd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:08:22 -0700 Subject: [PATCH 55/59] fix(browser): preserve doctor JSON failure status (#116811) * fix(browser): preserve doctor JSON exit status * fix(browser): defer doctor failure exit * test(browser): harden doctor JSON assertions --- .../src/cli/browser-cli-manage.test.ts | 91 ++++++++++++++++++- .../browser/src/cli/browser-cli-manage.ts | 7 +- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/extensions/browser/src/cli/browser-cli-manage.test.ts b/extensions/browser/src/cli/browser-cli-manage.test.ts index f6f8f5eaba7f..c3867f5d9438 100644 --- a/extensions/browser/src/cli/browser-cli-manage.test.ts +++ b/extensions/browser/src/cli/browser-cli-manage.test.ts @@ -1,5 +1,5 @@ // Browser tests cover browser cli manage plugin behavior. -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createBrowserManageProgram, getBrowserManageCallBrowserRequestMock, @@ -15,10 +15,26 @@ function lastRuntimeLog(): string { return value; } +function parseSingleRuntimeJson(): unknown { + const logs = getBrowserCliRuntimeCapture().runtimeLogs; + expect(logs).toHaveLength(1); + return JSON.parse(logs[0] ?? ""); +} + describe("browser manage output", () => { + let previousExitCode: typeof process.exitCode; + beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = undefined; getBrowserManageCallBrowserRequestMock().mockClear(); getBrowserCliRuntimeCapture().resetRuntimeCapture(); + getBrowserCliRuntime().exit.mockClear(); + getBrowserCliRuntime().writeJson.mockClear(); + }); + + afterEach(() => { + process.exitCode = previousExitCode; }); it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => { @@ -524,6 +540,72 @@ describe("browser manage output", () => { expect(output).toContain("OK gateway: browser control endpoint reachable"); expect(output).toContain("OK graphics: software"); expect(output).toContain("OK tabs: 1 visible, use tab reference t1"); + expect(getBrowserCliRuntime().writeJson).not.toHaveBeenCalled(); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("prints one complete JSON browser doctor failure before setting exit status", async () => { + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => { + if (req.path === "/") { + return { + enabled: false, + profile: "openclaw", + transport: "cdp", + running: false, + }; + } + if (req.path === "/profiles") { + return { profiles: [] }; + } + return {}; + }); + + const program = createBrowserManageProgram(); + await program.parseAsync(["browser", "--json", "doctor"], { from: "user" }); + + expect(parseSingleRuntimeJson()).toEqual( + expect.objectContaining({ + ok: false, + checks: expect.arrayContaining([ + expect.objectContaining({ name: "gateway", ok: true }), + expect.objectContaining({ name: "plugin", ok: false }), + ]), + }), + ); + expect(getBrowserCliRuntimeCapture().runtimeErrors).toEqual([]); + expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("prints one JSON browser doctor report and succeeds when every check passes", async () => { + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => { + if (req.path === "/") { + return { + enabled: true, + profile: "openclaw", + transport: "cdp", + running: true, + }; + } + if (req.path === "/profiles") { + return { profiles: [{ name: "openclaw", running: true }] }; + } + if (req.path === "/tabs") { + return { running: true, tabs: [] }; + } + return {}; + }); + + const program = createBrowserManageProgram(); + await program.parseAsync(["browser", "--json", "doctor"], { from: "user" }); + + expect(parseSingleRuntimeJson()).toMatchObject({ ok: true }); + expect(getBrowserCliRuntimeCapture().runtimeErrors).toEqual([]); + expect(getBrowserCliRuntime().writeJson).toHaveBeenCalledTimes(1); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); }); it("prints a readable browser doctor failure when gateway auth SecretRefs are unavailable", async () => { @@ -534,9 +616,7 @@ describe("browser manage output", () => { getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error); const program = createBrowserManageProgram(); - await expect(program.parseAsync(["browser", "doctor"], { from: "user" })).rejects.toThrow( - "__exit__:1", - ); + await program.parseAsync(["browser", "doctor"], { from: "user" }); const output = lastRuntimeLog(); expect(output).toContain( @@ -544,5 +624,8 @@ describe("browser manage output", () => { ); expect(output).toContain("OPENCLAW_GATEWAY_TOKEN"); expect(output).not.toContain("GatewaySecretRefUnavailableError"); + expect(getBrowserCliRuntime().writeJson).not.toHaveBeenCalled(); + expect(getBrowserCliRuntime().exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); }); diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index 0071eda3e73a..1ba6fd525eeb 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -413,12 +413,11 @@ export function registerBrowserManageCommands( const profile = parent?.browserProfile; await runBrowserCommand(async () => { const result = await runBrowserDoctor(parent, profile, opts.deep === true); - if (printJsonResult(parent, result)) { - return; + if (!printJsonResult(parent, result)) { + defaultRuntime.log(result.checks.map(formatDoctorLine).join("\n")); } - defaultRuntime.log(result.checks.map(formatDoctorLine).join("\n")); if (!result.ok) { - defaultRuntime.exit(1); + process.exitCode = 1; } }); }); From 7af2bb62622e1999c0dc2ecc7ef7bf4290987894 Mon Sep 17 00:00:00 2001 From: wangmiao0668000666 Date: Fri, 31 Jul 2026 20:09:28 +0800 Subject: [PATCH 56/59] fix(file-transfer): keep fetched media attachable in sandboxed replies (#116400) Fixes #116338 Co-authored-by: wangmiao0668000666 --- .../file-transfer/src/tools/descriptors.ts | 6 +- .../src/tools/dir-fetch-tool.test.ts | 19 +++- .../file-transfer/src/tools/dir-fetch-tool.ts | 2 +- .../src/tools/file-fetch-tool.test.ts | 13 +-- .../src/tools/file-write-tool.test.ts | 38 +++++++- src/agents/sandbox-paths.test.ts | 96 ++++++++++++++----- 6 files changed, 134 insertions(+), 40 deletions(-) diff --git a/extensions/file-transfer/src/tools/descriptors.ts b/extensions/file-transfer/src/tools/descriptors.ts index b7bb1ea9ab38..4c0675168252 100644 --- a/extensions/file-transfer/src/tools/descriptors.ts +++ b/extensions/file-transfer/src/tools/descriptors.ts @@ -8,9 +8,9 @@ type FileTransferToolDescriptor = Pick< "label" | "name" | "description" | "parameters" >; -// Stash fetched files in a non-TTL subdir so follow-up tool calls within -// the same turn can still reference them. -export const FILE_TRANSFER_SUBDIR = "file-transfer"; +// Keep fetched files in the managed tool-media namespace so sandboxed replies +// can attach them and follow-up file_write calls can reuse the media id. +export const FILE_TRANSFER_SUBDIR = "tool-file-transfer"; export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024; export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024; diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts index 656d5880122b..8fa3cd1a20dc 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import * as tar from "tar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DIR_FETCH_HARD_MAX_BYTES, FILE_TRANSFER_SUBDIR } from "./descriptors.js"; let tmpRoot: string; @@ -37,12 +38,13 @@ async function createTarBuffer(params: { async function importTool(tarBuffer: Buffer) { const archivePath = path.join(tmpRoot, `archive-${randomUUID()}.tar.gz`); const appendFileTransferAudit = vi.fn(async () => undefined); + const saveMediaBuffer = vi.fn(async () => { + await fs.writeFile(archivePath, tarBuffer); + return { path: archivePath }; + }); vi.resetModules(); vi.doMock("openclaw/plugin-sdk/media-store", () => ({ - saveMediaBuffer: vi.fn(async () => { - await fs.writeFile(archivePath, tarBuffer); - return { path: archivePath }; - }), + saveMediaBuffer, })); vi.doMock("../shared/audit.js", () => ({ appendFileTransferAudit })); vi.doMock("./node-tool-invoke.js", () => ({ @@ -67,6 +69,7 @@ async function importTool(tarBuffer: Buffer) { return { archivePath, appendFileTransferAudit, + saveMediaBuffer, module: await import("./dir-fetch-tool.js"), }; } @@ -86,7 +89,7 @@ describe("dir.fetch archive extraction", () => { await fs.writeFile(path.join(sourceDir, "ok.txt"), "ok"); }, }); - const { appendFileTransferAudit, module } = await importTool(tarBuffer); + const { appendFileTransferAudit, module, saveMediaBuffer } = await importTool(tarBuffer); const result = await executeDirFetch(module); @@ -106,6 +109,12 @@ describe("dir.fetch archive extraction", () => { const localPath = (result.details as { files: Array<{ localPath: string }> }).files[0] ?.localPath; await expect(fs.readFile(localPath!, "utf8")).resolves.toBe("ok"); + expect(saveMediaBuffer).toHaveBeenCalledWith( + tarBuffer, + "application/gzip", + FILE_TRANSFER_SUBDIR, + DIR_FETCH_HARD_MAX_BYTES, + ); expect(appendFileTransferAudit).toHaveBeenLastCalledWith( expect.objectContaining({ decision: "allowed" }), ); diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts index 265d6369e4dc..9c196a41d0d5 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -169,7 +169,7 @@ export function createDirFetchTool(): AnyAgentTool { throw new Error("dir.fetch sha256 mismatch (integrity failure)"); } - // Save tarball under the file-transfer subdir (no 2-min TTL). + // Keep the tarball and extracted paths under the same managed tool namespace. const savedTar = await saveMediaBuffer( tarBuffer, "application/gzip", diff --git a/extensions/file-transfer/src/tools/file-fetch-tool.test.ts b/extensions/file-transfer/src/tools/file-fetch-tool.test.ts index 221872335c2f..c64bf4154b25 100644 --- a/extensions/file-transfer/src/tools/file-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/file-fetch-tool.test.ts @@ -7,6 +7,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { FILE_TRANSFER_SUBDIR } from "./descriptors.js"; import { createFileFetchTool } from "./file-fetch-tool.js"; vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ @@ -57,7 +58,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/report.md", + path: "/gateway/media/tool-file-transfer/report.md", size: Buffer.byteLength(fileText), contentType: "text/markdown", }); @@ -95,7 +96,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/bom.md", + path: "/gateway/media/tool-file-transfer/bom.md", size: originalBuffer.byteLength, contentType: "text/markdown", }); @@ -111,7 +112,7 @@ describe("file_fetch tool", () => { expect(saveMediaBuffer).toHaveBeenCalledWith( originalBuffer, "text/markdown", - expect.any(String), + FILE_TRANSFER_SUBDIR, expect.any(Number), ); const details = result.details as { sha256: string; size: number }; @@ -134,7 +135,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/empty.png", + path: "/gateway/media/tool-file-transfer/empty.png", size: 0, contentType: "image/png", }); @@ -148,7 +149,7 @@ describe("file_fetch tool", () => { expect(result.content[0]?.type).toBe("text"); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; expect(text).toContain("Fetched /tmp/empty.png"); - expect(text).toContain("saved at /gateway/media/file-transfer/empty.png"); + expect(text).toContain("saved at /gateway/media/tool-file-transfer/empty.png"); }); it("still inlines a non-empty image payload", async () => { @@ -167,7 +168,7 @@ describe("file_fetch tool", () => { }); vi.mocked(saveMediaBuffer).mockResolvedValue({ id: "media-1", - path: "/gateway/media/file-transfer/photo.png", + path: "/gateway/media/tool-file-transfer/photo.png", size: buffer.byteLength, contentType: "image/png", }); diff --git a/extensions/file-transfer/src/tools/file-write-tool.test.ts b/extensions/file-transfer/src/tools/file-write-tool.test.ts index add0520f4ee3..d34cb7177343 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.test.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.test.ts @@ -1,12 +1,14 @@ // File Transfer tests cover file write tool plugin behavior. +import crypto from "node:crypto"; import { callGatewayTool, listNodes, resolveNodeIdFromList, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readMediaBuffer } from "openclaw/plugin-sdk/media-store"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { humanSize } from "../shared/params.js"; -import { FILE_WRITE_HARD_MAX_BYTES } from "./descriptors.js"; +import { FILE_TRANSFER_SUBDIR, FILE_WRITE_HARD_MAX_BYTES } from "./descriptors.js"; import { createFileWriteTool } from "./file-write-tool.js"; vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ @@ -101,4 +103,38 @@ describe("file_write tool", () => { expect(callGatewayTool).toHaveBeenCalledOnce(); }); + + it("reads file_fetch media from the shared managed tool namespace", async () => { + const buffer = Buffer.from("copied"); + vi.mocked(readMediaBuffer).mockResolvedValue({ + id: "media-1", + buffer, + path: "/gateway/media/tool-file-transfer/media-1.bin", + size: buffer.byteLength, + }); + vi.mocked(listNodes).mockResolvedValue([{ nodeId: "node-1", displayName: "Node 1" }]); + vi.mocked(resolveNodeIdFromList).mockReturnValue("node-1"); + vi.mocked(callGatewayTool).mockResolvedValue({ + payload: { + ok: true, + path: "/tmp/out.bin", + size: buffer.byteLength, + sha256: crypto.createHash("sha256").update(buffer).digest("hex"), + overwritten: false, + }, + }); + + const result = await createFileWriteTool().execute("tool-call-1", { + node: "node-1", + path: "/tmp/out.bin", + sourceMediaId: "media-1", + }); + + expect(readMediaBuffer).toHaveBeenCalledWith( + "media-1", + FILE_TRANSFER_SUBDIR, + FILE_WRITE_HARD_MAX_BYTES, + ); + expect(result.details).toMatchObject({ source: "media", size: buffer.byteLength }); + }); }); diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 102d9bfd8272..79d5f940feeb 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -41,6 +41,7 @@ async function withManagedMediaRoot(run: (ctx: { stateDir: string }) => Promi try { return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { await fs.mkdir(path.join(stateDir, "media", "outbound"), { recursive: true }); + await fs.mkdir(path.join(stateDir, "media", "tool-file-transfer"), { recursive: true }); await fs.mkdir(path.join(stateDir, "media", "tool-image-generation"), { recursive: true }); return await run({ stateDir }); }); @@ -242,6 +243,10 @@ describe("resolveSandboxedMediaSource", () => { name: "managed outbound media", relative: path.join("media", "outbound", "reply.png"), }, + { + name: "managed file-transfer tool media", + relative: path.join("media", "tool-file-transfer", "fetched.png"), + }, { name: "managed tool media", relative: path.join("media", "tool-image-generation", "generated.png"), @@ -475,47 +480,90 @@ describe("resolveSandboxedMediaSource", () => { ); }); - it("rejects symlinked managed media paths escaping the managed media root", async () => { - if (process.platform === "win32") { - return; - } - await withManagedMediaRoot(async ({ stateDir }) => { - await withSandboxRoot(async (sandboxDir) => { + it.each(["outbound", "tool-file-transfer"])( + "rejects symlinked managed media paths escaping the %s root", + async (subdir) => { + if (process.platform === "win32") { + return; + } + await withManagedMediaRoot(async ({ stateDir }) => { + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); + const outsideFile = path.join(outsideDir, "secret.png"); + const symlinkPath = path.join(stateDir, "media", subdir, "linked-secret.png"); + try { + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.symlink(outsideFile, symlinkPath); + + await expectSandboxRejection(symlinkPath, sandboxDir, /managed media root|symlink/i); + } finally { + await fs.rm(symlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + }, + ); + + it.each(["outbound", "tool-file-transfer"])( + "rejects checked managed media symlinks escaping the %s root", + async (subdir) => { + if (process.platform === "win32") { + return; + } + await withManagedMediaRoot(async ({ stateDir }) => { const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); const outsideFile = path.join(outsideDir, "secret.png"); - const symlinkPath = path.join(stateDir, "media", "outbound", "linked-secret.png"); + const symlinkPath = path.join(stateDir, "media", subdir, "linked-secret.png"); try { await fs.writeFile(outsideFile, "secret", "utf8"); await fs.symlink(outsideFile, symlinkPath); - await expectSandboxRejection(symlinkPath, sandboxDir, /managed media root|symlink/i); + await expect(resolveAllowedManagedMediaPath(symlinkPath)).rejects.toThrow( + /managed media root|symlink/i, + ); } finally { await fs.rm(symlinkPath, { force: true }); await fs.rm(outsideDir, { recursive: true, force: true }); } }); - }); - }); + }, + ); - it("rejects checked managed media symlinks escaping the managed media root", async () => { + it("rejects hardlinked file-transfer media that aliases a file outside managed media", async () => { if (process.platform === "win32") { return; } await withManagedMediaRoot(async ({ stateDir }) => { - const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-media-outside-")); - const outsideFile = path.join(outsideDir, "secret.png"); - const symlinkPath = path.join(stateDir, "media", "outbound", "linked-secret.png"); - try { - await fs.writeFile(outsideFile, "secret", "utf8"); - await fs.symlink(outsideFile, symlinkPath); - - await expect(resolveAllowedManagedMediaPath(symlinkPath)).rejects.toThrow( - /managed media root|symlink/i, + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp( + path.join(path.dirname(stateDir), "managed-media-hardlink-outside-"), ); - } finally { - await fs.rm(symlinkPath, { force: true }); - await fs.rm(outsideDir, { recursive: true, force: true }); - } + const outsideFile = path.join(outsideDir, "secret.png"); + const hardlinkPath = path.join( + stateDir, + "media", + "tool-file-transfer", + "linked-secret.png", + ); + try { + await fs.writeFile(outsideFile, "secret", "utf8"); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + + await expect(resolveAllowedManagedMediaPath(hardlinkPath)).rejects.toThrow(/hard.?link/i); + await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|managed media root/i); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); }); }); From a91d92796fd3c6ff31230de9337d895ecb8fbb43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:11:15 -0700 Subject: [PATCH 57/59] fix(whatsapp): preserve interactive replies and normalize media MIME (#116816) Co-authored-by: Peter Steinberger --- .../whatsapp/src/inbound/extract.test.ts | 122 +++++++++++++++++- extensions/whatsapp/src/inbound/extract.ts | 35 ++++- .../whatsapp/src/inbound/send-api.test.ts | 20 +++ .../whatsapp/src/outbound-media-contract.ts | 4 +- extensions/whatsapp/src/send.test.ts | 9 +- 5 files changed, 179 insertions(+), 11 deletions(-) diff --git a/extensions/whatsapp/src/inbound/extract.test.ts b/extensions/whatsapp/src/inbound/extract.test.ts index 484e621bbd0d..7e5a7b8a6566 100644 --- a/extensions/whatsapp/src/inbound/extract.test.ts +++ b/extensions/whatsapp/src/inbound/extract.test.ts @@ -1,7 +1,12 @@ // Whatsapp tests cover extract plugin behavior. import type { proto } from "baileys"; import { describe, expect, it } from "vitest"; -import { describeReplyContext, extractMentionedJids, hasInboundUserContent } from "./extract.js"; +import { + describeReplyContext, + extractMentionedJids, + extractText, + hasInboundUserContent, +} from "./extract.js"; describe("extractMentionedJids", () => { const botJid = "5511999999999@s.whatsapp.net"; @@ -153,6 +158,121 @@ describe("describeReplyContext", () => { }); }); +describe("extractText", () => { + it.each([ + { + name: "button display text", + message: { + buttonsResponseMessage: { selectedButtonId: "yes", selectedDisplayText: "Yes" }, + }, + expected: "Yes", + }, + { + name: "button identifier when display text is unavailable", + message: { buttonsResponseMessage: { selectedButtonId: "yes" } }, + expected: "yes", + }, + { + name: "button identifier when display text is blank", + message: { + buttonsResponseMessage: { selectedButtonId: "yes", selectedDisplayText: " " }, + }, + expected: "yes", + }, + { + name: "list selection title", + message: { + listResponseMessage: { title: "Option A", singleSelectReply: { selectedRowId: "a" } }, + }, + expected: "Option A", + }, + { + name: "list row identifier when its title is unavailable", + message: { listResponseMessage: { singleSelectReply: { selectedRowId: "a" } } }, + expected: "a", + }, + { + name: "template button display text", + message: { + templateButtonReplyMessage: { selectedId: "button-1", selectedDisplayText: "Confirm" }, + }, + expected: "Confirm", + }, + { + name: "template button identifier when display text is unavailable", + message: { templateButtonReplyMessage: { selectedId: "button-1" } }, + expected: "button-1", + }, + { + name: "interactive response body", + message: { + interactiveResponseMessage: { + body: { text: "Continue" }, + nativeFlowResponseMessage: { name: "single_select", paramsJson: "{}" }, + }, + }, + expected: "Continue", + }, + { + name: "native-flow selection title when the interactive body is unavailable", + message: { + interactiveResponseMessage: { + nativeFlowResponseMessage: { + name: "single_select", + paramsJson: '{"id":"shipping-express","title":"Express shipping"}', + }, + }, + }, + expected: "Express shipping", + }, + { + name: "native-flow selection identifier when its title is unavailable", + message: { + interactiveResponseMessage: { + nativeFlowResponseMessage: { + name: "single_select", + paramsJson: '{"id":"shipping-express"}', + }, + }, + }, + expected: "shipping-express", + }, + { + name: "ephemeral button response", + message: { + ephemeralMessage: { + message: { + buttonsResponseMessage: { selectedButtonId: "ok", selectedDisplayText: "OK" }, + }, + }, + }, + expected: "OK", + }, + ])("preserves $name as inbound message text", ({ message, expected }) => { + expect(extractText(message as proto.IMessage)).toBe(expected); + }); + + it("ignores malformed native-flow response JSON", () => { + expect( + extractText({ + interactiveResponseMessage: { + nativeFlowResponseMessage: { name: "single_select", paramsJson: "{" }, + }, + } as proto.IMessage), + ).toBeUndefined(); + }); + + it("ignores non-record native-flow response JSON", () => { + expect( + extractText({ + interactiveResponseMessage: { + nativeFlowResponseMessage: { name: "single_select", paramsJson: "[]" }, + }, + } as proto.IMessage), + ).toBeUndefined(); + }); +}); + describe("hasInboundUserContent", () => { it("returns true for plain text conversation", () => { expect(hasInboundUserContent({ conversation: "hello" })).toBe(true); diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index 5ea7312c3c75..2b0a45d7c580 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -7,7 +7,7 @@ import { type NormalizedLocation, } from "openclaw/plugin-sdk/channel-inbound"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveComparableIdentity, type WhatsAppReplyContext } from "../identity.js"; import { jidToE164 } from "../text-runtime.js"; import { parseVcard } from "../vcard.js"; @@ -136,6 +136,26 @@ export function extractMentionedJids(rawMessage: proto.IMessage | undefined): st return uniqueStrings(flattened); } +function extractNativeFlowResponseText( + response: proto.Message.IInteractiveResponseMessage | null | undefined, +): string | undefined { + const paramsJson = response?.nativeFlowResponseMessage?.paramsJson; + if (!paramsJson) { + return undefined; + } + try { + const params: unknown = JSON.parse(paramsJson); + if (!isRecord(params)) { + return undefined; + } + return [params.title, params.id].find( + (value): value is string => typeof value === "string" && Boolean(value.trim()), + ); + } catch { + return undefined; + } +} + export function extractText(rawMessage: proto.IMessage | undefined): string | undefined { const message = unwrapMessage(rawMessage); if (!message) { @@ -161,6 +181,19 @@ export function extractText(rawMessage: proto.IMessage | undefined): string | un if (caption?.trim()) { return caption.trim(); } + const interactiveSelection = [ + candidate.buttonsResponseMessage?.selectedDisplayText, + candidate.buttonsResponseMessage?.selectedButtonId, + candidate.listResponseMessage?.title, + candidate.listResponseMessage?.singleSelectReply?.selectedRowId, + candidate.templateButtonReplyMessage?.selectedDisplayText, + candidate.templateButtonReplyMessage?.selectedId, + candidate.interactiveResponseMessage?.body?.text, + extractNativeFlowResponseText(candidate.interactiveResponseMessage), + ].find((value) => Boolean(value?.trim())); + if (interactiveSelection) { + return interactiveSelection.trim(); + } } const contactPlaceholder = extractContactPlaceholder(message) ?? diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index 852d4dd01c10..feff6a7e6253 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { AnyMessageContent, MiscMessageGenerationOptions, WAMessage } from "baileys"; import { listMessageReceiptPlatformIds } from "openclaw/plugin-sdk/channel-outbound"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { prepareWhatsAppOutboundMedia } from "../outbound-media-contract.js"; import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js"; import { createWebSendApi } from "./send-api.js"; import type { WhatsAppSendResult } from "./send-result.js"; @@ -329,6 +330,25 @@ describe("createWebSendApi", () => { }); }); + it.each([ + { kind: "image", contentType: " Image/PNG; charset=binary ", mimetype: "image/png" }, + { kind: "video", contentType: " Video/MP4; charset=binary ", mimetype: "video/mp4" }, + ])( + "preserves the native $kind payload after canonicalizing mixed-case media MIME", + async ({ kind, contentType, mimetype }) => { + const payload = Buffer.from(kind); + const media = await prepareWhatsAppOutboundMedia({ buffer: payload, contentType }); + + await api.sendMessage("+1555", "cap", media.buffer, media.mimetype); + + expectSendContentFields(0, { + [kind]: payload, + caption: "cap", + mimetype, + }); + }, + ); + it("prepopulates image thumbnails and dimensions before Baileys media upload", async () => { const payload = Buffer.from("img"); const thumbnail = Buffer.from("thumb"); diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index 3d487bf308a9..1d62c30c7d63 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -152,8 +152,8 @@ function normalizeWhatsAppLoadedMedia( const normalizedContentType = normalizeMimeType(media.contentType); const resolvedContentType = !normalizedContentType || normalizedContentType === "application/octet-stream" - ? (filenameMimeType ?? media.contentType) - : media.contentType; + ? (filenameMimeType ?? normalizedContentType) + : normalizedContentType; const kind = inferWhatsAppMediaKind(media, resolvedContentType); // Match the existing URL/filename voice rule used by the transcode decision; // otherwise native .ogg/.opus uploads carry an inconsistent payload MIME. diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index e625c381016b..00102bb500d5 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -409,7 +409,7 @@ describe("web outbound", () => { expect(sendMessage).toHaveBeenNthCalledWith(2, "+1555", "voice note", undefined, undefined); }); - it("normalizes MIME parameters when inferring media kind", async () => { + it("normalizes MIME parameters before handing media to the socket transport", async () => { const buf = Buffer.from("image"); loadWebMediaMock.mockResolvedValueOnce({ buffer: buf, @@ -422,12 +422,7 @@ describe("web outbound", () => { mediaUrl: "/tmp/image.png", }); - expect(sendMessage).toHaveBeenLastCalledWith( - "+1555", - "caption", - buf, - " Image/PNG; charset=binary ", - ); + expect(sendMessage).toHaveBeenLastCalledWith("+1555", "caption", buf, "image/png"); }); it("reports the accepted voice send before a caption failure", async () => { From b9e55935f879d04543969de3e958f8276fd368b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:13:01 -0700 Subject: [PATCH 58/59] fix(telegram): preserve edit previews, message cache, and group history (#116818) * test(telegram): cover edited message preview and cache ownership * fix(telegram): preserve edit previews and refresh message context * fix(telegram): keep edited messages out of new group history --------- Co-authored-by: Peter Steinberger --- .../telegram/src/outbound-message-context.ts | 20 +- extensions/telegram/src/rich-message.ts | 2 + extensions/telegram/src/send-edit.ts | 44 +++- extensions/telegram/src/send.test.ts | 197 ++++++++++++++++++ 4 files changed, 247 insertions(+), 16 deletions(-) diff --git a/extensions/telegram/src/outbound-message-context.ts b/extensions/telegram/src/outbound-message-context.ts index 3d0a57acb178..4ae88f0176e7 100644 --- a/extensions/telegram/src/outbound-message-context.ts +++ b/extensions/telegram/src/outbound-message-context.ts @@ -136,6 +136,8 @@ export async function recordOutboundMessageForPromptContext(params: { successfulSendThread?: TelegramThreadSpec; promptContextTimestampMs?: number; promptContextProjection?: TelegramPromptContextProjection; + /** Edits refresh an existing cache entry without inserting another self-history turn. */ + recordGroupHistory?: boolean; }): Promise { try { const providerGeneralTopicId = @@ -169,14 +171,16 @@ export async function recordOutboundMessageForPromptContext(params: { ...(providerObservedThreadId !== undefined ? { providerObservedThreadId } : {}), ...(messageThreadId !== undefined ? { threadId: messageThreadId } : {}), }); - const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage); - outboundGroupHistoryRecorders.get(params.account.accountId)?.({ - chatId: params.chatId, - messageId: params.messageId, - text: params.text ?? cacheMessage.text ?? cacheMessage.caption, - ...(messageThreadId !== undefined ? { messageThreadId } : {}), - ...(timestamp !== undefined ? { timestamp } : {}), - }); + if (params.recordGroupHistory !== false) { + const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage); + outboundGroupHistoryRecorders.get(params.account.accountId)?.({ + chatId: params.chatId, + messageId: params.messageId, + text: params.text ?? cacheMessage.text ?? cacheMessage.caption, + ...(messageThreadId !== undefined ? { messageThreadId } : {}), + ...(timestamp !== undefined ? { timestamp } : {}), + }); + } return true; } catch (error) { logVerbose(`telegram: failed to record outbound message context: ${String(error)}`); diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index f3f149ecb84a..237bb4406147 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -2,6 +2,7 @@ import type { Bot } from "grammy"; import type { ForceReply, InlineKeyboardMarkup, + LinkPreviewOptions, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove, @@ -82,6 +83,7 @@ export type TelegramEditRichMessageTextParams = { message_id?: number; inline_message_id?: string; rich_message: TelegramInputRichMessage; + link_preview_options?: LinkPreviewOptions; reply_markup?: InlineKeyboardMarkup; }; diff --git a/extensions/telegram/src/send-edit.ts b/extensions/telegram/src/send-edit.ts index 25876985a632..8c2fc49d3337 100644 --- a/extensions/telegram/src/send-edit.ts +++ b/extensions/telegram/src/send-edit.ts @@ -4,6 +4,10 @@ import type { TelegramInlineButtons } from "./button-types.js"; import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; import { isRecoverableTelegramNetworkError, isTelegramServerError } from "./network-errors.js"; +import { + recordOutboundMessageForPromptContext, + type TelegramOutboundPromptContextMessage, +} from "./outbound-message-context.js"; import { buildTelegramRichMarkdownPlan, getTelegramRichRawApi, @@ -26,6 +30,7 @@ import { import { prepareTelegramOutbound } from "./send-outbound.js"; import type { OpenClawConfig } from "./send.runtime.js"; import { resolveMarkdownTableMode } from "./send.runtime.js"; +import { resolveTelegramBotUserIdFromToken } from "./token.js"; type TelegramEditMessageTextParams = Parameters[3]; type TelegramEditMessageCaptionParams = Parameters< @@ -148,6 +153,7 @@ async function editMessageTelegramWithContext( ) => request(fn, label, shouldLog ? { shouldLog } : undefined); const textMode = opts.textMode ?? "markdown"; + const linkPreviewEnabled = opts.linkPreview ?? account.config.linkPreview ?? true; // Caller-authored HTML edits keep legacy parse_mode HTML semantics too. const useRichMessages = account.config.richMessages === true && textMode !== "html"; const tableMode = resolveMarkdownTableMode({ @@ -161,7 +167,7 @@ async function editMessageTelegramWithContext( const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined; const richMessagePlan = useRichMessages ? buildTelegramRichMarkdownPlan(text, { - skipEntityDetection: opts.linkPreview === false, + skipEntityDetection: !linkPreviewEnabled, tableMode, }) : undefined; @@ -177,14 +183,14 @@ async function editMessageTelegramWithContext( const textEditParams: TelegramEditMessageTextParams = { parse_mode: "HTML", }; - if (opts.linkPreview === false) { + if (!linkPreviewEnabled) { textEditParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { textEditParams.reply_markup = replyMarkup; } const plainTextParams: TelegramEditMessageTextParams = {}; - if (opts.linkPreview === false) { + if (!linkPreviewEnabled) { plainTextParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { @@ -206,8 +212,13 @@ async function editMessageTelegramWithContext( const performTextEdit = () => { if (richRawApi && richMessagePlan) { - const richEditParams: Pick = - replyMarkup === undefined ? {} : { reply_markup: replyMarkup }; + const richEditParams: Pick< + TelegramEditRichMessageTextParams, + "link_preview_options" | "reply_markup" + > = { + ...(linkPreviewEnabled ? {} : { link_preview_options: { is_disabled: true } }), + ...(replyMarkup === undefined ? {} : { reply_markup: replyMarkup }), + }; warnTelegramRichBlocksDegradations({ context: "editMessage", reasons: richMessagePlan.degradationReasons, @@ -282,16 +293,17 @@ async function editMessageTelegramWithContext( ), }); + let editedMessage: TelegramOutboundPromptContextMessage | true | undefined; try { const editMode = opts.editMode ?? "text"; if (editMode === "caption") { - await performCaptionEdit(); + editedMessage = await performCaptionEdit(); } else { try { - await performTextEdit(); + editedMessage = await performTextEdit(); } catch (err) { if (editMode === "auto" && isTelegramMessageHasNoTextError(err)) { - await performCaptionEdit(); + editedMessage = await performCaptionEdit(); } else { throw err; } @@ -305,6 +317,22 @@ async function editMessageTelegramWithContext( } } + if (editedMessage && editedMessage !== true && typeof editedMessage.message_id === "number") { + const botUserId = resolveTelegramBotUserIdFromToken(opts.token || account.token); + await recordOutboundMessageForPromptContext({ + cfg, + account, + chatId, + message: editedMessage, + messageId: editedMessage.message_id, + recordGroupHistory: false, + ...(botUserId !== undefined ? { botUserId } : {}), + ...(editedMessage.message_thread_id !== undefined + ? { messageThreadId: editedMessage.message_thread_id } + : {}), + }); + } + logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`); return { ok: true, messageId: String(messageId), chatId }; } diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index ae293c76c199..58d2491168c7 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -10,12 +10,17 @@ import { import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { markdownToTelegramHtml, telegramHtmlToPlainTextFallback } from "./format.js"; +import { + recordTelegramGroupHistoryEntry, + selectTelegramGroupHistoryAfterLastSelf, +} from "./group-history-window.js"; import { buildTelegramConversationContext, createTelegramMessageCache, hasProviderObservedTelegramThreadBinding, resolveTelegramMessageCacheScope, } from "./message-cache.js"; +import { registerTelegramOutboundGroupHistoryRecorder } from "./outbound-message-context.js"; import { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js"; import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-block-model.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -4583,6 +4588,198 @@ describe("editMessageTelegram", () => { ); expect(botRawApi.editMessageText).not.toHaveBeenCalled(); }); + + it.each([ + { + name: "inherits the disabled account default", + accountLinkPreview: false, + linkPreview: undefined, + expectedDisabled: true, + }, + { + name: "lets an explicit enabled value override the account default", + accountLinkPreview: false, + linkPreview: true, + expectedDisabled: false, + }, + { + name: "lets an explicit disabled value override the account default", + accountLinkPreview: true, + linkPreview: false, + expectedDisabled: true, + }, + ])("$name for edited Telegram messages", async (testCase) => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "https://example.com", { + token: "tok", + cfg: { channels: { telegram: { linkPreview: testCase.accountLinkPreview } } }, + ...(testCase.linkPreview !== undefined ? { linkPreview: testCase.linkPreview } : {}), + }); + + const params = requireRecord( + firstMockCall(botApi.editMessageText, "editMessageText preview call")[3], + "edited Telegram preview params", + ); + if (testCase.expectedDisabled) { + expect(params.link_preview_options).toEqual({ is_disabled: true }); + } else { + expect(params).not.toHaveProperty("link_preview_options"); + } + }); + + it("preserves disabled previews when editing rich Telegram messages", async () => { + botRawApi.editMessageText.mockResolvedValue({ + message_id: 1, + chat: { id: "123", type: "private" }, + text: "https://example.com", + }); + + await editMessageTelegram("123", 1, "https://example.com", { + token: "tok", + cfg: { channels: { telegram: { richMessages: true } } }, + linkPreview: false, + }); + + expect(botRawApi.editMessageText).toHaveBeenCalledWith( + expect.objectContaining({ + chat_id: "123", + message_id: 1, + link_preview_options: { is_disabled: true }, + }), + ); + }); + + it.each([ + { name: "text", editMode: "text" as const, field: "text" as const }, + { name: "caption", editMode: "caption" as const, field: "caption" as const }, + ])("refreshes cached $name from Telegram's authoritative edit response", async (testCase) => { + const storePath = `/tmp/openclaw-telegram-edited-context-${process.pid}-${Date.now()}-${testCase.name}.json`; + const cfg = { session: { store: storePath } }; + const chat = { id: -100123, type: "supergroup" as const, title: "Ops" }; + const cache = createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }); + await cache.record({ + accountId: "default", + chatId: chat.id, + threadId: 77, + msg: { + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + [testCase.field]: "outdated content", + }, + }); + const editedMessage = { + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + edit_date: 1_779_394_750, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + [testCase.field]: "authoritative edited content", + }; + if (testCase.editMode === "caption") { + botApi.editMessageCaption.mockResolvedValue(editedMessage); + } else { + botApi.editMessageText.mockResolvedValue(editedMessage); + } + + await editMessageTelegram(chat.id, 902, "authoritative edited content", { + token: "42:test-token", + cfg, + editMode: testCase.editMode, + }); + + const cached = await cache.get({ + accountId: "default", + chatId: chat.id, + messageId: "902", + }); + expect(cached?.body).toBe("authoritative edited content"); + expect(hasProviderObservedTelegramThreadBinding(cached, 77)).toBe(true); + }); + + it("refreshes edited group messages without duplicating self history or hiding later replies", async () => { + const storePath = `/tmp/openclaw-telegram-edit-history-${process.pid}-${Date.now()}.json`; + const cfg = { session: { store: storePath } }; + const chat = { id: -100123, type: "supergroup" as const, title: "Ops" }; + const historyKey = `${chat.id}:topic:77`; + const groupHistory = new Map< + string, + Array<{ sender: string; body: string; messageId: string; timestamp: number }> + >(); + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "OpenClaw (you)", + body: "original response", + messageId: "902", + timestamp: 1_779_394_740_000, + }, + }); + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "Teammate", + body: "context that must remain visible", + messageId: "903", + timestamp: 1_779_394_741_000, + }, + }); + const unregister = registerTelegramOutboundGroupHistoryRecorder({ + accountId: "default", + recorder: (record) => + recordTelegramGroupHistoryEntry({ + historyMap: groupHistory, + historyKey, + limit: 50, + entry: { + sender: "OpenClaw (you)", + body: record.text ?? "", + messageId: String(record.messageId), + timestamp: record.timestamp ?? 0, + }, + }), + }); + botApi.editMessageText.mockResolvedValue({ + chat, + message_id: 902, + message_thread_id: 77, + date: 1_779_394_740, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + text: "authoritative edited response", + }); + + try { + await editMessageTelegram(chat.id, 902, "authoritative edited response", { + token: "42:test-token", + cfg, + }); + } finally { + unregister(); + } + + const entries = groupHistory.get(historyKey) ?? []; + expect(entries.map((entry) => entry.messageId)).toEqual(["902", "903"]); + expect(selectTelegramGroupHistoryAfterLastSelf(entries)).toEqual([ + expect.objectContaining({ + sender: "Teammate", + body: "context that must remain visible", + }), + ]); + const cached = await createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }).get({ accountId: "default", chatId: chat.id, messageId: "902" }); + expect(cached?.body).toBe("authoritative edited response"); + }); }); describe("sendPollTelegram", () => { From 6540e5ac79c46fca5eef420544f57ec692b55252 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 05:15:34 -0700 Subject: [PATCH 59/59] fix(setup): isolate failed channel probes and report doctor diagnostics (#116824) Co-authored-by: Peter Steinberger --- src/commands/doctor-gateway-health.test.ts | 40 ++++++++ src/commands/doctor-gateway-health.ts | 13 ++- src/flows/channel-setup.status.test.ts | 106 +++++++++++++++++++++ src/flows/channel-setup.status.ts | 46 +++++---- 4 files changed, 187 insertions(+), 18 deletions(-) diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index f4db3a74892f..d77d5e348668 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -82,11 +82,51 @@ describe("checkGatewayHealth", () => { method: "channels.status", params: { probe: true, timeoutMs: 5000 }, timeoutMs: 6000, + config: cfg, }); expect(runtime.error).not.toHaveBeenCalled(); expect(note.mock.calls.map(([, title]) => title)).not.toContain("OpenClaw version mismatch"); }); + it("reports failed channel diagnostics without marking a reachable gateway unhealthy", async () => { + callGateway + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce(new Error("channel probe timed out")); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await expect( + checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }), + ).resolves.toEqual({ authenticated: true, healthOk: true, status: { ok: true } }); + + expect(note).toHaveBeenCalledWith( + [ + "Channel status probe failed: channel probe timed out", + "Retry: openclaw channels status --probe", + ].join("\n"), + "Channel warnings", + ); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("redacts credentials and terminal controls in channel probe failures", async () => { + const token = "sk-abcdefghijklmnopqrstuv"; + callGateway + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce( + new Error(`\u001B[31mchannel probe failed\nAuthorization: Bearer ${token}`), + ); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await checkGatewayHealth({ runtime: runtime as never, cfg }); + + const [message, title] = note.mock.calls.at(-1) ?? []; + expect(title).toBe("Channel warnings"); + expect(message).toContain("channel probe failed\\nAuthorization: Bearer"); + expect(message).not.toContain(token); + expect(message).not.toContain("\u001B"); + expect(message.split("\n")).toHaveLength(2); + }); + it("notes CLI and gateway version mismatch when the gateway reports another runtime version", async () => { callGateway.mockResolvedValueOnce({ runtimeVersion: "2026.4.23" }).mockResolvedValueOnce({}); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index f449e3c4221c..6b97b74dd9be 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -1,5 +1,7 @@ /** Gateway health probes used by doctor before deeper daemon and memory diagnostics. */ import { note } from "../../packages/terminal-core/src/note.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { formatCliCommand } from "../cli/command-format.js"; import { probeGatewayStatus } from "../cli/daemon-cli/probe.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -115,6 +117,7 @@ export async function checkGatewayHealth(params: { method: "channels.status", params: { probe: true, timeoutMs: 5000 }, timeoutMs: 6000, + config: params.cfg, }); const issues = collectChannelStatusIssues(statusLocal); if (issues.length > 0) { @@ -130,8 +133,14 @@ export async function checkGatewayHealth(params: { "Channel warnings", ); } - } catch { - // ignore: doctor already reported gateway health + } catch (error) { + note( + [ + `Channel status probe failed: ${sanitizeTerminalText(formatErrorMessage(error))}`, + `Retry: ${formatCliCommand("openclaw channels status --probe")}`, + ].join("\n"), + "Channel warnings", + ); } return { healthOk, authenticated: true, status }; } catch (err) { diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 250d18b1953a..5f9b9a34ffbd 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -14,6 +14,7 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh type FormatChannelSelectionLine = typeof import("../channels/registry.js").formatChannelSelectionLine; type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; +type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; type NoteChannelPrimerChannels = Parameters< typeof import("./channel-setup.status.js").noteChannelPrimer >[1]; @@ -260,6 +261,111 @@ describe("resolveChannelSetupSelectionContributions", () => { ]); }); + it.each(["rejected status check", "synchronous status check", "adapter resolution"] as const)( + "keeps healthy channels selectable after a %s failure", + async (failurePoint) => { + const installedPlugins = [ + { + id: "matrix", + meta: makeMeta("matrix", "Matrix"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + { + id: "telegram", + meta: makeMeta("telegram", "Telegram"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + ] satisfies ChannelSetupPlugin[]; + listChatChannels.mockReturnValue([ + makeMeta("matrix", "Matrix"), + makeMeta("telegram", "Telegram"), + ]); + isChannelConfigured.mockImplementation((_, channelId) => channelId === "matrix"); + + const failure = new Error("lazy Matrix setup module unavailable"); + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins, + resolveAdapter: (channel) => { + if (channel === "matrix" && failurePoint === "adapter resolution") { + throw failure; + } + return { + channel, + getStatus: + channel === "matrix" + ? failurePoint === "synchronous status check" + ? () => { + throw failure; + } + : async () => { + throw failure; + } + : async () => ({ + channel: "telegram", + configured: true, + statusLines: ["Telegram: configured"], + selectionHint: "configured", + quickstartScore: 5, + }), + } as never; + }, + }); + + expect(summary.statusByChannel.get("matrix")).toEqual({ + channel: "matrix", + configured: true, + statusLines: ["Matrix: status unavailable (lazy Matrix setup module unavailable)"], + selectionHint: "status unavailable", + }); + expect(summary.statusByChannel.get("telegram")).toEqual({ + channel: "telegram", + configured: true, + statusLines: ["Telegram: configured"], + selectionHint: "configured", + quickstartScore: 5, + }); + expect(summary.statusLines).toEqual([ + "Matrix: status unavailable (lazy Matrix setup module unavailable)", + "Telegram: configured", + ]); + }, + ); + + it("redacts credentials and terminal controls in failed channel status checks", async () => { + const token = "sk-abcdefghijklmnopqrstuv"; + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins: [ + { + id: "matrix", + meta: makeMeta("matrix", "Matrix"), + capabilities: { chatTypes: [] }, + config: {} as ChannelSetupPlugin["config"], + }, + ], + resolveAdapter: (channel) => + ({ + channel, + getStatus: async () => { + throw new Error(`\u001B[31mloader failed\nAuthorization: Bearer ${token}`); + }, + }) as never, + }); + + const statusLine = summary.statusLines[0]; + expect(statusLine).toContain( + "Matrix: status unavailable (loader failed\\nAuthorization: Bearer", + ); + expect(statusLine).not.toContain(token); + expect(statusLine).not.toContain("\u001B"); + expect(statusLine).not.toContain("\n"); + }); + it("localizes channel status note labels", async () => { listChatChannels.mockReturnValue([ makeMeta("discord", "Discord"), diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index cf4116a9fc24..964a20d4a94a 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -20,6 +20,7 @@ import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-s import type { ChannelChoice } from "../commands/onboard-types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { findBundledPluginSourceInMap, resolveBundledPluginSources, @@ -355,22 +356,35 @@ export async function collectChannelStatus(params: { resolveChannelSetupWizardAdapterForPlugin( installedPlugins.find((plugin) => plugin.id === channel), )); - const statusEntries = await Promise.all( - installedPlugins.flatMap((plugin) => { - if (!shouldShowChannelInSetup(plugin.meta)) { - return []; - } - const adapter = resolveAdapter(plugin.id); - if (!adapter) { - return []; - } - return adapter.getStatus({ - cfg: params.cfg, - options: params.options, - accountOverrides: params.accountOverrides, - }); - }), - ); + const statusEntries = ( + await Promise.all( + installedPlugins + .filter((plugin) => shouldShowChannelInSetup(plugin.meta)) + .map(async (plugin): Promise => { + try { + const adapter = resolveAdapter(plugin.id); + if (!adapter) { + return undefined; + } + return await adapter.getStatus({ + cfg: params.cfg, + options: params.options, + accountOverrides: params.accountOverrides, + }); + } catch (error) { + const detail = formatSetupFreeText(formatErrorMessage(error)); + return { + channel: plugin.id, + configured: isChannelConfigured(params.cfg, plugin.id), + statusLines: [ + `${formatSetupSelectionLabel(plugin.meta.label, plugin.id)}: status unavailable (${detail})`, + ], + selectionHint: "status unavailable", + }; + } + }), + ) + ).filter((status): status is ChannelSetupStatus => status !== undefined); const statusByChannel = new Map( statusEntries.map((entry: ChannelSetupStatus) => [entry.channel, entry]), );