// Prompt composition scenarios build reusable agent prompt fixtures. import fs from "node:fs/promises"; import path from "node:path"; import { appendBootstrapPromptWarning, analyzeBootstrapBudget, buildBootstrapInjectionStats, buildBootstrapPromptWarning, } from "../../../src/agents/bootstrap-budget.js"; import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-files.js"; import { buildCurrentInboundPrompt } from "../../../src/agents/embedded-agent-runner/run/runtime-context-prompt.js"; import { buildEmbeddedSystemPrompt } from "../../../src/agents/embedded-agent-runner/system-prompt.js"; import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js"; import { createStubTool } from "../../../src/agents/test-helpers/agent-tool-stubs.js"; import { buildDirectChatContext, buildGroupChatContext, buildGroupIntro, } from "../../../src/auto-reply/reply/groups.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix, } from "../../../src/auto-reply/reply/inbound-meta.js"; import { buildReplyPromptEnvelope } from "../../../src/auto-reply/reply/prompt-prelude.js"; import type { TemplateContext } from "../../../src/auto-reply/templating.js"; import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { makeTempWorkspace, writeWorkspaceFile } from "../../../src/test-helpers/workspace.js"; // Prompt composition scenarios for system/body prompt stability tests. /** One turn in a prompt composition scenario. */ export type PromptScenarioTurn = { id: string; label: string; systemPrompt: string; bodyPrompt: string; notes: string[]; }; /** Multi-turn prompt composition scenario fixture. */ export type PromptScenario = { scenario: string; focus: string; expectedStableSystemAfterTurnIds: string[]; turns: PromptScenarioTurn[]; }; function buildCommonSystemParams(workspaceDir: string) { const toolNames = [ "bash", "read", "edit", "grep", "glob", "message", "memory_search", "memory_get", "web_search", "x_search", "web_fetch", ]; return { runtimeInfo: { agentId: "main", host: "cache-lab", repoRoot: workspaceDir, os: "Darwin 24.0.0", arch: "arm64", node: process.version, model: "anthropic/claude-sonnet-4-6", defaultModel: "anthropic/claude-sonnet-4-6", shell: "zsh", }, userTimezone: "America/Los_Angeles", userTime: "Monday, March 16th, 2026 - 9:00 PM", userTimeFormat: "12" as const, toolNames, }; } function buildSystemPrompt(params: { workspaceDir: string; extraSystemPrompt?: string; skillsPrompt?: string; reactionGuidance?: { level: "minimal" | "extensive"; channel: string }; contextFiles?: Array<{ path: string; content: string }>; silentReplyPromptMode?: "generic" | "none"; }) { const { runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames } = buildCommonSystemParams(params.workspaceDir); return buildAgentSystemPrompt({ workspaceDir: params.workspaceDir, extraSystemPrompt: params.extraSystemPrompt, runtimeInfo, userTimezone, userTime, userTimeFormat, toolNames, modelAliasLines: [], promptMode: "full", silentReplyPromptMode: params.silentReplyPromptMode, acpEnabled: true, skillsPrompt: params.skillsPrompt, reactionGuidance: params.reactionGuidance, contextFiles: params.contextFiles, }); } function buildAutoReplyBody(params: { ctx: TemplateContext; body: string; eventLine?: string }) { return [params.eventLine, buildInboundUserContextPrefix(params.ctx), params.body] .filter(Boolean) .join("\n\n"); } function buildAutoReplyModelPrompt(params: { ctx: TemplateContext; body: string }): string { const inboundUserContext = buildInboundUserContextPrefix(params.ctx); const envelope = buildReplyPromptEnvelope({ ctx: params.ctx, sessionCtx: params.ctx, baseBody: params.body, hasUserBody: true, inboundUserContext, isBareSessionReset: false, startupAction: "new", }); return buildCurrentInboundPrompt({ context: envelope.currentInboundContext, prompt: envelope.queuedBody, }); } async function readContextFiles(workspaceDir: string, fileNames: string[]) { return Promise.all( fileNames.map(async (fileName) => ({ path: fileName, content: await fs.readFile(path.join(workspaceDir, fileName), "utf-8"), })), ); } function buildAutoReplySystemPrompt(params: { workspaceDir: string; sessionCtx: TemplateContext; includeGroupChatContext?: boolean; includeGroupIntro?: boolean; groupSystemPrompt?: string; }) { const extraSystemPromptParts = [ buildInboundMetaSystemPrompt(params.sessionCtx), params.sessionCtx.ChatType === "direct" || params.sessionCtx.ChatType === "dm" ? buildDirectChatContext({ sessionCtx: params.sessionCtx, }) : "", params.includeGroupChatContext ? buildGroupChatContext({ sessionCtx: params.sessionCtx, silentToken: SILENT_REPLY_TOKEN, silentReplyPolicy: "allow", }) : "", params.includeGroupIntro ? buildGroupIntro({ defaultActivation: "mention", }) : "", params.groupSystemPrompt?.trim() ?? "", ].filter(Boolean); return buildSystemPrompt({ workspaceDir: params.workspaceDir, extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined, silentReplyPromptMode: params.sessionCtx.ChatType === "direct" || params.sessionCtx.ChatType === "dm" || params.includeGroupChatContext ? "none" : "generic", }); } function buildToolRichSystemPrompt(params: { workspaceDir: string; skillsPrompt: string; contextFiles: Array<{ path: string; content: string }>; }) { const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildCommonSystemParams( params.workspaceDir, ); const tools = [ "bash", "read", "edit", "grep", "glob", "message", "memory_search", "memory_get", "web_search", "x_search", "web_fetch", ].map((name) => Object.assign({}, createStubTool(name), { description: `${name} tool` })); return buildEmbeddedSystemPrompt({ workspaceDir: params.workspaceDir, reasoningTagHint: false, runtimeInfo, tools, modelAliasLines: [], userTimezone, userTime, userTimeFormat, acpEnabled: true, skillsPrompt: params.skillsPrompt, reactionGuidance: { level: "extensive", channel: "Telegram" }, contextFiles: params.contextFiles, }); } function createDirectScenario(workspaceDir: string): PromptScenario { const baseCtx: TemplateContext = { Provider: "slack", Surface: "slack", OriginatingChannel: "slack", OriginatingTo: "D123", AccountId: "A1", ChatType: "dm", SenderId: "U1", SenderName: "Alice", Body: "hi", BodyStripped: "hi", }; return { scenario: "auto-reply-direct", focus: "Normal direct-chat turns with ids, reply context, think hint, and runtime event body injection", expectedStableSystemAfterTurnIds: ["t2", "t3", "t4"], turns: [ { id: "t1", label: "Direct turn with reply context", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: { ...baseCtx, MessageSid: "m1", ReplyToId: "r1", ReplyToBody: "prior message", WasMentioned: true, }, }), bodyPrompt: buildAutoReplyBody({ ctx: { ...baseCtx, MessageSid: "m1", ReplyToId: "r1", ReplyToBody: "prior message", WasMentioned: true, }, body: "Please summarize yesterday's decision.", }), notes: ["Direct chat baseline", "Per-message ids and reply context change in body only"], }, { id: "t2", label: "Direct turn with new message id", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: { ...baseCtx, MessageSid: "m2", ReplyToId: "r2", }, }), bodyPrompt: buildAutoReplyBody({ ctx: { ...baseCtx, MessageSid: "m2", ReplyToId: "r2", }, body: "Now open the read tool and inspect AGENTS.md.", }), notes: ["Steady-state direct turn", "No runtime event"], }, { id: "t3", label: "Direct turn with runtime event and think hint", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: { ...baseCtx, MessageSid: "m3", ReplyToId: "r3", }, }), bodyPrompt: buildAutoReplyBody({ ctx: { ...baseCtx, MessageSid: "m3", ReplyToId: "r3", }, eventLine: "System: [t] Model switched.", body: "low use tools if needed and tell me which file controls startup behavior", }), notes: ["Touches runtime event body path", "Touches think-hint parsing path"], }, { id: "t4", label: "Direct turn after runtime event", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: { ...baseCtx, MessageSid: "m4", ReplyToId: "r4", }, }), bodyPrompt: buildAutoReplyBody({ ctx: { ...baseCtx, MessageSid: "m4", ReplyToId: "r4", }, body: "Repeat the startup file path only.", }), notes: ["Checks steady-state after event turn"], }, ], }; } function createGroupScenario(workspaceDir: string): PromptScenario { const baseCtx: TemplateContext = { Provider: "slack", Surface: "slack", OriginatingChannel: "slack", OriginatingTo: "C123", AccountId: "A1", ChatType: "group", GroupSubject: "ops", GroupChannel: "#ops", GroupMembers: "Bob, Cara, Dan, Eve", SenderId: "U2", SenderName: "Bob", Body: "hi", BodyStripped: "hi", }; return { scenario: "auto-reply-group", focus: "Group chat bootstrap, steady state, and runtime event turns", expectedStableSystemAfterTurnIds: ["t2", "t3"], turns: [ { id: "t1", label: "First group turn with session-stable intro", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: { ...baseCtx, MessageSid: "g1", WasMentioned: true, InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }], }, includeGroupChatContext: true, includeGroupIntro: true, }), bodyPrompt: buildAutoReplyBody({ ctx: { ...baseCtx, MessageSid: "g1", WasMentioned: true, InboundHistory: [{ sender: "Cara", timestamp: 1, body: "status?" }], }, body: "Can you investigate this issue?", }), notes: ["Group intro belongs to the session-stable system prompt"], }, { id: "t2", label: "Steady-state group turn", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: { ...baseCtx, MessageSid: "g2", WasMentioned: false, InboundHistory: [ { sender: "Cara", timestamp: 1, body: "status?" }, { sender: "Dan", timestamp: 2, body: "please help" }, ], }, includeGroupChatContext: true, includeGroupIntro: true, }), bodyPrompt: buildAutoReplyBody({ ctx: { ...baseCtx, MessageSid: "g2", WasMentioned: false, InboundHistory: [ { sender: "Cara", timestamp: 1, body: "status?" }, { sender: "Dan", timestamp: 2, body: "please help" }, ], }, body: "Give a short update.", }), notes: ["Group intro remains stable after turn one"], }, { id: "t3", label: "Group turn with runtime event", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: { ...baseCtx, MessageSid: "g2", WasMentioned: false, InboundHistory: [ { sender: "Cara", timestamp: 1, body: "status?" }, { sender: "Dan", timestamp: 2, body: "please help" }, ], }, includeGroupChatContext: true, includeGroupIntro: true, }), bodyPrompt: buildAutoReplyBody({ ctx: { ...baseCtx, MessageSid: "g3", WasMentioned: true, InboundHistory: [ { sender: "Cara", timestamp: 1, body: "status?" }, { sender: "Dan", timestamp: 2, body: "please help" }, { sender: "Eve", timestamp: 3, body: "what changed?" }, ], }, eventLine: "System: [t] Node connected.", body: "Tell the room whether tools are available.", }), notes: ["Runtime event lands in body", "System prompt should stay stable vs t2"], }, ], }; } function createDiscordBoundaryScenario(workspaceDir: string): PromptScenario { const body = "Please summarize the deploy log."; const baseCtx: TemplateContext = { Provider: "discord", Surface: "discord", OriginatingChannel: "discord", OriginatingTo: "channel:987654321", AccountId: "A1", ChatType: "channel", GroupSubject: "#ops-bridge", GroupChannel: "#ops-bridge", GroupSpace: "guild-123", SenderId: "U3", SenderName: "Cael", MessageSid: "1503084621145964846", Body: body, BodyStripped: body, UntrustedStructuredContext: [ { label: "Discord channel metadata", source: "discord", type: "channel_metadata", payload: { topic: "Deploy coordination", }, }, ], }; return { scenario: "auto-reply-discord-boundary", focus: "Discord inbound body remains one user turn while supplemental context is structured metadata", expectedStableSystemAfterTurnIds: [], turns: [ { id: "t1", label: "Discord turn with channel metadata", systemPrompt: buildAutoReplySystemPrompt({ workspaceDir, sessionCtx: baseCtx, includeGroupChatContext: true, }), bodyPrompt: buildAutoReplyModelPrompt({ ctx: baseCtx, body, }), notes: [ "Inbound body should appear once in the model-bound prompt", "Channel metadata should not use raw EXTERNAL_UNTRUSTED_CONTENT wrappers", ], }, ], }; } async function createToolRichScenario(workspaceDir: string): Promise { const skillsPrompt = [ "", "checksRun checks before landing changes./skills/checks/SKILL.md", "releaseRelease OpenClaw safely./skills/release/SKILL.md", "", ].join("\n"); const contextFiles = await readContextFiles(workspaceDir, ["AGENTS.md", "TOOLS.md", "SOUL.md"]); const systemPrompt = buildToolRichSystemPrompt({ workspaceDir, skillsPrompt, contextFiles, }); return { scenario: "tool-rich-agent-run", focus: "Tool-enabled system prompt with skills, reactions, workspace bootstrap, and a follow-up after fictional tool calls", expectedStableSystemAfterTurnIds: ["t2"], turns: [ { id: "t1", label: "Tool-rich turn asking for search, read, and file edits", systemPrompt, bodyPrompt: [ "Conversation info (untrusted metadata):", "```json", JSON.stringify({ message_id: "tool-1", sender_id: "U9", was_mentioned: true }, null, 2), "```", "", "high Search the workspace, read AGENTS.md, inspect the failing test, and propose a patch.", ].join("\n"), notes: ["Touches tool list in system prompt", "Touches high-thinking hint in body"], }, { id: "t2", label: "Follow-up after a fictional tool call", systemPrompt, bodyPrompt: [ "Conversation info (untrusted metadata):", "```json", JSON.stringify({ message_id: "tool-2", sender_id: "U9" }, null, 2), "```", "", "Tool transcript summary (untrusted, for context):", "```json", JSON.stringify( [ { role: "assistant", action: "tool_use", name: "read", target: "AGENTS.md" }, { role: "tool", name: "read", result: "Loaded AGENTS.md" }, { role: "assistant", action: "tool_use", name: "grep", target: "failing test" }, { role: "tool", name: "grep", result: "Matched src/foo.ts:42" }, ], null, 2, ), "```", "", "Continue and explain the root cause.", ].join("\n"), notes: ["Simulates tool-call-heavy conversation", "System prompt should stay stable"], }, ], }; } async function createBootstrapWarningScenario(workspaceDir: string): Promise { const bootstrapConfig = { agents: { defaults: { bootstrapMaxChars: 1_500, bootstrapTotalMaxChars: 2_200, }, }, } satisfies OpenClawConfig; const largeAgents = "# AGENTS.md\n\n" + "Rules.\n".repeat(5_000); const largeTools = "# TOOLS.md\n\n" + "Notes.\n".repeat(3_000); await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: largeAgents }); await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: largeTools }); const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({ workspaceDir, config: bootstrapConfig, }); const analysis = analyzeBootstrapBudget({ files: buildBootstrapInjectionStats({ bootstrapFiles, injectedFiles: contextFiles, }), bootstrapMaxChars: bootstrapConfig.agents.defaults.bootstrapMaxChars, bootstrapTotalMaxChars: bootstrapConfig.agents.defaults.bootstrapTotalMaxChars, }); if (!analysis.hasTruncation) { throw new Error("bootstrap-warning scenario expected truncated bootstrap context"); } const warningFirst = buildBootstrapPromptWarning({ analysis, mode: "once", seenSignatures: [], }); const warningSeen = buildBootstrapPromptWarning({ analysis, mode: "once", seenSignatures: warningFirst.warningSignaturesSeen, previousSignature: warningFirst.signature, }); const warningAlways = buildBootstrapPromptWarning({ analysis, mode: "always", seenSignatures: warningFirst.warningSignaturesSeen, previousSignature: warningFirst.signature, }); return { scenario: "bootstrap-warning", focus: "Workspace bootstrap truncation warnings inside # Project Context", expectedStableSystemAfterTurnIds: ["t2", "t3"], turns: [ { id: "t1", label: "First warning emission", systemPrompt: buildSystemPrompt({ workspaceDir, contextFiles, }), bodyPrompt: appendBootstrapPromptWarning("hello", warningFirst.lines), notes: ["Warning is appended to the turn body", "System prompt should stay stable"], }, { id: "t2", label: "Same truncation signature after once-mode dedupe", systemPrompt: buildSystemPrompt({ workspaceDir, contextFiles, }), bodyPrompt: appendBootstrapPromptWarning("hello again", warningSeen.lines), notes: ["Once-mode removes warning lines", "Only the body tail changes now"], }, { id: "t3", label: "Always-mode warning", systemPrompt: buildSystemPrompt({ workspaceDir, contextFiles, }), bodyPrompt: appendBootstrapPromptWarning("one more turn", warningAlways.lines), notes: [ "Always-mode keeps warning in the body prompt tail", "System prompt remains stable", ], }, ], }; } async function createMaintenanceScenario(workspaceDir: string): Promise { await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: [ "## Session Startup", "Read AGENTS.md and MEMORY.md before responding.", "", "## Red Lines", "Do not delete production data.", "", "## Safety", "Never reveal secrets.", ].join("\n"), }); const memoryFlushPrompt = [ "Pre-compaction memory flush.", "Store durable memories only in memory/2026-03-15.md (create memory/ if needed).", "Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.", "If nothing to store, reply with NO_REPLY.", "Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles)", "Reference UTC: 2026-03-16 04:30 UTC", ].join("\n"); const memoryFlushSystemPrompt = buildSystemPrompt({ workspaceDir, extraSystemPrompt: [ "Pre-compaction memory flush turn.", "The session is near auto-compaction; capture durable memories to disk.", "Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).", "You may reply, but usually NO_REPLY is correct.", ].join(" "), }); const postCompaction = [ "[Post-compaction context refresh]", "", "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence.", "", "Critical rules from AGENTS.md:", "", "## Session Startup", "Read AGENTS.md and MEMORY.md before responding.", "", "## Red Lines", "Do not delete production data.", "", "Current time: Sunday, March 15th, 2026 - 9:30 PM (America/Los_Angeles)", "Reference UTC: 2026-03-16 04:30 UTC", ].join("\n"); const postCompactionSystemPrompt = buildSystemPrompt({ workspaceDir, extraSystemPrompt: buildInboundMetaSystemPrompt({ Provider: "slack", Surface: "slack", OriginatingChannel: "slack", OriginatingTo: "D123", AccountId: "A1", ChatType: "direct", }), }); return { scenario: "maintenance-prompts", focus: "Memory flush and post-compaction maintenance prompts", expectedStableSystemAfterTurnIds: [], turns: [ { id: "t1", label: "Pre-compaction memory flush run", systemPrompt: memoryFlushSystemPrompt, bodyPrompt: memoryFlushPrompt, notes: [ "Writes to memory/2026-03-15.md", "Separate maintenance run; expected to differ from normal user turns", ], }, { id: "t2", label: "Post-compaction refresh context run", systemPrompt: postCompactionSystemPrompt, bodyPrompt: postCompaction, notes: [ "Separate maintenance context payload", "Expected to differ from normal user turns", ], }, ], }; } /** Create a temp workspace with prompt composition context files. */ export async function createWorkspaceWithPromptCompositionFiles(): Promise { const workspaceDir = await makeTempWorkspace("openclaw-prompt-cache-"); await writeWorkspaceFile({ dir: workspaceDir, name: "AGENTS.md", content: [ "# AGENTS.md", "", "## Session Startup", "Read AGENTS.md and TOOLS.md before making changes.", "", "## Red Lines", "Do not rewrite user commits.", ].join("\n"), }); await writeWorkspaceFile({ dir: workspaceDir, name: "TOOLS.md", content: "# TOOLS.md\n\nUse rg before grep.\n", }); await writeWorkspaceFile({ dir: workspaceDir, name: "SOUL.md", content: "# SOUL.md\n\nBe concise but kind.\n", }); return workspaceDir; } /** Create all prompt composition scenarios plus cleanup handles. */ export async function createPromptCompositionScenarios(): Promise<{ workspaceDir: string; warningWorkspaceDir: string; scenarios: PromptScenario[]; cleanup: () => Promise; }> { const workspaceDir = await createWorkspaceWithPromptCompositionFiles(); const warningWorkspaceDir = await makeTempWorkspace("openclaw-prompt-cache-warning-"); const scenarios = [ createDirectScenario(workspaceDir), createGroupScenario(workspaceDir), createDiscordBoundaryScenario(workspaceDir), await createToolRichScenario(workspaceDir), await createBootstrapWarningScenario(warningWorkspaceDir), await createMaintenanceScenario(workspaceDir), ]; return { workspaceDir, warningWorkspaceDir, scenarios, cleanup: async () => { await fs.rm(workspaceDir, { recursive: true, force: true }); await fs.rm(warningWorkspaceDir, { recursive: true, force: true }); }, }; }