Files
openclaw/src/gateway/server-methods/agent.test.ts
Onur Solmaz a7d56e3554 feat: ACP thread-bound agents (#23580)
* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
2026-02-26 11:00:09 +01:00

511 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js";
import { agentHandlers } from "./agent.js";
import type { GatewayRequestContext } from "./types.js";
const mocks = vi.hoisted(() => ({
loadSessionEntry: vi.fn(),
updateSessionStore: vi.fn(),
agentCommand: vi.fn(),
registerAgentRunContext: vi.fn(),
sessionsResetHandler: vi.fn(),
loadConfigReturn: {} as Record<string, unknown>,
}));
vi.mock("../session-utils.js", async () => {
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
return {
...actual,
loadSessionEntry: mocks.loadSessionEntry,
};
});
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
updateSessionStore: mocks.updateSessionStore,
resolveAgentIdFromSessionKey: () => "main",
resolveExplicitAgentSessionKey: () => undefined,
resolveAgentMainSessionKey: ({
cfg,
agentId,
}: {
cfg?: { session?: { mainKey?: string } };
agentId: string;
}) => `agent:${agentId}:${cfg?.session?.mainKey ?? "main"}`,
};
});
vi.mock("../../commands/agent.js", () => ({
agentCommand: mocks.agentCommand,
}));
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
loadConfig: () => mocks.loadConfigReturn,
};
});
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
}));
vi.mock("../../infra/agent-events.js", () => ({
registerAgentRunContext: mocks.registerAgentRunContext,
onAgentEvent: vi.fn(),
}));
vi.mock("./sessions.js", () => ({
sessionsHandlers: {
"sessions.reset": (...args: unknown[]) =>
(mocks.sessionsResetHandler as (...args: unknown[]) => unknown)(...args),
},
}));
vi.mock("../../sessions/send-policy.js", () => ({
resolveSendPolicy: () => "allow",
}));
vi.mock("../../utils/delivery-context.js", async () => {
const actual = await vi.importActual<typeof import("../../utils/delivery-context.js")>(
"../../utils/delivery-context.js",
);
return {
...actual,
normalizeSessionDeliveryFields: () => ({}),
};
});
const makeContext = (): GatewayRequestContext =>
({
dedupe: new Map(),
addChatRun: vi.fn(),
logGateway: { info: vi.fn(), error: vi.fn() },
}) as unknown as GatewayRequestContext;
type AgentHandlerArgs = Parameters<typeof agentHandlers.agent>[0];
type AgentParams = AgentHandlerArgs["params"];
type AgentIdentityGetHandlerArgs = Parameters<(typeof agentHandlers)["agent.identity.get"]>[0];
type AgentIdentityGetParams = AgentIdentityGetHandlerArgs["params"];
function mockMainSessionEntry(entry: Record<string, unknown>, cfg: Record<string, unknown> = {}) {
mocks.loadSessionEntry.mockReturnValue({
cfg,
storePath: "/tmp/sessions.json",
entry: {
sessionId: "existing-session-id",
updatedAt: Date.now(),
...entry,
},
canonicalKey: "agent:main:main",
});
}
function captureUpdatedMainEntry() {
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {};
await updater(store);
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
});
return () => capturedEntry;
}
function primeMainAgentRun(params?: { sessionId?: string; cfg?: Record<string, unknown> }) {
mockMainSessionEntry(
{ sessionId: params?.sessionId ?? "existing-session-id" },
params?.cfg ?? {},
);
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
}
async function runMainAgent(message: string, idempotencyKey: string) {
const respond = vi.fn();
await invokeAgent(
{
message,
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey,
},
{ respond, reqId: idempotencyKey },
);
return respond;
}
function readLastAgentCommandCall():
| {
message?: string;
sessionId?: string;
}
| undefined {
return mocks.agentCommand.mock.calls.at(-1)?.[0] as
| { message?: string; sessionId?: string }
| undefined;
}
function mockSessionResetSuccess(params: {
reason: "new" | "reset";
key?: string;
sessionId?: string;
}) {
const key = params.key ?? "agent:main:main";
const sessionId = params.sessionId ?? "reset-session-id";
mocks.sessionsResetHandler.mockImplementation(
async (opts: {
params: { key: string; reason: string };
respond: (ok: boolean, payload?: unknown) => void;
}) => {
expect(opts.params.key).toBe(key);
expect(opts.params.reason).toBe(params.reason);
opts.respond(true, {
ok: true,
key,
entry: { sessionId },
});
},
);
}
async function invokeAgent(
params: AgentParams,
options?: {
respond?: ReturnType<typeof vi.fn>;
reqId?: string;
context?: GatewayRequestContext;
},
) {
const respond = options?.respond ?? vi.fn();
await agentHandlers.agent({
params,
respond: respond as never,
context: options?.context ?? makeContext(),
req: { type: "req", id: options?.reqId ?? "agent-test-req", method: "agent" },
client: null,
isWebchatConnect: () => false,
});
return respond;
}
async function invokeAgentIdentityGet(
params: AgentIdentityGetParams,
options?: {
respond?: ReturnType<typeof vi.fn>;
reqId?: string;
context?: GatewayRequestContext;
},
) {
const respond = options?.respond ?? vi.fn();
await agentHandlers["agent.identity.get"]({
params,
respond: respond as never,
context: options?.context ?? makeContext(),
req: {
type: "req",
id: options?.reqId ?? "agent-identity-test-req",
method: "agent.identity.get",
},
client: null,
isWebchatConnect: () => false,
});
return respond;
}
describe("gateway agent handler", () => {
it("preserves ACP metadata from the current stored session entry", async () => {
const existingAcpMeta = {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
};
mockMainSessionEntry({
acp: existingAcpMeta,
});
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
"agent:main:main": {
sessionId: "existing-session-id",
updatedAt: Date.now(),
acp: existingAcpMeta,
},
};
const result = await updater(store);
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
return result;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await runMainAgent("test", "test-idem-acp-meta");
expect(mocks.updateSessionStore).toHaveBeenCalled();
expect(capturedEntry).toBeDefined();
expect(capturedEntry?.acp).toEqual(existingAcpMeta);
});
it("preserves cliSessionIds from existing session entry", async () => {
const existingCliSessionIds = { "claude-cli": "abc-123-def" };
const existingClaudeCliSessionId = "abc-123-def";
mockMainSessionEntry({
cliSessionIds: existingCliSessionIds,
claudeCliSessionId: existingClaudeCliSessionId,
});
const getCapturedEntry = captureUpdatedMainEntry();
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await runMainAgent("test", "test-idem");
expect(mocks.updateSessionStore).toHaveBeenCalled();
const capturedEntry = getCapturedEntry();
expect(capturedEntry).toBeDefined();
expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds);
expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
});
it("injects a timestamp into the message passed to agentCommand", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); // Wed Jan 28, 8:30 PM EST
mocks.agentCommand.mockClear();
mocks.loadConfigReturn = {
agents: {
defaults: {
userTimezone: "America/New_York",
},
},
};
primeMainAgentRun({ cfg: mocks.loadConfigReturn });
await invokeAgent(
{
message: "Is it the weekend?",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: "test-timestamp-inject",
},
{ reqId: "ts-1" },
);
// Wait for the async agentCommand call
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls[0][0];
expect(callArgs.message).toBe("[Wed 2026-01-28 20:30 EST] Is it the weekend?");
mocks.loadConfigReturn = {};
vi.useRealTimers();
});
it("respects explicit bestEffortDeliver=false for main session runs", async () => {
mocks.agentCommand.mockClear();
primeMainAgentRun();
await invokeAgent(
{
message: "strict delivery",
agentId: "main",
sessionKey: "agent:main:main",
deliver: true,
replyChannel: "telegram",
to: "123",
bestEffortDeliver: false,
idempotencyKey: "test-strict-delivery",
},
{ reqId: "strict-1" },
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(callArgs.bestEffortDeliver).toBe(false);
});
it("handles missing cliSessionIds gracefully", async () => {
mockMainSessionEntry({});
const getCapturedEntry = captureUpdatedMainEntry();
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await runMainAgent("test", "test-idem-2");
expect(mocks.updateSessionStore).toHaveBeenCalled();
const capturedEntry = getCapturedEntry();
expect(capturedEntry).toBeDefined();
// Should be undefined, not cause an error
expect(capturedEntry?.cliSessionIds).toBeUndefined();
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
});
it("prunes legacy main alias keys when writing a canonical session entry", async () => {
mocks.loadSessionEntry.mockReturnValue({
cfg: {
session: { mainKey: "work" },
agents: { list: [{ id: "main", default: true }] },
},
storePath: "/tmp/sessions.json",
entry: {
sessionId: "existing-session-id",
updatedAt: Date.now(),
},
canonicalKey: "agent:main:work",
});
let capturedStore: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
"agent:main:work": { sessionId: "existing-session-id", updatedAt: 10 },
"agent:main:MAIN": { sessionId: "legacy-session-id", updatedAt: 5 },
};
await updater(store);
capturedStore = store;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "test",
agentId: "main",
sessionKey: "main",
idempotencyKey: "test-idem-alias-prune",
},
{ reqId: "3" },
);
expect(mocks.updateSessionStore).toHaveBeenCalled();
expect(capturedStore).toBeDefined();
expect(capturedStore?.["agent:main:work"]).toBeDefined();
expect(capturedStore?.["agent:main:MAIN"]).toBeUndefined();
});
it("handles bare /new by resetting the same session and sending reset greeting prompt", async () => {
mockSessionResetSuccess({ reason: "new" });
primeMainAgentRun({ sessionId: "reset-session-id" });
await invokeAgent(
{
message: "/new",
sessionKey: "agent:main:main",
idempotencyKey: "test-idem-new",
},
{ reqId: "4" },
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
expect(mocks.sessionsResetHandler).toHaveBeenCalledTimes(1);
const call = readLastAgentCommandCall();
expect(call?.message).toBe(BARE_SESSION_RESET_PROMPT);
expect(call?.message).toContain("Execute your Session Startup sequence now");
expect(call?.sessionId).toBe("reset-session-id");
});
it("uses /reset suffix as the post-reset message and still injects timestamp", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); // Wed Jan 28, 8:30 PM EST
mocks.agentCommand.mockClear();
mocks.loadConfigReturn = {
agents: {
defaults: {
userTimezone: "America/New_York",
},
},
};
mockSessionResetSuccess({ reason: "reset" });
mocks.sessionsResetHandler.mockClear();
primeMainAgentRun({
sessionId: "reset-session-id",
cfg: mocks.loadConfigReturn,
});
await invokeAgent(
{
message: "/reset check status",
sessionKey: "agent:main:main",
idempotencyKey: "test-idem-reset-suffix",
},
{ reqId: "4b" },
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
expect(mocks.sessionsResetHandler).toHaveBeenCalledTimes(1);
const call = readLastAgentCommandCall();
expect(call?.message).toBe("[Wed 2026-01-28 20:30 EST] check status");
expect(call?.sessionId).toBe("reset-session-id");
mocks.loadConfigReturn = {};
vi.useRealTimers();
});
it("rejects malformed agent session keys early in agent handler", async () => {
mocks.agentCommand.mockClear();
const respond = await invokeAgent(
{
message: "test",
sessionKey: "agent:main",
idempotencyKey: "test-malformed-session-key",
},
{ reqId: "4" },
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("malformed session key"),
}),
);
});
it("rejects malformed session keys in agent.identity.get", async () => {
const respond = await invokeAgentIdentityGet(
{
sessionKey: "agent:main",
},
{ reqId: "5" },
);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("malformed session key"),
}),
);
});
});