diff --git a/CHANGELOG.md b/CHANGELOG.md index fde13f274..89021c87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. - Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. - Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn. - Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber. diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts new file mode 100644 index 000000000..9b8e0a2d5 --- /dev/null +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -0,0 +1,182 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import type { GatewayRequestContext } from "./types.js"; + +const mockState = vi.hoisted(() => ({ + transcriptPath: "", + sessionId: "sess-1", + finalText: "[[reply_to_current]]", +})); + +vi.mock("../session-utils.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + loadSessionEntry: () => ({ + cfg: {}, + storePath: path.join(path.dirname(mockState.transcriptPath), "sessions.json"), + entry: { + sessionId: mockState.sessionId, + sessionFile: mockState.transcriptPath, + }, + canonicalKey: "main", + }), + }; +}); + +vi.mock("../../auto-reply/dispatch.js", () => ({ + dispatchInboundMessage: vi.fn( + async (params: { + dispatcher: { + sendFinalReply: (payload: { text: string }) => boolean; + markComplete: () => void; + waitForIdle: () => Promise; + }; + }) => { + params.dispatcher.sendFinalReply({ text: mockState.finalText }); + params.dispatcher.markComplete(); + await params.dispatcher.waitForIdle(); + return { ok: true }; + }, + ), +})); + +const { chatHandlers } = await import("./chat.js"); + +function createTranscriptFixture(prefix: string) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const transcriptPath = path.join(dir, "sess.jsonl"); + fs.writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: mockState.sessionId, + timestamp: new Date(0).toISOString(), + cwd: "/tmp", + })}\n`, + "utf-8", + ); + mockState.transcriptPath = transcriptPath; +} + +function extractFirstTextBlock(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + const message = (payload as { message?: unknown }).message; + if (!message || typeof message !== "object") { + return undefined; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return undefined; + } + const first = content[0]; + if (!first || typeof first !== "object") { + return undefined; + } + const firstText = (first as { text?: unknown }).text; + return typeof firstText === "string" ? firstText : undefined; +} + +function createChatContext(): Pick< + GatewayRequestContext, + | "broadcast" + | "nodeSendToSession" + | "agentRunSeq" + | "chatAbortControllers" + | "chatRunBuffers" + | "chatDeltaSentAt" + | "chatAbortedRuns" + | "removeChatRun" + | "dedupe" + | "registerToolEventRecipient" + | "logGateway" +> { + return { + broadcast: vi.fn() as unknown as GatewayRequestContext["broadcast"], + nodeSendToSession: vi.fn() as unknown as GatewayRequestContext["nodeSendToSession"], + agentRunSeq: new Map(), + chatAbortControllers: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatAbortedRuns: new Map(), + removeChatRun: vi.fn(), + dedupe: new Map(), + registerToolEventRecipient: vi.fn(), + logGateway: { + warn: vi.fn(), + debug: vi.fn(), + } as GatewayRequestContext["logGateway"], + }; +} + +describe("chat directive tag stripping for non-streaming final payloads", () => { + it("chat.inject keeps message defined when directive tag is the only content", async () => { + createTranscriptFixture("openclaw-chat-inject-directive-only-"); + const respond = vi.fn(); + const context = createChatContext(); + + await chatHandlers["chat.inject"]({ + params: { sessionKey: "main", message: "[[reply_to_current]]" }, + respond, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + context: context as GatewayRequestContext, + }); + + expect(respond).toHaveBeenCalled(); + const [ok, payload] = respond.mock.calls.at(-1) ?? []; + expect(ok).toBe(true); + expect(payload).toMatchObject({ ok: true }); + const chatCall = (context.broadcast as unknown as ReturnType).mock.calls.at(-1); + expect(chatCall?.[0]).toBe("chat"); + expect(chatCall?.[1]).toEqual( + expect.objectContaining({ + state: "final", + message: expect.any(Object), + }), + ); + expect(extractFirstTextBlock(chatCall?.[1])).toBe(""); + }); + + it("chat.send non-streaming final keeps message defined for directive-only assistant text", async () => { + createTranscriptFixture("openclaw-chat-send-directive-only-"); + mockState.finalText = "[[reply_to_current]]"; + const respond = vi.fn(); + const context = createChatContext(); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-directive-only", + }, + respond, + req: {} as never, + client: null, + isWebchatConnect: () => false, + context: context as GatewayRequestContext, + }); + + await vi.waitFor(() => { + expect((context.broadcast as unknown as ReturnType).mock.calls.length).toBe(1); + }); + + const chatCall = (context.broadcast as unknown as ReturnType).mock.calls[0]; + expect(chatCall?.[0]).toBe("chat"); + expect(chatCall?.[1]).toEqual( + expect.objectContaining({ + runId: "idem-directive-only", + state: "final", + message: expect.any(Object), + }), + ); + expect(extractFirstTextBlock(chatCall?.[1])).toBe(""); + }); +});