mirror of https://github.com/openclaw/openclaw.git
fix(compaction): resolve model override in runtime context for all context engines (#56710)
Merged via squash.
Prepared head SHA: 72550aa5f0
Co-authored-by: oliviareid-svg <269669958+oliviareid-svg@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
parent
78d1120a41
commit
bf0f33db32
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
### Breaking
|
||||
|
|
|
|||
|
|
@ -712,6 +712,41 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
|
|||
expect(typeof runtimeContext?.rewriteTranscriptEntries).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves the effective compaction model before manual engine-owned compaction", async () => {
|
||||
await compactEmbeddedPiSession(
|
||||
wrappedCompactionArgs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
authProfileId: "openai:p1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolveModelMock).toHaveBeenCalledWith(
|
||||
"anthropic",
|
||||
"claude-opus-4-6",
|
||||
expect.any(String),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(contextEngineCompactMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeContext: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
authProfileId: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire after_compaction when compaction fails", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
const sync = vi.fn(async () => {});
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ import {
|
|||
runBeforeCompactionHooks,
|
||||
runPostCompactionSideEffects,
|
||||
} from "./compaction-hooks.js";
|
||||
import {
|
||||
buildEmbeddedCompactionRuntimeContext,
|
||||
resolveEmbeddedCompactionTarget,
|
||||
} from "./compaction-runtime-context.js";
|
||||
import {
|
||||
compactWithSafetyTimeout,
|
||||
resolveCompactionTimeoutMs,
|
||||
|
|
@ -286,31 +290,17 @@ export async function compactEmbeddedPiSessionDirect(
|
|||
workspaceDir: resolvedWorkspace,
|
||||
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
|
||||
});
|
||||
// Resolve compaction model: prefer config override, then fall back to caller-supplied model
|
||||
const compactionModelOverride = params.config?.agents?.defaults?.compaction?.model?.trim();
|
||||
let provider: string;
|
||||
let modelId: string;
|
||||
// When switching provider via override, drop the primary auth profile to avoid
|
||||
// sending the wrong credentials (e.g. OpenAI profile token to OpenRouter).
|
||||
let authProfileId: string | undefined = params.authProfileId;
|
||||
if (compactionModelOverride) {
|
||||
const slashIdx = compactionModelOverride.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
provider = compactionModelOverride.slice(0, slashIdx).trim();
|
||||
modelId = compactionModelOverride.slice(slashIdx + 1).trim() || DEFAULT_MODEL;
|
||||
// Provider changed — drop primary auth profile so getApiKeyForModel
|
||||
// falls back to provider-based key resolution for the override model.
|
||||
if (provider !== (params.provider ?? "").trim()) {
|
||||
authProfileId = undefined;
|
||||
}
|
||||
} else {
|
||||
provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
modelId = compactionModelOverride;
|
||||
}
|
||||
} else {
|
||||
provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
}
|
||||
const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({
|
||||
config: params.config,
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
authProfileId: params.authProfileId,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const provider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER;
|
||||
const modelId = resolvedCompactionTarget.model ?? DEFAULT_MODEL;
|
||||
const authProfileId = resolvedCompactionTarget.authProfileId;
|
||||
const fail = (reason: string): EmbeddedPiCompactResult => {
|
||||
log.warn(
|
||||
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
||||
|
|
@ -955,12 +945,19 @@ export async function compactEmbeddedPiSession(
|
|||
ensureContextEnginesInitialized();
|
||||
const contextEngine = await resolveContextEngine(params.config);
|
||||
try {
|
||||
// Resolve token budget from model context window so the context engine
|
||||
// knows the compaction target. The runner's afterTurn path passes this
|
||||
// automatically, but the /compact command path needs to compute it here.
|
||||
const ceProvider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const ceModelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
|
||||
const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({
|
||||
config: params.config,
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
authProfileId: params.authProfileId,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
// Resolve token budget from the effective compaction model so engine-
|
||||
// owned /compact implementations see the same target as the runtime.
|
||||
const ceProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER;
|
||||
const ceModelId = resolvedCompactionTarget.model ?? DEFAULT_MODEL;
|
||||
const { model: ceModel } = await resolveModelAsync(
|
||||
ceProvider,
|
||||
ceModelId,
|
||||
|
|
@ -995,6 +992,32 @@ export async function compactEmbeddedPiSession(
|
|||
workspaceDir: resolveUserPath(params.workspaceDir),
|
||||
messageProvider: resolvedMessageProvider,
|
||||
};
|
||||
const runtimeContext = {
|
||||
...params,
|
||||
...buildEmbeddedCompactionRuntimeContext({
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
authProfileId: params.authProfileId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
senderId: params.senderId,
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
thinkLevel: params.thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
bashElevated: params.bashElevated,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
}),
|
||||
};
|
||||
// Engine-owned compaction doesn't load the transcript at this level, so
|
||||
// message counts are unavailable. We pass sessionFile so hook subscribers
|
||||
// can read the transcript themselves if they need exact counts.
|
||||
|
|
@ -1022,7 +1045,7 @@ export async function compactEmbeddedPiSession(
|
|||
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
|
||||
customInstructions: params.customInstructions,
|
||||
force: params.trigger === "manual",
|
||||
runtimeContext: params as Record<string, unknown>,
|
||||
runtimeContext,
|
||||
});
|
||||
if (result.ok && result.compacted) {
|
||||
await runContextEngineMaintenance({
|
||||
|
|
@ -1031,7 +1054,7 @@ export async function compactEmbeddedPiSession(
|
|||
sessionKey: params.sessionKey,
|
||||
sessionFile: params.sessionFile,
|
||||
reason: "compaction",
|
||||
runtimeContext: params as Record<string, unknown>,
|
||||
runtimeContext,
|
||||
});
|
||||
}
|
||||
if (engineOwnsCompaction && result.ok && result.compacted) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js";
|
||||
import {
|
||||
buildEmbeddedCompactionRuntimeContext,
|
||||
resolveEmbeddedCompactionTarget,
|
||||
} from "./compaction-runtime-context.js";
|
||||
|
||||
describe("buildEmbeddedCompactionRuntimeContext", () => {
|
||||
it("preserves sender and current message routing for compaction", () => {
|
||||
|
|
@ -74,4 +77,71 @@ describe("buildEmbeddedCompactionRuntimeContext", () => {
|
|||
model: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies compaction.model override with provider/model format", () => {
|
||||
const result = buildEmbeddedCompactionRuntimeContext({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentDir: "/tmp/agent",
|
||||
config: {
|
||||
agents: { defaults: { compaction: { model: "anthropic/claude-opus-4-6" } } },
|
||||
} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
modelId: "minimax-m2.7:cloud",
|
||||
authProfileId: "ollama:default",
|
||||
});
|
||||
expect(result.provider).toBe("anthropic");
|
||||
expect(result.model).toBe("claude-opus-4-6");
|
||||
// Auth profile dropped because provider changed
|
||||
expect(result.authProfileId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies compaction.model override with model-only format", () => {
|
||||
const result = buildEmbeddedCompactionRuntimeContext({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentDir: "/tmp/agent",
|
||||
config: {
|
||||
agents: { defaults: { compaction: { model: "gpt-4o" } } },
|
||||
} as OpenClawConfig,
|
||||
provider: "openai",
|
||||
modelId: "gpt-3.5-turbo",
|
||||
authProfileId: "openai:p1",
|
||||
});
|
||||
expect(result.provider).toBe("openai");
|
||||
expect(result.model).toBe("gpt-4o");
|
||||
// Auth profile preserved because provider didn't change
|
||||
expect(result.authProfileId).toBe("openai:p1");
|
||||
});
|
||||
|
||||
it("uses session model when no compaction.model override configured", () => {
|
||||
const result = buildEmbeddedCompactionRuntimeContext({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentDir: "/tmp/agent",
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
modelId: "minimax-m2.7:cloud",
|
||||
authProfileId: "ollama:default",
|
||||
});
|
||||
expect(result.provider).toBe("ollama");
|
||||
expect(result.model).toBe("minimax-m2.7:cloud");
|
||||
expect(result.authProfileId).toBe("ollama:default");
|
||||
});
|
||||
|
||||
it("applies runtime defaults when resolving the effective compaction target", () => {
|
||||
expect(
|
||||
resolveEmbeddedCompactionTarget({
|
||||
config: {
|
||||
agents: { defaults: { compaction: { model: "anthropic/" } } },
|
||||
} as OpenClawConfig,
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4",
|
||||
authProfileId: "openai:p1",
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModel: "gpt-5.4",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "gpt-5.4",
|
||||
authProfileId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,47 @@ export type EmbeddedCompactionRuntimeContext = {
|
|||
ownerNumbers?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective compaction target from config, falling back to the
|
||||
* caller-supplied provider/model and optionally applying runtime defaults.
|
||||
*/
|
||||
export function resolveEmbeddedCompactionTarget(params: {
|
||||
config?: OpenClawConfig;
|
||||
provider?: string | null;
|
||||
modelId?: string | null;
|
||||
authProfileId?: string | null;
|
||||
defaultProvider?: string;
|
||||
defaultModel?: string;
|
||||
}): { provider: string | undefined; model: string | undefined; authProfileId: string | undefined } {
|
||||
const provider = params.provider?.trim() || params.defaultProvider;
|
||||
const model = params.modelId?.trim() || params.defaultModel;
|
||||
const override = params.config?.agents?.defaults?.compaction?.model?.trim();
|
||||
if (!override) {
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
authProfileId: params.authProfileId ?? undefined,
|
||||
};
|
||||
}
|
||||
const slashIdx = override.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
const overrideProvider = override.slice(0, slashIdx).trim();
|
||||
const overrideModel = override.slice(slashIdx + 1).trim() || params.defaultModel;
|
||||
// When switching provider via override, drop the primary auth profile to
|
||||
// avoid sending the wrong credentials.
|
||||
const authProfileId =
|
||||
overrideProvider !== (params.provider ?? "")?.trim()
|
||||
? undefined
|
||||
: (params.authProfileId ?? undefined);
|
||||
return { provider: overrideProvider, model: overrideModel, authProfileId };
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
model: override,
|
||||
authProfileId: params.authProfileId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmbeddedCompactionRuntimeContext(params: {
|
||||
sessionKey?: string | null;
|
||||
messageChannel?: string | null;
|
||||
|
|
@ -50,6 +91,12 @@ export function buildEmbeddedCompactionRuntimeContext(params: {
|
|||
extraSystemPrompt?: string;
|
||||
ownerNumbers?: string[];
|
||||
}): EmbeddedCompactionRuntimeContext {
|
||||
const resolved = resolveEmbeddedCompactionTarget({
|
||||
config: params.config,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
authProfileId: params.authProfileId,
|
||||
});
|
||||
return {
|
||||
sessionKey: params.sessionKey ?? undefined,
|
||||
messageChannel: params.messageChannel ?? undefined,
|
||||
|
|
@ -58,15 +105,15 @@ export function buildEmbeddedCompactionRuntimeContext(params: {
|
|||
currentChannelId: params.currentChannelId ?? undefined,
|
||||
currentThreadTs: params.currentThreadTs ?? undefined,
|
||||
currentMessageId: params.currentMessageId ?? undefined,
|
||||
authProfileId: params.authProfileId ?? undefined,
|
||||
authProfileId: resolved.authProfileId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
senderId: params.senderId ?? undefined,
|
||||
provider: params.provider ?? undefined,
|
||||
model: params.modelId ?? undefined,
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
thinkLevel: params.thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
bashElevated: params.bashElevated,
|
||||
|
|
|
|||
|
|
@ -1,34 +1,26 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import {
|
||||
isOllamaCompatProvider,
|
||||
resolveOllamaBaseUrlForRun,
|
||||
resolveOllamaCompatNumCtxEnabled,
|
||||
shouldInjectOllamaCompatNumCtx,
|
||||
wrapOllamaCompatNumCtx,
|
||||
} from "../../../plugin-sdk/ollama.js";
|
||||
import { appendBootstrapPromptWarning } from "../../bootstrap-budget.js";
|
||||
import { buildAgentSystemPrompt } from "../../system-prompt.js";
|
||||
import { buildEmbeddedSystemPrompt } from "../system-prompt.js";
|
||||
import {
|
||||
buildAfterTurnRuntimeContext,
|
||||
buildSessionsYieldContextMessage,
|
||||
composeSystemPromptWithHookContext,
|
||||
persistSessionsYieldContextMessage,
|
||||
prependSystemPromptAddition,
|
||||
queueSessionsYieldInterruptMessage,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolvePromptBuildHookResult,
|
||||
resolvePromptModeForSession,
|
||||
stripSessionsYieldArtifacts,
|
||||
shouldInjectHeartbeatPrompt,
|
||||
decodeHtmlEntitiesInObject,
|
||||
wrapStreamFnRepairMalformedToolCallArguments,
|
||||
wrapStreamFnSanitizeMalformedToolCalls,
|
||||
wrapStreamFnTrimToolCallNames,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "./attempt.js";
|
||||
import { shouldInjectHeartbeatPromptForTrigger } from "./trigger-policy.js";
|
||||
|
||||
type FakeWrappedStream = {
|
||||
result: () => Promise<unknown>;
|
||||
|
|
@ -152,98 +144,6 @@ describe("resolvePromptBuildHookResult", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("sessions_yield helpers", () => {
|
||||
it("builds a hidden follow-up context note", () => {
|
||||
expect(buildSessionsYieldContextMessage("Waiting for subagent")).toContain(
|
||||
"Waiting for subagent",
|
||||
);
|
||||
expect(buildSessionsYieldContextMessage("Waiting for subagent")).toContain(
|
||||
"ended intentionally via sessions_yield",
|
||||
);
|
||||
});
|
||||
|
||||
it("queues a hidden interrupt steering message", () => {
|
||||
const steer = vi.fn();
|
||||
queueSessionsYieldInterruptMessage({ agent: { steer } });
|
||||
expect(steer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: "custom",
|
||||
customType: "openclaw.sessions_yield_interrupt",
|
||||
display: false,
|
||||
details: { source: "sessions_yield" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists a hidden yield context message without triggering a turn", async () => {
|
||||
const sendCustomMessage = vi.fn(async () => {});
|
||||
await persistSessionsYieldContextMessage(
|
||||
{
|
||||
sendCustomMessage,
|
||||
},
|
||||
"Waiting for subagent",
|
||||
);
|
||||
expect(sendCustomMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customType: "openclaw.sessions_yield",
|
||||
display: false,
|
||||
details: { source: "sessions_yield", message: "Waiting for subagent" },
|
||||
content: expect.stringContaining("Waiting for subagent"),
|
||||
}),
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("strips trailing yield interrupt artifacts from memory and transcript state", () => {
|
||||
const replaceMessages = vi.fn();
|
||||
const rewriteFile = vi.fn();
|
||||
const activeSession = {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{ role: "custom", customType: "openclaw.sessions_yield_interrupt" },
|
||||
{ role: "assistant", stopReason: "aborted" },
|
||||
],
|
||||
agent: { replaceMessages },
|
||||
sessionManager: {
|
||||
fileEntries: [
|
||||
{ type: "session", id: "session-root" },
|
||||
{
|
||||
type: "custom_message",
|
||||
id: "interrupt",
|
||||
parentId: "session-root",
|
||||
customType: "openclaw.sessions_yield_interrupt",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "aborted",
|
||||
parentId: "interrupt",
|
||||
message: { role: "assistant", stopReason: "aborted" },
|
||||
},
|
||||
],
|
||||
byId: new Map([
|
||||
["interrupt", { id: "interrupt" }],
|
||||
["aborted", { id: "aborted" }],
|
||||
]),
|
||||
leafId: "aborted",
|
||||
_rewriteFile: rewriteFile,
|
||||
},
|
||||
};
|
||||
|
||||
stripSessionsYieldArtifacts(activeSession as never);
|
||||
|
||||
expect(replaceMessages).toHaveBeenCalledWith([
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
]);
|
||||
expect(activeSession.sessionManager.fileEntries).toEqual([
|
||||
{ type: "session", id: "session-root" },
|
||||
]);
|
||||
expect(activeSession.sessionManager.byId.has("interrupt")).toBe(false);
|
||||
expect(activeSession.sessionManager.byId.has("aborted")).toBe(false);
|
||||
expect(activeSession.sessionManager.leafId).toBe("session-root");
|
||||
expect(rewriteFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeSystemPromptWithHookContext", () => {
|
||||
it("returns undefined when no hook system context is provided", () => {
|
||||
expect(composeSystemPromptWithHookContext({ baseSystemPrompt: "base" })).toBeUndefined();
|
||||
|
|
@ -322,71 +222,6 @@ describe("resolvePromptModeForSession", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("shouldInjectHeartbeatPrompt", () => {
|
||||
it("uses trigger policy defaults for non-cron triggers", () => {
|
||||
expect(shouldInjectHeartbeatPromptForTrigger("user")).toBe(true);
|
||||
expect(shouldInjectHeartbeatPromptForTrigger("heartbeat")).toBe(true);
|
||||
expect(shouldInjectHeartbeatPromptForTrigger("memory")).toBe(true);
|
||||
expect(shouldInjectHeartbeatPromptForTrigger(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses trigger policy overrides for cron", () => {
|
||||
expect(shouldInjectHeartbeatPromptForTrigger("cron")).toBe(false);
|
||||
});
|
||||
|
||||
it("injects the heartbeat prompt for default-agent non-cron runs", () => {
|
||||
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "user" })).toBe(true);
|
||||
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "heartbeat" })).toBe(true);
|
||||
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "memory" })).toBe(true);
|
||||
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: undefined })).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses the heartbeat prompt for cron-triggered runs", () => {
|
||||
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "cron" })).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses the heartbeat prompt for non-default agents", () => {
|
||||
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: false, trigger: "user" })).toBe(false);
|
||||
});
|
||||
|
||||
it("omits heartbeat prompt content for cron-triggered full-mode runs on non-cron session keys", () => {
|
||||
const sessionKey = "agent:main:kos:thread:abc";
|
||||
expect(resolvePromptModeForSession(sessionKey)).toBe("full");
|
||||
|
||||
const heartbeatPrompt = shouldInjectHeartbeatPrompt({
|
||||
isDefaultAgent: true,
|
||||
trigger: "cron",
|
||||
})
|
||||
? resolveHeartbeatPrompt(undefined)
|
||||
: undefined;
|
||||
|
||||
const prompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
defaultThinkLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
reasoningTagHint: false,
|
||||
heartbeatPrompt,
|
||||
promptMode: resolvePromptModeForSession(sessionKey),
|
||||
runtimeInfo: {
|
||||
host: "host",
|
||||
os: "Darwin",
|
||||
arch: "arm64",
|
||||
node: "v22.0.0",
|
||||
model: "openai/gpt-5.4",
|
||||
},
|
||||
tools: [],
|
||||
modelAliasLines: [],
|
||||
userTimezone: "UTC",
|
||||
userTime: "00:00",
|
||||
userTimeFormat: "24",
|
||||
});
|
||||
|
||||
expect(prompt).not.toContain("## Heartbeats");
|
||||
expect(prompt).not.toContain("HEARTBEAT_OK");
|
||||
expect(prompt).not.toContain("Read HEARTBEAT.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAttemptFsWorkspaceOnly", () => {
|
||||
it("uses global tools.fs.workspaceOnly when agent has no override", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
|
|
@ -1948,50 +1783,6 @@ describe("shouldInjectOllamaCompatNumCtx", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("resolveEmbeddedAgentStreamFn", () => {
|
||||
it("keeps the session-managed HTTP stream when no override applies", () => {
|
||||
const currentStreamFn = vi.fn();
|
||||
|
||||
const resolved = resolveEmbeddedAgentStreamFn({
|
||||
currentStreamFn: currentStreamFn as never,
|
||||
shouldUseWebSocketTransport: false,
|
||||
sessionId: "session-1",
|
||||
model: { provider: "xai" } as never,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(currentStreamFn);
|
||||
});
|
||||
|
||||
it("keeps the session-managed HTTP stream when websocket auth is unavailable", () => {
|
||||
const currentStreamFn = vi.fn();
|
||||
|
||||
const resolved = resolveEmbeddedAgentStreamFn({
|
||||
currentStreamFn: currentStreamFn as never,
|
||||
shouldUseWebSocketTransport: true,
|
||||
wsApiKey: undefined,
|
||||
sessionId: "session-1",
|
||||
model: { provider: "xai" } as never,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(currentStreamFn);
|
||||
});
|
||||
|
||||
it("prefers a provider-owned stream override when present", () => {
|
||||
const currentStreamFn = vi.fn();
|
||||
const providerStreamFn = vi.fn();
|
||||
|
||||
const resolved = resolveEmbeddedAgentStreamFn({
|
||||
currentStreamFn: currentStreamFn as never,
|
||||
providerStreamFn: providerStreamFn as never,
|
||||
shouldUseWebSocketTransport: false,
|
||||
sessionId: "session-1",
|
||||
model: { provider: "xai" } as never,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(providerStreamFn);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeHtmlEntitiesInObject", () => {
|
||||
it("decodes HTML entities in string values", () => {
|
||||
const result = decodeHtmlEntitiesInObject(
|
||||
|
|
@ -2078,7 +1869,7 @@ describe("buildAfterTurnRuntimeContext", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("passes primary model through even when compaction.model is set (override resolved in compactDirect)", () => {
|
||||
it("resolves compaction.model override in runtime context so all context engines use the correct model", () => {
|
||||
const legacy = buildAfterTurnRuntimeContext({
|
||||
attempt: {
|
||||
sessionKey: "agent:main:session:abc",
|
||||
|
|
@ -2108,11 +1899,14 @@ describe("buildAfterTurnRuntimeContext", () => {
|
|||
agentDir: "/tmp/agent",
|
||||
});
|
||||
|
||||
// buildAfterTurnLegacyCompactionParams no longer resolves the override;
|
||||
// compactEmbeddedPiSessionDirect does it centrally for both auto + manual paths.
|
||||
// buildEmbeddedCompactionRuntimeContext now resolves the override eagerly
|
||||
// so that context engines (including third-party ones) receive the correct
|
||||
// compaction model in the runtime context.
|
||||
expect(legacy).toMatchObject({
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
provider: "openrouter",
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
// Auth profile dropped because provider changed from openai-codex to openrouter
|
||||
authProfileId: undefined,
|
||||
});
|
||||
});
|
||||
it("includes resolved auth profile fields for context-engine afterTurn compaction", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue