Skip to content

Commit d7390e1

Browse files
orihakimiclaude
andcommitted
fix(webchat): fire before_message_write on chat.send user persistence [AI-assisted]
ClawSweeper rated this PR 🦞 diamond lobster on proof but 🧂 unranked krab on patch quality, with two P1 findings at chat.ts:2684-2690: [P1] Keep agent user turns on the SessionManager writer [P1] Preserve transcript hooks for gateway user writes The original write at chat.ts:2684-2690 called appendSessionTranscriptMessage directly and bypassed plugin-registered before_message_write hooks. The canonical hook-respecting pattern lives at extensions/codex/src/app-server/ transcript-mirror.ts:147 — run runAgentHarnessBeforeMessageWriteHook first, write only the (possibly transformed) message it returns, skip the write entirely if a plugin blocks. Mirror that pattern for the webchat user-persistence path. Now plugin policy (redaction, provenance stamping, block-list) applies to gateway user writes the same way it applies to codex-side assistant writes. Also drop the silent .catch() that swallowed real append failures, and emit the post-hook (and post-append) message to subscribers so what they see matches what is on disk. Tests added in chat.user-transcript-persistence.test.ts: - hook returns block=true → nothing written, transcript unchanged. - hook rewrites content → on-disk content is the rewritten value, the original content does not land on disk. - no hooks registered → identity passthrough preserved. Targeted vitest: 14/14 in chat.user-transcript-persistence.test.ts. Full server-methods suite: 1638/1638 across 109 files, no regressions. pnpm lint --quiet, pnpm tsgo, pnpm tsgo:test all clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c9d306c commit d7390e1

2 files changed

Lines changed: 166 additions & 17 deletions

File tree

