Skip to content

Commit 5c57a45

Browse files
committed
fix: add non-streaming directive-tag regression tests (#23298) (thanks @SidQin-cyber)
1 parent e649073 commit 5c57a45

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818

1919
### Fixes
2020

21+
- 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.
2122
- 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.
2223
- 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.
2324
- 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.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
5+
import { describe, expect, it, vi } from "vitest";
6+
import type { GatewayRequestContext } from "./types.js";
7+
8+
const mockState = vi.hoisted(() => ({
9+
transcriptPath: "",
10+
sessionId: "sess-1",
11+
finalText: "[[reply_to_current]]",
12+
}));
13+
14+
vi.mock("../session-utils.js", async (importOriginal) => {
15+
const original = await importOriginal<typeof import("../session-utils.js")>();
16+
return {
17+
...original,
18+
loadSessionEntry: () => ({
19+
cfg: {},
20+
storePath: path.join(path.dirname(mockState.transcriptPath), "sessions.json"),
21+
entry: {
22+
sessionId: mockState.sessionId,
23+
sessionFile: mockState.transcriptPath,
24+
},
25+
canonicalKey: "main",
26+
}),
27+
};
28+
});
29+
30+
vi.mock("../../auto-reply/dispatch.js", () => ({
31+
dispatchInboundMessage: vi.fn(
32+
async (params: {
33+
dispatcher: {
34+
sendFinalReply: (payload: { text: string }) => boolean;
35+
markComplete: () => void;
36+
waitForIdle: () => Promise<void>;
37+
};
38+
}) => {
39+
params.dispatcher.sendFinalReply({ text: mockState.finalText });
40+
params.dispatcher.markComplete();
41+
await params.dispatcher.waitForIdle();
42+
return { ok: true };
43+
},
44+
),
45+
}));
46+
47+
const { chatHandlers } = await import("./chat.js");
48+
49+
function createTranscriptFixture(prefix: string) {
50+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
51+
const transcriptPath = path.join(dir, "sess.jsonl");
52+
fs.writeFileSync(
53+
transcriptPath,
54+
`${JSON.stringify({
55+
type: "session",
56+
version: CURRENT_SESSION_VERSION,
57+
id: mockState.sessionId,
58+
timestamp: new Date(0).toISOString(),
59+
cwd: "/tmp",
60+
})}\n`,
61+
"utf-8",
62+
);
63+
mockState.transcriptPath = transcriptPath;
64+
}
65+
66+
function extractFirstTextBlock(payload: unknown): string | undefined {
67+
if (!payload || typeof payload !== "object") {
68+
return undefined;
69+
}
70+
const message = (payload as { message?: unknown }).message;
71+
if (!message || typeof message !== "object") {
72+
return undefined;
73+
}
74+
const content = (message as { content?: unknown }).content;
75+
if (!Array.isArray(content)) {
76+
return undefined;
77+
}
78+
const first = content[0];
79+
if (!first || typeof first !== "object") {
80+
return undefined;
81+
}
82+
const firstText = (first as { text?: unknown }).text;
83+
return typeof firstText === "string" ? firstText : undefined;
84+
}
85+
86+
function createChatContext(): Pick<
87+
GatewayRequestContext,
88+
| "broadcast"
89+
| "nodeSendToSession"
90+
| "agentRunSeq"
91+
| "chatAbortControllers"
92+
| "chatRunBuffers"
93+
| "chatDeltaSentAt"
94+
| "chatAbortedRuns"
95+
| "removeChatRun"
96+
| "dedupe"
97+
| "registerToolEventRecipient"
98+
| "logGateway"
99+
> {
100+
return {
101+
broadcast: vi.fn() as unknown as GatewayRequestContext["broadcast"],
102+
nodeSendToSession: vi.fn() as unknown as GatewayRequestContext["nodeSendToSession"],
103+
agentRunSeq: new Map<string, number>(),
104+
chatAbortControllers: new Map(),
105+
chatRunBuffers: new Map(),
106+
chatDeltaSentAt: new Map(),
107+
chatAbortedRuns: new Map(),
108+
removeChatRun: vi.fn(),
109+
dedupe: new Map(),
110+
registerToolEventRecipient: vi.fn(),
111+
logGateway: {
112+
warn: vi.fn(),
113+
debug: vi.fn(),
114+
} as GatewayRequestContext["logGateway"],
115+
};
116+
}
117+
118+
describe("chat directive tag stripping for non-streaming final payloads", () => {
119+
it("chat.inject keeps message defined when directive tag is the only content", async () => {
120+
createTranscriptFixture("openclaw-chat-inject-directive-only-");
121+
const respond = vi.fn();
122+
const context = createChatContext();
123+
124+
await chatHandlers["chat.inject"]({
125+
params: { sessionKey: "main", message: "[[reply_to_current]]" },
126+
respond,
127+
req: {} as never,
128+
client: null as never,
129+
isWebchatConnect: () => false,
130+
context: context as GatewayRequestContext,
131+
});
132+
133+
expect(respond).toHaveBeenCalled();
134+
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
135+
expect(ok).toBe(true);
136+
expect(payload).toMatchObject({ ok: true });
137+
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.at(-1);
138+
expect(chatCall?.[0]).toBe("chat");
139+
expect(chatCall?.[1]).toEqual(
140+
expect.objectContaining({
141+
state: "final",
142+
message: expect.any(Object),
143+
}),
144+
);
145+
expect(extractFirstTextBlock(chatCall?.[1])).toBe("");
146+
});
147+
148+
it("chat.send non-streaming final keeps message defined for directive-only assistant text", async () => {
149+
createTranscriptFixture("openclaw-chat-send-directive-only-");
150+
mockState.finalText = "[[reply_to_current]]";
151+
const respond = vi.fn();
152+
const context = createChatContext();
153+
154+
await chatHandlers["chat.send"]({
155+
params: {
156+
sessionKey: "main",
157+
message: "hello",
158+
idempotencyKey: "idem-directive-only",
159+
},
160+
respond,
161+
req: {} as never,
162+
client: null,
163+
isWebchatConnect: () => false,
164+
context: context as GatewayRequestContext,
165+
});
166+
167+
await vi.waitFor(() => {
168+
expect((context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
169+
});
170+
171+
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
172+
expect(chatCall?.[0]).toBe("chat");
173+
expect(chatCall?.[1]).toEqual(
174+
expect.objectContaining({
175+
runId: "idem-directive-only",
176+
state: "final",
177+
message: expect.any(Object),
178+
}),
179+
);
180+
expect(extractFirstTextBlock(chatCall?.[1])).toBe("");
181+
});
182+
});

0 commit comments

Comments
 (0)