diff --git a/src/auto-reply/reply/get-reply-directives.target-session.test.ts b/src/auto-reply/reply/get-reply-directives.target-session.test.ts new file mode 100644 index 00000000000..b945842c66a --- /dev/null +++ b/src/auto-reply/reply/get-reply-directives.target-session.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { TemplateContext } from "../templating.js"; +import { buildTestCtx } from "./test-ctx.js"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; + +const mocks = vi.hoisted(() => ({ + createModelSelectionState: vi.fn(), + applyInlineDirectiveOverrides: vi.fn(), + resolveFastModeState: vi.fn(), + resolveReplyExecOverrides: vi.fn(), +})); + +function makeSessionEntry(overrides: Partial = {}): SessionEntry { + return { + sessionId: "session-id", + updatedAt: Date.now(), + ...overrides, + }; +} + +async function loadResolveReplyDirectivesForTest() { + vi.resetModules(); + vi.doMock("../../agents/agent-scope.js", () => ({ + listAgentEntries: vi.fn(() => []), + })); + vi.doMock("../../agents/defaults.js", () => ({ + DEFAULT_CONTEXT_TOKENS: 8192, + })); + vi.doMock("../../agents/fast-mode.js", () => ({ + resolveFastModeState: (...args: unknown[]) => mocks.resolveFastModeState(...args), + })); + vi.doMock("../../agents/sandbox/runtime-status.js", () => ({ + resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })), + })); + vi.doMock("../../routing/session-key.js", () => ({ + normalizeAgentId: (value: string) => value, + })); + vi.doMock("../commands-text-routing.js", () => ({ + shouldHandleTextCommands: vi.fn(() => false), + })); + vi.doMock("./commands-context.js", () => ({ + buildCommandContext: vi.fn(() => ({ + surface: "whatsapp", + channel: "whatsapp", + channelId: "whatsapp", + ownerList: [], + senderIsOwner: false, + isAuthorizedSender: false, + senderId: undefined, + abortKey: "abort-key", + rawBodyNormalized: "hello", + commandBodyNormalized: "hello", + from: "whatsapp:+1000", + to: "whatsapp:+2000", + })), + })); + vi.doMock("./directive-handling.parse.js", () => ({ + parseInlineDirectives: vi.fn((body: string) => ({ + cleaned: body, + hasThinkDirective: false, + hasVerboseDirective: false, + hasFastDirective: false, + hasReasoningDirective: false, + hasElevatedDirective: false, + hasExecDirective: false, + hasModelDirective: false, + hasQueueDirective: false, + hasStatusDirective: false, + queueReset: false, + thinkLevel: undefined, + verboseLevel: undefined, + fastMode: undefined, + reasoningLevel: undefined, + elevatedLevel: undefined, + rawElevatedLevel: undefined, + rawModelDirective: undefined, + execSecurity: undefined, + })), + })); + vi.doMock("./get-reply-directive-aliases.js", () => ({ + reserveSkillCommandNames: vi.fn(), + resolveConfiguredDirectiveAliases: vi.fn(() => []), + })); + vi.doMock("./get-reply-directives-apply.js", () => ({ + applyInlineDirectiveOverrides: (...args: unknown[]) => mocks.applyInlineDirectiveOverrides(...args), + })); + vi.doMock("./get-reply-exec-overrides.js", () => ({ + resolveReplyExecOverrides: (...args: unknown[]) => mocks.resolveReplyExecOverrides(...args), + })); + vi.doMock("./get-reply-fast-path.js", () => ({ + shouldUseReplyFastTestRuntime: vi.fn(() => false), + })); + vi.doMock("./groups.js", () => ({ + defaultGroupActivation: vi.fn(() => "always"), + resolveGroupRequireMention: vi.fn(async () => false), + })); + vi.doMock("./model-selection.js", () => ({ + createFastTestModelSelectionState: vi.fn(), + createModelSelectionState: (...args: unknown[]) => mocks.createModelSelectionState(...args), + resolveContextTokens: vi.fn(() => 4096), + })); + vi.doMock("./reply-elevated.js", () => ({ + formatElevatedUnavailableMessage: vi.fn(() => "elevated unavailable"), + resolveElevatedPermissions: vi.fn(() => ({ + enabled: true, + allowed: true, + failures: [], + })), + })); + return await importFreshModule( + import.meta.url, + "./get-reply-directives.js", + ); +} + +describe("resolveReplyDirectives", () => { + beforeEach(() => { + mocks.createModelSelectionState.mockReset(); + mocks.applyInlineDirectiveOverrides.mockReset(); + mocks.resolveFastModeState.mockReset(); + mocks.resolveReplyExecOverrides.mockReset(); + + mocks.createModelSelectionState.mockResolvedValue({ + provider: "openai", + model: "gpt-4o-mini", + allowedModelKeys: new Set(), + allowedModelCatalog: [], + resetModelOverride: false, + resolveDefaultThinkingLevel: vi.fn(async () => "off"), + resolveDefaultReasoningLevel: vi.fn(async () => "off"), + }); + mocks.applyInlineDirectiveOverrides.mockImplementation(async (params) => ({ + kind: "continue", + directives: params.directives, + provider: params.provider, + model: params.model, + contextTokens: params.contextTokens, + })); + mocks.resolveFastModeState.mockImplementation(({ sessionEntry }) => ({ + enabled: sessionEntry?.sessionId === "target-session", + })); + mocks.resolveReplyExecOverrides.mockReturnValue(undefined); + }); + + it("prefers the target session entry from sessionStore for directive state", async () => { + const { resolveReplyDirectives } = await loadResolveReplyDirectivesForTest(); + const wrapperSessionEntry = makeSessionEntry({ + sessionId: "wrapper-session", + thinkingLevel: "low", + verboseLevel: "off", + reasoningLevel: "off", + elevatedLevel: "off", + parentSessionKey: "wrapper-parent", + }); + const targetSessionEntry = makeSessionEntry({ + sessionId: "target-session", + thinkingLevel: "high", + verboseLevel: "full", + reasoningLevel: "high", + elevatedLevel: "on", + parentSessionKey: "target-parent", + }); + + const result = await resolveReplyDirectives({ + ctx: buildTestCtx({ + Body: "hello", + CommandBody: "hello", + ParentSessionKey: "ctx-parent", + }), + cfg: {}, + agentId: "main", + agentDir: "/tmp/main-agent", + workspaceDir: "/tmp", + agentCfg: {}, + sessionCtx: { + Body: "hello", + BodyStripped: "hello", + BodyForAgent: "hello", + CommandBody: "hello", + Provider: "whatsapp", + } as TemplateContext, + sessionEntry: wrapperSessionEntry, + sessionStore: { + "agent:main:whatsapp:+2000": targetSessionEntry, + }, + sessionKey: "agent:main:whatsapp:+2000", + storePath: "/tmp/sessions.json", + sessionScope: "per-sender", + groupResolution: undefined, + isGroup: false, + triggerBodyNormalized: "hello", + commandAuthorized: false, + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: new Map(), + provider: "openai", + model: "gpt-4o-mini", + hasResolvedHeartbeatModelOverride: false, + typing: { + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: vi.fn(), + }, + opts: undefined, + skillFilter: undefined, + }); + + expect(mocks.resolveFastModeState).toHaveBeenCalledWith( + expect.objectContaining({ + sessionEntry: targetSessionEntry, + }), + ); + expect(mocks.createModelSelectionState).toHaveBeenCalledWith( + expect.objectContaining({ + sessionEntry: targetSessionEntry, + parentSessionKey: "target-parent", + }), + ); + expect(mocks.applyInlineDirectiveOverrides).toHaveBeenCalledWith( + expect.objectContaining({ + sessionEntry: targetSessionEntry, + }), + ); + expect(mocks.resolveReplyExecOverrides).toHaveBeenCalledWith( + expect.objectContaining({ + sessionEntry: targetSessionEntry, + }), + ); + expect(result).toEqual({ + kind: "continue", + result: expect.objectContaining({ + resolvedThinkLevel: "high", + resolvedFastMode: true, + resolvedVerboseLevel: "full", + resolvedReasoningLevel: "high", + resolvedElevatedLevel: "on", + }), + }); + }); +}); diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index e4d6f37afd0..433bf5037b8 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -177,6 +177,7 @@ export async function resolveReplyDirectives(params: { const agentEntry = listAgentEntries(cfg).find( (entry) => normalizeAgentId(entry.id) === normalizeAgentId(agentId), ); + const targetSessionEntry = sessionStore[sessionKey] ?? sessionEntry; let provider = initialProvider; let model = initialModel; @@ -380,7 +381,7 @@ export async function resolveReplyDirectives(params: { }); const defaultActivation = defaultGroupActivation(requireMention); const resolvedThinkLevel = - directives.thinkLevel ?? (sessionEntry?.thinkingLevel as ThinkLevel | undefined); + directives.thinkLevel ?? (targetSessionEntry?.thinkingLevel as ThinkLevel | undefined); const resolvedFastMode = directives.fastMode ?? resolveFastModeState({ @@ -388,21 +389,21 @@ export async function resolveReplyDirectives(params: { provider, model, agentId, - sessionEntry, + sessionEntry: targetSessionEntry, }).enabled; const resolvedVerboseLevel = directives.verboseLevel ?? - (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? + (targetSessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); let resolvedReasoningLevel: ReasoningLevel = directives.reasoningLevel ?? - (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? + (targetSessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? (agentEntry?.reasoningDefault as ReasoningLevel | undefined) ?? "off"; const resolvedElevatedLevel = elevatedAllowed ? (directives.elevatedLevel ?? - (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? + (targetSessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? (agentCfg?.elevatedDefault as ElevatedLevel | undefined) ?? "on") : "off"; @@ -430,8 +431,8 @@ export async function resolveReplyDirectives(params: { useFastReplyRuntime && !directives.hasModelDirective && !hasResolvedHeartbeatModelOverride && - !normalizeOptionalString(sessionEntry?.modelOverride) && - !normalizeOptionalString(sessionEntry?.providerOverride) + !normalizeOptionalString(targetSessionEntry?.modelOverride) && + !normalizeOptionalString(targetSessionEntry?.providerOverride) ? createFastTestModelSelectionState({ agentCfg, provider, @@ -441,10 +442,10 @@ export async function resolveReplyDirectives(params: { cfg, agentId, agentCfg, - sessionEntry, + sessionEntry: targetSessionEntry, sessionStore, sessionKey, - parentSessionKey: ctx.ParentSessionKey, + parentSessionKey: targetSessionEntry?.parentSessionKey ?? ctx.ParentSessionKey, storePath, defaultProvider, defaultModel, @@ -467,7 +468,8 @@ export async function resolveReplyDirectives(params: { agentEntry?.reasoningDefault !== undefined && agentEntry?.reasoningDefault !== null; const reasoningExplicitlySet = directives.reasoningLevel !== undefined || - (sessionEntry?.reasoningLevel !== undefined && sessionEntry?.reasoningLevel !== null) || + (targetSessionEntry?.reasoningLevel !== undefined && + targetSessionEntry?.reasoningLevel !== null) || hasAgentReasoningDefault; const thinkingActive = resolvedThinkLevelWithDefault !== "off"; if (!reasoningExplicitlySet && resolvedReasoningLevel === "off" && !thinkingActive) { @@ -502,7 +504,7 @@ export async function resolveReplyDirectives(params: { agentDir, agentCfg, agentEntry, - sessionEntry, + sessionEntry: targetSessionEntry, sessionStore, sessionKey, storePath, @@ -539,7 +541,7 @@ export async function resolveReplyDirectives(params: { const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult; const execOverrides = resolveReplyExecOverrides({ directives, - sessionEntry, + sessionEntry: targetSessionEntry, agentExecDefaults: agentEntry?.tools?.exec, });