| |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { buildTestCtx } from "../auto-reply/reply/test-ctx.js"; |
| import type { FinalizedMsgContext } from "../auto-reply/templating.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 { |
| registerAcpRuntimeBackend, |
| resolveAcpSessionAvailability, |
| testing, |
| 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, |
| shouldSendFullToolDetails: false, |
| 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 runtime lookup for non-command deny turns even when CommandBody is populated", async () => { |
| const result = await tryDispatchAcpReplyHook( |
| { |
| ...event, |
| sendPolicy: "deny", |
| ctx: buildTestCtx({ |
| SessionKey: "agent:test:session", |
| CommandBody: "write a test", |
| 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("checks command bypass when BodyForCommands has the clean command and CommandBody has an envelope", async () => { |
| bypassMock.mockResolvedValue(true); |
| dispatchMock.mockResolvedValue({ |
| queuedFinal: true, |
| counts: { tool: 0, block: 0, final: 1 }, |
| }); |
|
|
| const wrappedEvent = { |
| ...event, |
| sendPolicy: "deny" as const, |
| ctx: buildTestCtx({ |
| SessionKey: "agent:test:session", |
| CommandBody: "[WhatsApp +15551234567 +1m Fri 2026-05-08 16:12 UTC] /status", |
| BodyForCommands: "/status", |
| BodyForAgent: "/status", |
| }), |
| }; |
|
|
| const result = await tryDispatchAcpReplyHook(wrappedEvent, ctx); |
|
|
| expect(bypassMock).toHaveBeenCalledWith(wrappedEvent.ctx, ctx.cfg); |
| expect(dispatchMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| ctx: wrappedEvent.ctx, |
| bypassForCommand: true, |
| }), |
| ); |
| expect(result).toEqual({ |
| handled: true, |
| queuedFinal: true, |
| counts: { tool: 0, block: 0, final: 1 }, |
| }); |
| }); |
|
|
| 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("normalizes plugin-constructed finalized contexts at the runtime boundary", async () => { |
| bypassMock.mockResolvedValue(false); |
| dispatchMock.mockResolvedValue({ |
| queuedFinal: false, |
| counts: { tool: 0, block: 0, final: 0 }, |
| }); |
| const legacyCtx = { |
| Body: "/status", |
| BodyForAgent: "/status", |
| CommandBody: "/status", |
| CommandAuthorized: true, |
| SessionKey: "agent:test:session", |
| } as FinalizedMsgContext; |
|
|
| await tryDispatchAcpReplyHook({ ...event, ctx: legacyCtx }, ctx); |
|
|
| expect(legacyCtx).toMatchObject({ |
| commandText: "/status", |
| agentText: "/status", |
| rawText: "/status", |
| }); |
| expectDispatchPayloadFields({ ctx: legacyCtx }); |
| }); |
|
|
| it("preserves authoritative empty canonical text over stale plugin aliases", async () => { |
| bypassMock.mockResolvedValue(false); |
| dispatchMock.mockResolvedValue({ |
| queuedFinal: false, |
| counts: { tool: 0, block: 0, final: 0 }, |
| }); |
| const canonicalCtx = buildTestCtx({ |
| Body: "/reset", |
| CommandBody: "/reset", |
| BodyForCommands: "/reset", |
| }); |
| canonicalCtx.commandText = ""; |
|
|
| await tryDispatchAcpReplyHook({ ...event, ctx: canonicalCtx }, ctx); |
|
|
| expect(canonicalCtx.commandText).toBe(""); |
| expect(bypassMock).toHaveBeenCalledWith(canonicalCtx, ctx.cfg); |
| }); |
|
|
| it("normalizes plugin-supplied canonical fields without finalization provenance", async () => { |
| bypassMock.mockResolvedValue(false); |
| dispatchMock.mockResolvedValue({ |
| queuedFinal: false, |
| counts: { tool: 0, block: 0, final: 0 }, |
| }); |
| const pluginCtx = { |
| Body: "hello\r\nworld", |
| commandText: "[System Message] /reset", |
| agentText: "[Assistant] hello", |
| rawText: "System: injected", |
| CommandAuthorized: false, |
| SessionKey: "agent:test:session", |
| } as FinalizedMsgContext; |
|
|
| await tryDispatchAcpReplyHook({ ...event, ctx: pluginCtx }, ctx); |
|
|
| |
| |
| expect(pluginCtx).toMatchObject({ |
| Body: "hello\nworld", |
| commandText: "[System Message] /reset", |
| agentText: "[Assistant] hello", |
| rawText: "System: injected", |
| }); |
| }); |
|
|
| 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, |
| shouldSendFullToolDetails: false, |
| }); |
| 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("passes runtime toolsAllow through to ACP dispatch", async () => { |
| bypassMock.mockResolvedValue(false); |
| dispatchMock.mockResolvedValue({ |
| queuedFinal: false, |
| counts: { tool: 0, block: 0, final: 0 }, |
| }); |
|
|
| await tryDispatchAcpReplyHook({ ...event, toolsAllow: ["message"] }, ctx); |
|
|
| expect(dispatchMock).toHaveBeenCalledOnce(); |
| const [payload] = dispatchMock.mock.calls[0] ?? []; |
| expect((payload as { toolsAllow?: string[] }).toolsAllow).toStrictEqual(["message"]); |
| }); |
|
|
| 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, |
| ); |
|
|
| |
| |
| |
| 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, |
| ); |
|
|
| |
| 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, |
| }); |
| }); |
| }); |
|
|
| describe("resolveAcpSessionAvailability", () => { |
| beforeEach(() => testing.resetAcpRuntimeBackendsForTests()); |
| afterEach(() => testing.resetAcpRuntimeBackendsForTests()); |
|
|
| it("requires an allowed agent and a healthy registered backend", () => { |
| expect( |
| resolveAcpSessionAvailability({ config: {}, backendId: "acpx", agentId: "opencode" }), |
| ).toMatchObject({ available: false }); |
| registerAcpRuntimeBackend({ |
| id: "acpx", |
| runtime: { |
| ensureSession: vi.fn(), |
| async *runTurn() {}, |
| cancel: vi.fn(), |
| close: vi.fn(), |
| }, |
| }); |
| expect( |
| resolveAcpSessionAvailability({ config: {}, backendId: "acpx", agentId: "opencode" }), |
| ).toEqual({ available: true }); |
| expect( |
| resolveAcpSessionAvailability({ |
| config: { acp: { allowedAgents: ["pi"] } }, |
| backendId: "acpx", |
| agentId: "opencode", |
| }), |
| ).toMatchObject({ available: false, message: expect.stringContaining("not allowed") }); |
| }); |
|
|
| it("honors the canonical ACP dispatch policy", () => { |
| expect( |
| resolveAcpSessionAvailability({ |
| config: { acp: { dispatch: { enabled: false } } }, |
| backendId: "acpx", |
| agentId: "pi", |
| }), |
| ).toMatchObject({ available: false, message: expect.stringContaining("dispatch is disabled") }); |
| }); |
| }); |
|
|