From 586f32fe25130dc7ae181864d53a34d301eb4ffd Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 5 Aug 2026 14:56:00 +0100 Subject: [PATCH] fix: preserve concurrent ClickClack attachment refreshes --- .../src/discussions/service-events.test.ts | 71 +++++++++++++++++++ .../clickclack/src/discussions/service.ts | 11 ++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/extensions/clickclack/src/discussions/service-events.test.ts b/extensions/clickclack/src/discussions/service-events.test.ts index f15874513d88..4b2a2eeeb803 100644 --- a/extensions/clickclack/src/discussions/service-events.test.ts +++ b/extensions/clickclack/src/discussions/service-events.test.ts @@ -4,6 +4,7 @@ import type { } from "openclaw/plugin-sdk/core"; import { describe, expect, it, vi } from "vitest"; import type { ClickClackDiscussionBinding } from "./binding-store.js"; +import { resolveClickClackDiscussionRoute } from "./routing.js"; import { createHarness } from "./service-test-support.js"; function createGatewayEventsHarness() { @@ -85,6 +86,76 @@ describe("ClickClack discussion session events", () => { } }); + it("does not overwrite a replacement attachment when metadata reconciliation settles late", async () => { + const harness = createHarness({ + sessionId: "session-original", + label: "Original", + category: "Projects", + }); + const sessionKey = "agent:main:event-concurrent-reset"; + try { + await harness.service.open(sessionKey); + harness.updateChannel.mockClear(); + let releaseUpdate: (() => void) | undefined; + harness.updateChannel.mockImplementationOnce(async (_channelId, patch) => { + await new Promise((resolve) => { + releaseUpdate = resolve; + }); + return { + id: "chn_discussion", + route_id: "discussion-route", + workspace_id: "wsp_team", + name: patch.name ?? "renamed", + kind: "public", + external_managed: true, + external_ref: "agent:main:main", + external_url: patch.external_url ?? "https://control.example/control/chat/main", + sidebar_section: patch.sidebar_section ?? "Projects", + ...(patch.display_title !== undefined ? { display_title: patch.display_title } : {}), + archived: false, + created_at: "2026-07-19T00:00:00.000Z", + }; + }); + harness.setSessionEntry({ + sessionId: "session-original", + label: "Renamed", + category: "Projects", + }); + + const reconcile = harness.service.reconcile(sessionKey); + await vi.waitFor(() => expect(harness.updateChannel).toHaveBeenCalledOnce()); + + harness.setSessionEntry({ + sessionId: "session-replacement", + label: "Renamed", + category: "Projects", + }); + expect( + resolveClickClackDiscussionRoute({ + runtime: harness.runtime, + config: harness.config, + accountId: "default", + serverBaseUrl: "https://clickclack.example", + workspaceId: "wsp_team", + channelId: "chn_discussion", + }), + ).toMatchObject({ state: "active" }); + expect(harness.store.lookup(sessionKey)).toMatchObject({ + sessionId: "session-replacement", + }); + + releaseUpdate?.(); + await reconcile; + + expect(harness.store.lookup(sessionKey)).toMatchObject({ + sessionId: "session-replacement", + label: "Renamed", + }); + } finally { + harness.service.cleanup(); + } + }); + it("keeps one durable room through archive, reset, deletion, and recreation events", async () => { vi.useFakeTimers(); const gateway = createGatewayEventsHarness(); diff --git a/extensions/clickclack/src/discussions/service.ts b/extensions/clickclack/src/discussions/service.ts index a755c7c480dc..f38a441eddd2 100644 --- a/extensions/clickclack/src/discussions/service.ts +++ b/extensions/clickclack/src/discussions/service.ts @@ -439,8 +439,17 @@ export class ClickClackDiscussionService { updated = await client.updateChannel(currentBinding.channelId, patch); assertChannelPatch(updated, patch); } + const latestBinding = this.#store.get(sessionKey); + if ( + !latestBinding || + latestBinding.serverBaseUrl !== currentBinding.serverBaseUrl || + latestBinding.channelId !== currentBinding.channelId || + latestBinding.externalRef !== currentBinding.externalRef + ) { + return; + } this.#store.set(sessionKey, { - ...currentBinding, + ...latestBinding, externalUrl, label, section,