diff --git a/src/agents/subagent-announce-delivery.runtime.ts b/src/agents/subagent-announce-delivery.runtime.ts index bf686e442c2..e94dfa6c775 100644 --- a/src/agents/subagent-announce-delivery.runtime.ts +++ b/src/agents/subagent-announce-delivery.runtime.ts @@ -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"; diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index fe9aad7cf74..f7ae88331c0 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -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, diff --git a/src/agents/subagent-announce.test-support.ts b/src/agents/subagent-announce.test-support.ts new file mode 100644 index 00000000000..a299e5eee96 --- /dev/null +++ b/src/agents/subagent-announce.test-support.ts @@ -0,0 +1,65 @@ +import type { loadConfig } from "../config/config.js"; +import type { callGateway } from "../gateway/call.js"; + +type DeliveryRuntimeMockOptions = { + callGateway: (request: unknown) => Promise; + loadConfig: () => ReturnType; + 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; + }; + }; + }; + channel?: string; +}) { + return { + mode: (params.channel && params.cfg?.messages?.queue?.byChannel?.[params.channel]) ?? "none", + }; +} + +export function createSubagentAnnounceDeliveryRuntimeMock(options: DeliveryRuntimeMockOptions) { + return { + callGateway: (async >(request: Parameters[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, + }; +} diff --git a/src/agents/subagent-announce.test.ts b/src/agents/subagent-announce.test.ts index 79643023469..950bcfa0daa 100644 --- a/src/agents/subagent-announce.test.ts +++ b/src/agents/subagent-announce.test.ts @@ -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 }; @@ -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 = { +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; - }; - }; - }; - 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 >(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(); diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index f34fa0f41c5..7ffc0b0be9f 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -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; - }; - }; - }; - 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) => diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index b8d269f5b52..87f6b5fda7f 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -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, diff --git a/src/gateway/operator-scopes.ts b/src/gateway/operator-scopes.ts new file mode 100644 index 00000000000..1dca6e41f0a --- /dev/null +++ b/src/gateway/operator-scopes.ts @@ -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; diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 443e265efea..077679ea5fb 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -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);