src/gateway/server-methods/chat.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolveSendableOutboundReplyParts,
1111
} from "openclaw/plugin-sdk/reply-payload";
1212
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
13+
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
1314
import { resolveDefaultModelForAgent } from "../../agents/model-selection.js";
1415
import { rewriteTranscriptEntriesInSessionFile } from "../../agents/pi-embedded-runner/transcript-rewrite.js";
1516
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
@@ -2678,25 +2679,34 @@ export const chatHandlers: GatewayRequestHandlers = {
26782679
savedImages: persistedImages,
26792680
timestamp: now,
26802681
});
2681-
// Call sites that invoke emitUserTranscriptUpdate are gated on
2682-
// !hasBeforeAgentRunGate, so this append never collides with
2683-
// attempt.ts's redacted-user-message write on the hook-block path.
2684-
// appendSessionTranscriptMessage chains parentId off the current
2685-
// leaf, so consecutive sends preserve compaction/history walks.
2686-
await appendSessionTranscriptMessage({
2682+
// Fire before_message_write so plugin policy (redaction,
2683+
// provenance, block-list) applies to webchat user writes the
2684+
// same way it does for the codex transcript mirror path at
2685+
// extensions/codex/src/app-server/transcript-mirror.ts:147 .
2686+
// appendSessionTranscriptMessage chains parentId off the
2687+
// current leaf, so consecutive sends preserve compaction and
2688+
// history walks.
2689+
const hookedMessage = runAgentHarnessBeforeMessageWriteHook({
2690+
message: userMessage as AgentMessage,
2691+
...(agentId ? { agentId } : {}),
2692+
sessionKey,
2693+
});
2694+
if (!hookedMessage) {
2695+
context.logGateway.info(
2696+
`webchat user transcript append blocked by before_message_write hook sessionKey=${sessionKey}`,
2697+
);
2698+
return;
2699+
}
2700+
const { message: appendedMessage } = await appendSessionTranscriptMessage({
26872701
transcriptPath,
2688-
message: userMessage,
2702+
message: hookedMessage,
26892703
sessionId: resolvedSessionId,
26902704
config: cfg,
2691-
}).catch((err) => {
2692-
context.logGateway.warn(
2693-
`webchat user transcript append failed: ${formatForLog(err)}`,
2694-
);
26952705
});
26962706
emitSessionTranscriptUpdate({
26972707
sessionFile: transcriptPath,
26982708
sessionKey,
2699-
message: userMessage,
2709+
message: appendedMessage,
27002710
});
27012711
},
27022712
{

src/gateway/server-methods/chat.user-transcript-persistence.test.ts

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import fs from "node:fs";
2-
import { describe, expect, it } from "vitest";
2+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
35
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
6+
import {
7+
initializeGlobalHookRunner,
8+
resetGlobalHookRunner,
9+
} from "../../plugins/hook-runner-global.js";
10+
import { createMockPluginRegistry } from "../../plugins/hooks.test-helpers.js";
411
import { createTranscriptFixtureSync } from "./chat.test-helpers.js";
512

613
function readTranscriptLines(transcriptPath: string): Array<Record<string, unknown>> {
@@ -126,7 +133,7 @@ describe("gateway chat.send webchat user-transcript persistence", () => {
126133
});
127134
expect(userEntries).toHaveLength(2);
128135
expect((userEntries[0] as { id?: unknown }).id).toBe(first.messageId);
129-
expect((userEntries[1] as { id?: unknown }).parentId).toBe(first.messageId);
136+
expect((userEntries[1] as { parentId?: unknown }).parentId).toBe(first.messageId);
130137
} finally {
131138
fs.rmSync(dir, { recursive: true, force: true });
132139
}
@@ -148,9 +155,7 @@ describe("gateway chat.send webchat user-transcript persistence", () => {
148155
const entries = readTranscriptLines(transcriptPath);
149156
// Walk leaves forward to build the conversation order, the same shape
150157
// chat.history reconstructs after reconnect.
151-
const messageEntries = entries.filter((entry) => entry.type === "message") as Array<
152-
Record<string, unknown>
153-
>;
158+
const messageEntries = entries.filter((entry) => entry.type === "message");
154159
const byId = new Map<string, Record<string, unknown>>();
155160
for (const entry of messageEntries) {
156161
const id = entry.id as string;
@@ -167,3 +172,137 @@ describe("gateway chat.send webchat user-transcript persistence", () => {
167172
}
168173
});
169174
});
175+
176+
// The chat.send webchat user-write path must fire `before_message_write` so
177+
// plugin-registered policy (redaction, provenance, block-list) applies to
178+
// gateway user writes the same way it applies to the codex transcript mirror
179+
// at extensions/codex/src/app-server/transcript-mirror.ts:147. These tests
180+
// pin the composition: hook → append → emit, mirroring exactly what
181+
// chat.ts does at the webchat persistence site.
182+
describe("gateway chat.send webchat user-transcript before_message_write hook", () => {
183+
afterEach(() => {
184+
resetGlobalHookRunner();
185+
});
186+
187+
it("does not write to disk when the hook blocks the user message", async () => {
188+
initializeGlobalHookRunner(
189+
createMockPluginRegistry([
190+
{
191+
hookName: "before_message_write",
192+
handler: () => ({ block: true }),
193+
},
194+
]),
195+
);
196+
197+
const { dir, transcriptPath } = createTranscriptFixtureSync({
198+
prefix: "openclaw-webchat-user-hook-block-",
199+
sessionId: "sess-hook-block",
200+
});
201+
202+
try {
203+
const initialLineCount = readTranscriptLines(transcriptPath).length;
204+
const userMessage = buildUserMessage("blocked content", 1_700_000_100_000);
205+
206+
// Mirror exactly what chat.ts does at the webchat persistence site.
207+
const hookedMessage = runAgentHarnessBeforeMessageWriteHook({
208+
message: userMessage as AgentMessage,
209+
agentId: "test-agent",
210+
sessionKey: "sess-hook-block",
211+
});
212+
213+
expect(hookedMessage).toBeNull();
214+
// If the hook blocks, chat.ts returns without appending — no disk write,
215+
// no emit. Assert that the transcript file is unchanged.
216+
expect(readTranscriptLines(transcriptPath).length).toBe(initialLineCount);
217+
} finally {
218+
fs.rmSync(dir, { recursive: true, force: true });
219+
}
220+
});
221+
222+
it("writes the hook-transformed message rather than the original", async () => {
223+
initializeGlobalHookRunner(
224+
createMockPluginRegistry([
225+
{
226+
hookName: "before_message_write",
227+
handler: (event) => {
228+
const original = (event as { message: Record<string, unknown> }).message;
229+
return {
230+
message: {
231+
...original,
232+
content: "<redacted by plugin policy>",
233+
} as AgentMessage,
234+
};
235+
},
236+
},
237+
]),
238+
);
239+
240+
const { dir, transcriptPath } = createTranscriptFixtureSync({
241+
prefix: "openclaw-webchat-user-hook-transform-",
242+
sessionId: "sess-hook-transform",
243+
});
244+
245+
try {
246+
const userMessage = buildUserMessage(
247+
"secret-token-abc123 should not land on disk",
248+
1_700_000_101_000,
249+
);
250+
251+
const hookedMessage = runAgentHarnessBeforeMessageWriteHook({
252+
message: userMessage as AgentMessage,
253+
agentId: "test-agent",
254+
sessionKey: "sess-hook-transform",
255+
});
256+
257+
expect(hookedMessage).not.toBeNull();
258+
const result = await appendSessionTranscriptMessage({
259+
transcriptPath,
260+
message: hookedMessage,
261+
sessionId: "sess-hook-transform",
262+
});
263+
264+
const entries = readTranscriptLines(transcriptPath);
265+
const last = entries.at(-1) as Record<string, unknown>;
266+
const lastMessage = last.message as Record<string, unknown>;
267+
expect(lastMessage.content).toBe("<redacted by plugin policy>");
268+
expect(JSON.stringify(entries)).not.toContain("secret-token-abc123");
269+
expect(last).toHaveProperty("id", result.messageId);
270+
} finally {
271+
fs.rmSync(dir, { recursive: true, force: true });
272+
}
273+
});
274+
275+
it("passes the original message through unchanged when no hooks are registered", async () => {
276+
// No initializeGlobalHookRunner — the helper short-circuits to identity.
277+
const { dir, transcriptPath } = createTranscriptFixtureSync({
278+
prefix: "openclaw-webchat-user-hook-none-",
279+
sessionId: "sess-hook-none",
280+
});
281+
282+
try {
283+
const userMessage = buildUserMessage("plain user content", 1_700_000_102_000);
284+
285+
const hookedMessage = runAgentHarnessBeforeMessageWriteHook({
286+
message: userMessage as AgentMessage,
287+
agentId: "test-agent",
288+
sessionKey: "sess-hook-none",
289+
});
290+
291+
// With no global hook runner, the helper returns the original message.
292+
expect(hookedMessage).toBe(userMessage);
293+
294+
await appendSessionTranscriptMessage({
295+
transcriptPath,
296+
message: hookedMessage,
297+
sessionId: "sess-hook-none",
298+
});
299+
300+
const entries = readTranscriptLines(transcriptPath);
301+
const last = entries.at(-1) as Record<string, unknown>;
302+
const lastMessage = last.message as Record<string, unknown>;
303+
expect(lastMessage.content).toBe("plain user content");
304+
} finally {
305+
fs.rmSync(dir, { recursive: true, force: true });
306+
}
307+
});
308+
});

0 commit comments

Comments
 (0)