Skip to content

Commit bb085eb

Browse files
authored
Merge branch 'main' into vincentkoc-code/auth-skill-env-regressions
2 parents 9652c20 + e309a15 commit bb085eb

3 files changed

Lines changed: 275 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ Docs: https://docs.openclaw.ai
222222
- Google Chat/multi-account webhook auth fallback: when `channels.googlechat.accounts.default` carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369.
223223
- Models/tool probing: raise the tool-capability probe budget from 32 to 256 tokens so reasoning models that spend tokens on thinking before returning a required tool call are less likely to be misclassified as not supporting tools. (#7521) Thanks @jakobdylanc.
224224
- Gateway/transient network classification: treat wrapped `...: fetch failed` transport messages as transient while avoiding broad matches like `Web fetch failed (404): ...`, preventing Discord reconnect wrappers from crashing the gateway without suppressing non-network tool failures. (#38530) Thanks @xinhuagu.
225+
- ACP/console silent reply suppression: filter ACP `NO_REPLY` lead fragments and silent-only finals before `openclaw agent` logging/delivery so console-backed ACP sessions no longer leak `NO`/`NO_REPLY` placeholders. (#38436) Thanks @ql-wade.
225226

226227
## 2026.3.2
227228

src/commands/agent.acp.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AcpRuntimeError } from "../acp/runtime/errors.js";
77
import * as embeddedModule from "../agents/pi-embedded.js";
88
import type { OpenClawConfig } from "../config/config.js";
99
import * as configModule from "../config/config.js";
10+
import { onAgentEvent } from "../infra/agent-events.js";
1011
import type { RuntimeEnv } from "../runtime.js";
1112
import { agentCommand } from "./agent.js";
1213

@@ -195,6 +196,188 @@ describe("agentCommand ACP runtime routing", () => {
195196
});
196197
});
197198

