Tests: complete ACP meta dedupe coverage

This commit is contained in:
Onur
2026-03-01 09:54:55 +01:00
committed by Onur Solmaz
parent 9cfc630be9
commit 54ed2efc20
2 changed files with 426 additions and 0 deletions

View File

@@ -146,6 +146,26 @@ describe("createAcpReplyProjector", () => {
]);
});
it("hides available_commands_update by default", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg(),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({
type: "status",
text: "available commands updated (7)",
tag: "available_commands_update",
});
expect(deliveries).toEqual([]);
});
it("dedupes repeated tool lifecycle updates in minimal mode", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
@@ -262,6 +282,51 @@ describe("createAcpReplyProjector", () => {
expect(deliveries).toEqual([{ kind: "block", text: "hello" }]);
});
it("allows non-identical status updates in metaMode=verbose while suppressing exact duplicates", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
metaMode: "verbose",
tagVisibility: {
available_commands_update: true,
},
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({
type: "status",
text: "available commands updated (7)",
tag: "available_commands_update",
});
await projector.onEvent({
type: "status",
text: "available commands updated (7)",
tag: "available_commands_update",
});
await projector.onEvent({
type: "status",
text: "available commands updated (8)",
tag: "available_commands_update",
});
expect(deliveries).toEqual([
{ kind: "tool", text: prefixSystemMessage("available commands updated (7)") },
{ kind: "tool", text: prefixSystemMessage("available commands updated (8)") },
]);
});
it("truncates oversized turns once and emits one truncation notice", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
@@ -301,6 +366,57 @@ describe("createAcpReplyProjector", () => {
]);
});
it("enforces maxMetaEventsPerTurn without suppressing assistant text", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
maxMetaEventsPerTurn: 1,
showUsage: true,
tagVisibility: {
usage_update: true,
},
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({
type: "status",
text: "usage updated: 10/100",
tag: "usage_update",
used: 10,
size: 100,
});
await projector.onEvent({
type: "status",
text: "usage updated: 11/100",
tag: "usage_update",
used: 11,
size: 100,
});
await projector.onEvent({
type: "text_delta",
text: "hello",
tag: "agent_message_chunk",
});
await projector.flush(true);
expect(deliveries).toEqual([
{ kind: "tool", text: prefixSystemMessage("usage updated: 10/100") },
{ kind: "block", text: "hello" },
]);
});
it("supports tagVisibility overrides for tool updates", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({

View File

@@ -41,6 +41,9 @@ const acpMocks = vi.hoisted(() => ({
const sessionBindingMocks = vi.hoisted(() => ({
listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []),
}));
const messageActionMocks = vi.hoisted(() => ({
runMessageAction: vi.fn(async (_params: unknown) => ({ ok: true as const })),
}));
const ttsMocks = vi.hoisted(() => {
const state = {
synthesizeFinalAudio: false,
@@ -142,6 +145,9 @@ vi.mock("../../tts/tts.js", () => ({
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
}));
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
runMessageAction: (params: unknown) => messageActionMocks.runMessageAction(params),
}));
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
@@ -207,6 +213,8 @@ describe("dispatchReplyFromConfig", () => {
beforeEach(() => {
acpManagerTesting.resetAcpSessionManagerForTests();
resetInboundDedupe();
mocks.routeReply.mockReset();
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]);
diagnosticMocks.logMessageQueued.mockClear();
diagnosticMocks.logMessageProcessed.mockClear();
@@ -222,6 +230,8 @@ describe("dispatchReplyFromConfig", () => {
acpMocks.upsertAcpSessionMeta.mockReset();
acpMocks.upsertAcpSessionMeta.mockResolvedValue(null);
acpMocks.requireAcpRuntimeBackend.mockReset();
messageActionMocks.runMessageAction.mockReset();
messageActionMocks.runMessageAction.mockResolvedValue({ ok: true as const });
sessionBindingMocks.listBySession.mockReset();
sessionBindingMocks.listBySession.mockReturnValue([]);
ttsMocks.state.synthesizeFinalAudio = false;
@@ -1162,6 +1172,306 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("edits ACP tool lifecycle updates in place when channel edit is available", async () => {
setNoAbort();
mocks.routeReply.mockClear();
const runtime = createAcpRuntime([
{
type: "tool_call",
tag: "tool_call",
toolCallId: "call-1",
status: "in_progress",
title: "Run command",
text: "Run command (in_progress)",
},
{
type: "tool_call",
tag: "tool_call_update",
toolCallId: "call-1",
status: "completed",
title: "Run command",
text: "Run command (completed)",
},
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
mocks.routeReply
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" })
.mockResolvedValueOnce({ ok: true, messageId: "final-msg-1" });
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:thread-1",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "run tool",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
expect(messageActionMocks.runMessageAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "edit",
params: expect.objectContaining({
channel: "telegram",
to: "telegram:thread-1",
messageId: "tool-msg-1",
}),
}),
);
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
});
it("falls back to new ACP tool message when edit action fails", async () => {
setNoAbort();
mocks.routeReply.mockClear();
messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported"));
const runtime = createAcpRuntime([
{
type: "tool_call",
tag: "tool_call",
toolCallId: "call-2",
status: "in_progress",
title: "Run command",
text: "Run command (in_progress)",
},
{
type: "tool_call",
tag: "tool_call_update",
toolCallId: "call-2",
status: "completed",
title: "Run command",
text: "Run command (completed)",
},
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
mocks.routeReply
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" })
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" })
.mockResolvedValueOnce({ ok: true, messageId: "final-msg-2" });
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:thread-1",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "run tool",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
expect(messageActionMocks.runMessageAction).toHaveBeenCalledTimes(1);
expect(mocks.routeReply).toHaveBeenCalledTimes(2);
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
});
it("falls back to new ACP tool message when first tool send has no message id", async () => {
setNoAbort();
mocks.routeReply.mockClear();
const runtime = createAcpRuntime([
{
type: "tool_call",
tag: "tool_call",
toolCallId: "call-3",
status: "in_progress",
title: "Run command",
text: "Run command (in_progress)",
},
{
type: "tool_call",
tag: "tool_call_update",
toolCallId: "call-3",
status: "completed",
title: "Run command",
text: "Run command (completed)",
},
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
mocks.routeReply
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-3-fallback" })
.mockResolvedValueOnce({ ok: true, messageId: "final-msg-3" });
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:thread-1",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "run tool",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
expect(messageActionMocks.runMessageAction).not.toHaveBeenCalled();
expect(mocks.routeReply).toHaveBeenCalledTimes(2);
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
});
it("starts ACP typing lifecycle only when visible output is projected", async () => {
setNoAbort();
const hiddenRuntime = createAcpRuntime([
{
type: "status",
tag: "usage_update",
text: "usage updated: 10/100",
used: 10,
size: 100,
},
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime: hiddenRuntime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const onReplyStart = vi.fn();
const hiddenCtx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "hidden-only",
MessageSid: "acp-hidden-1",
});
await dispatchReplyFromConfig({
ctx: hiddenCtx,
cfg,
dispatcher,
replyOptions: { onReplyStart },
});
expect(onReplyStart).not.toHaveBeenCalled();
acpManagerTesting.resetAcpSessionManagerForTests();
const visibleRuntime = createAcpRuntime([
{
type: "text_delta",
text: "visible output",
},
{ type: "done" },
]);
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime: visibleRuntime,
});
const visibleCtx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "visible",
MessageSid: "acp-visible-1",
});
await dispatchReplyFromConfig({
ctx: visibleCtx,
cfg,
dispatcher: createDispatcher(),
replyOptions: { onReplyStart },
});
expect(onReplyStart).toHaveBeenCalledTimes(1);
});
it("closes oneshot ACP sessions after the turn completes", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);