From 72c8a0ab23e0ae1a2e6c50f8e094772b1bbc9e9b Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 16 Feb 2026 17:29:52 -0800 Subject: [PATCH] fix: reconcile memfs/standard prompt sections safely (#985) --- src/agent/create.ts | 18 +- src/agent/memoryFilesystem.ts | 49 +++-- src/agent/memoryPrompt.ts | 201 ++++++++++++++++++ src/agent/modify.ts | 52 ++--- src/cli/App.tsx | 9 +- src/headless.ts | 15 +- src/index.ts | 19 +- .../agent/memoryPrompt.integration.test.ts | 98 +++++++++ src/tests/agent/memoryPrompt.test.ts | 70 ++++++ 9 files changed, 464 insertions(+), 67 deletions(-) create mode 100644 src/agent/memoryPrompt.ts create mode 100644 src/tests/agent/memoryPrompt.integration.test.ts create mode 100644 src/tests/agent/memoryPrompt.test.ts diff --git a/src/agent/create.ts b/src/agent/create.ts index cfe5ec1..f77cb09 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -10,6 +10,7 @@ import { DEFAULT_AGENT_NAME } from "../constants"; import { getModelContextWindow } from "./available-models"; import { getClient } from "./client"; import { getDefaultMemoryBlocks } from "./memory"; +import { type MemoryPromptMode, reconcileMemoryPrompt } from "./memoryPrompt"; import { formatAvailableModels, getDefaultModel, @@ -17,10 +18,7 @@ import { resolveModel, } from "./model"; import { updateAgentLLMConfig } from "./modify"; -import { - resolveSystemPrompt, - SYSTEM_PROMPT_MEMORY_ADDON, -} from "./promptAssets"; +import { resolveSystemPrompt } from "./promptAssets"; import { SLEEPTIME_MEMORY_PERSONA } from "./prompts/sleeptime"; /** @@ -63,6 +61,8 @@ export interface CreateAgentOptions { systemPromptCustom?: string; /** Additional text to append to the resolved system prompt */ systemPromptAppend?: string; + /** Which managed memory prompt mode to apply */ + memoryPromptMode?: MemoryPromptMode; /** Block labels to initialize (from default blocks) */ initBlocks?: string[]; /** Base tools to include */ @@ -272,7 +272,8 @@ export async function createAgent( // Resolve system prompt content: // 1. If systemPromptCustom is provided, use it as-is // 2. Otherwise, resolve systemPromptPreset to content - // 3. If systemPromptAppend is provided, append it to the result + // 3. Reconcile to the selected managed memory mode + // 4. If systemPromptAppend is provided, append it to the result let systemPromptContent: string; if (options.systemPromptCustom) { systemPromptContent = options.systemPromptCustom; @@ -280,9 +281,10 @@ export async function createAgent( systemPromptContent = await resolveSystemPrompt(options.systemPromptPreset); } - // Append the non-memfs memory section by default. - // If memfs is enabled, updateAgentSystemPromptMemfs() will swap it for the memfs version. - systemPromptContent = `${systemPromptContent}\n${SYSTEM_PROMPT_MEMORY_ADDON}`; + systemPromptContent = reconcileMemoryPrompt( + systemPromptContent, + options.memoryPromptMode ?? "standard", + ); // Append additional instructions if provided if (options.systemPromptAppend) { diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts index 95e90a2..e44755e 100644 --- a/src/agent/memoryFilesystem.ts +++ b/src/agent/memoryFilesystem.ts @@ -149,11 +149,11 @@ export interface ApplyMemfsFlagsResult { * Shared between interactive (index.ts), headless (headless.ts), and * the /memfs enable command (App.tsx) to avoid duplicating the setup logic. * - * Steps when enabling: - * 1. Validate Letta Cloud requirement - * 2. Persist memfs setting - * 3. Detach old API-based memory tools - * 4. Update system prompt to include memfs section + * Steps when toggling: + * 1. Validate Letta Cloud requirement (for explicit enable) + * 2. Reconcile system prompt to the target memory mode + * 3. Persist memfs setting locally + * 4. Detach old API-based memory tools (when enabling) * 5. Add git-memory-enabled tag + clone/pull repo * * @throws {Error} if Letta Cloud validation fails or git setup fails @@ -167,7 +167,7 @@ export async function applyMemfsFlags( const { getServerUrl } = await import("./client"); const { settingsManager } = await import("../settings-manager"); - // 1. Validate + persist setting + // 1. Validate explicit enable on supported backend. if (memfsFlag) { const serverUrl = getServerUrl(); if (!serverUrl.includes("api.letta.com")) { @@ -175,26 +175,39 @@ export async function applyMemfsFlags( "--memfs is only available on Letta Cloud (api.letta.com).", ); } - settingsManager.setMemfsEnabled(agentId, true); - } else if (noMemfsFlag) { - settingsManager.setMemfsEnabled(agentId, false); } - const isEnabled = settingsManager.isMemfsEnabled(agentId); + const hasExplicitToggle = Boolean(memfsFlag || noMemfsFlag); + const targetEnabled = memfsFlag + ? true + : noMemfsFlag + ? false + : settingsManager.isMemfsEnabled(agentId); - // 2. Detach old API-based memory tools when enabling + // 2. Reconcile system prompt first, then persist local memfs setting. + if (hasExplicitToggle) { + const { updateAgentSystemPromptMemfs } = await import("./modify"); + const promptUpdate = await updateAgentSystemPromptMemfs( + agentId, + targetEnabled, + ); + if (!promptUpdate.success) { + throw new Error(promptUpdate.message); + } + settingsManager.setMemfsEnabled(agentId, targetEnabled); + } + + const isEnabled = hasExplicitToggle + ? targetEnabled + : settingsManager.isMemfsEnabled(agentId); + + // 3. Detach old API-based memory tools when explicitly enabling. if (isEnabled && memfsFlag) { const { detachMemoryTools } = await import("../tools/toolset"); await detachMemoryTools(agentId); } - // 3. Update system prompt to include/exclude memfs section - if (memfsFlag || noMemfsFlag) { - const { updateAgentSystemPromptMemfs } = await import("./modify"); - await updateAgentSystemPromptMemfs(agentId, isEnabled); - } - - // 4. Add git tag + clone/pull repo + // 4. Add git tag + clone/pull repo. let pullSummary: string | undefined; if (isEnabled) { const { addGitMemoryTag, isGitRepo, cloneMemoryRepo, pullMemory } = diff --git a/src/agent/memoryPrompt.ts b/src/agent/memoryPrompt.ts new file mode 100644 index 0000000..566a55a --- /dev/null +++ b/src/agent/memoryPrompt.ts @@ -0,0 +1,201 @@ +import { + SYSTEM_PROMPT_MEMFS_ADDON, + SYSTEM_PROMPT_MEMORY_ADDON, +} from "./promptAssets"; + +export type MemoryPromptMode = "standard" | "memfs"; + +export interface MemoryPromptDrift { + code: + | "legacy_memory_language_with_memfs" + | "memfs_language_with_standard_mode" + | "orphan_memfs_fragment"; + message: string; +} + +interface Heading { + level: number; + title: string; + startOffset: number; +} + +function normalizeNewlines(text: string): string { + return text.replace(/\r\n/g, "\n"); +} + +function scanHeadingsOutsideFences(text: string): Heading[] { + const lines = text.split("\n"); + const headings: Heading[] = []; + let inFence = false; + let fenceToken = ""; + let offset = 0; + + for (const line of lines) { + const trimmed = line.trimStart(); + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + if (fenceMatch) { + const token = fenceMatch[1] ?? fenceMatch[0] ?? ""; + const tokenChar = token.startsWith("`") ? "`" : "~"; + if (!inFence) { + inFence = true; + fenceToken = tokenChar; + } else if (fenceToken === tokenChar) { + inFence = false; + fenceToken = ""; + } + } + + if (!inFence) { + const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/); + if (headingMatch) { + const hashes = headingMatch[1] ?? ""; + const rawTitle = headingMatch[2] ?? ""; + if (hashes && rawTitle) { + const level = hashes.length; + const title = rawTitle.replace(/\s+#*$/, "").trim(); + headings.push({ + level, + title, + startOffset: offset, + }); + } + } + } + + offset += line.length + 1; + } + + return headings; +} + +function stripHeadingSections( + text: string, + shouldStrip: (heading: Heading) => boolean, +): string { + let current = text; + while (true) { + const headings = scanHeadingsOutsideFences(current); + const target = headings.find(shouldStrip); + if (!target) { + return current; + } + + const nextHeading = headings.find( + (heading) => + heading.startOffset > target.startOffset && + heading.level <= target.level, + ); + const end = nextHeading ? nextHeading.startOffset : current.length; + current = `${current.slice(0, target.startOffset)}${current.slice(end)}`; + } +} + +function getMemfsTailFragment(): string { + const tailAnchor = "# See what changed"; + const start = SYSTEM_PROMPT_MEMFS_ADDON.indexOf(tailAnchor); + if (start === -1) return ""; + return SYSTEM_PROMPT_MEMFS_ADDON.slice(start).trim(); +} + +function stripExactAddon(text: string, addon: string): string { + const trimmedAddon = addon.trim(); + if (!trimmedAddon) return text; + let current = text; + while (current.includes(trimmedAddon)) { + current = current.replace(trimmedAddon, ""); + } + return current; +} + +function stripOrphanMemfsTail(text: string): string { + const tail = getMemfsTailFragment(); + if (!tail) return text; + let current = text; + while (current.includes(tail)) { + current = current.replace(tail, ""); + } + return current; +} + +function compactBlankLines(text: string): string { + return text.replace(/\n{3,}/g, "\n\n").trimEnd(); +} + +export function stripManagedMemorySections(systemPrompt: string): string { + let current = normalizeNewlines(systemPrompt); + + // Strip exact current addons first (fast path). + current = stripExactAddon(current, SYSTEM_PROMPT_MEMORY_ADDON); + current = stripExactAddon(current, SYSTEM_PROMPT_MEMFS_ADDON); + + // Strip known orphan fragment produced by the old regex bug. + current = stripOrphanMemfsTail(current); + + // Strip legacy/variant memory sections by markdown heading parsing. + current = stripHeadingSections( + current, + (heading) => heading.title === "Memory", + ); + current = stripHeadingSections(current, (heading) => + heading.title.startsWith("Memory Filesystem"), + ); + + return compactBlankLines(current); +} + +export function reconcileMemoryPrompt( + systemPrompt: string, + mode: MemoryPromptMode, +): string { + const base = stripManagedMemorySections(systemPrompt).trimEnd(); + const addon = + mode === "memfs" + ? SYSTEM_PROMPT_MEMFS_ADDON.trimStart() + : SYSTEM_PROMPT_MEMORY_ADDON.trimStart(); + return `${base}\n\n${addon}`.trim(); +} + +export function detectMemoryPromptDrift( + systemPrompt: string, + expectedMode: MemoryPromptMode, +): MemoryPromptDrift[] { + const prompt = normalizeNewlines(systemPrompt); + const drifts: MemoryPromptDrift[] = []; + + const hasLegacyMemoryLanguage = prompt.includes( + "Your memory consists of core memory (composed of memory blocks)", + ); + const hasMemfsLanguage = + prompt.includes("## Memory Filesystem") || + prompt.includes("Your memory is stored in a git repository at"); + const hasOrphanFragment = + prompt.includes("# See what changed") && + prompt.includes("git add system/") && + prompt.includes('git commit -m ": "'); + + if (expectedMode === "memfs" && hasLegacyMemoryLanguage) { + drifts.push({ + code: "legacy_memory_language_with_memfs", + message: + "System prompt contains legacy memory-block language while memfs is enabled.", + }); + } + + if (expectedMode === "standard" && hasMemfsLanguage) { + drifts.push({ + code: "memfs_language_with_standard_mode", + message: + "System prompt contains Memory Filesystem language while memfs is disabled.", + }); + } + + if (hasOrphanFragment && !hasMemfsLanguage) { + drifts.push({ + code: "orphan_memfs_fragment", + message: + "System prompt contains orphaned memfs sync fragment without a full memfs section.", + }); + } + + return drifts; +} diff --git a/src/agent/modify.ts b/src/agent/modify.ts index b5b32f6..f094245 100644 --- a/src/agent/modify.ts +++ b/src/agent/modify.ts @@ -245,13 +245,25 @@ export async function updateAgentSystemPrompt( systemPromptId: string, ): Promise { try { - const { resolveSystemPrompt, SYSTEM_PROMPT_MEMORY_ADDON } = await import( - "./promptAssets" + const { resolveSystemPrompt } = await import("./promptAssets"); + const { detectMemoryPromptDrift, reconcileMemoryPrompt } = await import( + "./memoryPrompt" ); + const { settingsManager } = await import("../settings-manager"); + + const client = await getClient(); + const currentAgent = await client.agents.retrieve(agentId); const baseContent = await resolveSystemPrompt(systemPromptId); - // Append the non-memfs memory section by default. - // If memfs is enabled, the caller should follow up with updateAgentSystemPromptMemfs(). - const systemPromptContent = `${baseContent}\n${SYSTEM_PROMPT_MEMORY_ADDON}`; + + const settingIndicatesMemfs = settingsManager.isMemfsEnabled(agentId); + const promptIndicatesMemfs = detectMemoryPromptDrift( + currentAgent.system || "", + "standard", + ).some((drift) => drift.code === "memfs_language_with_standard_mode"); + + const memoryMode = + settingIndicatesMemfs || promptIndicatesMemfs ? "memfs" : "standard"; + const systemPromptContent = reconcileMemoryPrompt(baseContent, memoryMode); const updateResult = await updateAgentSystemPromptRaw( agentId, @@ -266,7 +278,6 @@ export async function updateAgentSystemPrompt( } // Re-fetch agent to get updated state - const client = await getClient(); const agent = await client.agents.retrieve(agentId); return { @@ -284,10 +295,10 @@ export async function updateAgentSystemPrompt( } /** - * Updates an agent's system prompt to swap between the memfs and non-memfs memory sections. + * Updates an agent's system prompt to swap between managed memory modes. * - * When enabling memfs: strips any existing # Memory section, appends the memfs memory addon. - * When disabling memfs: strips any existing # Memory section, appends the non-memfs memory addon. + * Uses the shared memory prompt reconciler so we safely replace managed memory + * sections without corrupting fenced code blocks or leaving orphan fragments. * * @param agentId - The agent ID to update * @param enableMemfs - Whether to enable (add) or disable (remove) the memfs addon @@ -300,26 +311,15 @@ export async function updateAgentSystemPromptMemfs( try { const client = await getClient(); const agent = await client.agents.retrieve(agentId); - let currentSystemPrompt = agent.system || ""; + const { reconcileMemoryPrompt } = await import("./memoryPrompt"); - const { SYSTEM_PROMPT_MEMFS_ADDON, SYSTEM_PROMPT_MEMORY_ADDON } = - await import("./promptAssets"); - - // Strip any existing memory section (covers both old inline "# Memory" / "## Memory" - // sections and the new addon format including "## Memory Filesystem" subsections). - // Matches from "# Memory" or "## Memory" to the next top-level heading or end of string. - const memoryHeaderRegex = - /\n#{1,2} Memory\b[\s\S]*?(?=\n#{1,2} (?!Memory|Filesystem|Structure|How It Works|Syncing|History)[^\n]|$)/; - currentSystemPrompt = currentSystemPrompt.replace(memoryHeaderRegex, ""); - - // Append the appropriate memory section - const addon = enableMemfs - ? SYSTEM_PROMPT_MEMFS_ADDON - : SYSTEM_PROMPT_MEMORY_ADDON; - currentSystemPrompt = `${currentSystemPrompt}\n${addon}`; + const nextSystemPrompt = reconcileMemoryPrompt( + agent.system || "", + enableMemfs ? "memfs" : "standard", + ); await client.agents.update(agentId, { - system: currentSystemPrompt, + system: nextSystemPrompt, }); return { diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 4c71464..4aca584 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -9732,13 +9732,8 @@ ${SYSTEM_REMINDER_CLOSE} phase: "running", }); - const { updateAgentSystemPromptRaw } = await import( - "../agent/modify" - ); - const result = await updateAgentSystemPromptRaw( - agentId, - prompt.content, - ); + const { updateAgentSystemPrompt } = await import("../agent/modify"); + const result = await updateAgentSystemPrompt(agentId, promptId); if (result.success) { setCurrentSystemPromptId(promptId); diff --git a/src/headless.ts b/src/headless.ts index 4e19c82..6d6ee04 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -266,6 +266,12 @@ export async function handleHeadlessCommand( const baseToolsRaw = values["base-tools"] as string | undefined; const memfsFlag = values.memfs as boolean | undefined; const noMemfsFlag = values["no-memfs"] as boolean | undefined; + const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag + ? "memfs" + : noMemfsFlag + ? "standard" + : undefined; + const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag; const fromAfFile = values["from-af"] as string | undefined; const preLoadSkillsRaw = values["pre-load-skills"] as string | undefined; const maxTurnsRaw = values["max-turns"] as string | undefined; @@ -593,6 +599,7 @@ export async function handleHeadlessCommand( systemPromptPreset, systemPromptCustom: systemCustom, systemPromptAppend: systemAppend, + memoryPromptMode: requestedMemoryPromptMode, initBlocks, baseTools, memoryBlocks, @@ -602,9 +609,11 @@ export async function handleHeadlessCommand( const result = await createAgent(createOptions); agent = result.agent; - // Enable memfs by default on Letta Cloud for new agents - const { enableMemfsIfCloud } = await import("./agent/memoryFilesystem"); - await enableMemfsIfCloud(agent.id); + // Enable memfs by default on Letta Cloud for new agents when no explicit memfs flags are provided. + if (shouldAutoEnableMemfsForNewAgent) { + const { enableMemfsIfCloud } = await import("./agent/memoryFilesystem"); + await enableMemfsIfCloud(agent.id); + } } // Priority 4: Try to resume from project settings (.letta/settings.local.json) diff --git a/src/index.ts b/src/index.ts index 1c36889..af4c1e5 100755 --- a/src/index.ts +++ b/src/index.ts @@ -552,6 +552,12 @@ async function main(): Promise { const skillsDirectory = (values.skills as string | undefined) ?? undefined; const memfsFlag = values.memfs as boolean | undefined; const noMemfsFlag = values["no-memfs"] as boolean | undefined; + const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag + ? "memfs" + : noMemfsFlag + ? "standard" + : undefined; + const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag; const noSkillsFlag = values["no-skills"] as boolean | undefined; const fromAfFile = (values.import as string | undefined) ?? @@ -1630,17 +1636,20 @@ async function main(): Promise { skillsDirectory, parallelToolCalls: true, systemPromptPreset, + memoryPromptMode: requestedMemoryPromptMode, initBlocks, baseTools, }); agent = result.agent; setAgentProvenance(result.provenance); - // Enable memfs by default on Letta Cloud for new agents - const { enableMemfsIfCloud } = await import( - "./agent/memoryFilesystem" - ); - await enableMemfsIfCloud(agent.id); + // Enable memfs by default on Letta Cloud for new agents when no explicit memfs flags are provided. + if (shouldAutoEnableMemfsForNewAgent) { + const { enableMemfsIfCloud } = await import( + "./agent/memoryFilesystem" + ); + await enableMemfsIfCloud(agent.id); + } } // Priority 4: Try to resume from project settings LRU (.letta/settings.local.json) diff --git a/src/tests/agent/memoryPrompt.integration.test.ts b/src/tests/agent/memoryPrompt.integration.test.ts new file mode 100644 index 0000000..ad44a85 --- /dev/null +++ b/src/tests/agent/memoryPrompt.integration.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { getClient } from "../../agent/client"; +import { createAgent } from "../../agent/create"; +import { updateAgentSystemPromptMemfs } from "../../agent/modify"; +import { + SYSTEM_PROMPT_MEMFS_ADDON, + SYSTEM_PROMPT_MEMORY_ADDON, +} from "../../agent/promptAssets"; + +const describeIntegration = process.env.LETTA_API_KEY + ? describe + : describe.skip; + +function expectedPrompt(base: string, addon: string): string { + return `${base.trimEnd()}\n\n${addon.trimStart()}`.trim(); +} + +describeIntegration("memory prompt integration", () => { + const createdAgentIds: string[] = []; + + beforeAll(() => { + // Avoid polluting user's normal local LRU state in integration runs. + process.env.LETTA_CODE_AGENT_ROLE = "subagent"; + }); + + afterAll(async () => { + const client = await getClient(); + for (const agentId of createdAgentIds) { + try { + await client.agents.delete(agentId); + } catch { + // Best-effort cleanup. + } + } + }); + + test( + "new agent prompt is exact for memfs enabled and disabled modes", + async () => { + const base = [ + "You are a test agent.", + "Follow user instructions precisely.", + ].join("\n"); + + const created = await createAgent({ + name: `prompt-memfs-${Date.now()}`, + systemPromptCustom: base, + memoryPromptMode: "memfs", + }); + createdAgentIds.push(created.agent.id); + + const client = await getClient(); + + const expectedMemfs = expectedPrompt(base, SYSTEM_PROMPT_MEMFS_ADDON); + let fetched = await client.agents.retrieve(created.agent.id); + expect(fetched.system).toBe(expectedMemfs); + expect((fetched.system.match(/## Memory Filesystem/g) || []).length).toBe( + 1, + ); + expect((fetched.system.match(/# See what changed/g) || []).length).toBe( + 1, + ); + + const enableAgain = await updateAgentSystemPromptMemfs( + created.agent.id, + true, + ); + expect(enableAgain.success).toBe(true); + fetched = await client.agents.retrieve(created.agent.id); + expect(fetched.system).toBe(expectedMemfs); + + const disable = await updateAgentSystemPromptMemfs( + created.agent.id, + false, + ); + expect(disable.success).toBe(true); + const expectedStandard = expectedPrompt(base, SYSTEM_PROMPT_MEMORY_ADDON); + fetched = await client.agents.retrieve(created.agent.id); + expect(fetched.system).toBe(expectedStandard); + expect(fetched.system).not.toContain("## Memory Filesystem"); + expect(fetched.system).toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + + const reEnable = await updateAgentSystemPromptMemfs( + created.agent.id, + true, + ); + expect(reEnable.success).toBe(true); + fetched = await client.agents.retrieve(created.agent.id); + expect(fetched.system).toBe(expectedMemfs); + expect((fetched.system.match(/# See what changed/g) || []).length).toBe( + 1, + ); + }, + { timeout: 120000 }, + ); +}); diff --git a/src/tests/agent/memoryPrompt.test.ts b/src/tests/agent/memoryPrompt.test.ts new file mode 100644 index 0000000..955d31c --- /dev/null +++ b/src/tests/agent/memoryPrompt.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; + +import { + detectMemoryPromptDrift, + reconcileMemoryPrompt, +} from "../../agent/memoryPrompt"; +import { + SYSTEM_PROMPT_MEMFS_ADDON, + SYSTEM_PROMPT_MEMORY_ADDON, +} from "../../agent/promptAssets"; + +function countOccurrences(haystack: string, needle: string): number { + if (!needle) return 0; + return haystack.split(needle).length - 1; +} + +describe("memoryPrompt reconciler", () => { + test("replaces existing standard memory section with memfs section", () => { + const base = "You are a test agent."; + const standard = `${base}\n\n${SYSTEM_PROMPT_MEMORY_ADDON.trimStart()}`; + + const reconciled = reconcileMemoryPrompt(standard, "memfs"); + + expect(reconciled).toContain("## Memory Filesystem"); + expect(reconciled).not.toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + expect(countOccurrences(reconciled, "## Memory Filesystem")).toBe(1); + }); + + test("does not leave orphan memfs sync fragment when switching from memfs to standard", () => { + const base = "You are a test agent."; + const memfs = `${base}\n\n${SYSTEM_PROMPT_MEMFS_ADDON.trimStart()}`; + + const reconciled = reconcileMemoryPrompt(memfs, "standard"); + + expect(reconciled).toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + expect(reconciled).not.toContain("## Memory Filesystem"); + expect(reconciled).not.toContain("# See what changed"); + expect(reconciled).not.toContain('git commit -m ": "'); + }); + + test("cleans orphan memfs tail fragment before rebuilding target mode", () => { + const tailStart = SYSTEM_PROMPT_MEMFS_ADDON.indexOf("# See what changed"); + expect(tailStart).toBeGreaterThanOrEqual(0); + const orphanTail = SYSTEM_PROMPT_MEMFS_ADDON.slice(tailStart).trim(); + + const drifted = `Header text\n\n${orphanTail}`; + const drifts = detectMemoryPromptDrift(drifted, "standard"); + expect(drifts.some((d) => d.code === "orphan_memfs_fragment")).toBe(true); + + const reconciled = reconcileMemoryPrompt(drifted, "standard"); + expect(reconciled).toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + expect(reconciled).not.toContain("# See what changed"); + }); + + test("memfs reconciliation is idempotent and keeps single syncing section", () => { + const base = "You are a test agent."; + const once = reconcileMemoryPrompt(base, "memfs"); + const twice = reconcileMemoryPrompt(once, "memfs"); + + expect(twice).toBe(once); + expect(countOccurrences(twice, "## Syncing")).toBe(1); + expect(countOccurrences(twice, "# See what changed")).toBe(1); + }); +});