refactor: share announce test runtime seams

This commit is contained in:
Peter Steinberger
2026-04-04 23:36:55 +09:00
parent 5584af7ac3
commit b9201e8333
8 changed files with 161 additions and 121 deletions

View File

@@ -1,4 +1,14 @@
export { loadConfig } from "../config/config.js";
export {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
export { callGateway } from "../gateway/call.js";
export { resolveQueueSettings } from "../auto-reply/reply/queue.js";
export { resolveExternalBestEffortDeliveryTarget } from "../infra/outbound/best-effort-delivery.js";
export { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-router.js";
export { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
export { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
export { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./pi-embedded.js";

View File

@@ -1,18 +1,5 @@
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { resolveExternalBestEffortDeliveryTarget } from "../infra/outbound/best-effort-delivery.js";
import { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-router.js";
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { normalizeAccountId, normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { isCronSessionKey } from "../sessions/session-key-utils.js";
@@ -31,7 +18,21 @@ import {
} from "../utils/message-channel.js";
import { buildAnnounceIdempotencyKey, resolveQueueAnnounceId } from "./announce-idempotency.js";
import type { AgentInternalEvent } from "./internal-events.js";
import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./pi-embedded.js";
import {
callGateway,
createBoundDeliveryRouter,
getGlobalHookRunner,
isEmbeddedPiRunActive,
loadConfig,
loadSessionStore,
queueEmbeddedPiMessage,
resolveAgentIdFromSessionKey,
resolveConversationIdFromTargets,
resolveExternalBestEffortDeliveryTarget,
resolveMainSessionKey,
resolveQueueSettings,
resolveStorePath,
} from "./subagent-announce-delivery.runtime.js";
import {
runSubagentAnnounceDispatch,
type SubagentAnnounceDeliveryResult,

View File

@@ -0,0 +1,65 @@
import type { loadConfig } from "../config/config.js";
import type { callGateway } from "../gateway/call.js";
type DeliveryRuntimeMockOptions = {
callGateway: (request: unknown) => Promise<unknown>;
loadConfig: () => ReturnType<typeof loadConfig>;
loadSessionStore: (storePath: string) => unknown;
resolveAgentIdFromSessionKey: (sessionKey: string) => string;
resolveMainSessionKey: (cfg: unknown) => string;
resolveStorePath: (store: unknown, options: unknown) => string;
isEmbeddedPiRunActive: (sessionId: string) => boolean;
queueEmbeddedPiMessage: (sessionId: string, text: string) => boolean;
hasHooks?: () => boolean;
};
function resolveExternalBestEffortDeliveryTarget(params: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string;
}) {
return {
deliver: Boolean(params.channel && params.to),
channel: params.channel,
to: params.to,
accountId: params.accountId,
threadId: params.threadId,
};
}
function resolveQueueSettings(params: {
cfg?: {
messages?: {
queue?: {
byChannel?: Record<string, string>;
};
};
};
channel?: string;
}) {
return {
mode: (params.channel && params.cfg?.messages?.queue?.byChannel?.[params.channel]) ?? "none",
};
}
export function createSubagentAnnounceDeliveryRuntimeMock(options: DeliveryRuntimeMockOptions) {
return {
callGateway: (async <T = Record<string, unknown>>(request: Parameters<typeof callGateway>[0]) =>
(await options.callGateway(request)) as T) as typeof callGateway,
loadConfig: options.loadConfig,
loadSessionStore: options.loadSessionStore,
resolveAgentIdFromSessionKey: options.resolveAgentIdFromSessionKey,
resolveMainSessionKey: options.resolveMainSessionKey,
resolveStorePath: options.resolveStorePath,
isEmbeddedPiRunActive: options.isEmbeddedPiRunActive,
queueEmbeddedPiMessage: options.queueEmbeddedPiMessage,
getGlobalHookRunner: () => ({ hasHooks: () => options.hasHooks?.() ?? false }),
createBoundDeliveryRouter: () => ({
resolveDestination: () => ({ mode: "none" }),
}),
resolveConversationIdFromTargets: () => "",
resolveExternalBestEffortDeliveryTarget,
resolveQueueSettings,
};
}

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSubagentAnnounceDeliveryRuntimeMock } from "./subagent-announce.test-support.js";
type AgentCallRequest = { method?: string; params?: Record<string, unknown> };
@@ -15,7 +16,7 @@ const readLatestAssistantReplyMock = vi.fn(async (_params?: unknown) => "raw sub
const isEmbeddedPiRunActiveMock = vi.fn((_sessionId: string) => false);
const queueEmbeddedPiMessageMock = vi.fn((_sessionId: string, _text: string) => false);
const waitForEmbeddedPiRunEndMock = vi.fn(async (_sessionId: string, _timeoutMs?: number) => true);
let mockConfig: Record<string, unknown> = {
let mockConfig: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
@@ -35,16 +36,6 @@ const { subagentRegistryRuntimeMock } = vi.hoisted(() => ({
},
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({ hasHooks: () => false }),
}));
vi.mock("../config/sessions.js", () => ({
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
resolveAgentIdFromSessionKey: (sessionKey: string) =>
resolveAgentIdFromSessionKeyMock(sessionKey),
resolveMainSessionKey: (cfg: unknown) => resolveMainSessionKeyMock(cfg),
resolveStorePath: (store: unknown, options: unknown) => resolveStorePathMock(store, options),
}));
vi.mock("./subagent-announce.runtime.js", () => ({
callGateway: (request: unknown) => callGatewayMock(request),
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
@@ -64,44 +55,22 @@ vi.mock("./tools/agent-step.js", () => ({
readLatestAssistantReply: (params?: unknown) => readLatestAssistantReplyMock(params),
}));
vi.mock("./subagent-announce-delivery.runtime.js", () => ({
createBoundDeliveryRouter: () => ({
resolveDestination: () => ({ mode: "none" }),
vi.mock("./subagent-announce-delivery.runtime.js", () =>
createSubagentAnnounceDeliveryRuntimeMock({
callGateway: (request: unknown) => callGatewayMock(request),
loadConfig: () => mockConfig,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
resolveAgentIdFromSessionKey: (sessionKey: string) =>
resolveAgentIdFromSessionKeyMock(sessionKey),
resolveMainSessionKey: (cfg: unknown) => resolveMainSessionKeyMock(cfg),
resolveStorePath: (store: unknown, options: unknown) => resolveStorePathMock(store, options),
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
queueEmbeddedPiMessage: (sessionId: string, text: string) =>
queueEmbeddedPiMessageMock(sessionId, text),
}),
resolveConversationIdFromTargets: () => "",
resolveExternalBestEffortDeliveryTarget: (params: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string;
}) => ({
deliver: Boolean(params.channel && params.to),
channel: params.channel,
to: params.to,
accountId: params.accountId,
threadId: params.threadId,
}),
resolveQueueSettings: (params: {
cfg?: {
messages?: {
queue?: {
byChannel?: Record<string, string>;
};
};
};
channel?: string;
}) => ({
mode: (params.channel && params.cfg?.messages?.queue?.byChannel?.[params.channel]) ?? "none",
}),
}));
vi.mock("./pi-embedded.js", () => ({
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
queueEmbeddedPiMessage: (sessionId: string, text: string) =>
queueEmbeddedPiMessageMock(sessionId, text),
}));
);
vi.mock("./subagent-announce.registry.runtime.js", () => subagentRegistryRuntimeMock);
import { __testing as subagentAnnounceDeliveryTesting } from "./subagent-announce-delivery.js";
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
describe("subagent announce seam flow", () => {
@@ -142,11 +111,6 @@ describe("subagent announce seam flow", () => {
scope: "per-sender",
},
};
subagentAnnounceDeliveryTesting.setDepsForTest({
callGateway: (async <T = Record<string, unknown>>(request: unknown) =>
(await callGatewayMock(request)) as T) as typeof import("../gateway/call.js").callGateway,
loadConfig: () => mockConfig,
});
subagentRegistryRuntimeMock.shouldIgnorePostCompletionAnnounceForSession.mockReset();
subagentRegistryRuntimeMock.shouldIgnorePostCompletionAnnounceForSession.mockReturnValue(false);
subagentRegistryRuntimeMock.isSubagentSessionRunActive.mockReset();

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSubagentAnnounceDeliveryRuntimeMock } from "./subagent-announce.test-support.js";
type GatewayCall = {
method?: string;
@@ -45,15 +46,6 @@ function createGatewayCallModuleMock() {
};
}
function createSessionsModuleMock() {
return {
loadSessionStore: vi.fn(() => sessionStore),
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions-main.json",
resolveMainSessionKey: () => "agent:main:main",
};
}
function createSubagentDepthModuleMock() {
return {
getSubagentDepthFromSessionStore: (sessionKey?: string) => requesterDepthResolver(sessionKey),
@@ -80,40 +72,32 @@ function createTimeoutHistoryWithNoReply() {
vi.mock("../gateway/call.js", createGatewayCallModuleMock);
vi.mock("./subagent-depth.js", createSubagentDepthModuleMock);
vi.mock("./subagent-announce-delivery.runtime.js", () => ({
createBoundDeliveryRouter: () => ({
resolveDestination: () => ({ mode: "none" }),
vi.mock("./subagent-announce-delivery.runtime.js", () =>
createSubagentAnnounceDeliveryRuntimeMock({
callGateway: async (request: unknown) => {
const typed = request as GatewayCall;
gatewayCalls.push(typed);
if (typed.method === "chat.history") {
return { messages: chatHistoryMessages };
}
return await callGatewayImpl(typed);
},
loadConfig: () => configOverride,
loadSessionStore: () => sessionStore,
resolveAgentIdFromSessionKey: () => "main",
resolveMainSessionKey: () => "agent:main:main",
resolveStorePath: () => "/tmp/sessions-main.json",
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
queueEmbeddedPiMessage: () => false,
}),
resolveConversationIdFromTargets: () => "",
resolveExternalBestEffortDeliveryTarget: (params: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string;
}) => ({
deliver: Boolean(params.channel && params.to),
channel: params.channel,
to: params.to,
accountId: params.accountId,
threadId: params.threadId,
}),
resolveQueueSettings: (params: {
cfg?: {
messages?: {
queue?: {
byChannel?: Record<string, string>;
};
};
};
channel?: string;
}) => ({
mode: (params.channel && params.cfg?.messages?.queue?.byChannel?.[params.channel]) ?? "none",
}),
}));
);
vi.mock("./subagent-announce.runtime.js", () => ({
callGateway: createGatewayCallModuleMock().callGateway,
loadConfig: () => configOverride,
...createSessionsModuleMock(),
loadSessionStore: vi.fn(() => sessionStore),
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions-main.json",
resolveMainSessionKey: () => "agent:main:main",
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
queueEmbeddedPiMessage: (_sessionId: string, _text: string) => false,
waitForEmbeddedPiRunEnd: (sessionId: string, timeoutMs?: number) =>

View File

@@ -1,20 +1,24 @@
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js";
import {
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
TALK_SECRETS_SCOPE,
WRITE_SCOPE,
type OperatorScope,
} from "./operator-scopes.js";
export const ADMIN_SCOPE = "operator.admin" as const;
export const READ_SCOPE = "operator.read" as const;
export const WRITE_SCOPE = "operator.write" as const;
export const APPROVALS_SCOPE = "operator.approvals" as const;
export const PAIRING_SCOPE = "operator.pairing" as const;
export const TALK_SECRETS_SCOPE = "operator.talk.secrets" as const;
export type OperatorScope =
| typeof ADMIN_SCOPE
| typeof READ_SCOPE
| typeof WRITE_SCOPE
| typeof APPROVALS_SCOPE
| typeof PAIRING_SCOPE
| typeof TALK_SECRETS_SCOPE;
export {
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
TALK_SECRETS_SCOPE,
WRITE_SCOPE,
type OperatorScope,
};
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
ADMIN_SCOPE,

View File

@@ -0,0 +1,14 @@
export const ADMIN_SCOPE = "operator.admin" as const;
export const READ_SCOPE = "operator.read" as const;
export const WRITE_SCOPE = "operator.write" as const;
export const APPROVALS_SCOPE = "operator.approvals" as const;
export const PAIRING_SCOPE = "operator.pairing" as const;
export const TALK_SECRETS_SCOPE = "operator.talk.secrets" as const;
export type OperatorScope =
| typeof ADMIN_SCOPE
| typeof READ_SCOPE
| typeof WRITE_SCOPE
| typeof APPROVALS_SCOPE
| typeof PAIRING_SCOPE
| typeof TALK_SECRETS_SCOPE;

View File

@@ -5,6 +5,7 @@ import type { TalkProviderConfig } from "../../config/types.gateway.js";
import type { OpenClawConfig, TtsConfig, TtsProviderConfigMap } from "../../config/types.js";
import { canonicalizeSpeechProviderId, getSpeechProvider } from "../../tts/provider-registry.js";
import { synthesizeSpeech, type TtsDirectiveOverrides } from "../../tts/tts.js";
import { ADMIN_SCOPE, TALK_SECRETS_SCOPE } from "../operator-scopes.js";
import {
ErrorCodes,
errorShape,
@@ -16,9 +17,6 @@ import {
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.js";
const ADMIN_SCOPE = "operator.admin";
const TALK_SECRETS_SCOPE = "operator.talk.secrets";
function canReadTalkSecrets(client: { connect?: { scopes?: string[] } } | null): boolean {
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);