From 7c8f08a88ccc5b81248b58a8d7060505f4d6b239 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 22:57:48 -0700 Subject: [PATCH 01/18] fix(ci): split discord thread-creation tests under the max-lines cap #117354 added 246 lines of thread initial-message chunking coverage, taking send.creates-thread.test.ts to 1082 counted lines against the 1000-line cap and leaving main check-lint red. Move the 9 chunking tests to a sibling file. The suite remains 51 tests total: 51 before; 42 + 9 after. --- .../src/send.creates-thread.chunking.test.ts | 277 +++++++++++++++++ .../discord/src/send.creates-thread.test.ts | 280 +----------------- extensions/discord/src/send.test-harness.ts | 36 +++ 3 files changed, 320 insertions(+), 273 deletions(-) create mode 100644 extensions/discord/src/send.creates-thread.chunking.test.ts diff --git a/extensions/discord/src/send.creates-thread.chunking.test.ts b/extensions/discord/src/send.creates-thread.chunking.test.ts new file mode 100644 index 000000000000..b285fd15ca24 --- /dev/null +++ b/extensions/discord/src/send.creates-thread.chunking.test.ts @@ -0,0 +1,277 @@ +import { ChannelType, Routes } from "discord-api-types/v10"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { hasDiscordMessageCreateAmbiguity } from "./retry.js"; +import { + makeDiscordRest, + requestBody, + requestPath, + type MockCallSource, +} from "./send.test-harness.js"; + +let createThreadDiscord: typeof import("./send.js").createThreadDiscord; +let DiscordThreadInitialMessageError: typeof import("./send.js").DiscordThreadInitialMessageError; + +const DISCORD_TEST_CFG = { + channels: { + discord: { + accounts: { + default: {}, + }, + }, + }, +}; + +function discordClientOpts(rest: ReturnType["rest"]) { + return { cfg: DISCORD_TEST_CFG, rest, token: "t" }; +} + +const requireRecord = createRequireRecord("object", "expected-label"); + +beforeAll(async () => { + ({ createThreadDiscord, DiscordThreadInitialMessageError } = await import("./send.js")); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("sendMessageDiscord", () => { + it("keeps forum starter messages within Discord's content limit", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ id: "t1" }); + const content = "a".repeat(2001); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(requestBody(postMock as unknown as MockCallSource, 0)).toEqual({ + name: "thread", + message: { content: "a".repeat(2000) }, + }); + expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ + content: "a", + enforce_nonce: true, + }); + }); + + it("keeps sub-limit multi-line forum content in one starter message", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ id: "t1" }); + const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(1); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "thread", + message: { content }, + }); + }); + + it("reports a delivered forum starter when a continuation chunk fails", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock + .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) + .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + discordClientOpts(rest), + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "not_delivered", + failedChunkIndex: 1, + totalChunkCount: 2, + }); + }); + + it("reports an exhausted ambiguous forum continuation as unknown delivery", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); + postMock + .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) + .mockRejectedValue(ambiguous); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + } catch (error) { + thrown = error; + } + + expect(postMock).toHaveBeenCalledTimes(3); + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); + expect(requireRecord(thrown, "thread initial message error").message).toContain( + "delivery of the remaining initial content could not be confirmed", + ); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 2, + }); + }); + + it("chunks long initial messages for non-forum threads", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "t1" }); + const content = "a".repeat(2001); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(3); + expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ + content: "a".repeat(2000), + enforce_nonce: true, + }); + expect(requestPath(postMock as unknown as MockCallSource, 2)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 2)).toMatchObject({ + content: "a", + enforce_nonce: true, + }); + }); + + it("keeps sub-limit multi-line non-forum content in one initial message", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "t1", channel_id: "t1" }); + const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content }); + }); + + it("reports delivered non-forum chunks when a later chunk fails", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(4001) }, + discordClientOpts(rest), + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: false, + deliveredChunkCount: 1, + deliveredMessageIds: ["msg1"], + failedChunkDelivery: "not_delivered", + failedChunkIndex: 1, + totalChunkCount: 3, + }); + }); + + it("reports an exhausted ambiguous non-forum chunk as unknown delivery", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockRejectedValue(ambiguous); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(4001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + } catch (error) { + thrown = error; + } + + expect(postMock).toHaveBeenCalledTimes(4); + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); + expect(requireRecord(thrown, "thread initial message error").message).toContain( + "delivery of the remaining initial content could not be confirmed", + ); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: false, + deliveredChunkCount: 1, + deliveredMessageIds: ["msg1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 3, + }); + }); + + it("retries continuation sends with a stable nonce per chunk", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockResolvedValueOnce({ id: "msg2", channel_id: "t1" }); + + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + + expect(postMock).toHaveBeenCalledTimes(4); + const firstAttempt = requestBody(postMock as unknown as MockCallSource, 1); + const retryAttempt = requestBody(postMock as unknown as MockCallSource, 2); + const nextChunk = requestBody(postMock as unknown as MockCallSource, 3); + expect(firstAttempt.enforce_nonce).toBe(true); + expect(retryAttempt.nonce).toBe(firstAttempt.nonce); + expect(nextChunk.enforce_nonce).toBe(true); + expect(nextChunk.nonce).not.toBe(firstAttempt.nonce); + }); +}); diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index 9fe29c6fed12..e6db36986a60 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -4,8 +4,13 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { RateLimitError } from "./internal/discord.js"; -import { hasDiscordMessageCreateAmbiguity } from "./retry.js"; -import { makeDiscordRest } from "./send.test-harness.js"; +import { + makeDiscordRest, + requestBody, + requestPath, + timerDelayAt, + type MockCallSource, +} from "./send.test-harness.js"; vi.mock("openclaw/plugin-sdk/web-media", async () => { const { discordWebMediaMockFactory } = await import("./send.test-harness.js"); @@ -42,41 +47,8 @@ function discordClientOpts(rest: ReturnType["rest"]) { return { cfg: DISCORD_TEST_CFG, rest, token: "t" }; } -type MockCallSource = { - mock: { - calls: ArrayLike>; - }; -}; - const requireRecord = createRequireRecord("object", "expected-label"); -function mockArg(source: MockCallSource, callIndex: number, argIndex: number, label: string) { - const call = source.mock.calls[callIndex]; - if (!call) { - throw new Error(`expected mock call: ${label}`); - } - return call[argIndex]; -} - -function requestOptions(source: MockCallSource, callIndex = 0) { - return requireRecord( - mockArg(source, callIndex, 1, `request options ${callIndex}`), - "request options", - ); -} - -function requestPath(source: MockCallSource, callIndex = 0) { - return mockArg(source, callIndex, 0, `request path ${callIndex}`); -} - -function requestBody(source: MockCallSource, callIndex = 0) { - return requireRecord(requestOptions(source, callIndex).body, `request body ${callIndex}`); -} - -function timerDelayAt(source: MockCallSource, callIndex = 0) { - return mockArg(source, callIndex, 1, `timer delay ${callIndex}`); -} - function createDiscordForumPayloadHarness(parentType: ChannelType = ChannelType.GuildForum) { const parentId = "700"; const { rest, getMock, postMock } = makeDiscordRest(); @@ -303,110 +275,6 @@ describe("sendMessageDiscord", () => { }); }); - it("keeps forum starter messages within Discord's content limit", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildForum }); - postMock.mockResolvedValue({ id: "t1" }); - const content = "a".repeat(2001); - - await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); - - expect(postMock).toHaveBeenCalledTimes(2); - expect(requestBody(postMock as unknown as MockCallSource, 0)).toEqual({ - name: "thread", - message: { content: "a".repeat(2000) }, - }); - expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( - Routes.channelMessages("t1"), - ); - expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ - content: "a", - enforce_nonce: true, - }); - }); - - it("keeps sub-limit multi-line forum content in one starter message", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildForum }); - postMock.mockResolvedValue({ id: "t1" }); - const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); - - await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); - - expect(postMock).toHaveBeenCalledTimes(1); - expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ - name: "thread", - message: { content }, - }); - }); - - it("reports a delivered forum starter when a continuation chunk fails", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildForum }); - postMock - .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) - .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); - - let thrown: unknown; - try { - await createThreadDiscord( - "chan1", - { name: "thread", content: "a".repeat(2001) }, - discordClientOpts(rest), - ); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); - expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ - starterMessageDelivered: true, - deliveredChunkCount: 1, - deliveredMessageIds: ["starter1"], - failedChunkDelivery: "not_delivered", - failedChunkIndex: 1, - totalChunkCount: 2, - }); - }); - - it("reports an exhausted ambiguous forum continuation as unknown delivery", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildForum }); - const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); - postMock - .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) - .mockRejectedValue(ambiguous); - - let thrown: unknown; - try { - await createThreadDiscord( - "chan1", - { name: "thread", content: "a".repeat(2001) }, - { - ...discordClientOpts(rest), - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }, - ); - } catch (error) { - thrown = error; - } - - expect(postMock).toHaveBeenCalledTimes(3); - expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); - expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); - expect(requireRecord(thrown, "thread initial message error").message).toContain( - "delivery of the remaining initial content could not be confirmed", - ); - expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ - starterMessageDelivered: true, - deliveredChunkCount: 1, - deliveredMessageIds: ["starter1"], - failedChunkDelivery: "unknown", - failedChunkIndex: 1, - totalChunkCount: 2, - }); - }); - it("inherits default_auto_archive_duration for forum threads", async () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ @@ -568,140 +436,6 @@ describe("sendMessageDiscord", () => { }); }); - it("chunks long initial messages for non-forum threads", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildText }); - postMock.mockResolvedValue({ id: "t1" }); - const content = "a".repeat(2001); - - await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); - - expect(postMock).toHaveBeenCalledTimes(3); - expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( - Routes.channelMessages("t1"), - ); - expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ - content: "a".repeat(2000), - enforce_nonce: true, - }); - expect(requestPath(postMock as unknown as MockCallSource, 2)).toBe( - Routes.channelMessages("t1"), - ); - expect(requestBody(postMock as unknown as MockCallSource, 2)).toMatchObject({ - content: "a", - enforce_nonce: true, - }); - }); - - it("keeps sub-limit multi-line non-forum content in one initial message", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildText }); - postMock.mockResolvedValue({ id: "t1", channel_id: "t1" }); - const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); - - await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); - - expect(postMock).toHaveBeenCalledTimes(2); - expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content }); - }); - - it("reports delivered non-forum chunks when a later chunk fails", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildText }); - postMock - .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) - .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) - .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); - - let thrown: unknown; - try { - await createThreadDiscord( - "chan1", - { name: "thread", content: "a".repeat(4001) }, - discordClientOpts(rest), - ); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); - expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ - starterMessageDelivered: false, - deliveredChunkCount: 1, - deliveredMessageIds: ["msg1"], - failedChunkDelivery: "not_delivered", - failedChunkIndex: 1, - totalChunkCount: 3, - }); - }); - - it("reports an exhausted ambiguous non-forum chunk as unknown delivery", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildText }); - const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); - postMock - .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) - .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) - .mockRejectedValue(ambiguous); - - let thrown: unknown; - try { - await createThreadDiscord( - "chan1", - { name: "thread", content: "a".repeat(4001) }, - { - ...discordClientOpts(rest), - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }, - ); - } catch (error) { - thrown = error; - } - - expect(postMock).toHaveBeenCalledTimes(4); - expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); - expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); - expect(requireRecord(thrown, "thread initial message error").message).toContain( - "delivery of the remaining initial content could not be confirmed", - ); - expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ - starterMessageDelivered: false, - deliveredChunkCount: 1, - deliveredMessageIds: ["msg1"], - failedChunkDelivery: "unknown", - failedChunkIndex: 1, - totalChunkCount: 3, - }); - }); - - it("retries continuation sends with a stable nonce per chunk", async () => { - const { rest, getMock, postMock } = makeDiscordRest(); - getMock.mockResolvedValue({ type: ChannelType.GuildText }); - postMock - .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) - .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) - .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) - .mockResolvedValueOnce({ id: "msg2", channel_id: "t1" }); - - await createThreadDiscord( - "chan1", - { name: "thread", content: "a".repeat(2001) }, - { - ...discordClientOpts(rest), - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }, - ); - - expect(postMock).toHaveBeenCalledTimes(4); - const firstAttempt = requestBody(postMock as unknown as MockCallSource, 1); - const retryAttempt = requestBody(postMock as unknown as MockCallSource, 2); - const nextChunk = requestBody(postMock as unknown as MockCallSource, 3); - expect(firstAttempt.enforce_nonce).toBe(true); - expect(retryAttempt.nonce).toBe(firstAttempt.nonce); - expect(nextChunk.enforce_nonce).toBe(true); - expect(nextChunk.nonce).not.toBe(firstAttempt.nonce); - }); - it("keeps created non-forum thread details when initial message send fails", async () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ type: ChannelType.GuildText }); diff --git a/extensions/discord/src/send.test-harness.ts b/extensions/discord/src/send.test-harness.ts index 0eacb0108a2e..1627137db10f 100644 --- a/extensions/discord/src/send.test-harness.ts +++ b/extensions/discord/src/send.test-harness.ts @@ -1,6 +1,7 @@ // Discord plugin module implements send harness behavior. import { createServer } from "node:http"; import type { MockFn } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { vi } from "vitest"; import { RequestClient } from "./internal/discord.js"; @@ -25,6 +26,41 @@ type DiscordLoopbackRequest = { path: string | undefined; }; +export type MockCallSource = { + mock: { + calls: ArrayLike>; + }; +}; + +const requireRecord = createRequireRecord("object", "expected-label"); + +function mockArg(source: MockCallSource, callIndex: number, argIndex: number, label: string) { + const call = source.mock.calls[callIndex]; + if (!call) { + throw new Error(`expected mock call: ${label}`); + } + return call[argIndex]; +} + +function requestOptions(source: MockCallSource, callIndex = 0) { + return requireRecord( + mockArg(source, callIndex, 1, `request options ${callIndex}`), + "request options", + ); +} + +export function requestPath(source: MockCallSource, callIndex = 0) { + return mockArg(source, callIndex, 0, `request path ${callIndex}`); +} + +export function requestBody(source: MockCallSource, callIndex = 0) { + return requireRecord(requestOptions(source, callIndex).body, `request body ${callIndex}`); +} + +export function timerDelayAt(source: MockCallSource, callIndex = 0) { + return mockArg(source, callIndex, 1, `timer delay ${callIndex}`); +} + export async function createDiscordLoopbackRest(options?: { respond?: (request: DiscordLoopbackRequest) => unknown; }): Promise<{ From d94c755eb7a78d2a35a64691029420986451e84d Mon Sep 17 00:00:00 2001 From: Leon-SK668 <0668001470@xydigit.com> Date: Thu, 13 Aug 2026 14:04:35 +0800 Subject: [PATCH 02/18] fix(skills): invalidate plugin skill memo when ACP availability changes (#121181) --- src/skills/loading/plugin-skills.test.ts | 65 ++++++++++++++++++++++++ src/skills/loading/plugin-skills.ts | 29 ++++++----- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/skills/loading/plugin-skills.test.ts b/src/skills/loading/plugin-skills.test.ts index 303232fa2fca..6d79cb10329b 100644 --- a/src/skills/loading/plugin-skills.test.ts +++ b/src/skills/loading/plugin-skills.test.ts @@ -136,6 +136,20 @@ async function setupAcpxAndHelperRegistry() { return { workspaceDir, acpxRoot, helperRoot }; } +function useStableMetadataSnapshot(manifestRegistry: PluginManifestRegistry): void { + const snapshot = { + manifestRegistry, + plugins: manifestRegistry.plugins, + normalizePluginId: (pluginId: string) => + manifestRegistry.plugins.find((plugin) => plugin.legacyPluginIds?.includes(pluginId))?.id ?? + pluginId, + }; + hoisted.loadPluginMetadataSnapshot + .mockReturnValueOnce(snapshot) + .mockReturnValueOnce(snapshot) + .mockReturnValueOnce(snapshot); +} + async function setupPluginOutsideSkills() { const workspaceDir = await tempDirs.make("openclaw-"); const pluginRoot = await tempDirs.make("openclaw-plugin-"); @@ -236,6 +250,57 @@ describe("resolvePluginSkillDirs", () => { expect(dirs).toEqual(expectedDirs({ acpxRoot, helperRoot })); }); + it.each([ + { + name: "unavailable to available", + initiallyAvailable: false, + firstIncludesAcpx: false, + secondIncludesAcpx: true, + }, + { + name: "available to unavailable", + initiallyAvailable: true, + firstIncludesAcpx: true, + secondIncludesAcpx: false, + }, + ])( + "invalidates the memo when ACP changes from $name with stable inputs", + async ({ initiallyAvailable, firstIncludesAcpx, secondIncludesAcpx }) => { + const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry(); + const manifestRegistry = buildRegistry({ acpxRoot, helperRoot }); + useStableMetadataSnapshot(manifestRegistry); + const config = { + acp: { enabled: true }, + plugins: { + entries: { + acpx: { enabled: true }, + helper: { enabled: true }, + }, + }, + } as OpenClawConfig; + if (initiallyAvailable) { + registerHealthyAcpBackend(); + } + + const first = resolvePluginSkillDirs({ workspaceDir, config }); + + if (initiallyAvailable) { + acpRuntimeTesting.resetAcpRuntimeBackendsForTests(); + } else { + registerHealthyAcpBackend(); + } + const second = resolvePluginSkillDirs({ workspaceDir, config }); + + const dirsForState = (includeAcpx: boolean) => [ + ...(includeAcpx ? [path.resolve(acpxRoot, "skills")] : []), + path.resolve(helperRoot, "skills"), + ]; + expect(first).toEqual(dirsForState(firstIncludesAcpx)); + expect(second).toEqual(dirsForState(secondIncludesAcpx)); + expect(resolvePluginSkillDirs({ workspaceDir, config })).toBe(second); + }, + ); + it("rejects plugin skill paths that escape the plugin root", async () => { const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true }); diff --git a/src/skills/loading/plugin-skills.ts b/src/skills/loading/plugin-skills.ts index 0653f9ca9c73..0578e9bda46c 100644 --- a/src/skills/loading/plugin-skills.ts +++ b/src/skills/loading/plugin-skills.ts @@ -22,12 +22,13 @@ const log = createSubsystemLogger("skills"); type PluginSkillLinkType = "dir" | "junction"; // Plugin metadata is process-stable while the gateway runs, but this resolver sits on the -// per-turn skills-refresh path. The single-slot memo keeps repeat turns from re-walking and -// re-publishing every plugin skill dir; lifecycle clears evict it on plugin reload/install. +// per-turn skills-refresh path. ACP availability changes outside that metadata lifecycle, so it +// stays in the memo identity to prevent stale ACPX skill exposure without repeating directory IO. let pluginSkillDirsMemo: { workspaceDir: string; config: OpenClawConfig | undefined; snapshot: unknown; + acpRuntimeAvailable: boolean; dirs: string[]; } | null = null; @@ -55,16 +56,6 @@ export function resolvePluginSkillDirs(params: { env: process.env, allowWorkspaceScopedCurrent: true, }); - const canMemoize = params.pluginSkillsDir === undefined; - if ( - canMemoize && - pluginSkillDirsMemo && - pluginSkillDirsMemo.workspaceDir === workspaceDir && - pluginSkillDirsMemo.config === params.config && - pluginSkillDirsMemo.snapshot === metadataSnapshot - ) { - return pluginSkillDirsMemo.dirs; - } const registry = metadataSnapshot.manifestRegistry; if (registry.plugins.length === 0) { publishPluginSkills([], { @@ -72,11 +63,22 @@ export function resolvePluginSkillDirs(params: { }); return []; } + const acpRuntimeAvailable = isAcpRuntimeSpawnAvailable({ config }); + const canMemoize = params.pluginSkillsDir === undefined; + if ( + canMemoize && + pluginSkillDirsMemo && + pluginSkillDirsMemo.workspaceDir === workspaceDir && + pluginSkillDirsMemo.config === params.config && + pluginSkillDirsMemo.snapshot === metadataSnapshot && + pluginSkillDirsMemo.acpRuntimeAvailable === acpRuntimeAvailable + ) { + return pluginSkillDirsMemo.dirs; + } const normalizedPlugins = normalizePluginsConfigWithResolver( config.plugins, metadataSnapshot.normalizePluginId, ); - const acpRuntimeAvailable = isAcpRuntimeSpawnAvailable({ config }); const memorySlot = normalizedPlugins.slots.memory; let selectedMemoryPluginId: string | null = null; const seen = new Set(); @@ -147,6 +149,7 @@ export function resolvePluginSkillDirs(params: { workspaceDir, config: params.config, snapshot: metadataSnapshot, + acpRuntimeAvailable, dirs: resolved, }; } From 7620a58b295599d108b2fef056a2edc736933550 Mon Sep 17 00:00:00 2001 From: machine3at Date: Thu, 13 Aug 2026 11:38:36 +0530 Subject: [PATCH 03/18] fix(memory-wiki): guard wiki_get against missing or wrong-typed lookup (#122549) * fix(memory-wiki): return clean error from wiki_get on missing lookup wiki_get crashed with "Cannot read properties of undefined (reading 'trim')" when called with a wrong parameter name (e.g. path instead of lookup), leaving lookup undefined. - Validate and trim lookup at the wiki_get tool boundary; return a clean error (found: false) when it is missing or empty instead of falling through to page resolution - Add regression test covering the wrong-param call path * fix(memory-wiki): normalize wiki_get parameters Punchcard-Session: calm-workshop-cedar-hx --------- Co-authored-by: Vincent Koc --- extensions/memory-wiki/src/query.test.ts | 23 +++++++++++++++++++++++ extensions/memory-wiki/src/tool.ts | 16 ++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index d3c0f3eb925f..f9016241115b 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -1785,6 +1785,29 @@ describe("getMemoryWikiPage", () => { ); }); + it("reports a clean error instead of crashing for malformed wiki_get params", async () => { + const { config } = await createQueryVault({ + initialize: true, + config: { search: { backend: "shared", corpus: "memory" } }, + }); + const tool = createWikiGetTool(config, createAppConfig()); + const malformedParams = [ + null, + { path: "sources/example/note.md" }, + { lookup: " " }, + { lookup: 42 }, + ]; + + for (const params of malformedParams) { + const result = await tool.execute("wiki-get-bad-param", params); + + expect(result.details).toEqual({ found: false }); + expect(result.content).toEqual([ + { type: "text", text: "wiki_get requires a non-empty `lookup` path or id." }, + ]); + } + }); + it("normalizes extensionless shared memory lookups before reading", async () => { const { config } = await createQueryVault({ initialize: true, diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index 8be1545fe13c..cb11e6dfe884 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { optionalFiniteNumberSchema } from "openclaw/plugin-sdk/channel-actions"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import type { AnyAgentTool, OpenClawConfig } from "../api.js"; import { applyMemoryWikiMutation, normalizeMemoryWikiMutationInput } from "./apply.js"; @@ -277,13 +278,20 @@ export function createWikiGetTool( "Read a wiki page by id or relative path, or fall back to the active memory corpus when shared search is enabled.", parameters: WikiGetSchema, execute: async (_toolCallId, rawParams) => { - const params = rawParams as { - lookup: string; + const params = asNonArrayRecord(rawParams) as { + lookup?: string; fromLine?: number; lineCount?: number; backend?: ResolvedMemoryWikiConfig["search"]["backend"]; corpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; }; + const lookup = typeof params.lookup === "string" ? params.lookup.trim() : ""; + if (!lookup) { + return { + content: [{ type: "text", text: "wiki_get requires a non-empty `lookup` path or id." }], + details: { found: false }, + }; + } await syncImportedSourcesIfNeeded(config, appConfig); const result = await getMemoryWikiPage({ config, @@ -292,7 +300,7 @@ export function createWikiGetTool( agentSessionKey: memoryContext.agentSessionKey, sandboxed: memoryContext.sandboxed, conversationRecall: memoryContext.conversationRecall, - lookup: params.lookup, + lookup, fromLine: params.fromLine, lineCount: params.lineCount, ...(params.backend ? { searchBackend: params.backend } : {}), @@ -300,7 +308,7 @@ export function createWikiGetTool( }); if (!result) { return { - content: [{ type: "text", text: `Wiki page not found: ${params.lookup}` }], + content: [{ type: "text", text: `Wiki page not found: ${lookup}` }], details: { found: false }, }; } From 3f4f57a0211c0e0337906336068ee05a6de91536 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 23:14:34 -0700 Subject: [PATCH 04/18] test(extensions): scope remaining multi-agent fixtures to explicit owners (#123021) Class provenance: #114388 made multi-agent ownership explicit; prior partial sweeps #122883 and #122978 repaired Codex, Copilot, ClickClack, and policy fixtures. Per-file changes: - extensions/telegram/src/bot.create-telegram-bot.test.ts: declare startup owners for the four reload fixtures and add the default Telegram binding for the topic override case while preserving dynamic account/topic routing assertions. --- extensions/telegram/src/bot.create-telegram-bot.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 472b8ef389a8..d6cf75bbb9c8 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -2259,7 +2259,7 @@ describe("createTelegramBot", () => { defaults: { model: "openai/gpt-4.1", }, - list: [{ id: "agent-a" }, { id: "agent-b" }], + list: [{ id: "agent-a", default: true }, { id: "agent-b" }], }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, @@ -3689,7 +3689,7 @@ describe("createTelegramBot", () => { }, }, agents: { - list: [{ id: "agent-a" }, { id: "agent-b" }], + list: [{ id: "agent-a", default: true }, { id: "agent-b" }], }, bindings: [ { @@ -3763,8 +3763,9 @@ describe("createTelegramBot", () => { }, }, agents: { - list: [{ id: "topic-a" }, { id: "topic-b" }], + list: [{ id: "topic-a", default: true }, { id: "topic-b" }], }, + bindings: [{ agentId: "topic-a", match: { channel: "telegram", accountId: "default" } }], }); loadConfig.mockImplementation(configForTopicAgent); @@ -5052,7 +5053,7 @@ describe("createTelegramBot", () => { }, }, agents: { - list: [{ id: "agent-a" }, { id: "agent-b" }], + list: [{ id: "agent-a", default: true }, { id: "agent-b" }], }, bindings: [ { From cc2ed816bee42e0c42c3344126e92afc8f575fa7 Mon Sep 17 00:00:00 2001 From: Leon-SK668 <0668001470@xydigit.com> Date: Thu, 13 Aug 2026 14:17:32 +0800 Subject: [PATCH 05/18] fix(mac): preserve Xcode preflight failure diagnostics (#121170) --- scripts/lib/swift-toolchain.sh | 4 +++- test/scripts/package-mac-app.test.ts | 29 +++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/scripts/lib/swift-toolchain.sh b/scripts/lib/swift-toolchain.sh index 30ac5462b068..6c01c555e53b 100644 --- a/scripts/lib/swift-toolchain.sh +++ b/scripts/lib/swift-toolchain.sh @@ -4,7 +4,9 @@ REQUIRED_SWIFT_TOOLS_MAJOR=6 REQUIRED_SWIFT_TOOLS_MINOR=2 require_swift_toolchain() { - if ! xcrun xcodebuild -version >/dev/null 2>&1; then + local xcodebuild_version + if ! xcodebuild_version="$(xcrun xcodebuild -version 2>&1)"; then + printf '%s\n' "$xcodebuild_version" >&2 echo "ERROR: OpenClaw macOS app packaging requires a full Xcode developer directory." >&2 echo " Command Line Tools do not include the required SwiftUI macro plugins." >&2 echo " Use: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index 7c024a3d58e1..2c4a7605e2b3 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -62,6 +62,7 @@ function runSwiftToolchainHarness(options: { swiftVersion: string; selectedDeveloperDir: "command-line-tools" | "custom-xcode" | "invalid" | "xcode"; developerDirOverride?: "custom-xcode" | "invalid" | "xcode"; + xcodebuildFailure?: string; }) { const root = tempDirs.make("openclaw-package-swift-root-"); const toolsDir = path.join(root, "tools"); @@ -84,9 +85,14 @@ function runSwiftToolchainHarness(options: { mkdirSync(path.dirname(xcodebuild), { recursive: true }); writeFileSync( xcodebuild, - ["#!/usr/bin/env bash", '[[ "$*" == "-version" ]] || exit 2', "echo 'Xcode 26.0'", ""].join( - "\n", - ), + [ + "#!/usr/bin/env bash", + '[[ "$*" == "-version" ]] || exit 2', + ...(options.xcodebuildFailure + ? [`printf '%s\\n' ${JSON.stringify(options.xcodebuildFailure)} >&2`, "exit 1"] + : ["echo 'Xcode 26.0'"]), + "", + ].join("\n"), "utf8", ); chmodSync(xcodebuild, 0o755); @@ -877,6 +883,23 @@ describe("package-mac-app plist stamping", () => { expect(result.stderr).toContain("requires a full Xcode developer directory"); }); + it("preserves the native Xcode failure before generic selection guidance", () => { + const diagnostic = "xcodebuild: error: SDK metadata is unavailable"; + const result = runSwiftToolchainHarness({ + swiftVersion: "6.2.1", + selectedDeveloperDir: "xcode", + xcodebuildFailure: diagnostic, + }); + + expect(result.status).toBe(1); + const diagnosticIndex = result.stderr.indexOf(diagnostic); + const guidanceIndex = result.stderr.indexOf( + "ERROR: OpenClaw macOS app packaging requires a full Xcode developer directory", + ); + expect(diagnosticIndex).toBeGreaterThanOrEqual(0); + expect(guidanceIndex).toBeGreaterThan(diagnosticIndex); + }); + it("runs Sparkle build metadata derivation from the repository root", () => { const helperBlock = getSparkleBuildHelperBlock(); const tempRoot = tempDirs.make("openclaw-package-sparkle-root-"); From 2464c17ab56034007d6f1f90083c8cd742fa1bb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 23:24:27 -0700 Subject: [PATCH 06/18] fix(ui): open the current session desktop directly (#122992) * fix(ui): open session desktop directly * fix(ui): make session desktop retryable --- .../components/desktop/desktop-panel-state.ts | 31 +++ ui/src/components/desktop/desktop-panel.ts | 86 ++++---- ui/src/components/desktop/desktop-source.ts | 13 ++ ui/src/e2e/desktop-panel.e2e.test.ts | 204 +++++++++++++++++- ui/src/pages/chat/chat-pane-header.ts | 14 +- ui/src/pages/chat/chat-pane-placement.ts | 18 ++ ui/src/pages/chat/chat-pane-terminal.test.ts | 88 ++++++-- 7 files changed, 383 insertions(+), 71 deletions(-) create mode 100644 ui/src/components/desktop/desktop-panel-state.ts create mode 100644 ui/src/components/desktop/desktop-source.ts diff --git a/ui/src/components/desktop/desktop-panel-state.ts b/ui/src/components/desktop/desktop-panel-state.ts new file mode 100644 index 000000000000..ac02e54b6bf5 --- /dev/null +++ b/ui/src/components/desktop/desktop-panel-state.ts @@ -0,0 +1,31 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; + +export type DesktopPanelState = + | "picker" + | "inventory-error" + | "credentials" + | "connecting" + | "connected" + | "disconnected"; + +export function renderDesktopPanelRecovery(props: { + inventoryError: boolean; + reason: string | null; + onRetry: () => void; +}) { + return html` +
+ ${props.inventoryError + ? nothing + : html`
+ ${t("desktop.disconnected", { + reason: props.reason ?? t("desktop.unknownReason"), + })} +
`} + +
+ `; +} diff --git a/ui/src/components/desktop/desktop-panel.ts b/ui/src/components/desktop/desktop-panel.ts index 4af4619d0468..4f07c8a11260 100644 --- a/ui/src/components/desktop/desktop-panel.ts +++ b/ui/src/components/desktop/desktop-panel.ts @@ -23,7 +23,9 @@ import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts"; import { DesktopClient, type DesktopConnectionHandle } from "./desktop-client.ts"; import { desktopCredentialRequirement } from "./desktop-panel-credentials.ts"; import { desktopPanelLauncherStyles } from "./desktop-panel-launcher-styles.ts"; +import { type DesktopPanelState, renderDesktopPanelRecovery } from "./desktop-panel-state.ts"; import { desktopPanelStyles } from "./desktop-panel-styles.ts"; +import { desktopSourceForEnvironment } from "./desktop-source.ts"; const CLOSE_GLYPH = svg``; const DOCK_BOTTOM_GLYPH = svg``; @@ -38,7 +40,6 @@ const panelLayout = createDockPanelLayout({ defaultHeight: 420, defaultWidth: 560, }); -type DesktopPanelState = "picker" | "credentials" | "connecting" | "connected" | "disconnected"; type DesktopAppId = WorkerDesktopAppId; type DesktopCredentials = { username?: string; password?: string }; type PendingDesktopConnection = { @@ -49,16 +50,6 @@ type PendingDesktopConnection = { }; type ObservedDesktopConnection = PendingDesktopConnection & { observed: DesktopObserveResult }; -function desktopSourceForEnvironment(environment: Pick): DesktopSource { - if (environment.id === "gateway") { - return { kind: "host" }; - } - if (environment.id.startsWith("node:") && environment.id.length > "node:".length) { - return { kind: "node", nodeId: environment.id.slice("node:".length) }; - } - return { kind: "environment", environmentId: environment.id }; -} - /** `` — dockable RFB access to Gateway desktop sources. */ class OpenClawDesktopPanel extends OpenClawLitElement { @property({ attribute: false }) client: GatewayBrowserClient | null = null; @@ -151,7 +142,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { const wasOpen = this.dockLayout.open; this.dockLayout.setOpen(true); if (detail?.environmentId) { - void this.connectEnvironment(detail.environmentId, false); + void this.connectRequestedEnvironment(detail.environmentId); } else if (!wasOpen) { void this.refreshEnvironments(); } else if (detail?.open !== true) { @@ -191,24 +182,26 @@ class OpenClawDesktopPanel extends OpenClawLitElement { this.launchErrorText = null; } - private async refreshEnvironments(): Promise { + private async refreshEnvironments(expectedOperationId?: number): Promise { const client = this.client; if (!client || !this.available) { - return; + return false; } - const operationId = ++this.operationId; + const operationId = expectedOperationId ?? ++this.operationId; this.loading = true; this.errorText = null; try { const result = await client.request("environments.list", {}); if (operationId !== this.operationId) { - return; + return false; } this.environments = result.environments.filter((environment) => environment.desktop === true); + return true; } catch (error) { if (operationId === this.operationId) { this.errorText = t("desktop.errors.listFailed", { error: formatUiError(error) }); } + return false; } finally { if (operationId === this.operationId) { this.loading = false; @@ -216,6 +209,22 @@ class OpenClawDesktopPanel extends OpenClawLitElement { } } + private async connectRequestedEnvironment(environmentId: string): Promise { + this.returnToPicker(); + this.environmentId = environmentId; + this.state = "connecting"; + const operationId = this.operationId; + const inventoryLoaded = await this.refreshEnvironments(operationId); + if (operationId !== this.operationId) { + return; + } + if (!inventoryLoaded) { + this.state = "inventory-error"; + return; + } + void this.connectEnvironment(environmentId, false); + } + private async connectEnvironment( environmentId: string, control: boolean, @@ -229,11 +238,11 @@ class OpenClawDesktopPanel extends OpenClawLitElement { this.clearLaunchState(); this.credentials = undefined; this.credentialAuth = undefined; - this.desktopApps = [ - ...(this.environments.find((environment) => environment.id === environmentId)?.worker - ?.desktopApps ?? []), - ]; } + this.desktopApps = [ + ...(this.environments.find((environment) => environment.id === environmentId)?.worker + ?.desktopApps ?? []), + ]; this.disconnectConnection(); const operationId = this.operationId; const environment = this.environments.find((candidate) => candidate.id === environmentId) ?? { @@ -628,27 +637,6 @@ class OpenClawDesktopPanel extends OpenClawLitElement { `; } - private renderDisconnected() { - return html` -
-
- ${t("desktop.disconnected", { - reason: this.disconnectedReason ?? t("desktop.unknownReason"), - })} -
- -
- `; - } - private renderCredentials() { const ardAccount = this.credentialAuth === "ard-account"; return html` @@ -710,10 +698,18 @@ class OpenClawDesktopPanel extends OpenClawLitElement { : nothing} ${this.state === "picker" ? this.renderPicker() - : this.state === "credentials" - ? this.renderCredentials() - : this.state === "disconnected" - ? this.renderDisconnected() + : this.state === "inventory-error" || this.state === "disconnected" + ? renderDesktopPanelRecovery({ + inventoryError: this.state === "inventory-error", + reason: this.disconnectedReason, + onRetry: () => + this.environmentId && + void (this.state === "inventory-error" + ? this.connectRequestedEnvironment(this.environmentId) + : this.connectEnvironment(this.environmentId, this.controlling)), + }) + : this.state === "credentials" + ? this.renderCredentials() : this.renderConnection()} diff --git a/ui/src/components/desktop/desktop-source.ts b/ui/src/components/desktop/desktop-source.ts new file mode 100644 index 000000000000..a888b57d598a --- /dev/null +++ b/ui/src/components/desktop/desktop-source.ts @@ -0,0 +1,13 @@ +import type { DesktopSource, EnvironmentSummary } from "@openclaw/gateway-protocol"; + +export function desktopSourceForEnvironment( + environment: Pick, +): DesktopSource { + if (environment.id === "gateway") { + return { kind: "host" }; + } + if (environment.id.startsWith("node:") && environment.id.length > "node:".length) { + return { kind: "node", nodeId: environment.id.slice("node:".length) }; + } + return { kind: "environment", environmentId: environment.id }; +} diff --git a/ui/src/e2e/desktop-panel.e2e.test.ts b/ui/src/e2e/desktop-panel.e2e.test.ts index b80047503ae6..d09d8b7ae896 100644 --- a/ui/src/e2e/desktop-panel.e2e.test.ts +++ b/ui/src/e2e/desktop-panel.e2e.test.ts @@ -27,6 +27,21 @@ function sessionsList(placement: "local" | "active") { }; } +const workerDesktopEnvironment = { + id: "worker-desktop-1", + type: "worker", + status: "available", + desktop: true, + worker: { + providerId: "crabbox", + state: "attached", + ageMs: 1_000, + attachedSessionIds: ["main"], + tunnelStatus: "connected", + desktopApps: ["browser", "terminal"], + }, +} as const; + async function openPalette(page: import("playwright").Page) { await page.evaluate(() => { window.dispatchEvent(new CustomEvent("openclaw:command-palette-open")); @@ -43,6 +58,16 @@ async function openDesktopPanel(page: import("playwright").Page) { return panel; } +async function openDirectDesktop(page: import("playwright").Page, environmentId: string) { + await page.evaluate((targetEnvironmentId) => { + window.dispatchEvent( + new CustomEvent("openclaw:desktop-toggle", { + detail: { open: true, environmentId: targetEnvironmentId }, + }), + ); + }, environmentId); +} + async function installDesktopClientFake(panel: import("playwright").Locator) { await panel.evaluate((element) => { ( @@ -105,8 +130,185 @@ suite.define(() => { expect(await page.getByRole("option", { name: "Desktop", exact: true }).count()).toBe(1); await page.getByRole("option", { name: "Desktop", exact: true }).click(); - await page.locator("openclaw-desktop-panel section[aria-label='Desktop']").waitFor(); + const panel = page.locator("openclaw-desktop-panel"); + await panel.locator("section[aria-label='Desktop']").waitFor(); + await panel.getByText("Desktop sources", { exact: true }).waitFor(); await gateway.waitForRequest("environments.list"); + expect(await gateway.getRequests("desktop.observe")).toHaveLength(0); + }); + }); + + it("refreshes direct-target inventory before observing the exact worker", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: ["desktop.launch", "desktop.observe", "environments.list"], + methodResponses: { + "sessions.list": sessionsList("active"), + "environments.list": { + environments: [workerDesktopEnvironment], + }, + "desktop.observe": { + transport: "rfb", + wsPath: "/desktop/observe?token=direct", + expiresAtMs: 60_000, + control: false, + }, + }, + }); + await page.goto(`${suite.server.baseUrl}chat`); + const panel = page.locator("openclaw-desktop-panel"); + await installDesktopClientFake(panel); + const requestCount = (await gateway.getRequests()).length; + + await openDirectDesktop(page, "worker-desktop-1"); + + const observeRequest = await gateway.waitForRequest("desktop.observe"); + expect(observeRequest.params).toEqual({ + source: { kind: "environment", environmentId: "worker-desktop-1" }, + control: false, + }); + expect( + (await gateway.getRequests()) + .slice(requestCount) + .filter((request) => ["environments.list", "desktop.observe"].includes(request.method)) + .map((request) => request.method), + ).toEqual(["environments.list", "desktop.observe"]); + expect(await panel.getByText("Desktop sources", { exact: true }).count()).toBe(0); + await panel.getByRole("button", { name: "Browser", exact: true }).waitFor(); + await panel.getByRole("button", { name: "Terminal", exact: true }).waitFor(); + }); + }); + + it("reports an unavailable direct target without showing another source", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: ["desktop.observe", "environments.list"], + methodResponses: { + "sessions.list": sessionsList("active"), + "environments.list": { + environments: [{ id: "gateway", type: "local", status: "available", desktop: true }], + }, + "desktop.observe": { + __mockError: { + code: "UNAVAILABLE", + message: "requested worker desktop is temporarily unavailable", + }, + }, + }, + }); + await page.goto(`${suite.server.baseUrl}chat`); + + await openDirectDesktop(page, "missing-worker"); + + const observeRequest = await gateway.waitForRequest("desktop.observe"); + expect(observeRequest.params).toEqual({ + source: { kind: "environment", environmentId: "missing-worker" }, + control: false, + }); + const panel = page.locator("openclaw-desktop-panel"); + await panel.getByText(/requested worker desktop is temporarily unavailable/).waitFor(); + expect(await panel.getByText("Desktop sources", { exact: true }).count()).toBe(0); + expect(await panel.getByText("This machine", { exact: true }).count()).toBe(0); + }); + }); + + it("shows direct-target inventory failure without observing or falling back", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: ["desktop.observe", "environments.list"], + methodResponses: { + "sessions.list": sessionsList("active"), + "environments.list": { + __mockError: { + code: "UNAVAILABLE", + message: "desktop inventory is temporarily unavailable", + }, + }, + "desktop.observe": { + transport: "rfb", + wsPath: "/desktop/observe?token=degraded", + expiresAtMs: 60_000, + control: false, + }, + }, + }); + await page.goto(`${suite.server.baseUrl}chat`); + await openDirectDesktop(page, "worker-desktop-1"); + + const panel = page.locator("openclaw-desktop-panel"); + await panel.getByRole("alert").filter({ hasText: "inventory" }).waitFor(); + expect(await gateway.getRequests("desktop.observe")).toHaveLength(0); + expect(await panel.getByText("Desktop sources", { exact: true }).count()).toBe(0); + expect(await panel.getByText("This machine", { exact: true }).count()).toBe(0); + + await gateway.setMethodResponse("environments.list", { + environments: [workerDesktopEnvironment], + }); + await installDesktopClientFake(panel); + const requestCount = (await gateway.getRequests()).length; + await panel.getByRole("button", { name: "Retry", exact: true }).click(); + + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBe(2); + const observeRequest = await gateway.waitForRequest("desktop.observe"); + expect(observeRequest.params).toEqual({ + source: { kind: "environment", environmentId: "worker-desktop-1" }, + control: false, + }); + expect( + (await gateway.getRequests()) + .slice(requestCount) + .filter((request) => ["environments.list", "desktop.observe"].includes(request.method)) + .map((request) => request.method), + ).toEqual(["environments.list", "desktop.observe"]); + await panel.getByRole("button", { name: "Browser", exact: true }).waitFor(); + await panel.getByRole("button", { name: "Terminal", exact: true }).waitFor(); + expect(await panel.getAttribute("data-connect-count")).toBe("1"); + expect(await panel.getByText("Desktop sources", { exact: true }).count()).toBe(0); + }); + }); + + it("does not observe a direct target after its inventory refresh is closed", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: ["desktop.observe", "environments.list"], + methodResponses: { + "sessions.list": sessionsList("active"), + "environments.list": { environments: [] }, + "desktop.observe": { + transport: "rfb", + wsPath: "/desktop/observe?token=stale", + expiresAtMs: 60_000, + control: false, + }, + }, + }); + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.deferNext("environments.list"); + const inventoryCount = (await gateway.getRequests("environments.list")).length; + + await openDirectDesktop(page, "worker-desktop-1"); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBe(inventoryCount + 1); + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("openclaw:desktop-toggle", { detail: { open: false } }), + ); + }); + await gateway.resolveDeferred("environments.list", { environments: [] }); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + + expect( + await page.locator("openclaw-desktop-panel section[aria-label='Desktop']").count(), + ).toBe(0); + expect(await gateway.getRequests("desktop.observe")).toHaveLength(0); }); }); diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 28deb8a45520..aac62b97f69f 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -25,7 +25,7 @@ import { } from "../../lib/sessions/session-key.ts"; import { isActiveTask } from "../../lib/tasks/data.ts"; import { renderBoardViewSwitch } from "./board-session-surface.ts"; -import { resolveChatPanePlacement } from "./chat-pane-placement.ts"; +import { resolveChatPaneDesktopTarget, resolveChatPanePlacement } from "./chat-pane-placement.ts"; import { ChatPaneSessionMenu } from "./chat-pane-session-menu.ts"; import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; import { resolveChatAgentId } from "./chat-state-route.ts"; @@ -192,13 +192,19 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { ? { "continue-in-terminal": continueInTerminalDisabledReason } : {}), }; - const desktopPanelAvailable = isDesktopPanelAvailable(this.context.gateway.snapshot); - const openDesktopPanel = () => + const desktopEnvironmentId = resolveChatPaneDesktopTarget(row); + const desktopPanelAvailable = + desktopEnvironmentId !== null && isDesktopPanelAvailable(this.context.gateway.snapshot); + const openDesktopPanel = () => { + if (!desktopEnvironmentId) { + return; + } window.dispatchEvent( new CustomEvent(DESKTOP_PANEL_TOGGLE_EVENT, { - detail: { open: true }, + detail: { open: true, environmentId: desktopEnvironmentId }, }), ); + }; const browserPanelAction = sessionWorkspace.onToggleBrowser ? html`