fix: prefer target entry for reply directives

This commit is contained in:
Tak Hoffman
2026-04-10 21:18:24 -05:00
parent efab9763dc
commit f1b6934700
2 changed files with 260 additions and 12 deletions

View File

@@ -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> = {}): 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<typeof import("./get-reply-directives.js")>(
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<string>(),
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",
}),
});
});
});

View File

@@ -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,
});