From b322a46a4311d214a1eef838fe9422f5a7f242ea Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:44:59 -0800 Subject: [PATCH] refactor: Unify reminder management across interactive and headless modes (#1001) Co-authored-by: Letta Co-authored-by: cpacker --- src/cli/App.tsx | 210 ++---------- src/headless.ts | 273 +++++++-------- src/reminders/catalog.ts | 69 ++++ src/reminders/engine.ts | 320 ++++++++++++++++++ src/reminders/state.ts | 44 +++ .../bootstrap-reminders-reset-wiring.test.ts | 11 +- .../cli/reflection-auto-launch-wiring.test.ts | 24 +- src/tests/headless/reminder-wiring.test.ts | 49 +++ src/tests/headless/skills-reminder.test.ts | 72 +--- src/tests/reminders/catalog.test.ts | 34 ++ src/tests/reminders/engine-parity.test.ts | 72 ++++ src/tests/reminders/permission-mode.test.ts | 70 ++++ src/tests/reminders/skills-recovery.test.ts | 54 +++ 13 files changed, 907 insertions(+), 395 deletions(-) create mode 100644 src/reminders/catalog.ts create mode 100644 src/reminders/engine.ts create mode 100644 src/reminders/state.ts create mode 100644 src/tests/headless/reminder-wiring.test.ts create mode 100644 src/tests/reminders/catalog.test.ts create mode 100644 src/tests/reminders/engine-parity.test.ts create mode 100644 src/tests/reminders/permission-mode.test.ts create mode 100644 src/tests/reminders/skills-recovery.test.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index e4621cd..93026da 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -82,6 +82,12 @@ import { type RalphState, ralphMode, } from "../ralph/mode"; +import { buildSharedReminderParts } from "../reminders/engine"; +import { + createSharedReminderState, + resetSharedReminderState, + syncReminderStateFromContextTracker, +} from "../reminders/state"; import { updateProjectSettings } from "../settings"; import { settingsManager } from "../settings-manager"; import { telemetry } from "../telemetry"; @@ -191,13 +197,10 @@ import { import { formatCompact } from "./helpers/format"; import { parsePatchOperations } from "./helpers/formatArgsDisplay"; import { - buildCompactionMemoryReminder, - buildMemoryReminder, getReflectionSettings, parseMemoryPreference, type ReflectionSettings, reflectionSettingsToLegacyMode, - shouldFireStepCountTrigger, } from "./helpers/memoryReminder"; import { type QueuedMessage, @@ -1503,33 +1506,16 @@ export default function App({ // Show exit stats on exit (double Ctrl+C) const [showExitStats, setShowExitStats] = useState(false); - // Track if we've sent the session context for this CLI session - const hasSentSessionContextRef = useRef(false); + const sharedReminderStateRef = useRef(createSharedReminderState()); // Track if we've set the conversation summary for this new conversation // Initialized to true for resumed conversations (they already have context) const hasSetConversationSummaryRef = useRef(resumedExistingConversation); // Store first user query for conversation summary const firstUserQueryRef = useRef(null); - - // Track skills injection state (LET-7353) - const discoveredSkillsRef = useRef( - null, - ); - const hasInjectedSkillsRef = useRef(false); - const resetBootstrapReminderState = useCallback(() => { - hasSentSessionContextRef.current = false; - hasInjectedSkillsRef.current = false; - discoveredSkillsRef.current = null; + resetSharedReminderState(sharedReminderStateRef.current); }, []); - - // Track conversation turn count for periodic memory reminders - const turnCountRef = useRef(0); - - // Track last notified permission mode to detect changes - const lastNotifiedModeRef = useRef("default"); - // Static items (things that are done rendering and can be frozen) const [staticItems, setStaticItems] = useState([]); @@ -2778,8 +2764,10 @@ export default function App({ // Git-backed memory: check status periodically (fire-and-forget). // Runs every N turns to detect uncommitted changes or unpushed commits. const isIntervalTurn = - turnCountRef.current > 0 && - turnCountRef.current % MEMFS_CONFLICT_CHECK_INTERVAL === 0; + sharedReminderStateRef.current.turnCount > 0 && + sharedReminderStateRef.current.turnCount % + MEMFS_CONFLICT_CHECK_INTERVAL === + 0; if (isIntervalTurn && !memfsGitCheckInFlightRef.current) { memfsGitCheckInFlightRef.current = true; @@ -5150,9 +5138,6 @@ export default function App({ setStaticRenderEpoch((e) => e + 1); resetTrajectoryBases(); - // Reset turn counter for memory reminders when switching agents - turnCountRef.current = 0; - // Update agent state - also update ref immediately for any code that runs before re-render agentIdRef.current = targetAgentId; setAgentId(targetAgentId); @@ -5291,9 +5276,6 @@ export default function App({ setStaticRenderEpoch((e) => e + 1); resetTrajectoryBases(); - // Reset turn counter for memory reminders - turnCountRef.current = 0; - // Update agent state agentIdRef.current = agent.id; setAgentId(agent.id); @@ -6622,9 +6604,6 @@ export default function App({ // Ensure bootstrap reminders are re-injected for the new conversation. resetBootstrapReminderState(); - // Reset turn counter for memory reminders - turnCountRef.current = 0; - // Re-run SessionStart hooks for new conversation sessionHooksRanRef.current = false; runSessionStartHooks( @@ -6703,9 +6682,6 @@ export default function App({ // Ensure bootstrap reminders are re-injected for the new conversation. resetBootstrapReminderState(); - // Reset turn counter for memory reminders - turnCountRef.current = 0; - // Re-run SessionStart hooks for new conversation sessionHooksRanRef.current = false; runSessionStartHooks( @@ -8088,9 +8064,6 @@ ${SYSTEM_REMINDER_CLOSE}`; const contentParts = overrideContentParts ?? buildMessageContentFromDisplay(msg); - // Prepend plan mode reminder if in plan mode - const planModeReminder = getPlanModeReminder(); - // Prepend ralph mode reminder if in ralph mode let ralphModeReminder = ""; if (ralphMode.getState().isActive) { @@ -8106,30 +8079,6 @@ ${SYSTEM_REMINDER_CLOSE}`; } } - // Prepend session context on first message of CLI session (if enabled) - let sessionContextReminder = ""; - const sessionContextEnabled = settingsManager.getSetting( - "sessionContextEnabled", - ); - if ( - !hasSentSessionContextRef.current && - sessionContextEnabled && - sessionContextReminderEnabled - ) { - const { buildSessionContext } = await import( - "./helpers/sessionContext" - ); - sessionContextReminder = buildSessionContext({ - agentInfo: { - id: agentId, - name: agentName, - description: agentDescription, - lastRunAt: agentLastRunAt, - }, - }); - hasSentSessionContextRef.current = true; - } - // Inject SessionStart hook feedback (stdout on exit 2) into first message only let sessionStartHookFeedback = ""; if (sessionStartFeedbackRef.current.length > 0) { @@ -8155,24 +8104,6 @@ ${SYSTEM_REMINDER_CLOSE} const reflectionSettings = getReflectionSettings(); const memfsEnabledForAgent = settingsManager.isMemfsEnabled(agentId); - const shouldFireStepTrigger = shouldFireStepCountTrigger( - turnCountRef.current, - reflectionSettings, - ); - let memoryReminderContent = ""; - if ( - shouldFireStepTrigger && - (reflectionSettings.behavior === "reminder" || !memfsEnabledForAgent) - ) { - // Step-count reminder mode (or non-memfs fallback) - memoryReminderContent = await buildMemoryReminder( - turnCountRef.current, - agentId, - ); - } - - // Increment turn count for next iteration - turnCountRef.current += 1; // Build git memory sync reminder if uncommitted changes or unpushed commits let memoryGitReminder = ""; @@ -8198,20 +8129,6 @@ ${SYSTEM_REMINDER_CLOSE} pendingGitReminderRef.current = null; } - // Build permission mode change alert if mode changed since last notification - let permissionModeAlert = ""; - const currentMode = permissionMode.getMode(); - if (currentMode !== lastNotifiedModeRef.current) { - const modeDescriptions: Record = { - default: "Normal approval flow.", - acceptEdits: "File edits auto-approved.", - plan: "Read-only mode. Focus on exploration and planning.", - bypassPermissions: "All tools auto-approved. Bias toward action.", - }; - permissionModeAlert = `${SYSTEM_REMINDER_OPEN}Permission mode changed to: ${currentMode}. ${modeDescriptions[currentMode]}${SYSTEM_REMINDER_CLOSE}\n\n`; - lastNotifiedModeRef.current = currentMode; - } - // Combine reminders with content as separate text parts. // This preserves each reminder boundary in the API payload. // Note: Task notifications now come through messageQueue directly (added by messageQueueBridge) @@ -8257,56 +8174,28 @@ ${SYSTEM_REMINDER_CLOSE} return false; } }; - pushReminder(sessionContextReminder); - - // Inject available skills as system-reminder (LET-7353) - // Discover each turn so on-disk skill changes can trigger reinjection. - { - const { - discoverSkills: discover, - SKILLS_DIR: defaultDir, - formatSkillsAsSystemReminder, - } = await import("../agent/skills"); - const { getSkillsDirectory, getSkillSources } = await import( - "../agent/context" - ); - - const previousSkillsReminder = discoveredSkillsRef.current - ? formatSkillsAsSystemReminder(discoveredSkillsRef.current) - : null; - - let latestSkills = discoveredSkillsRef.current ?? []; - try { - const skillsDir = - getSkillsDirectory() || join(process.cwd(), defaultDir); - const { skills } = await discover(skillsDir, agentId, { - sources: getSkillSources(), - }); - latestSkills = skills; - } catch { - // Keep the previous snapshot when discovery fails. - } - - discoveredSkillsRef.current = latestSkills; - const latestSkillsReminder = formatSkillsAsSystemReminder( - discoveredSkillsRef.current, - ); - if ( - previousSkillsReminder !== null && - previousSkillsReminder !== latestSkillsReminder - ) { - contextTrackerRef.current.pendingSkillsReinject = true; - } - - const needsSkillsReinject = - contextTrackerRef.current.pendingSkillsReinject; - if (!hasInjectedSkillsRef.current || needsSkillsReinject) { - if (latestSkillsReminder) { - pushReminder(latestSkillsReminder); - } - hasInjectedSkillsRef.current = true; - contextTrackerRef.current.pendingSkillsReinject = false; - } + syncReminderStateFromContextTracker( + sharedReminderStateRef.current, + contextTrackerRef.current, + ); + const { getSkillSources } = await import("../agent/context"); + const { parts: sharedReminderParts } = await buildSharedReminderParts({ + mode: "interactive", + agent: { + id: agentId, + name: agentName, + description: agentDescription, + lastRunAt: agentLastRunAt, + }, + state: sharedReminderStateRef.current, + sessionContextReminderEnabled, + reflectionSettings, + skillSources: getSkillSources(), + resolvePlanModeReminder: getPlanModeReminder, + maybeLaunchReflectionSubagent, + }); + for (const part of sharedReminderParts) { + reminderParts.push(part); } // Build conversation switch alert if a switch is pending (behind feature flag) @@ -8325,41 +8214,10 @@ ${SYSTEM_REMINDER_CLOSE} pendingConversationSwitchRef.current = null; pushReminder(sessionStartHookFeedback); - pushReminder(permissionModeAlert); pushReminder(conversationSwitchAlert); - pushReminder(planModeReminder); pushReminder(ralphModeReminder); - pushReminder(bashCommandPrefix); pushReminder(userPromptSubmitHookFeedback); - pushReminder(memoryReminderContent); - - // Step-count auto-launch mode: fire reflection in background on interval. - if ( - shouldFireStepTrigger && - reflectionSettings.trigger === "step-count" && - reflectionSettings.behavior === "auto-launch" - ) { - await maybeLaunchReflectionSubagent("step-count"); - } - - // Consume compaction-triggered reflection behavior on next user turn. - if (contextTrackerRef.current.pendingReflectionTrigger) { - contextTrackerRef.current.pendingReflectionTrigger = false; - if (reflectionSettings.trigger === "compaction-event") { - if ( - reflectionSettings.behavior === "auto-launch" && - memfsEnabledForAgent - ) { - await maybeLaunchReflectionSubagent("compaction-event"); - } else { - const compactionReminderContent = - await buildCompactionMemoryReminder(agentId); - pushReminder(compactionReminderContent); - } - } - } - pushReminder(memoryGitReminder); const messageContent = reminderParts.length > 0 diff --git a/src/headless.ts b/src/headless.ts index 07189f3..3b942f4 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -33,6 +33,7 @@ import { toLines, } from "./cli/helpers/accumulator"; import { classifyApprovals } from "./cli/helpers/approvalClassification"; +import { createContextTracker } from "./cli/helpers/contextTracker"; import { formatErrorDetails } from "./cli/helpers/errorFormatter"; import { getReflectionSettings, @@ -54,6 +55,14 @@ import { mergeQueuedTurnInput, type QueuedTurnInput, } from "./queue/turnQueueRuntime"; +import { + buildSharedReminderParts, + prependReminderPartsToContent, +} from "./reminders/engine"; +import { + createSharedReminderState, + syncReminderStateFromContextTracker, +} from "./reminders/state"; import { settingsManager } from "./settings-manager"; import { isHeadlessAutoAllowTool, @@ -94,40 +103,6 @@ const LLM_API_ERROR_MAX_RETRIES = 3; const CONVERSATION_BUSY_MAX_RETRIES = 1; // Only retry once, fail on 2nd 409 const CONVERSATION_BUSY_RETRY_DELAY_MS = 2500; // 2.5 seconds -export function prependSkillsReminderToContent( - content: MessageCreate["content"], - skillsReminder: string, -): MessageCreate["content"] { - if (!skillsReminder) { - return content; - } - - if (typeof content === "string") { - return `${skillsReminder}\n\n${content}`; - } - - if (Array.isArray(content)) { - return [ - { - type: "text", - text: `${skillsReminder}\n\n`, - }, - ...content, - ] as MessageCreate["content"]; - } - - return content; -} - -export function shouldReinjectSkillsAfterCompaction(lines: Line[]): boolean { - return lines.some( - (line) => - line.kind === "event" && - line.eventType === "compaction" && - line.phase === "finished" && - (line.summary !== undefined || line.stats !== undefined), - ); -} export type BidirectionalQueuedInput = QueuedTurnInput< MessageCreate["content"] >; @@ -1177,6 +1152,9 @@ export async function handleHeadlessCommand( console.log(JSON.stringify(initEvent)); } + const reminderContextTracker = createContextTracker(); + const sharedReminderState = createSharedReminderState(); + // Helper to resolve any pending approvals before sending user input const resolveAllPendingApprovals = async () => { const { getResumeData } = await import("./agent/check-approval"); @@ -1321,16 +1299,23 @@ export async function handleHeadlessCommand( approvalMessages, { agentId: agent.id }, ); - if (outputFormat === "stream-json") { - // Consume quickly but don't emit message frames to stdout - for await (const _ of approvalStream) { - // no-op - } - } else { - await drainStreamWithResume( - approvalStream, - createBuffers(agent.id), - () => {}, + const drainResult = await drainStreamWithResume( + approvalStream, + createBuffers(agent.id), + () => {}, + undefined, + undefined, + undefined, + reminderContextTracker, + ); + // If the approval drain errored or was cancelled, abort rather than + // looping back and re-fetching approvals (which would restart the cycle). + if ( + drainResult.stopReason === "error" || + drainResult.stopReason === "cancelled" + ) { + throw new Error( + `Approval drain ended with stop reason: ${drainResult.stopReason}`, ); } } @@ -1343,7 +1328,6 @@ export async function handleHeadlessCommand( } // Build message content with reminders - const { permissionMode } = await import("./permissions/mode"); const contentParts: MessageCreate["content"] = []; const pushPart = (text: string) => { if (!text) return; @@ -1363,59 +1347,56 @@ ${SYSTEM_REMINDER_CLOSE} pushPart(systemReminder); } - // Inject available skills as system-reminder (LET-7353) - { - const { - discoverSkills, - SKILLS_DIR: defaultDir, - formatSkillsAsSystemReminder, - } = await import("./agent/skills"); - const { getSkillsDirectory } = await import("./agent/context"); - const { join } = await import("node:path"); - try { - const skillsDir = getSkillsDirectory() || join(process.cwd(), defaultDir); - const { skills } = await discoverSkills(skillsDir, agent.id, { - sources: resolvedSkillSources, - }); - const skillsReminder = formatSkillsAsSystemReminder(skills); - if (skillsReminder) { - pushPart(skillsReminder); - } - - // Pre-load specific skills' full content (used by subagents with skills: field) - if (preLoadSkillsRaw) { - const { readFile: readFileAsync } = await import("node:fs/promises"); - const skillIds = preLoadSkillsRaw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const loadedContents: string[] = []; - for (const skillId of skillIds) { - const skill = skills.find((s) => s.id === skillId); - if (skill?.path) { - try { - const content = await readFileAsync(skill.path, "utf-8"); - loadedContents.push(`<${skillId}>\n${content}\n`); - } catch { - // Skill file not readable, skip - } - } - } - if (loadedContents.length > 0) { - pushPart( - `\n${loadedContents.join("\n\n")}\n`, - ); - } - } - } catch { - // Skills discovery failed, skip - } + syncReminderStateFromContextTracker( + sharedReminderState, + reminderContextTracker, + ); + const lastRunAt = (agent as { last_run_completion?: string }) + .last_run_completion; + const { parts: sharedReminderParts } = await buildSharedReminderParts({ + mode: "headless-one-shot", + agent: { + id: agent.id, + name: agent.name, + description: agent.description, + lastRunAt: lastRunAt ?? null, + }, + state: sharedReminderState, + sessionContextReminderEnabled: systemInfoReminderEnabled, + reflectionSettings: effectiveReflectionSettings, + skillSources: resolvedSkillSources, + resolvePlanModeReminder: async () => { + const { PLAN_MODE_REMINDER } = await import("./agent/promptAssets"); + return PLAN_MODE_REMINDER; + }, + }); + for (const part of sharedReminderParts) { + pushPart(part.text); } - // Add plan mode reminder if in plan mode (highest priority) - if (permissionMode.getMode() === "plan") { - const { PLAN_MODE_REMINDER } = await import("./agent/promptAssets"); - pushPart(PLAN_MODE_REMINDER); + // Pre-load specific skills' full content (used by subagents with skills: field) + if (preLoadSkillsRaw) { + const { readFile: readFileAsync } = await import("node:fs/promises"); + const skillIds = preLoadSkillsRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const loadedContents: string[] = []; + for (const skillId of skillIds) { + const skillPath = sharedReminderState.skillPathById[skillId]; + if (!skillPath) continue; + try { + const content = await readFileAsync(skillPath, "utf-8"); + loadedContents.push(`<${skillId}>\n${content}\n`); + } catch { + // Skill file not readable, skip + } + } + if (loadedContents.length > 0) { + pushPart( + `\n${loadedContents.join("\n\n")}\n`, + ); + } } // Add user prompt @@ -1739,6 +1720,7 @@ ${SYSTEM_REMINDER_CLOSE} undefined, undefined, streamJsonHook, + reminderContextTracker, ); stopReason = result.stopReason; approvals = result.approvals || []; @@ -1751,6 +1733,10 @@ ${SYSTEM_REMINDER_CLOSE} stream, buffers, () => {}, // No UI refresh needed in headless mode + undefined, + undefined, + undefined, + reminderContextTracker, ); stopReason = result.stopReason; approvals = result.approvals || []; @@ -2271,12 +2257,8 @@ async function runBidirectionalMode( // Track current operation for interrupt support let currentAbortController: AbortController | null = null; - // Skills reminder lifecycle in bidirectional mode: - // - Inject once on first user turn - // - Reinject only after compaction completion or skills diff - let hasInjectedSkillsReminder = false; - let pendingSkillsReinject = false; - let cachedSkillsReminder: string | null = null; + const reminderContextTracker = createContextTracker(); + const sharedReminderState = createSharedReminderState(); // Resolve pending approvals for this conversation before retrying user input. const resolveAllPendingApprovals = async () => { @@ -2394,11 +2376,23 @@ async function runBidirectionalMode( approvalMessages, { agentId: agent.id }, ); - await drainStreamWithResume( + const drainResult = await drainStreamWithResume( approvalStream, createBuffers(agent.id), () => {}, + undefined, + undefined, + undefined, + reminderContextTracker, ); + if ( + drainResult.stopReason === "error" || + drainResult.stopReason === "cancelled" + ) { + throw new Error( + `Approval drain ended with stop reason: ${drainResult.stopReason}`, + ); + } } }; @@ -2795,48 +2789,33 @@ async function runBidirectionalMode( let sawStreamError = false; // Track if we emitted an error during streaming let preStreamTransientRetries = 0; - // Inject available skills as system-reminder for bidirectional mode (LET-7353). - // Discover each turn so skill file changes are naturally picked up. - let enrichedContent = userContent; - try { - const { - discoverSkills: discover, - SKILLS_DIR: defaultDir, - formatSkillsAsSystemReminder, - } = await import("./agent/skills"); - const { getSkillsDirectory } = await import("./agent/context"); - const { join } = await import("node:path"); - const skillsDir = - getSkillsDirectory() || join(process.cwd(), defaultDir); - const { skills } = await discover(skillsDir, agent.id, { - sources: skillSources, - }); - const latestSkillsReminder = formatSkillsAsSystemReminder(skills); - - // Trigger reinjection when the available-skills block changed on disk. - if ( - cachedSkillsReminder !== null && - latestSkillsReminder !== cachedSkillsReminder - ) { - pendingSkillsReinject = true; - } - cachedSkillsReminder = latestSkillsReminder; - - const shouldInjectSkillsReminder = - !hasInjectedSkillsReminder || pendingSkillsReinject; - if (shouldInjectSkillsReminder && latestSkillsReminder) { - enrichedContent = prependSkillsReminderToContent( - enrichedContent, - latestSkillsReminder, - ); - } - if (shouldInjectSkillsReminder) { - hasInjectedSkillsReminder = true; - pendingSkillsReinject = false; - } - } catch { - // Skills discovery failed, skip - } + syncReminderStateFromContextTracker( + sharedReminderState, + reminderContextTracker, + ); + const lastRunAt = (agent as { last_run_completion?: string }) + .last_run_completion; + const { parts: sharedReminderParts } = await buildSharedReminderParts({ + mode: "headless-bidirectional", + agent: { + id: agent.id, + name: agent.name, + description: agent.description, + lastRunAt: lastRunAt ?? null, + }, + state: sharedReminderState, + sessionContextReminderEnabled: systemInfoReminderEnabled, + reflectionSettings, + skillSources, + resolvePlanModeReminder: async () => { + const { PLAN_MODE_REMINDER } = await import("./agent/promptAssets"); + return PLAN_MODE_REMINDER; + }, + }); + const enrichedContent = prependReminderPartsToContent( + userContent, + sharedReminderParts, + ); // Initial input is the user message let currentInput: MessageCreate[] = [ @@ -3005,6 +2984,7 @@ async function runBidirectionalMode( currentAbortController?.signal, undefined, streamJsonHook, + reminderContextTracker, ); const stopReason = result.stopReason; lastStopReason = stopReason; // Track for result subtype @@ -3176,9 +3156,6 @@ async function runBidirectionalMode( // Emit result const durationMs = performance.now() - startTime; const lines = toLines(buffers); - if (shouldReinjectSkillsAfterCompaction(lines)) { - pendingSkillsReinject = true; - } const reversed = [...lines].reverse(); const lastAssistant = reversed.find( (line) => diff --git a/src/reminders/catalog.ts b/src/reminders/catalog.ts new file mode 100644 index 0000000..34a2b04 --- /dev/null +++ b/src/reminders/catalog.ts @@ -0,0 +1,69 @@ +export type SharedReminderMode = + | "interactive" + | "headless-one-shot" + | "headless-bidirectional"; + +export type SharedReminderId = + | "session-context" + | "skills" + | "permission-mode" + | "plan-mode" + | "reflection-step-count" + | "reflection-compaction"; + +export interface SharedReminderDefinition { + id: SharedReminderId; + description: string; + modes: SharedReminderMode[]; +} + +export const SHARED_REMINDER_CATALOG: ReadonlyArray = + [ + { + id: "session-context", + description: "First-turn device/agent/git context", + modes: ["interactive", "headless-one-shot", "headless-bidirectional"], + }, + { + id: "skills", + description: "Available skills system reminder (with reinjection)", + modes: ["interactive", "headless-one-shot", "headless-bidirectional"], + }, + { + id: "permission-mode", + description: "Permission mode reminder", + modes: ["interactive", "headless-one-shot", "headless-bidirectional"], + }, + { + id: "plan-mode", + description: "Plan mode behavioral reminder", + modes: ["interactive", "headless-one-shot", "headless-bidirectional"], + }, + { + id: "reflection-step-count", + description: "Step-count reflection reminder/auto-launch behavior", + modes: ["interactive", "headless-one-shot", "headless-bidirectional"], + }, + { + id: "reflection-compaction", + description: + "Compaction-triggered reflection reminder/auto-launch behavior", + modes: ["interactive", "headless-one-shot", "headless-bidirectional"], + }, + ]; + +export const SHARED_REMINDER_IDS = SHARED_REMINDER_CATALOG.map( + (entry) => entry.id, +); + +const SHARED_REMINDER_BY_ID = new Map< + SharedReminderId, + SharedReminderDefinition +>(SHARED_REMINDER_CATALOG.map((entry) => [entry.id, entry])); + +export function reminderEnabledInMode( + id: SharedReminderId, + mode: SharedReminderMode, +): boolean { + return SHARED_REMINDER_BY_ID.get(id)?.modes.includes(mode) ?? false; +} diff --git a/src/reminders/engine.ts b/src/reminders/engine.ts new file mode 100644 index 0000000..d5f5809 --- /dev/null +++ b/src/reminders/engine.ts @@ -0,0 +1,320 @@ +import { join } from "node:path"; +import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; +import { getSkillsDirectory } from "../agent/context"; +import { + discoverSkills, + formatSkillsAsSystemReminder, + SKILLS_DIR, + type SkillSource, +} from "../agent/skills"; +import { + buildCompactionMemoryReminder, + buildMemoryReminder, + type ReflectionSettings, + shouldFireStepCountTrigger, +} from "../cli/helpers/memoryReminder"; +import { buildSessionContext } from "../cli/helpers/sessionContext"; +import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../constants"; +import { permissionMode } from "../permissions/mode"; +import { settingsManager } from "../settings-manager"; +import { + SHARED_REMINDER_CATALOG, + type SharedReminderId, + type SharedReminderMode, +} from "./catalog"; +import type { SharedReminderState } from "./state"; + +type ReflectionTriggerSource = "step-count" | "compaction-event"; + +export interface AgentReminderContext { + id: string; + name: string | null; + description?: string | null; + lastRunAt?: string | null; + serverUrl?: string; +} + +export interface SharedReminderContext { + mode: SharedReminderMode; + agent: AgentReminderContext; + state: SharedReminderState; + sessionContextReminderEnabled: boolean; + reflectionSettings: ReflectionSettings; + skillSources: SkillSource[]; + resolvePlanModeReminder: () => string | Promise; + maybeLaunchReflectionSubagent?: ( + triggerSource: ReflectionTriggerSource, + ) => Promise; +} + +export type ReminderTextPart = { type: "text"; text: string }; + +export interface SharedReminderBuildResult { + parts: ReminderTextPart[]; + appliedReminderIds: SharedReminderId[]; +} + +type SharedReminderProvider = ( + context: SharedReminderContext, +) => Promise; + +async function buildSessionContextReminder( + context: SharedReminderContext, +): Promise { + if ( + !context.sessionContextReminderEnabled || + context.state.hasSentSessionContext + ) { + return null; + } + + if (!settingsManager.getSetting("sessionContextEnabled")) { + return null; + } + + const reminder = buildSessionContext({ + agentInfo: { + id: context.agent.id, + name: context.agent.name, + description: context.agent.description, + lastRunAt: context.agent.lastRunAt, + }, + serverUrl: context.agent.serverUrl, + }); + + context.state.hasSentSessionContext = true; + return reminder || null; +} + +async function buildSkillsReminder( + context: SharedReminderContext, +): Promise { + const previousSkillsReminder = context.state.cachedSkillsReminder; + // Keep a stable empty baseline so a later successful discovery can diff + // against "" and trigger reinjection, even after an earlier discovery failure. + let latestSkillsReminder = previousSkillsReminder ?? ""; + + try { + const skillsDir = getSkillsDirectory() || join(process.cwd(), SKILLS_DIR); + const { skills } = await discoverSkills(skillsDir, context.agent.id, { + sources: context.skillSources, + }); + latestSkillsReminder = formatSkillsAsSystemReminder(skills); + context.state.skillPathById = Object.fromEntries( + skills + .filter( + (skill) => typeof skill.path === "string" && skill.path.length > 0, + ) + .map((skill) => [skill.id, skill.path as string]), + ); + } catch { + // Keep previous snapshot when discovery fails. + } + + if ( + previousSkillsReminder !== null && + previousSkillsReminder !== latestSkillsReminder + ) { + context.state.pendingSkillsReinject = true; + } + + context.state.cachedSkillsReminder = latestSkillsReminder; + + const shouldInject = + !context.state.hasInjectedSkillsReminder || + context.state.pendingSkillsReinject; + if (!shouldInject) { + return null; + } + + context.state.hasInjectedSkillsReminder = true; + context.state.pendingSkillsReinject = false; + return latestSkillsReminder || null; +} + +async function buildPlanModeReminder( + context: SharedReminderContext, +): Promise { + if (permissionMode.getMode() !== "plan") { + return null; + } + + const reminder = await context.resolvePlanModeReminder(); + return reminder || null; +} + +const PERMISSION_MODE_DESCRIPTIONS = { + default: "Normal approval flow.", + acceptEdits: "File edits auto-approved.", + plan: "Read-only mode. Focus on exploration and planning.", + bypassPermissions: "All tools auto-approved. Bias toward action.", +} as const; + +async function buildPermissionModeReminder( + context: SharedReminderContext, +): Promise { + const currentMode = permissionMode.getMode(); + const previousMode = context.state.lastNotifiedPermissionMode; + + const shouldEmit = (() => { + if (context.mode === "interactive") { + if (previousMode === null) { + // First turn: only remind if in a non-default mode (e.g. bypassPermissions). + return currentMode !== "default"; + } + return previousMode !== currentMode; + } + return previousMode !== currentMode; + })(); + + context.state.lastNotifiedPermissionMode = currentMode; + if (!shouldEmit) { + return null; + } + + const description = + PERMISSION_MODE_DESCRIPTIONS[ + currentMode as keyof typeof PERMISSION_MODE_DESCRIPTIONS + ] ?? "Permission behavior updated."; + const prefix = + previousMode === null + ? "Permission mode active" + : "Permission mode changed to"; + + return `${SYSTEM_REMINDER_OPEN}${prefix}: ${currentMode}. ${description}${SYSTEM_REMINDER_CLOSE}\n\n`; +} + +async function buildReflectionStepReminder( + context: SharedReminderContext, +): Promise { + const shouldFireStepTrigger = shouldFireStepCountTrigger( + context.state.turnCount, + context.reflectionSettings, + ); + + const memfsEnabled = settingsManager.isMemfsEnabled(context.agent.id); + let reminder: string | null = null; + + if (shouldFireStepTrigger) { + if (context.reflectionSettings.behavior === "reminder" || !memfsEnabled) { + reminder = await buildMemoryReminder( + context.state.turnCount, + context.agent.id, + ); + } else { + if (context.maybeLaunchReflectionSubagent) { + await context.maybeLaunchReflectionSubagent("step-count"); + } else { + reminder = await buildMemoryReminder( + context.state.turnCount, + context.agent.id, + ); + } + } + } + + // Keep turn-based cadence aligned across modes by incrementing once per user turn. + context.state.turnCount += 1; + return reminder; +} + +async function buildReflectionCompactionReminder( + context: SharedReminderContext, +): Promise { + if (!context.state.pendingReflectionTrigger) { + return null; + } + + context.state.pendingReflectionTrigger = false; + + if (context.reflectionSettings.trigger !== "compaction-event") { + return null; + } + + const memfsEnabled = settingsManager.isMemfsEnabled(context.agent.id); + if (context.reflectionSettings.behavior === "auto-launch" && memfsEnabled) { + if (context.maybeLaunchReflectionSubagent) { + await context.maybeLaunchReflectionSubagent("compaction-event"); + return null; + } + } + + return buildCompactionMemoryReminder(context.agent.id); +} + +export const sharedReminderProviders: Record< + SharedReminderId, + SharedReminderProvider +> = { + "session-context": buildSessionContextReminder, + skills: buildSkillsReminder, + "permission-mode": buildPermissionModeReminder, + "plan-mode": buildPlanModeReminder, + "reflection-step-count": buildReflectionStepReminder, + "reflection-compaction": buildReflectionCompactionReminder, +}; + +export function assertSharedReminderCoverage(): void { + const catalogIds = new Set(SHARED_REMINDER_CATALOG.map((entry) => entry.id)); + const providerIds = new Set(Object.keys(sharedReminderProviders)); + + for (const id of catalogIds) { + if (!providerIds.has(id)) { + throw new Error(`Missing shared reminder provider for "${id}"`); + } + } + + for (const id of providerIds) { + if (!catalogIds.has(id as SharedReminderId)) { + throw new Error(`Shared reminder provider "${id}" is not in catalog`); + } + } +} + +assertSharedReminderCoverage(); + +export async function buildSharedReminderParts( + context: SharedReminderContext, +): Promise { + const parts: ReminderTextPart[] = []; + const appliedReminderIds: SharedReminderId[] = []; + + for (const reminder of SHARED_REMINDER_CATALOG) { + if (!reminder.modes.includes(context.mode)) { + continue; + } + + const provider = sharedReminderProviders[reminder.id]; + const text = await provider(context); + if (!text) { + continue; + } + + parts.push({ type: "text", text }); + appliedReminderIds.push(reminder.id); + } + + return { parts, appliedReminderIds }; +} + +export function prependReminderPartsToContent( + content: MessageCreate["content"], + reminderParts: ReminderTextPart[], +): MessageCreate["content"] { + if (reminderParts.length === 0) { + return content; + } + + if (typeof content === "string") { + return [ + ...reminderParts, + { type: "text", text: content }, + ] as MessageCreate["content"]; + } + + if (Array.isArray(content)) { + return [...reminderParts, ...content] as MessageCreate["content"]; + } + + return content; +} diff --git a/src/reminders/state.ts b/src/reminders/state.ts new file mode 100644 index 0000000..d80fa5d --- /dev/null +++ b/src/reminders/state.ts @@ -0,0 +1,44 @@ +import type { ContextTracker } from "../cli/helpers/contextTracker"; +import type { PermissionMode } from "../permissions/mode"; + +export interface SharedReminderState { + hasSentSessionContext: boolean; + hasInjectedSkillsReminder: boolean; + cachedSkillsReminder: string | null; + skillPathById: Record; + lastNotifiedPermissionMode: PermissionMode | null; + turnCount: number; + pendingSkillsReinject: boolean; + pendingReflectionTrigger: boolean; +} + +export function createSharedReminderState(): SharedReminderState { + return { + hasSentSessionContext: false, + hasInjectedSkillsReminder: false, + cachedSkillsReminder: null, + skillPathById: {}, + lastNotifiedPermissionMode: null, + turnCount: 0, + pendingSkillsReinject: false, + pendingReflectionTrigger: false, + }; +} + +export function resetSharedReminderState(state: SharedReminderState): void { + Object.assign(state, createSharedReminderState()); +} + +export function syncReminderStateFromContextTracker( + state: SharedReminderState, + contextTracker: ContextTracker, +): void { + if (contextTracker.pendingSkillsReinject) { + state.pendingSkillsReinject = true; + contextTracker.pendingSkillsReinject = false; + } + if (contextTracker.pendingReflectionTrigger) { + state.pendingReflectionTrigger = true; + contextTracker.pendingReflectionTrigger = false; + } +} diff --git a/src/tests/cli/bootstrap-reminders-reset-wiring.test.ts b/src/tests/cli/bootstrap-reminders-reset-wiring.test.ts index 9e9467d..edf4d40 100644 --- a/src/tests/cli/bootstrap-reminders-reset-wiring.test.ts +++ b/src/tests/cli/bootstrap-reminders-reset-wiring.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; describe("bootstrap reminder reset wiring", () => { - test("defines helper that clears session, skills, and discovery cache", () => { + test("defines helper that resets shared reminder state", () => { const appPath = fileURLToPath( new URL("../../cli/App.tsx", import.meta.url), ); @@ -12,9 +12,12 @@ describe("bootstrap reminder reset wiring", () => { expect(source).toContain( "const resetBootstrapReminderState = useCallback(() => {", ); - expect(source).toContain("hasSentSessionContextRef.current = false;"); - expect(source).toContain("hasInjectedSkillsRef.current = false;"); - expect(source).toContain("discoveredSkillsRef.current = null;"); + expect(source).toContain( + "resetSharedReminderState(sharedReminderStateRef.current);", + ); + expect(source).not.toContain("hasSentSessionContextRef.current = false;"); + expect(source).not.toContain("hasInjectedSkillsRef.current = false;"); + expect(source).not.toContain("discoveredSkillsRef.current = null;"); }); test("invokes helper for all conversation/agent switch entry points", () => { diff --git a/src/tests/cli/reflection-auto-launch-wiring.test.ts b/src/tests/cli/reflection-auto-launch-wiring.test.ts index 46e60af..9d62c7d 100644 --- a/src/tests/cli/reflection-auto-launch-wiring.test.ts +++ b/src/tests/cli/reflection-auto-launch-wiring.test.ts @@ -3,20 +3,26 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; describe("reflection auto-launch wiring", () => { - test("handles step-count and compaction-event auto-launch modes", () => { + test("routes step-count and compaction-event auto-launch through shared reminder engine", () => { const appPath = fileURLToPath( new URL("../../cli/App.tsx", import.meta.url), ); - const source = readFileSync(appPath, "utf-8"); + const enginePath = fileURLToPath( + new URL("../../reminders/engine.ts", import.meta.url), + ); + const appSource = readFileSync(appPath, "utf-8"); + const engineSource = readFileSync(enginePath, "utf-8"); - expect(source).toContain("const maybeLaunchReflectionSubagent = async"); - expect(source).toContain( - 'await maybeLaunchReflectionSubagent("step-count")', + expect(appSource).toContain("const maybeLaunchReflectionSubagent = async"); + expect(appSource).toContain("hasActiveReflectionSubagent()"); + expect(appSource).toContain("spawnBackgroundSubagentTask({"); + expect(appSource).toContain("maybeLaunchReflectionSubagent,"); + + expect(engineSource).toContain( + 'await context.maybeLaunchReflectionSubagent("step-count")', ); - expect(source).toContain( - 'await maybeLaunchReflectionSubagent("compaction-event")', + expect(engineSource).toContain( + 'await context.maybeLaunchReflectionSubagent("compaction-event")', ); - expect(source).toContain("hasActiveReflectionSubagent()"); - expect(source).toContain("spawnBackgroundSubagentTask({"); }); }); diff --git a/src/tests/headless/reminder-wiring.test.ts b/src/tests/headless/reminder-wiring.test.ts new file mode 100644 index 0000000..370d34f --- /dev/null +++ b/src/tests/headless/reminder-wiring.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +describe("headless shared reminder wiring", () => { + test("one-shot mode builds shared reminders with system-info flag", () => { + const headlessPath = fileURLToPath( + new URL("../../headless.ts", import.meta.url), + ); + const source = readFileSync(headlessPath, "utf-8"); + + expect(source).toContain('mode: "headless-one-shot"'); + expect(source).toContain( + "sessionContextReminderEnabled: systemInfoReminderEnabled", + ); + }); + + test("bidirectional mode builds shared reminders with plan-mode resolver", () => { + const headlessPath = fileURLToPath( + new URL("../../headless.ts", import.meta.url), + ); + const source = readFileSync(headlessPath, "utf-8"); + + expect(source).toContain('mode: "headless-bidirectional"'); + expect(source).toContain("resolvePlanModeReminder: async () => {"); + expect(source).toContain("const { PLAN_MODE_REMINDER } = await import"); + }); + + test("all headless drains pass context tracker for compaction-driven reminder state", () => { + const headlessPath = fileURLToPath( + new URL("../../headless.ts", import.meta.url), + ); + const source = readFileSync(headlessPath, "utf-8"); + + expect(source).toContain("syncReminderStateFromContextTracker("); + expect(source).toContain("reminderContextTracker"); + }); + + test("one-shot approval drain uses shared stream processor", () => { + const headlessPath = fileURLToPath( + new URL("../../headless.ts", import.meta.url), + ); + const source = readFileSync(headlessPath, "utf-8"); + + expect(source).toContain("const approvalStream = await sendMessageStream("); + expect(source).toContain("await drainStreamWithResume("); + expect(source).not.toContain("for await (const _ of approvalStream)"); + }); +}); diff --git a/src/tests/headless/skills-reminder.test.ts b/src/tests/headless/skills-reminder.test.ts index b804429..68244b6 100644 --- a/src/tests/headless/skills-reminder.test.ts +++ b/src/tests/headless/skills-reminder.test.ts @@ -1,21 +1,19 @@ import { describe, expect, test } from "bun:test"; import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; -import type { Line } from "../../cli/helpers/accumulator"; -import { - prependSkillsReminderToContent, - shouldReinjectSkillsAfterCompaction, -} from "../../headless"; +import { prependReminderPartsToContent } from "../../reminders/engine"; -describe("headless skills reminder helpers", () => { - test("prepends reminder to string user content", () => { - const result = prependSkillsReminderToContent( - "hello", - "demo", - ); - expect(result).toBe("demo\n\nhello"); +describe("headless shared reminder content helpers", () => { + test("prepends reminder text to string user content as parts array", () => { + const result = prependReminderPartsToContent("hello", [ + { type: "text", text: "demo" }, + ]); + expect(Array.isArray(result)).toBe(true); + if (!Array.isArray(result)) return; + expect(result[0]).toEqual({ type: "text", text: "demo" }); + expect(result[1]).toEqual({ type: "text", text: "hello" }); }); - test("prepends reminder as a text part for multimodal user content", () => { + test("prepends reminder parts for multimodal user content", () => { const multimodal = [ { type: "text", text: "what is in this image?" }, { @@ -24,60 +22,18 @@ describe("headless skills reminder helpers", () => { }, ] as unknown as Exclude; - const result = prependSkillsReminderToContent( + const result = prependReminderPartsToContent( multimodal as MessageCreate["content"], - "demo", + [{ type: "text", text: "demo" }], ); expect(Array.isArray(result)).toBe(true); if (!Array.isArray(result)) return; expect(result[0]).toEqual({ type: "text", - text: "demo\n\n", + text: "demo", }); expect(result[1]).toEqual(multimodal[0]); expect(result[2]).toEqual(multimodal[1]); }); - - test("does not reinject on compaction start event", () => { - const lines: Line[] = [ - { - kind: "event", - id: "evt-1", - eventType: "compaction", - eventData: {}, - phase: "running", - }, - ]; - expect(shouldReinjectSkillsAfterCompaction(lines)).toBe(false); - }); - - test("reinjection triggers after compaction completion", () => { - const withSummary: Line[] = [ - { - kind: "event", - id: "evt-2", - eventType: "compaction", - eventData: {}, - phase: "finished", - summary: "Compacted old messages", - }, - ]; - expect(shouldReinjectSkillsAfterCompaction(withSummary)).toBe(true); - - const withStatsOnly: Line[] = [ - { - kind: "event", - id: "evt-3", - eventType: "compaction", - eventData: {}, - phase: "finished", - stats: { - contextTokensBefore: 12000, - contextTokensAfter: 7000, - }, - }, - ]; - expect(shouldReinjectSkillsAfterCompaction(withStatsOnly)).toBe(true); - }); }); diff --git a/src/tests/reminders/catalog.test.ts b/src/tests/reminders/catalog.test.ts new file mode 100644 index 0000000..00e3d04 --- /dev/null +++ b/src/tests/reminders/catalog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { + SHARED_REMINDER_CATALOG, + SHARED_REMINDER_IDS, +} from "../../reminders/catalog"; +import { + assertSharedReminderCoverage, + sharedReminderProviders, +} from "../../reminders/engine"; + +describe("shared reminder catalog", () => { + test("provider coverage matches catalog", () => { + expect(() => assertSharedReminderCoverage()).not.toThrow(); + }); + + test("catalog ids are unique", () => { + const unique = new Set(SHARED_REMINDER_IDS); + expect(unique.size).toBe(SHARED_REMINDER_IDS.length); + }); + + test("all reminders target all runtime modes", () => { + for (const reminder of SHARED_REMINDER_CATALOG) { + expect(reminder.modes).toContain("interactive"); + expect(reminder.modes).toContain("headless-one-shot"); + expect(reminder.modes).toContain("headless-bidirectional"); + } + }); + + test("provider ids and catalog ids stay in lockstep", () => { + expect(Object.keys(sharedReminderProviders).sort()).toEqual( + [...SHARED_REMINDER_IDS].sort(), + ); + }); +}); diff --git a/src/tests/reminders/engine-parity.test.ts b/src/tests/reminders/engine-parity.test.ts new file mode 100644 index 0000000..4f32aeb --- /dev/null +++ b/src/tests/reminders/engine-parity.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { SkillSource } from "../../agent/skills"; +import type { ReflectionSettings } from "../../cli/helpers/memoryReminder"; +import { SHARED_REMINDER_IDS } from "../../reminders/catalog"; +import { + buildSharedReminderParts, + sharedReminderProviders, +} from "../../reminders/engine"; +import { createSharedReminderState } from "../../reminders/state"; + +const originalProviders = { ...sharedReminderProviders }; +const providerMap = sharedReminderProviders; + +afterEach(() => { + for (const reminderId of SHARED_REMINDER_IDS) { + providerMap[reminderId] = originalProviders[reminderId]; + } +}); + +describe("shared reminder parity", () => { + test("shared reminder order is identical across interactive and headless modes", async () => { + for (const reminderId of SHARED_REMINDER_IDS) { + providerMap[reminderId] = async () => reminderId; + } + + const reflectionSettings: ReflectionSettings = { + trigger: "off", + behavior: "reminder", + stepCount: 25, + }; + + const base = { + agent: { + id: "agent-1", + name: "Agent 1", + description: "test", + lastRunAt: null, + }, + sessionContextReminderEnabled: true, + reflectionSettings, + skillSources: [] as SkillSource[], + resolvePlanModeReminder: () => "plan", + }; + + const interactive = await buildSharedReminderParts({ + ...base, + mode: "interactive", + state: createSharedReminderState(), + }); + const oneShot = await buildSharedReminderParts({ + ...base, + mode: "headless-one-shot", + state: createSharedReminderState(), + }); + const bidirectional = await buildSharedReminderParts({ + ...base, + mode: "headless-bidirectional", + state: createSharedReminderState(), + }); + + expect(interactive.appliedReminderIds).toEqual(SHARED_REMINDER_IDS); + expect(oneShot.appliedReminderIds).toEqual(SHARED_REMINDER_IDS); + expect(bidirectional.appliedReminderIds).toEqual(SHARED_REMINDER_IDS); + expect(interactive.parts.map((part) => part.text)).toEqual( + SHARED_REMINDER_IDS, + ); + expect(oneShot.parts.map((part) => part.text)).toEqual(SHARED_REMINDER_IDS); + expect(bidirectional.parts.map((part) => part.text)).toEqual( + SHARED_REMINDER_IDS, + ); + }); +}); diff --git a/src/tests/reminders/permission-mode.test.ts b/src/tests/reminders/permission-mode.test.ts new file mode 100644 index 0000000..05a8b2b --- /dev/null +++ b/src/tests/reminders/permission-mode.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { permissionMode } from "../../permissions/mode"; +import { + type SharedReminderContext, + sharedReminderProviders, +} from "../../reminders/engine"; +import { createSharedReminderState } from "../../reminders/state"; + +function baseContext( + mode: SharedReminderContext["mode"], +): SharedReminderContext { + return { + mode, + agent: { + id: "agent-1", + name: "Agent 1", + description: null, + lastRunAt: null, + }, + state: createSharedReminderState(), + sessionContextReminderEnabled: true, + reflectionSettings: { + trigger: "off", + behavior: "reminder", + stepCount: 25, + }, + skillSources: [], + resolvePlanModeReminder: () => "", + }; +} + +afterEach(() => { + permissionMode.setMode("default"); +}); + +describe("shared permission-mode reminder", () => { + test("emits on first headless turn", async () => { + permissionMode.setMode("default"); + const provider = sharedReminderProviders["permission-mode"]; + const reminder = await provider(baseContext("headless-one-shot")); + expect(reminder).toContain("Permission mode active: default"); + }); + + test("interactive does not emit on first turn in default mode", async () => { + permissionMode.setMode("default"); + const provider = sharedReminderProviders["permission-mode"]; + const context = baseContext("interactive"); + + const first = await provider(context); + expect(first).toBeNull(); + + permissionMode.setMode("bypassPermissions"); + const second = await provider(context); + expect(second).toContain("Permission mode changed to: bypassPermissions"); + }); + + test("interactive emits on first turn in bypassPermissions mode", async () => { + permissionMode.setMode("bypassPermissions"); + const provider = sharedReminderProviders["permission-mode"]; + const reminder = await provider(baseContext("interactive")); + expect(reminder).toContain("Permission mode active: bypassPermissions"); + }); + + test("interactive emits on first turn in acceptEdits mode", async () => { + permissionMode.setMode("acceptEdits"); + const provider = sharedReminderProviders["permission-mode"]; + const reminder = await provider(baseContext("interactive")); + expect(reminder).toContain("Permission mode active: acceptEdits"); + }); +}); diff --git a/src/tests/reminders/skills-recovery.test.ts b/src/tests/reminders/skills-recovery.test.ts new file mode 100644 index 0000000..f8d917e --- /dev/null +++ b/src/tests/reminders/skills-recovery.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import type { SharedReminderContext } from "../../reminders/engine"; +import { sharedReminderProviders } from "../../reminders/engine"; +import { createSharedReminderState } from "../../reminders/state"; + +function buildContext(): SharedReminderContext { + return { + mode: "interactive", + agent: { + id: "agent-1", + name: "Agent 1", + description: null, + lastRunAt: null, + }, + state: createSharedReminderState(), + sessionContextReminderEnabled: true, + reflectionSettings: { + trigger: "off", + behavior: "reminder", + stepCount: 25, + }, + skillSources: ["bundled"], + resolvePlanModeReminder: () => "", + }; +} + +describe("shared skills reminder", () => { + test("recovers from discovery failure and reinjects after next successful discovery", async () => { + const provider = sharedReminderProviders.skills; + const context = buildContext(); + + const mutableProcess = process as typeof process & { cwd: () => string }; + const originalCwd = mutableProcess.cwd; + try { + mutableProcess.cwd = () => { + throw new Error("cwd unavailable for test"); + }; + + const first = await provider(context); + expect(first).toBeNull(); + expect(context.state.hasInjectedSkillsReminder).toBe(true); + expect(context.state.cachedSkillsReminder).toBe(""); + } finally { + mutableProcess.cwd = originalCwd; + } + + const second = await provider(context); + expect(second).not.toBeNull(); + expect(context.state.pendingSkillsReinject).toBe(false); + if (second) { + expect(second).toContain(""); + } + }); +});