@@ -7,6 +7,7 @@ import { AcpRuntimeError } from "../acp/runtime/errors.js";
77import * as embeddedModule from "../agents/pi-embedded.js" ;
88import type { OpenClawConfig } from "../config/config.js" ;
99import * as configModule from "../config/config.js" ;
10+ import { onAgentEvent } from "../infra/agent-events.js" ;
1011import type { RuntimeEnv } from "../runtime.js" ;
1112import { 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" ) ;
0 commit comments