199+
it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => {
200+
await withTempHome(async (home) => {
201+
const storePath = path.join(home, "sessions.json");
202+
writeAcpSessionStore(storePath);
203+
mockConfig(home, storePath);
204+
205+
const assistantEvents: Array<{ text?: string; delta?: string }> = [];
206+
const stop = onAgentEvent((evt) => {
207+
if (evt.stream !== "assistant") {
208+
return;
209+
}
210+
assistantEvents.push({
211+
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
212+
delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined,
213+
});
214+
});
215+
216+
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
217+
const params = paramsUnknown as {
218+
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
219+
};
220+
for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY", "Actual answer"]) {
221+
await params.onEvent?.({ type: "text_delta", text });
222+
}
223+
await params.onEvent?.({ type: "done", stopReason: "stop" });
224+
});
225+
226+
mockAcpManager({
227+
runTurn: (params: unknown) => runTurn(params),
228+
});
229+
230+
try {
231+
await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime);
232+
} finally {
233+
stop();
234+
}
235+
236+
expect(assistantEvents).toEqual([{ text: "Actual answer", delta: "Actual answer" }]);
237+
238+
const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first));
239+
expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false);
240+
expect(logLines.some((line) => line.includes("Actual answer"))).toBe(true);
241+
});
242+
});
243+
244+
it("keeps silent-only ACP turns out of assistant output", async () => {
245+
await withTempHome(async (home) => {
246+
const storePath = path.join(home, "sessions.json");
247+
writeAcpSessionStore(storePath);
248+
mockConfig(home, storePath);
249+
250+
const assistantEvents: string[] = [];
251+
const stop = onAgentEvent((evt) => {
252+
if (evt.stream !== "assistant") {
253+
return;
254+
}
255+
if (typeof evt.data?.text === "string") {
256+
assistantEvents.push(evt.data.text);
257+
}
258+
});
259+
260+
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
261+
const params = paramsUnknown as {
262+
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
263+
};
264+
for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY"]) {
265+
await params.onEvent?.({ type: "text_delta", text });
266+
}
267+
await params.onEvent?.({ type: "done", stopReason: "stop" });
268+
});
269+
270+
mockAcpManager({
271+
runTurn: (params: unknown) => runTurn(params),
272+
});
273+
274+
try {
275+
await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime);
276+
} finally {
277+
stop();
278+
}
279+
280+
expect(assistantEvents).toEqual([]);
281+
282+
const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first));
283+
expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false);
284+
expect(logLines.some((line) => line.includes("No reply from agent."))).toBe(true);
285+
});
286+
});
287+
288+
it("preserves repeated identical ACP delta chunks", async () => {
289+
await withTempHome(async (home) => {
290+
const storePath = path.join(home, "sessions.json");
291+
writeAcpSessionStore(storePath);
292+
mockConfig(home, storePath);
293+
294+
const assistantEvents: Array<{ text?: string; delta?: string }> = [];
295+
const stop = onAgentEvent((evt) => {
296+
if (evt.stream !== "assistant") {
297+
return;
298+
}
299+
assistantEvents.push({
300+
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
301+
delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined,
302+
});
303+
});
304+
305+
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
306+
const params = paramsUnknown as {
307+
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
308+
};
309+
for (const text of ["b", "o", "o", "k"]) {
310+
await params.onEvent?.({ type: "text_delta", text });
311+
}
312+
await params.onEvent?.({ type: "done", stopReason: "stop" });
313+
});
314+
315+
mockAcpManager({
316+
runTurn: (params: unknown) => runTurn(params),
317+
});
318+
319+
try {
320+
await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime);
321+
} finally {
322+
stop();
323+
}
324+
325+
expect(assistantEvents).toEqual([
326+
{ text: "b", delta: "b" },
327+
{ text: "bo", delta: "o" },
328+
{ text: "boo", delta: "o" },
329+
{ text: "book", delta: "k" },
330+
]);
331+
332+
const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first));
333+
expect(logLines.some((line) => line.includes("book"))).toBe(true);
334+
});
335+
});
336+
337+
it("re-emits buffered NO prefix when ACP text becomes visible content", async () => {
338+
await withTempHome(async (home) => {
339+
const storePath = path.join(home, "sessions.json");
340+
writeAcpSessionStore(storePath);
341+
mockConfig(home, storePath);
342+
343+
const assistantEvents: Array<{ text?: string; delta?: string }> = [];
344+
const stop = onAgentEvent((evt) => {
345+
if (evt.stream !== "assistant") {
346+
return;
347+
}
348+
assistantEvents.push({
349+
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
350+
delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined,
351+
});
352+
});
353+
354+
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
355+
const params = paramsUnknown as {
356+
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
357+
};
358+
for (const text of ["NO", "W"]) {
359+
await params.onEvent?.({ type: "text_delta", text });
360+
}
361+
await params.onEvent?.({ type: "done", stopReason: "stop" });
362+
});
363+
364+
mockAcpManager({
365+
runTurn: (params: unknown) => runTurn(params),
366+
});
367+
368+
try {
369+
await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime);
370+
} finally {
371+
stop();
372+
}
373+
374+
expect(assistantEvents).toEqual([{ text: "NOW", delta: "NOW" }]);
375+
376+
const logLines = vi.mocked(runtime.log).mock.calls.map(([first]) => String(first));
377+
expect(logLines.some((line) => line.includes("NOW"))).toBe(true);
378+
});
379+
});
380+
198381
it("fails closed for ACP-shaped session keys missing ACP metadata", async () => {
199382
await withTempHome(async (home) => {
200383
const storePath = path.join(home, "sessions.json");

src/commands/agent.ts

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
3838
import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js";
3939
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
4040
import { ensureAgentWorkspace } from "../agents/workspace.js";
41+
import { normalizeReplyPayload } from "../auto-reply/reply/normalize-reply.js";
4142
import {
4243
formatThinkingLevels,
4344
formatXHighModelHint,
@@ -47,6 +48,11 @@ import {
4748
type ThinkLevel,
4849
type VerboseLevel,
4950
} from "../auto-reply/thinking.js";
51+
import {
52+
isSilentReplyPrefixText,
53+
isSilentReplyText,
54+
SILENT_REPLY_TOKEN,
55+
} from "../auto-reply/tokens.js";
5056
import { formatCliCommand } from "../cli/command-format.js";
5157
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
5258
import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js";
@@ -148,6 +154,80 @@ function prependInternalEventContext(
148154
return [renderedEvents, body].filter(Boolean).join("\n\n");
149155
}
150156

157+
function createAcpVisibleTextAccumulator() {
158+
let pendingSilentPrefix = "";
159+
let visibleText = "";
160+
const startsWithWordChar = (chunk: string): boolean => /^[\p{L}\p{N}]/u.test(chunk);
161+
162+
const resolveNextCandidate = (base: string, chunk: string): string => {
163+
if (!base) {
164+
return chunk;
165+
}
166+
if (
167+
isSilentReplyText(base, SILENT_REPLY_TOKEN) &&
168+
!chunk.startsWith(base) &&
169+
startsWithWordChar(chunk)
170+
) {
171+
return chunk;
172+
}
173+
// Some ACP backends emit cumulative snapshots even on text_delta-style hooks.
174+
// Accept those only when they strictly extend the buffered text.
175+
if (chunk.startsWith(base) && chunk.length > base.length) {
176+
return chunk;
177+
}
178+
return `${base}${chunk}`;
179+
};
180+
181+
const mergeVisibleChunk = (base: string, chunk: string): { text: string; delta: string } => {
182+
if (!base) {
183+
return { text: chunk, delta: chunk };
184+
}
185+
if (chunk.startsWith(base) && chunk.length > base.length) {
186+
const delta = chunk.slice(base.length);
187+
return { text: chunk, delta };
188+
}
189+
return {
190+
text: `${base}${chunk}`,
191+
delta: chunk,
192+
};
193+
};
194+
195+
return {
196+
consume(chunk: string): { text: string; delta: string } | null {
197+
if (!chunk) {
198+
return null;
199+
}
200+
201+
if (!visibleText) {
202+
const leadCandidate = resolveNextCandidate(pendingSilentPrefix, chunk);
203+
const trimmedLeadCandidate = leadCandidate.trim();
204+
if (
205+
isSilentReplyText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) ||
206+
isSilentReplyPrefixText(trimmedLeadCandidate, SILENT_REPLY_TOKEN)
207+
) {
208+
pendingSilentPrefix = leadCandidate;
209+
return null;
210+
}
211+
if (pendingSilentPrefix) {
212+
pendingSilentPrefix = "";
213+
visibleText = leadCandidate;
214+
return {
215+
text: visibleText,
216+
delta: leadCandidate,
217+
};
218+
}
219+
}
220+
221+
const nextVisible = mergeVisibleChunk(visibleText, chunk);
222+
visibleText = nextVisible.text;
223+
return nextVisible.delta ? nextVisible : null;
224+
},
225+
finalize(): string {
226+
return visibleText.trim();
227+
},
228+
};
229+
}
230+
151231
function runAgentAttempt(params: {
152232
providerOverride: string;
153233
modelOverride: string;
@@ -492,7 +572,7 @@ async function agentCommandInternal(
492572
},
493573
});
494574

495-
let streamedText = "";
575+
const visibleTextAccumulator = createAcpVisibleTextAccumulator();
496576
let stopReason: string | undefined;
497577
try {
498578
const dispatchPolicyError = resolveAcpDispatchPolicyError(cfg);
@@ -528,13 +608,16 @@ async function agentCommandInternal(
528608
if (!event.text) {
529609
return;
530610
}
531-
streamedText += event.text;
611+
const visibleUpdate = visibleTextAccumulator.consume(event.text);
612+
if (!visibleUpdate) {
613+
return;
614+
}
532615
emitAgentEvent({
533616
runId,
534617
stream: "assistant",
535618
data: {
536-
text: streamedText,
537-
delta: event.text,
619+
text: visibleUpdate.text,
620+
delta: visibleUpdate.delta,
538621
},
539622
});
540623
},
@@ -566,14 +649,10 @@ async function agentCommandInternal(
566649
},
567650
});
568651

569-
const finalText = streamedText.trim();
570-
const payloads = finalText
571-
? [
572-
{
573-
text: finalText,
574-
},
575-
]
576-
: [];
652+
const normalizedFinalPayload = normalizeReplyPayload({
653+
text: visibleTextAccumulator.finalize(),
654+
});
655+
const payloads = normalizedFinalPayload ? [normalizedFinalPayload] : [];
577656
const result = {
578657
payloads,
579658
meta: {

0 commit comments

Comments
 (0)