* 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)
760 lines
25 KiB
TypeScript
760 lines
25 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
|
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
|
import "../cron/isolated-agent.mocks.js";
|
|
import * as cliRunnerModule from "../agents/cli-runner.js";
|
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import * as configModule from "../config/config.js";
|
|
import * as sessionsModule from "../config/sessions.js";
|
|
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import { agentCommand } from "./agent.js";
|
|
|
|
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../agents/auth-profiles.js")>();
|
|
return {
|
|
...actual,
|
|
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
|
};
|
|
});
|
|
|
|
vi.mock("../agents/workspace.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../agents/workspace.js")>();
|
|
return {
|
|
...actual,
|
|
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })),
|
|
};
|
|
});
|
|
|
|
vi.mock("../agents/skills.js", () => ({
|
|
buildWorkspaceSkillSnapshot: vi.fn(() => undefined),
|
|
}));
|
|
|
|
vi.mock("../agents/skills/refresh.js", () => ({
|
|
getSkillsSnapshotVersion: vi.fn(() => 0),
|
|
}));
|
|
|
|
const runtime: RuntimeEnv = {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn(() => {
|
|
throw new Error("exit");
|
|
}),
|
|
};
|
|
|
|
const configSpy = vi.spyOn(configModule, "loadConfig");
|
|
const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent");
|
|
|
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
|
return withTempHomeBase(fn, { prefix: "openclaw-agent-" });
|
|
}
|
|
|
|
function mockConfig(
|
|
home: string,
|
|
storePath: string,
|
|
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>,
|
|
telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>,
|
|
agentsList?: Array<{ id: string; default?: boolean }>,
|
|
) {
|
|
configSpy.mockReturnValue({
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-opus-4-5" },
|
|
models: { "anthropic/claude-opus-4-5": {} },
|
|
workspace: path.join(home, "openclaw"),
|
|
...agentOverrides,
|
|
},
|
|
list: agentsList,
|
|
},
|
|
session: { store: storePath, mainKey: "main" },
|
|
channels: {
|
|
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function runWithDefaultAgentConfig(params: {
|
|
home: string;
|
|
args: Parameters<typeof agentCommand>[0];
|
|
agentsList?: Array<{ id: string; default?: boolean }>;
|
|
}) {
|
|
const store = path.join(params.home, "sessions.json");
|
|
mockConfig(params.home, store, undefined, undefined, params.agentsList);
|
|
await agentCommand(params.args, runtime);
|
|
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
}
|
|
|
|
function writeSessionStoreSeed(
|
|
storePath: string,
|
|
sessions: Record<string, Record<string, unknown>>,
|
|
) {
|
|
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
|
|
}
|
|
|
|
function createTelegramOutboundPlugin() {
|
|
return createOutboundTestPlugin({
|
|
id: "telegram",
|
|
outbound: {
|
|
deliveryMode: "direct",
|
|
sendText: async (ctx) => {
|
|
const sendTelegram = ctx.deps?.sendTelegram;
|
|
if (!sendTelegram) {
|
|
throw new Error("sendTelegram dependency missing");
|
|
}
|
|
const result = await sendTelegram(ctx.to, ctx.text, {
|
|
accountId: ctx.accountId ?? undefined,
|
|
verbose: false,
|
|
});
|
|
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
|
},
|
|
sendMedia: async (ctx) => {
|
|
const sendTelegram = ctx.deps?.sendTelegram;
|
|
if (!sendTelegram) {
|
|
throw new Error("sendTelegram dependency missing");
|
|
}
|
|
const result = await sendTelegram(ctx.to, ctx.text, {
|
|
accountId: ctx.accountId ?? undefined,
|
|
mediaUrl: ctx.mediaUrl,
|
|
verbose: false,
|
|
});
|
|
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
runCliAgentSpy.mockResolvedValue({
|
|
payloads: [{ text: "ok" }],
|
|
meta: {
|
|
durationMs: 5,
|
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
|
},
|
|
} as never);
|
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
|
payloads: [{ text: "ok" }],
|
|
meta: {
|
|
durationMs: 5,
|
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
|
},
|
|
});
|
|
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
|
});
|
|
|
|
describe("agentCommand", () => {
|
|
it("creates a session entry when deriving from --to", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand({ message: "hello", to: "+1555" }, runtime);
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
string,
|
|
{ sessionId: string }
|
|
>;
|
|
const entry = Object.values(saved)[0];
|
|
expect(entry.sessionId).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("persists thinking and verbose overrides", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand({ message: "hi", to: "+1222", thinking: "high", verbose: "on" }, runtime);
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
string,
|
|
{ thinkingLevel?: string; verboseLevel?: string }
|
|
>;
|
|
const entry = Object.values(saved)[0];
|
|
expect(entry.thinkingLevel).toBe("high");
|
|
expect(entry.verboseLevel).toBe("on");
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.thinkLevel).toBe("high");
|
|
expect(callArgs?.verboseLevel).toBe("on");
|
|
});
|
|
});
|
|
|
|
it("resumes when session-id is provided", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
writeSessionStoreSeed(store, {
|
|
foo: {
|
|
sessionId: "session-123",
|
|
updatedAt: Date.now(),
|
|
systemSent: true,
|
|
},
|
|
});
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand({ message: "resume me", sessionId: "session-123" }, runtime);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.sessionId).toBe("session-123");
|
|
});
|
|
});
|
|
|
|
it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => {
|
|
await withTempHome(async (home) => {
|
|
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
|
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
|
writeSessionStoreSeed(execStore, {
|
|
"agent:exec:hook:gmail:thread-1": {
|
|
sessionId: "session-exec-hook",
|
|
updatedAt: Date.now(),
|
|
systemSent: true,
|
|
},
|
|
});
|
|
mockConfig(home, storePattern, undefined, undefined, [
|
|
{ id: "dev" },
|
|
{ id: "exec", default: true },
|
|
]);
|
|
|
|
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.sessionKey).toBe("agent:exec:hook:gmail:thread-1");
|
|
expect(callArgs?.agentId).toBe("exec");
|
|
expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`);
|
|
});
|
|
});
|
|
|
|
it("resolves resumed session transcript path from custom session store directory", async () => {
|
|
await withTempHome(async (home) => {
|
|
const customStoreDir = path.join(home, "custom-state");
|
|
const store = path.join(customStoreDir, "sessions.json");
|
|
writeSessionStoreSeed(store, {});
|
|
mockConfig(home, store);
|
|
const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath");
|
|
|
|
await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime);
|
|
|
|
const matchingCall = resolveSessionFilePathSpy.mock.calls.find(
|
|
(call) => call[0] === "session-custom-123",
|
|
);
|
|
expect(matchingCall?.[2]).toEqual(
|
|
expect.objectContaining({
|
|
agentId: "main",
|
|
sessionsDir: customStoreDir,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("does not duplicate agent events from embedded runs", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
const assistantEvents: Array<{ runId: string; text?: string }> = [];
|
|
const stop = onAgentEvent((evt) => {
|
|
if (evt.stream !== "assistant") {
|
|
return;
|
|
}
|
|
assistantEvents.push({
|
|
runId: evt.runId,
|
|
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
|
|
});
|
|
});
|
|
|
|
vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => {
|
|
const runId = (params as { runId?: string } | undefined)?.runId ?? "run";
|
|
const data = { text: "hello", delta: "hello" };
|
|
(
|
|
params as {
|
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
|
}
|
|
).onAgentEvent?.({ stream: "assistant", data });
|
|
emitAgentEvent({ runId, stream: "assistant", data });
|
|
return {
|
|
payloads: [{ text: "hello" }],
|
|
meta: { agentMeta: { provider: "p", model: "m" } },
|
|
} as never;
|
|
});
|
|
|
|
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
|
stop();
|
|
|
|
const matching = assistantEvents.filter((evt) => evt.text === "hello");
|
|
expect(matching).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it("uses provider/model from agents.defaults.model.primary", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store, {
|
|
model: { primary: "openai/gpt-4.1-mini" },
|
|
models: {
|
|
"anthropic/claude-opus-4-5": {},
|
|
"openai/gpt-4.1-mini": {},
|
|
},
|
|
});
|
|
|
|
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.provider).toBe("openai");
|
|
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
|
});
|
|
});
|
|
|
|
it("uses default fallback list for session model overrides", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
writeSessionStoreSeed(store, {
|
|
"agent:main:subagent:test": {
|
|
sessionId: "session-subagent",
|
|
updatedAt: Date.now(),
|
|
providerOverride: "anthropic",
|
|
modelOverride: "claude-opus-4-5",
|
|
},
|
|
});
|
|
|
|
mockConfig(home, store, {
|
|
model: {
|
|
primary: "openai/gpt-4.1-mini",
|
|
fallbacks: ["openai/gpt-5.2"],
|
|
},
|
|
models: {
|
|
"anthropic/claude-opus-4-5": {},
|
|
"openai/gpt-4.1-mini": {},
|
|
"openai/gpt-5.2": {},
|
|
},
|
|
});
|
|
|
|
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
|
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
|
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
|
{ id: "gpt-5.2", name: "GPT-5.2", provider: "openai" },
|
|
]);
|
|
vi.mocked(runEmbeddedPiAgent)
|
|
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
|
|
.mockResolvedValueOnce({
|
|
payloads: [{ text: "ok" }],
|
|
meta: {
|
|
durationMs: 5,
|
|
agentMeta: { sessionId: "session-subagent", provider: "openai", model: "gpt-5.2" },
|
|
},
|
|
});
|
|
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
sessionKey: "agent:main:subagent:test",
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const attempts = vi
|
|
.mocked(runEmbeddedPiAgent)
|
|
.mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model }));
|
|
expect(attempts).toEqual([
|
|
{ provider: "anthropic", model: "claude-opus-4-5" },
|
|
{ provider: "openai", model: "gpt-5.2" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("keeps stored session model override when models allowlist is empty", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
writeSessionStoreSeed(store, {
|
|
"agent:main:subagent:allow-any": {
|
|
sessionId: "session-allow-any",
|
|
updatedAt: Date.now(),
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-custom-foo",
|
|
},
|
|
});
|
|
|
|
mockConfig(home, store, {
|
|
model: { primary: "anthropic/claude-opus-4-5" },
|
|
models: {},
|
|
});
|
|
|
|
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
|
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
|
|
]);
|
|
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
sessionKey: "agent:main:subagent:allow-any",
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.provider).toBe("openai");
|
|
expect(callArgs?.model).toBe("gpt-custom-foo");
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
string,
|
|
{ providerOverride?: string; modelOverride?: string }
|
|
>;
|
|
expect(saved["agent:main:subagent:allow-any"]?.providerOverride).toBe("openai");
|
|
expect(saved["agent:main:subagent:allow-any"]?.modelOverride).toBe("gpt-custom-foo");
|
|
});
|
|
});
|
|
|
|
it("persists cleared model and auth override fields when stored override falls back to default", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
writeSessionStoreSeed(store, {
|
|
"agent:main:subagent:clear-overrides": {
|
|
sessionId: "session-clear-overrides",
|
|
updatedAt: Date.now(),
|
|
providerOverride: "anthropic",
|
|
modelOverride: "claude-opus-4-5",
|
|
authProfileOverride: "profile-legacy",
|
|
authProfileOverrideSource: "user",
|
|
authProfileOverrideCompactionCount: 2,
|
|
fallbackNoticeSelectedModel: "anthropic/claude-opus-4-5",
|
|
fallbackNoticeActiveModel: "openai/gpt-4.1-mini",
|
|
fallbackNoticeReason: "fallback",
|
|
},
|
|
});
|
|
|
|
mockConfig(home, store, {
|
|
model: { primary: "openai/gpt-4.1-mini" },
|
|
models: {
|
|
"openai/gpt-4.1-mini": {},
|
|
},
|
|
});
|
|
|
|
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
|
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
|
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
|
]);
|
|
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
sessionKey: "agent:main:subagent:clear-overrides",
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.provider).toBe("openai");
|
|
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
string,
|
|
{
|
|
providerOverride?: string;
|
|
modelOverride?: string;
|
|
authProfileOverride?: string;
|
|
authProfileOverrideSource?: string;
|
|
authProfileOverrideCompactionCount?: number;
|
|
fallbackNoticeSelectedModel?: string;
|
|
fallbackNoticeActiveModel?: string;
|
|
fallbackNoticeReason?: string;
|
|
}
|
|
>;
|
|
const entry = saved["agent:main:subagent:clear-overrides"];
|
|
expect(entry?.providerOverride).toBeUndefined();
|
|
expect(entry?.modelOverride).toBeUndefined();
|
|
expect(entry?.authProfileOverride).toBeUndefined();
|
|
expect(entry?.authProfileOverrideSource).toBeUndefined();
|
|
expect(entry?.authProfileOverrideCompactionCount).toBeUndefined();
|
|
expect(entry?.fallbackNoticeSelectedModel).toBeUndefined();
|
|
expect(entry?.fallbackNoticeActiveModel).toBeUndefined();
|
|
expect(entry?.fallbackNoticeReason).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
writeSessionStoreSeed(store, {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: Date.now(),
|
|
},
|
|
});
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
sessionId: "sess-main",
|
|
sessionKey: "agent:main:subagent:abc",
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.sessionKey).toBe("agent:main:subagent:abc");
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
string,
|
|
{ sessionId?: string }
|
|
>;
|
|
expect(saved["agent:main:subagent:abc"]?.sessionId).toBe("sess-main");
|
|
});
|
|
});
|
|
|
|
it("persists resolved sessionFile for existing session keys", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
writeSessionStoreSeed(store, {
|
|
"agent:main:subagent:abc": {
|
|
sessionId: "sess-main",
|
|
updatedAt: Date.now(),
|
|
},
|
|
});
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
sessionKey: "agent:main:subagent:abc",
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
string,
|
|
{ sessionId?: string; sessionFile?: string }
|
|
>;
|
|
const entry = saved["agent:main:subagent:abc"];
|
|
expect(entry?.sessionId).toBe("sess-main");
|
|
expect(entry?.sessionFile).toContain(
|
|
`${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
|
|
);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
|
});
|
|
});
|
|
|
|
it("preserves topic transcript suffix when persisting missing sessionFile", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
writeSessionStoreSeed(store, {
|
|
"agent:main:telegram:group:123:topic:456": {
|
|
sessionId: "sess-topic",
|
|
updatedAt: Date.now(),
|
|
},
|
|
});
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
sessionKey: "agent:main:telegram:group:123:topic:456",
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
string,
|
|
{ sessionId?: string; sessionFile?: string }
|
|
>;
|
|
const entry = saved["agent:main:telegram:group:123:topic:456"];
|
|
expect(entry?.sessionId).toBe("sess-topic");
|
|
expect(entry?.sessionFile).toContain("sess-topic-topic-456.jsonl");
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
|
});
|
|
});
|
|
|
|
it("derives session key from --agent when no routing target is provided", async () => {
|
|
await withTempHome(async (home) => {
|
|
const callArgs = await runWithDefaultAgentConfig({
|
|
home,
|
|
args: { message: "hi", agentId: "ops" },
|
|
agentsList: [{ id: "ops" }],
|
|
});
|
|
expect(callArgs?.sessionKey).toBe("agent:ops:main");
|
|
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
|
|
});
|
|
});
|
|
|
|
it("rejects unknown agent overrides", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow(
|
|
'Unknown agent id "ghost"',
|
|
);
|
|
});
|
|
});
|
|
|
|
it("defaults thinking to low for reasoning-capable models", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
|
{
|
|
id: "claude-opus-4-5",
|
|
name: "Opus 4.5",
|
|
provider: "anthropic",
|
|
reasoning: true,
|
|
},
|
|
]);
|
|
|
|
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.thinkLevel).toBe("low");
|
|
});
|
|
});
|
|
|
|
it("prints JSON payload when requested", async () => {
|
|
await withTempHome(async (home) => {
|
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
|
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
|
|
meta: {
|
|
durationMs: 42,
|
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
|
},
|
|
});
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand({ message: "hi", to: "+1999", json: true }, runtime);
|
|
|
|
const logged = (runtime.log as unknown as MockInstance).mock.calls.at(-1)?.[0] as string;
|
|
const parsed = JSON.parse(logged) as {
|
|
payloads: Array<{ text: string; mediaUrl?: string | null }>;
|
|
meta: { durationMs: number };
|
|
};
|
|
expect(parsed.payloads[0].text).toBe("json-reply");
|
|
expect(parsed.payloads[0].mediaUrl).toBe("http://x.test/a.jpg");
|
|
expect(parsed.meta.durationMs).toBe(42);
|
|
});
|
|
});
|
|
|
|
it("passes the message through as the agent prompt", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand({ message: "ping", to: "+1333" }, runtime);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.prompt).toBe("ping");
|
|
});
|
|
});
|
|
|
|
it("passes through telegram accountId when delivering", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store, undefined, { botToken: "t-1" });
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "telegram", plugin: createTelegramOutboundPlugin(), source: "test" },
|
|
]),
|
|
);
|
|
const deps = {
|
|
sendMessageWhatsApp: vi.fn(),
|
|
sendMessageTelegram: vi.fn().mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
|
sendMessageSlack: vi.fn(),
|
|
sendMessageDiscord: vi.fn(),
|
|
sendMessageSignal: vi.fn(),
|
|
sendMessageIMessage: vi.fn(),
|
|
};
|
|
|
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
process.env.TELEGRAM_BOT_TOKEN = "";
|
|
try {
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
to: "123",
|
|
deliver: true,
|
|
channel: "telegram",
|
|
},
|
|
runtime,
|
|
deps,
|
|
);
|
|
|
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
|
"123",
|
|
"ok",
|
|
expect.objectContaining({ accountId: undefined, verbose: false }),
|
|
);
|
|
} finally {
|
|
if (prevTelegramToken === undefined) {
|
|
delete process.env.TELEGRAM_BOT_TOKEN;
|
|
} else {
|
|
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
it("uses reply channel as the message channel context", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
|
|
|
|
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.messageChannel).toBe("slack");
|
|
});
|
|
});
|
|
|
|
it("prefers runContext for embedded routing", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand(
|
|
{
|
|
message: "hi",
|
|
to: "+1555",
|
|
channel: "whatsapp",
|
|
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
|
},
|
|
runtime,
|
|
);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.messageChannel).toBe("slack");
|
|
expect(callArgs?.agentAccountId).toBe("acct-2");
|
|
});
|
|
});
|
|
|
|
it("forwards accountId to embedded runs", async () => {
|
|
await withTempHome(async (home) => {
|
|
const store = path.join(home, "sessions.json");
|
|
mockConfig(home, store);
|
|
|
|
await agentCommand({ message: "hi", to: "+1555", accountId: "kev" }, runtime);
|
|
|
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
expect(callArgs?.agentAccountId).toBe("kev");
|
|
});
|
|
});
|
|
|
|
it("logs output when delivery is disabled", async () => {
|
|
await withTempHome(async (home) => {
|
|
await runWithDefaultAgentConfig({
|
|
home,
|
|
args: { message: "hi", agentId: "ops" },
|
|
agentsList: [{ id: "ops" }],
|
|
});
|
|
|
|
expect(runtime.log).toHaveBeenCalledWith("ok");
|
|
});
|
|
});
|
|
});
|