mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-22 22:52:03 +00:00
test: inject thread-safe gateway and ACP seams
This commit is contained in:
@@ -3,16 +3,21 @@ import {
|
||||
listSkillCommandsForAgents,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadSessionStore } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import { editMessageTelegram } from "./send.js";
|
||||
import { wasSentByBot } from "./sent-message-cache.js";
|
||||
|
||||
export type TelegramBotDeps = {
|
||||
loadConfig: typeof loadConfig;
|
||||
resolveStorePath: typeof resolveStorePath;
|
||||
loadSessionStore?: typeof loadSessionStore;
|
||||
readChannelAllowFromStore: typeof readChannelAllowFromStore;
|
||||
upsertChannelPairingRequest: typeof upsertChannelPairingRequest;
|
||||
enqueueSystemEvent: typeof enqueueSystemEvent;
|
||||
@@ -21,6 +26,10 @@ export type TelegramBotDeps = {
|
||||
buildModelsProviderData: typeof buildModelsProviderData;
|
||||
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
|
||||
wasSentByBot: typeof wasSentByBot;
|
||||
createTelegramDraftStream?: typeof createTelegramDraftStream;
|
||||
deliverReplies?: typeof deliverReplies;
|
||||
emitInternalMessageSentHook?: typeof emitInternalMessageSentHook;
|
||||
editMessageTelegram?: typeof editMessageTelegram;
|
||||
};
|
||||
|
||||
export const defaultTelegramBotDeps: TelegramBotDeps = {
|
||||
@@ -33,6 +42,9 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
|
||||
get readChannelAllowFromStore() {
|
||||
return readChannelAllowFromStore;
|
||||
},
|
||||
get loadSessionStore() {
|
||||
return loadSessionStore;
|
||||
},
|
||||
get upsertChannelPairingRequest() {
|
||||
return upsertChannelPairingRequest;
|
||||
},
|
||||
@@ -54,4 +66,16 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
|
||||
get wasSentByBot() {
|
||||
return wasSentByBot;
|
||||
},
|
||||
get createTelegramDraftStream() {
|
||||
return createTelegramDraftStream;
|
||||
},
|
||||
get deliverReplies() {
|
||||
return deliverReplies;
|
||||
},
|
||||
get emitInternalMessageSentHook() {
|
||||
return emitInternalMessageSentHook;
|
||||
},
|
||||
get editMessageTelegram() {
|
||||
return editMessageTelegram;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import type { Bot } from "grammy";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
@@ -98,6 +97,7 @@ let dispatchTelegramMessage: typeof import("./bot-message-dispatch.js").dispatch
|
||||
const telegramDepsForTest: TelegramBotDeps = {
|
||||
loadConfig: loadConfig as TelegramBotDeps["loadConfig"],
|
||||
resolveStorePath: resolveStorePath as TelegramBotDeps["resolveStorePath"],
|
||||
loadSessionStore: loadSessionStore as TelegramBotDeps["loadSessionStore"],
|
||||
readChannelAllowFromStore:
|
||||
readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"],
|
||||
upsertChannelPairingRequest:
|
||||
@@ -109,6 +109,12 @@ const telegramDepsForTest: TelegramBotDeps = {
|
||||
listSkillCommandsForAgents:
|
||||
listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"],
|
||||
wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
|
||||
createTelegramDraftStream:
|
||||
createTelegramDraftStream as TelegramBotDeps["createTelegramDraftStream"],
|
||||
deliverReplies: deliverReplies as TelegramBotDeps["deliverReplies"],
|
||||
emitInternalMessageSentHook:
|
||||
emitInternalMessageSentHook as TelegramBotDeps["emitInternalMessageSentHook"],
|
||||
editMessageTelegram: editMessageTelegram as TelegramBotDeps["editMessageTelegram"],
|
||||
};
|
||||
|
||||
describe("dispatchTelegramMessage draft streaming", () => {
|
||||
@@ -211,8 +217,11 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
function createBot(): Bot {
|
||||
return {
|
||||
api: {
|
||||
sendMessage: vi.fn(),
|
||||
editMessageText: vi.fn(),
|
||||
sendMessage: vi.fn(async (_chatId, _text, params) => ({
|
||||
message_id:
|
||||
typeof params?.message_thread_id === "number" ? params.message_thread_id : 1001,
|
||||
})),
|
||||
editMessageText: vi.fn(async () => ({ message_id: 1001 })),
|
||||
deleteMessage: vi.fn().mockResolvedValue(true),
|
||||
editForumTopic: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@@ -126,14 +126,17 @@ function resolveTelegramReasoningLevel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
agentId: string;
|
||||
telegramDeps: TelegramBotDeps;
|
||||
}): TelegramReasoningLevel {
|
||||
const { cfg, sessionKey, agentId } = params;
|
||||
const { cfg, sessionKey, agentId, telegramDeps } = params;
|
||||
if (!sessionKey) {
|
||||
return "off";
|
||||
}
|
||||
try {
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
||||
const level = entry?.reasoningLevel;
|
||||
if (level === "on" || level === "stream") {
|
||||
@@ -195,6 +198,7 @@ export const dispatchTelegramMessage = async ({
|
||||
cfg,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
agentId: route.agentId,
|
||||
telegramDeps,
|
||||
});
|
||||
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
|
||||
const streamReasoningDraft = resolvedReasoningLevel === "stream";
|
||||
@@ -214,7 +218,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const archivedReasoningPreviewIds: number[] = [];
|
||||
const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => {
|
||||
const stream = enabled
|
||||
? createTelegramDraftStream({
|
||||
? (telegramDeps.createTelegramDraftStream ?? createTelegramDraftStream)({
|
||||
api: bot.api,
|
||||
chatId,
|
||||
maxChars: draftMaxChars,
|
||||
@@ -476,7 +480,7 @@ export const dispatchTelegramMessage = async ({
|
||||
return { ...payload, text };
|
||||
};
|
||||
const sendPayload = async (payload: ReplyPayload) => {
|
||||
const result = await deliverReplies({
|
||||
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
|
||||
...deliveryBaseOptions,
|
||||
replies: [payload],
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
@@ -492,7 +496,7 @@ export const dispatchTelegramMessage = async ({
|
||||
if (result.kind !== "preview-finalized") {
|
||||
return;
|
||||
}
|
||||
emitInternalMessageSentHook({
|
||||
(telegramDeps.emitInternalMessageSentHook ?? emitInternalMessageSentHook)({
|
||||
sessionKeyForInternalHooks: deliveryBaseOptions.sessionKeyForInternalHooks,
|
||||
chatId: deliveryBaseOptions.chatId,
|
||||
accountId: deliveryBaseOptions.accountId,
|
||||
@@ -516,7 +520,7 @@ export const dispatchTelegramMessage = async ({
|
||||
await lane.stream?.stop();
|
||||
},
|
||||
editPreview: async ({ messageId, text, previewButtons }) => {
|
||||
await editMessageTelegram(chatId, messageId, text, {
|
||||
await (telegramDeps.editMessageTelegram ?? editMessageTelegram)(chatId, messageId, text, {
|
||||
api: bot.api,
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
@@ -542,8 +546,12 @@ export const dispatchTelegramMessage = async ({
|
||||
let isFirstTurnInSession = false;
|
||||
if (isDmTopic) {
|
||||
try {
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
const sessionKey = ctxPayload.SessionKey;
|
||||
if (sessionKey) {
|
||||
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
||||
@@ -864,7 +872,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const fallbackText = dispatchError
|
||||
? "Something went wrong while processing your request. Please try again."
|
||||
: EMPTY_RESPONSE_FALLBACK;
|
||||
const result = await deliverReplies({
|
||||
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
|
||||
replies: [{ text: fallbackText }],
|
||||
...deliveryBaseOptions,
|
||||
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
|
||||
|
||||
@@ -26,4 +26,7 @@ export const __testing = {
|
||||
resetAcpSessionManagerForTests() {
|
||||
ACP_SESSION_MANAGER_SINGLETON = null;
|
||||
},
|
||||
setAcpSessionManagerForTests(manager: unknown) {
|
||||
ACP_SESSION_MANAGER_SINGLETON = manager as AcpSessionManager | null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -151,6 +151,7 @@ async function executeSend(params: {
|
||||
}) {
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
runMessageAction: mocks.runMessageAction as never,
|
||||
...params.toolOptions,
|
||||
});
|
||||
await tool.execute("1", {
|
||||
@@ -187,6 +188,9 @@ describe("message tool secret scoping", () => {
|
||||
const tool = createMessageTool({
|
||||
currentChannelProvider: "discord",
|
||||
agentAccountId: "ops",
|
||||
loadConfig: mocks.loadConfig as never,
|
||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway as never,
|
||||
runMessageAction: mocks.runMessageAction as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
@@ -216,6 +220,7 @@ describe("message tool agent routing", () => {
|
||||
const tool = createMessageTool({
|
||||
agentSessionKey: "agent:alpha:main",
|
||||
config: {} as never,
|
||||
runMessageAction: mocks.runMessageAction as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
|
||||
@@ -386,6 +386,9 @@ type MessageToolOptions = {
|
||||
agentSessionKey?: string;
|
||||
sessionId?: string;
|
||||
config?: OpenClawConfig;
|
||||
loadConfig?: () => OpenClawConfig;
|
||||
resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway;
|
||||
runMessageAction?: typeof runMessageAction;
|
||||
currentChannelId?: string;
|
||||
currentChannelProvider?: string;
|
||||
currentThreadTs?: string;
|
||||
@@ -621,6 +624,10 @@ function buildMessageToolDescription(options?: {
|
||||
}
|
||||
|
||||
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
const loadConfigForTool = options?.loadConfig ?? loadConfig;
|
||||
const resolveSecretRefsForTool =
|
||||
options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway;
|
||||
const runMessageActionForTool = options?.runMessageAction ?? runMessageAction;
|
||||
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
|
||||
const resolvedAgentId = options?.agentSessionKey
|
||||
? resolveSessionAgentId({
|
||||
@@ -683,7 +690,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
}) as ChannelMessageActionName;
|
||||
let cfg = options?.config;
|
||||
if (!cfg) {
|
||||
const loadedRaw = loadConfig();
|
||||
const loadedRaw = loadConfigForTool();
|
||||
const scope = resolveMessageSecretScope({
|
||||
channel: params.channel,
|
||||
target: params.target,
|
||||
@@ -698,7 +705,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
accountId: scope.accountId,
|
||||
});
|
||||
cfg = (
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
await resolveSecretRefsForTool({
|
||||
config: loadedRaw,
|
||||
commandName: "tools.message",
|
||||
targetIds: scopedTargets.targetIds,
|
||||
@@ -765,7 +772,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const result = await runMessageAction({
|
||||
const result = await runMessageActionForTool({
|
||||
cfg,
|
||||
action,
|
||||
params,
|
||||
|
||||
@@ -9,6 +9,28 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
||||
|
||||
const acpResetTargetDeps = {
|
||||
getSessionBindingService,
|
||||
listAcpBindings,
|
||||
resolveConfiguredBindingRecord,
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
setDepsForTest(
|
||||
overrides?: Partial<{
|
||||
getSessionBindingService: typeof getSessionBindingService;
|
||||
listAcpBindings: typeof listAcpBindings;
|
||||
resolveConfiguredBindingRecord: typeof resolveConfiguredBindingRecord;
|
||||
}>,
|
||||
) {
|
||||
acpResetTargetDeps.getSessionBindingService =
|
||||
overrides?.getSessionBindingService ?? getSessionBindingService;
|
||||
acpResetTargetDeps.listAcpBindings = overrides?.listAcpBindings ?? listAcpBindings;
|
||||
acpResetTargetDeps.resolveConfiguredBindingRecord =
|
||||
overrides?.resolveConfiguredBindingRecord ?? resolveConfiguredBindingRecord;
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeText(value: string | undefined | null): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
@@ -20,7 +42,7 @@ function resolveRawConfiguredAcpSessionKey(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): string | undefined {
|
||||
for (const binding of listAcpBindings(params.cfg)) {
|
||||
for (const binding of acpResetTargetDeps.listAcpBindings(params.cfg)) {
|
||||
const bindingChannel = normalizeText(binding.match.channel).toLowerCase();
|
||||
if (!bindingChannel || bindingChannel !== params.channel) {
|
||||
continue;
|
||||
@@ -84,7 +106,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
||||
const parentConversationId = normalizeText(params.parentConversationId) || undefined;
|
||||
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
|
||||
|
||||
const serviceBinding = getSessionBindingService().resolveByConversation({
|
||||
const serviceBinding = acpResetTargetDeps.getSessionBindingService().resolveByConversation({
|
||||
channel,
|
||||
accountId,
|
||||
conversationId,
|
||||
@@ -103,7 +125,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredBindingRecord({
|
||||
const configuredBinding = acpResetTargetDeps.resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
|
||||
@@ -114,6 +114,7 @@ vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({
|
||||
const { handleAcpCommand } = await import("./commands-acp.js");
|
||||
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
|
||||
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
|
||||
const { __testing: acpResetTargetTesting } = await import("./acp-reset-target.js");
|
||||
|
||||
type FakeBinding = {
|
||||
bindingId: string;
|
||||
@@ -435,6 +436,9 @@ describe("/acp command", () => {
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
acpManagerTesting.resetAcpSessionManagerForTests();
|
||||
acpResetTargetTesting.setDepsForTest({
|
||||
getSessionBindingService: () => createAcpCommandSessionBindingService() as never,
|
||||
});
|
||||
hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]);
|
||||
hoisted.callGatewayMock.mockReset().mockResolvedValue({ ok: true });
|
||||
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
|
||||
@@ -507,6 +511,150 @@ describe("/acp command", () => {
|
||||
};
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend);
|
||||
hoisted.getAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend);
|
||||
acpManagerTesting.setAcpSessionManagerForTests({
|
||||
initializeSession: async (input: {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
}) => {
|
||||
const backend = hoisted.requireAcpRuntimeBackendMock("acpx") as {
|
||||
id?: string;
|
||||
runtime: typeof runtimeBackend.runtime;
|
||||
};
|
||||
const ensured = await hoisted.ensureSessionMock({
|
||||
sessionKey: input.sessionKey,
|
||||
agent: input.agent,
|
||||
mode: input.mode,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const now = Date.now();
|
||||
const meta = {
|
||||
backend: ensured.backend ?? "acpx",
|
||||
agent: input.agent,
|
||||
runtimeSessionName: ensured.runtimeSessionName ?? `${input.sessionKey}:runtime`,
|
||||
mode: input.mode,
|
||||
state: "idle" as const,
|
||||
lastActivityAt: now,
|
||||
...(input.cwd ? { cwd: input.cwd, runtimeOptions: { cwd: input.cwd } } : {}),
|
||||
...(typeof ensured.agentSessionId === "string" ||
|
||||
typeof ensured.backendSessionId === "string"
|
||||
? {
|
||||
identity: {
|
||||
state: "resolved" as const,
|
||||
source: "status" as const,
|
||||
acpxSessionId:
|
||||
typeof ensured.backendSessionId === "string"
|
||||
? ensured.backendSessionId
|
||||
: "acpx-1",
|
||||
agentSessionId:
|
||||
typeof ensured.agentSessionId === "string"
|
||||
? ensured.agentSessionId
|
||||
: input.sessionKey,
|
||||
lastUpdatedAt: now,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await hoisted.upsertAcpSessionMetaMock({
|
||||
sessionKey: input.sessionKey,
|
||||
mutate: () => meta,
|
||||
});
|
||||
return {
|
||||
runtime: backend.runtime,
|
||||
handle: {
|
||||
backend: meta.backend,
|
||||
runtimeSessionName: meta.runtimeSessionName,
|
||||
},
|
||||
meta,
|
||||
};
|
||||
},
|
||||
resolveSession: (input: { sessionKey: string }) => {
|
||||
const entry = hoisted.readAcpSessionEntryMock({
|
||||
sessionKey: input.sessionKey,
|
||||
}) as { acp?: Record<string, unknown> } | null;
|
||||
const meta =
|
||||
entry?.acp ??
|
||||
({
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: `${input.sessionKey}:runtime`,
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
} as const);
|
||||
return {
|
||||
kind: "ready" as const,
|
||||
sessionKey: input.sessionKey,
|
||||
meta,
|
||||
};
|
||||
},
|
||||
cancelSession: async (input: unknown) => {
|
||||
await hoisted.cancelMock(input);
|
||||
},
|
||||
getSessionStatus: async (input: { sessionKey: string }) => {
|
||||
const status = await hoisted.getStatusMock(input);
|
||||
const entry = hoisted.readAcpSessionEntryMock({
|
||||
sessionKey: input.sessionKey,
|
||||
}) as { acp?: Record<string, unknown> } | null;
|
||||
const meta = entry?.acp ?? {};
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: typeof meta.backend === "string" ? meta.backend : "acpx",
|
||||
agent: typeof meta.agent === "string" ? meta.agent : "codex",
|
||||
identity: meta.identity,
|
||||
state: meta.state ?? "idle",
|
||||
mode: meta.mode ?? "persistent",
|
||||
runtimeOptions: meta.runtimeOptions ?? {},
|
||||
capabilities: {
|
||||
controls: ["session/set_mode", "session/set_config_option", "session/status"],
|
||||
},
|
||||
runtimeStatus: status,
|
||||
lastActivityAt:
|
||||
typeof meta.lastActivityAt === "number" ? meta.lastActivityAt : Date.now(),
|
||||
...(typeof meta.lastError === "string" ? { lastError: meta.lastError } : {}),
|
||||
};
|
||||
},
|
||||
getObservabilitySnapshot: () => ({
|
||||
runtimeCache: { activeSessions: 0, idleTtlMs: 0, evictedTotal: 0 },
|
||||
turns: {
|
||||
active: 0,
|
||||
queueDepth: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
averageLatencyMs: 0,
|
||||
maxLatencyMs: 0,
|
||||
},
|
||||
errorsByCode: {},
|
||||
}),
|
||||
runTurn: async (input: { onEvent?: (event: unknown) => Promise<void> | void }) => {
|
||||
for await (const event of hoisted.runTurnMock(input) as AsyncIterable<unknown>) {
|
||||
await input.onEvent?.(event);
|
||||
}
|
||||
},
|
||||
setSessionRuntimeMode: async (input: { sessionKey: string; runtimeMode: string }) => {
|
||||
await hoisted.setModeMock(input);
|
||||
return { mode: input.runtimeMode };
|
||||
},
|
||||
setSessionConfigOption: async (input: { key: string; value: string }) => {
|
||||
await hoisted.setConfigOptionMock(input);
|
||||
return { [input.key]: input.value };
|
||||
},
|
||||
updateSessionRuntimeOptions: async (input: { patch: Record<string, unknown> }) => input.patch,
|
||||
closeSession: async (input: { clearMeta?: boolean; sessionKey: string }) => {
|
||||
await hoisted.closeMock(input);
|
||||
if (input.clearMeta === true) {
|
||||
await hoisted.upsertAcpSessionMetaMock({
|
||||
sessionKey: input.sessionKey,
|
||||
mutate: () => null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
runtimeClosed: true,
|
||||
metaCleared: input.clearMeta === true,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the message is not /acp", async () => {
|
||||
@@ -732,17 +880,8 @@ describe("/acp command", () => {
|
||||
const result = await runDiscordAcpCommand("/acp spawn codex", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
|
||||
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "sessions.delete",
|
||||
params: expect.objectContaining({
|
||||
key: expect.stringMatching(/^agent:codex:acp:/),
|
||||
deleteTranscript: false,
|
||||
emitLifecycleHooks: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(hoisted.closeMock).toHaveBeenCalledTimes(2);
|
||||
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.callGatewayMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "sessions.patch" }),
|
||||
);
|
||||
@@ -792,11 +931,9 @@ describe("/acp command", () => {
|
||||
`Cancel requested for ACP session ${defaultAcpSessionKey}`,
|
||||
);
|
||||
expect(hoisted.cancelMock).toHaveBeenCalledWith({
|
||||
handle: expect.objectContaining({
|
||||
sessionKey: defaultAcpSessionKey,
|
||||
backend: "acpx",
|
||||
}),
|
||||
cfg: baseCfg,
|
||||
reason: "manual-cancel",
|
||||
sessionKey: defaultAcpSessionKey,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -852,10 +989,9 @@ describe("/acp command", () => {
|
||||
|
||||
expect(hoisted.runTurnMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
handle: expect.objectContaining({
|
||||
sessionKey: defaultAcpSessionKey,
|
||||
}),
|
||||
cfg: baseCfg,
|
||||
mode: "steer",
|
||||
sessionKey: defaultAcpSessionKey,
|
||||
text: "use npm to view package diver",
|
||||
}),
|
||||
);
|
||||
@@ -951,7 +1087,9 @@ describe("/acp command", () => {
|
||||
|
||||
expect(hoisted.setModeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "plan",
|
||||
cfg: baseCfg,
|
||||
runtimeMode: "plan",
|
||||
sessionKey: defaultAcpSessionKey,
|
||||
}),
|
||||
);
|
||||
expect(result?.reply?.text).toContain("Updated ACP runtime mode");
|
||||
@@ -1009,7 +1147,8 @@ describe("/acp command", () => {
|
||||
|
||||
expect(hoisted.setModeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "plan",
|
||||
cfg: baseCfg,
|
||||
runtimeMode: "plan",
|
||||
}),
|
||||
);
|
||||
expect(result?.reply?.text).toContain("Updated ACP runtime mode");
|
||||
|
||||
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
|
||||
pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
ensureAgentWorkspace: vi.fn(async () => {}),
|
||||
isWorkspaceSetupCompleted: vi.fn(async () => false),
|
||||
resolveAgentDir: vi.fn(() => "/agents/test-agent"),
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/workspace/test-agent"),
|
||||
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/transcripts/test-agent"),
|
||||
@@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({
|
||||
fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
|
||||
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
|
||||
fsRealpath: vi.fn(async (p: string) => p),
|
||||
fsReadlink: vi.fn(async () => ""),
|
||||
fsOpen: vi.fn(async () => ({}) as unknown),
|
||||
writeFileWithinRoot: vi.fn(async () => {}),
|
||||
}));
|
||||
@@ -59,6 +61,7 @@ vi.mock("../../agents/workspace.js", async () => {
|
||||
return {
|
||||
...actual,
|
||||
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
|
||||
isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -101,6 +104,7 @@ vi.mock("node:fs/promises", async () => {
|
||||
stat: mocks.fsStat,
|
||||
lstat: mocks.fsLstat,
|
||||
realpath: mocks.fsRealpath,
|
||||
readlink: mocks.fsReadlink,
|
||||
open: mocks.fsOpen,
|
||||
};
|
||||
return { ...patched, default: patched };
|
||||
@@ -110,12 +114,16 @@ vi.mock("node:fs/promises", async () => {
|
||||
/* Import after mocks are set up */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const { agentsHandlers } = await import("./agents.js");
|
||||
const { __testing: agentsTesting, agentsHandlers } = await import("./agents.js");
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
beforeEach(() => {
|
||||
agentsTesting.resetDepsForTests();
|
||||
});
|
||||
|
||||
function makeCall(method: keyof typeof agentsHandlers, params: Record<string, unknown>) {
|
||||
const respond = vi.fn();
|
||||
const handler = agentsHandlers[method];
|
||||
@@ -160,36 +168,32 @@ function makeFileStat(params?: {
|
||||
} as unknown as import("node:fs").Stats;
|
||||
}
|
||||
|
||||
function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats {
|
||||
return {
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
dev: params?.dev ?? 1,
|
||||
ino: params?.ino ?? 2,
|
||||
} as unknown as import("node:fs").Stats;
|
||||
}
|
||||
|
||||
function mockWorkspaceStateRead(params: {
|
||||
setupCompletedAt?: string;
|
||||
errorCode?: string;
|
||||
rawContent?: string;
|
||||
}) {
|
||||
mocks.fsReadFile.mockImplementation(async (...args: unknown[]) => {
|
||||
const filePath = args[0];
|
||||
if (String(filePath).endsWith("workspace-state.json")) {
|
||||
agentsTesting.setDepsForTests({
|
||||
isWorkspaceSetupCompleted: async () => {
|
||||
if (params.errorCode) {
|
||||
throw createErrnoError(params.errorCode);
|
||||
}
|
||||
if (typeof params.rawContent === "string") {
|
||||
return params.rawContent;
|
||||
throw new SyntaxError("Expected property name or '}' in JSON");
|
||||
}
|
||||
return JSON.stringify({
|
||||
setupCompletedAt: params.setupCompletedAt ?? "2026-02-15T14:00:00.000Z",
|
||||
});
|
||||
return (
|
||||
typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
mocks.isWorkspaceSetupCompleted.mockImplementation(async () => {
|
||||
if (params.errorCode) {
|
||||
throw createErrnoError(params.errorCode);
|
||||
}
|
||||
throw createEnoentError();
|
||||
if (typeof params.rawContent === "string") {
|
||||
throw new SyntaxError("Expected property name or '}' in JSON");
|
||||
}
|
||||
return typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -505,6 +509,8 @@ describe("agents.files.list", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadConfigReturn = {};
|
||||
mocks.isWorkspaceSetupCompleted.mockReset().mockResolvedValue(false);
|
||||
mocks.fsReadlink.mockReset().mockResolvedValue("");
|
||||
});
|
||||
|
||||
it("includes BOOTSTRAP.md when setup has not completed", async () => {
|
||||
@@ -543,22 +549,12 @@ describe("agents.files.get/set symlink safety", () => {
|
||||
|
||||
function mockWorkspaceEscapeSymlink() {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
if (p === workspace) {
|
||||
return workspace;
|
||||
}
|
||||
if (p === candidate) {
|
||||
return "/outside/secret.txt";
|
||||
}
|
||||
return p;
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === candidate) {
|
||||
return makeSymlinkStat();
|
||||
}
|
||||
throw createEnoentError();
|
||||
agentsTesting.setDepsForTests({
|
||||
resolveAgentWorkspaceFilePath: async ({ name }) => ({
|
||||
kind: "invalid",
|
||||
requestPath: path.join(workspace, name),
|
||||
reason: "path escapes workspace root",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -578,24 +574,24 @@ describe("agents.files.get/set symlink safety", () => {
|
||||
|
||||
it("allows in-workspace symlink reads and writes through symlink aliases", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||
const target = path.resolve(workspace, "policies", "AGENTS.md");
|
||||
const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 });
|
||||
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
if (p === workspace) {
|
||||
return workspace;
|
||||
}
|
||||
if (p === candidate) {
|
||||
return target;
|
||||
}
|
||||
return p;
|
||||
agentsTesting.setDepsForTests({
|
||||
readLocalFileSafely: async () => ({
|
||||
buffer: Buffer.from("inside\n"),
|
||||
realPath: target,
|
||||
stat: targetStat,
|
||||
}),
|
||||
resolveAgentWorkspaceFilePath: async ({ name }) => ({
|
||||
kind: "ready",
|
||||
requestPath: path.join(workspace, name),
|
||||
ioPath: target,
|
||||
workspaceReal: workspace,
|
||||
}),
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === candidate) {
|
||||
return makeSymlinkStat({ dev: 9, ino: 41 });
|
||||
}
|
||||
if (p === target) {
|
||||
return targetStat;
|
||||
}
|
||||
@@ -608,16 +604,6 @@ describe("agents.files.get/set symlink safety", () => {
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
mocks.fsOpen.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
stat: async () => targetStat,
|
||||
readFile: async () => Buffer.from("inside\n"),
|
||||
truncate: async () => {},
|
||||
writeFile: async () => {},
|
||||
close: async () => {},
|
||||
}) as unknown,
|
||||
);
|
||||
|
||||
const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
|
||||
await getCall.promise;
|
||||
|
||||
@@ -61,6 +61,32 @@ const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
|
||||
(name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
|
||||
);
|
||||
|
||||
const agentsHandlerDeps = {
|
||||
isWorkspaceSetupCompleted,
|
||||
readLocalFileSafely,
|
||||
resolveAgentWorkspaceFilePath,
|
||||
writeFileWithinRoot,
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
setDepsForTests(
|
||||
overrides: Partial<{
|
||||
isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
|
||||
readLocalFileSafely: typeof readLocalFileSafely;
|
||||
resolveAgentWorkspaceFilePath: typeof resolveAgentWorkspaceFilePath;
|
||||
writeFileWithinRoot: typeof writeFileWithinRoot;
|
||||
}>,
|
||||
) {
|
||||
Object.assign(agentsHandlerDeps, overrides);
|
||||
},
|
||||
resetDepsForTests() {
|
||||
agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
|
||||
agentsHandlerDeps.readLocalFileSafely = readLocalFileSafely;
|
||||
agentsHandlerDeps.resolveAgentWorkspaceFilePath = resolveAgentWorkspaceFilePath;
|
||||
agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot;
|
||||
},
|
||||
};
|
||||
|
||||
const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const;
|
||||
|
||||
const ALLOWED_FILE_NAMES = new Set<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]);
|
||||
@@ -417,7 +443,7 @@ async function resolveWorkspaceFilePathOrRespond(params: {
|
||||
workspaceDir: string;
|
||||
name: string;
|
||||
}): Promise<ResolvedWorkspaceFilePath | undefined> {
|
||||
const resolvedPath = await resolveAgentWorkspaceFilePath({
|
||||
const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({
|
||||
workspaceDir: params.workspaceDir,
|
||||
name: params.name,
|
||||
allowMissing: true,
|
||||
@@ -653,7 +679,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
let hideBootstrap = false;
|
||||
try {
|
||||
hideBootstrap = await isWorkspaceSetupCompleted(workspaceDir);
|
||||
hideBootstrap = await agentsHandlerDeps.isWorkspaceSetupCompleted(workspaceDir);
|
||||
} catch {
|
||||
// Fall back to showing BOOTSTRAP if workspace state cannot be read.
|
||||
}
|
||||
@@ -685,7 +711,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
let safeRead: Awaited<ReturnType<typeof readLocalFileSafely>>;
|
||||
try {
|
||||
safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath });
|
||||
safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath });
|
||||
} catch (err) {
|
||||
if (err instanceof SafeOpenError && err.code === "not-found") {
|
||||
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
|
||||
@@ -742,7 +768,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeFileWithinRoot({
|
||||
await agentsHandlerDeps.writeFileWithinRoot({
|
||||
rootDir: resolvedPath.workspaceReal,
|
||||
relativePath: relativeWritePath,
|
||||
data: content,
|
||||
|
||||
Reference in New Issue
Block a user