Files
openclaw/src/plugin-sdk/acp-runtime.test.ts
Tyler Bea 45fbf2d81a fix(channels): honor /verbose in group sessions (#85488)
* codex: honor verbose in group dispatch

* codex: address group verbose review findings

Record the final local review pass for the group /verbose PR.

Codex review against origin/main completed clean after tightening the shared group progress gate, keeping public plugin hook types stable, preserving ACP hidden tool boundaries, and adding regressions for live verbose gating and progress-callback suppression.

* codex: require explicit group verbose progress

Normal group tool/progress summaries now require an explicit session verbose override instead of inherited agent verbose defaults.

This addresses the PR review concern that existing verboseDefault configurations could expose group progress after upgrade. DMs and forum-topic behavior continue to use the effective verbose state, while normal groups use the live explicit session verbose state set by /verbose on|full|off.

* codex: document Slack group verbose caveat

* fix(channels): simplify verbose progress gating

* docs(changelog): note verbose channel fix

* fix(channels): preserve quiet default for group progress

* fix(channels): keep verbose error policy dynamic

* fix(channels): default verbose progress off everywhere

* fix(channels): keep followup verbose default quiet

* fix(channels): latch visible tool-error progress

* fix(channels): track failed verbose progress events

* fix(channels): latch delivered tool errors

* fix(channels): prevent progress opt-out bypass

* fix(channels): isolate followup error warning state

* fix(channels): keep full verbose followup warnings

* fix(channels): latch tool errors after visible progress

* fix(channels): require visible followup failure progress

* fix(channels): refresh followup verbose state

* fix(channels): honor live verbose for error details

* test(channels): expect live verbose off warning mode

* fix(channels): preserve static tool error suppression semantics

* fix(channels): bypass acp for colon verbose commands

* fix(channels): narrow dynamic tool warning override

* fix(channels): gate compaction notices on live verbose

* fix(channels): suppress quiet followup compaction callbacks

* fix(channels): suppress tts for hidden tool summaries

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-23 23:14:11 +01:00

245 lines
6.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildTestCtx } from "../auto-reply/reply/test-ctx.js";
const { bypassMock, dispatchMock } = vi.hoisted(() => ({
bypassMock: vi.fn(),
dispatchMock: vi.fn(),
}));
vi.mock("../auto-reply/reply/dispatch-acp.runtime.js", () => ({
shouldBypassAcpDispatchForCommand: bypassMock,
tryDispatchAcpReply: dispatchMock,
}));
import { tryDispatchAcpReplyHook } from "./acp-runtime.js";
const event = {
ctx: buildTestCtx({
SessionKey: "agent:test:session",
CommandBody: "/acp cancel",
BodyForCommands: "/acp cancel",
BodyForAgent: "/acp cancel",
}),
runId: "run-1",
sessionKey: "agent:test:session",
inboundAudio: false,
sessionTtsAuto: "off" as const,
ttsChannel: undefined,
suppressUserDelivery: false,
shouldRouteToOriginating: false,
originatingChannel: undefined,
originatingTo: undefined,
shouldSendToolSummaries: true,
sendPolicy: "allow" as const,
};
const ctx = {
cfg: {},
dispatcher: {
sendToolResult: () => false,
sendBlockReply: () => false,
sendFinalReply: () => false,
waitForIdle: async () => {},
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
markComplete: () => {},
},
abortSignal: undefined,
onReplyStart: undefined,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
};
function expectDispatchPayloadFields(expected: Record<string, unknown>): void {
expect(dispatchMock).toHaveBeenCalledTimes(1);
const [payload] = dispatchMock.mock.calls[0] ?? [];
expect(payload).toBeTypeOf("object");
for (const [key, value] of Object.entries(expected)) {
expect((payload as Record<string, unknown>)[key]).toBe(value);
}
}
describe("tryDispatchAcpReplyHook", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("skips ACP runtime lookup for plain-text deny turns", async () => {
const result = await tryDispatchAcpReplyHook(
{
...event,
sendPolicy: "deny",
ctx: buildTestCtx({
SessionKey: "agent:test:session",
BodyForCommands: "write a test",
BodyForAgent: "write a test",
}),
},
ctx,
);
expect(result).toBeUndefined();
expect(bypassMock).not.toHaveBeenCalled();
expect(dispatchMock).not.toHaveBeenCalled();
});
it("skips ACP dispatch when send policy denies delivery and no bypass applies", async () => {
bypassMock.mockResolvedValue(false);
const result = await tryDispatchAcpReplyHook({ ...event, sendPolicy: "deny" }, ctx);
expect(result).toBeUndefined();
expect(dispatchMock).not.toHaveBeenCalled();
});
it("dispatches through ACP when command bypass applies", async () => {
bypassMock.mockResolvedValue(true);
dispatchMock.mockResolvedValue({
queuedFinal: true,
counts: { tool: 1, block: 2, final: 3 },
});
const result = await tryDispatchAcpReplyHook({ ...event, sendPolicy: "deny" }, ctx);
expect(result).toEqual({
handled: true,
queuedFinal: true,
counts: { tool: 1, block: 2, final: 3 },
});
expectDispatchPayloadFields({
ctx: event.ctx,
cfg: ctx.cfg,
dispatcher: ctx.dispatcher,
bypassForCommand: true,
});
});
it("passes a live tool-summary predicate through to ACP runtime", async () => {
bypassMock.mockResolvedValue(false);
dispatchMock.mockResolvedValue({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
let shouldSendToolSummaries = true;
const eventWithGetter = {
...event,
get shouldSendToolSummaries() {
return shouldSendToolSummaries;
},
};
await tryDispatchAcpReplyHook(eventWithGetter, ctx);
expectDispatchPayloadFields({
shouldSendToolSummaries: true,
});
const [payload] = dispatchMock.mock.calls[0] ?? [];
const livePredicate = (payload as { shouldSendToolSummariesNow?: () => boolean })
.shouldSendToolSummariesNow;
expect(livePredicate).toBeTypeOf("function");
expect(livePredicate?.()).toBe(true);
shouldSendToolSummaries = false;
expect(livePredicate?.()).toBe(false);
});
it("returns unhandled when ACP dispatcher declines the turn", async () => {
bypassMock.mockResolvedValue(false);
dispatchMock.mockResolvedValue(undefined);
const result = await tryDispatchAcpReplyHook(event, ctx);
expect(result).toBeUndefined();
expect(dispatchMock).toHaveBeenCalledOnce();
});
it("dispatches non-tail ACP turn under deny when suppressUserDelivery is set", async () => {
bypassMock.mockResolvedValue(false);
dispatchMock.mockResolvedValue({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
const result = await tryDispatchAcpReplyHook(
{
...event,
sendPolicy: "deny",
suppressUserDelivery: true,
ctx: buildTestCtx({
SessionKey: "agent:test:session",
BodyForCommands: "write a test",
BodyForAgent: "write a test",
}),
},
ctx,
);
// Non-tail, non-command ACP turns under deny must still flow through ACP
// runtime so session/tool state stays consistent — delivery suppression is
// handled inside the ACP delivery path via suppressUserDelivery.
expectDispatchPayloadFields({
suppressUserDelivery: true,
suppressReplyLifecycle: true,
bypassForCommand: false,
});
expect(result).toEqual({
handled: true,
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
});
it("allows tail dispatch through when sendPolicy is deny", async () => {
bypassMock.mockResolvedValue(false);
dispatchMock.mockResolvedValue({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
const result = await tryDispatchAcpReplyHook(
{
...event,
sendPolicy: "deny",
isTailDispatch: true,
ctx: buildTestCtx({
SessionKey: "agent:test:session",
BodyForCommands: "continue after reset",
BodyForAgent: "continue after reset",
}),
},
ctx,
);
// Tail dispatch should proceed despite deny — delivery suppression is handled downstream
expect(dispatchMock).toHaveBeenCalledOnce();
expect(result).toEqual({
handled: true,
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
});
it("does not let ACP claim reset commands before local command handling", async () => {
bypassMock.mockResolvedValue(true);
dispatchMock.mockResolvedValue(undefined);
const result = await tryDispatchAcpReplyHook(
{
...event,
ctx: buildTestCtx({
SessionKey: "agent:test:session",
CommandBody: "/new",
BodyForCommands: "/new",
BodyForAgent: "/new",
}),
},
ctx,
);
expect(result).toBeUndefined();
expectDispatchPayloadFields({
bypassForCommand: true,
});
});
});