feat: inject toolset swap and slash-command context reminders (#1022)
This commit is contained in:
@@ -85,6 +85,8 @@ import {
|
||||
import { buildSharedReminderParts } from "../reminders/engine";
|
||||
import {
|
||||
createSharedReminderState,
|
||||
enqueueCommandIoReminder,
|
||||
enqueueToolsetChangeReminder,
|
||||
resetSharedReminderState,
|
||||
syncReminderStateFromContextTracker,
|
||||
} from "../reminders/state";
|
||||
@@ -95,6 +97,7 @@ import {
|
||||
analyzeToolApproval,
|
||||
checkToolPermission,
|
||||
executeTool,
|
||||
getToolNames,
|
||||
releaseToolExecutionContext,
|
||||
savePermissionRule,
|
||||
type ToolExecutionResult,
|
||||
@@ -117,7 +120,11 @@ import {
|
||||
setActiveCommandId as setActiveProfileCommandId,
|
||||
validateProfileLoad,
|
||||
} from "./commands/profile";
|
||||
import { type CommandHandle, createCommandRunner } from "./commands/runner";
|
||||
import {
|
||||
type CommandFinishedEvent,
|
||||
type CommandHandle,
|
||||
createCommandRunner,
|
||||
} from "./commands/runner";
|
||||
import { AgentSelector } from "./components/AgentSelector";
|
||||
// ApprovalDialog removed - all approvals now render inline
|
||||
import { ApprovalPreview } from "./components/ApprovalPreview";
|
||||
@@ -2281,14 +2288,47 @@ export default function App({
|
||||
commitEligibleLines(b);
|
||||
}, [commitEligibleLines]);
|
||||
|
||||
const recordCommandReminder = useCallback((event: CommandFinishedEvent) => {
|
||||
const input = event.input.trim();
|
||||
if (!input.startsWith("/")) {
|
||||
return;
|
||||
}
|
||||
enqueueCommandIoReminder(sharedReminderStateRef.current, {
|
||||
input,
|
||||
output: event.output,
|
||||
success: event.success,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const maybeRecordToolsetChangeReminder = useCallback(
|
||||
(params: {
|
||||
source: string;
|
||||
previousToolset: string | null;
|
||||
newToolset: string | null;
|
||||
previousTools: string[];
|
||||
newTools: string[];
|
||||
}) => {
|
||||
const toolsetChanged = params.previousToolset !== params.newToolset;
|
||||
const previousSnapshot = params.previousTools.join("\n");
|
||||
const nextSnapshot = params.newTools.join("\n");
|
||||
const toolsChanged = previousSnapshot !== nextSnapshot;
|
||||
if (!toolsetChanged && !toolsChanged) {
|
||||
return;
|
||||
}
|
||||
enqueueToolsetChangeReminder(sharedReminderStateRef.current, params);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const commandRunner = useMemo(
|
||||
() =>
|
||||
createCommandRunner({
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
createId: uid,
|
||||
onCommandFinished: recordCommandReminder,
|
||||
}),
|
||||
[refreshDerived],
|
||||
[recordCommandReminder, refreshDerived],
|
||||
);
|
||||
|
||||
const startOverlayCommand = useCallback(
|
||||
@@ -10034,6 +10074,8 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
|
||||
const persistedToolsetPreference =
|
||||
settingsManager.getToolsetPreference(agentId);
|
||||
const previousToolsetSnapshot = currentToolset;
|
||||
const previousToolNamesSnapshot = getToolNames();
|
||||
let toolsetNoticeLine: string | null = null;
|
||||
|
||||
if (persistedToolsetPreference === "auto") {
|
||||
@@ -10048,11 +10090,25 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
"Auto toolset selected: switched to " +
|
||||
toolsetName +
|
||||
". Use /toolset to set a manual override.";
|
||||
maybeRecordToolsetChangeReminder({
|
||||
source: "/model (auto toolset)",
|
||||
previousToolset: previousToolsetSnapshot,
|
||||
newToolset: toolsetName,
|
||||
previousTools: previousToolNamesSnapshot,
|
||||
newTools: getToolNames(),
|
||||
});
|
||||
} else {
|
||||
const { forceToolsetSwitch } = await import("../tools/toolset");
|
||||
if (currentToolset !== persistedToolsetPreference) {
|
||||
await forceToolsetSwitch(persistedToolsetPreference, agentId);
|
||||
setCurrentToolset(persistedToolsetPreference);
|
||||
maybeRecordToolsetChangeReminder({
|
||||
source: "/model (manual toolset override)",
|
||||
previousToolset: previousToolsetSnapshot,
|
||||
newToolset: persistedToolsetPreference,
|
||||
previousTools: previousToolNamesSnapshot,
|
||||
newTools: getToolNames(),
|
||||
});
|
||||
}
|
||||
setCurrentToolsetPreference(persistedToolsetPreference);
|
||||
toolsetNoticeLine =
|
||||
@@ -10093,6 +10149,7 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
consumeOverlayCommand,
|
||||
currentToolset,
|
||||
isAgentBusy,
|
||||
maybeRecordToolsetChangeReminder,
|
||||
resetPendingReasoningCycle,
|
||||
withCommandLock,
|
||||
],
|
||||
@@ -10297,6 +10354,8 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
const { forceToolsetSwitch, switchToolsetForModel } = await import(
|
||||
"../tools/toolset"
|
||||
);
|
||||
const previousToolsetSnapshot = currentToolset;
|
||||
const previousToolNamesSnapshot = getToolNames();
|
||||
|
||||
if (toolsetId === "auto") {
|
||||
const modelHandle =
|
||||
@@ -10317,6 +10376,13 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
settingsManager.setToolsetPreference(agentId, "auto");
|
||||
setCurrentToolsetPreference("auto");
|
||||
setCurrentToolset(derivedToolset);
|
||||
maybeRecordToolsetChangeReminder({
|
||||
source: "/toolset",
|
||||
previousToolset: previousToolsetSnapshot,
|
||||
newToolset: derivedToolset,
|
||||
previousTools: previousToolNamesSnapshot,
|
||||
newTools: getToolNames(),
|
||||
});
|
||||
cmd.finish(
|
||||
`Toolset mode set to auto (currently ${derivedToolset}).`,
|
||||
true,
|
||||
@@ -10328,6 +10394,13 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
settingsManager.setToolsetPreference(agentId, toolsetId);
|
||||
setCurrentToolsetPreference(toolsetId);
|
||||
setCurrentToolset(toolsetId);
|
||||
maybeRecordToolsetChangeReminder({
|
||||
source: "/toolset",
|
||||
previousToolset: previousToolsetSnapshot,
|
||||
newToolset: toolsetId,
|
||||
previousTools: previousToolNamesSnapshot,
|
||||
newTools: getToolNames(),
|
||||
});
|
||||
cmd.finish(
|
||||
`Switched toolset to ${toolsetId} (manual override)`,
|
||||
true,
|
||||
@@ -10342,9 +10415,11 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
agentId,
|
||||
commandRunner,
|
||||
consumeOverlayCommand,
|
||||
currentToolset,
|
||||
currentModelHandle,
|
||||
isAgentBusy,
|
||||
llmConfig,
|
||||
maybeRecordToolsetChangeReminder,
|
||||
withCommandLock,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -24,12 +24,22 @@ export type CommandHandle = {
|
||||
fail: (output: string) => void;
|
||||
};
|
||||
|
||||
export type CommandFinishedEvent = {
|
||||
id: string;
|
||||
input: string;
|
||||
output: string;
|
||||
success: boolean;
|
||||
dimOutput?: boolean;
|
||||
preformatted?: boolean;
|
||||
};
|
||||
|
||||
type CreateId = (prefix: string) => string;
|
||||
|
||||
type RunnerDeps = {
|
||||
buffersRef: MutableRefObject<Buffers>;
|
||||
refreshDerived: () => void;
|
||||
createId: CreateId;
|
||||
onCommandFinished?: (event: CommandFinishedEvent) => void;
|
||||
};
|
||||
|
||||
function upsertCommandLine(
|
||||
@@ -56,13 +66,33 @@ export function createCommandRunner({
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
createId,
|
||||
onCommandFinished,
|
||||
}: RunnerDeps) {
|
||||
function getHandle(id: string, input: string): CommandHandle {
|
||||
const update = (updateData: CommandUpdate) => {
|
||||
const previous = buffersRef.current.byId.get(id);
|
||||
const wasFinished =
|
||||
previous?.kind === "command" && previous.phase === "finished";
|
||||
|
||||
upsertCommandLine(buffersRef.current, id, input, updateData);
|
||||
if (!buffersRef.current.order.includes(id)) {
|
||||
buffersRef.current.order.push(id);
|
||||
}
|
||||
|
||||
const next = buffersRef.current.byId.get(id);
|
||||
const becameFinished =
|
||||
!wasFinished && next?.kind === "command" && next.phase === "finished";
|
||||
if (becameFinished) {
|
||||
onCommandFinished?.({
|
||||
id,
|
||||
input: next.input,
|
||||
output: next.output,
|
||||
success: next.success !== false,
|
||||
dimOutput: next.dimOutput,
|
||||
preformatted: next.preformatted,
|
||||
});
|
||||
}
|
||||
|
||||
refreshDerived();
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ export type SharedReminderId =
|
||||
| "permission-mode"
|
||||
| "plan-mode"
|
||||
| "reflection-step-count"
|
||||
| "reflection-compaction";
|
||||
| "reflection-compaction"
|
||||
| "command-io"
|
||||
| "toolset-change";
|
||||
|
||||
export interface SharedReminderDefinition {
|
||||
id: SharedReminderId;
|
||||
@@ -50,6 +52,16 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray<SharedReminderDefinition> =
|
||||
"Compaction-triggered reflection reminder/auto-launch behavior",
|
||||
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
|
||||
},
|
||||
{
|
||||
id: "command-io",
|
||||
description: "Recent slash command input/output context",
|
||||
modes: ["interactive"],
|
||||
},
|
||||
{
|
||||
id: "toolset-change",
|
||||
description: "Client-side toolset change context",
|
||||
modes: ["interactive"],
|
||||
},
|
||||
];
|
||||
|
||||
export const SHARED_REMINDER_IDS = SHARED_REMINDER_CATALOG.map(
|
||||
|
||||
@@ -242,6 +242,107 @@ async function buildReflectionCompactionReminder(
|
||||
return buildCompactionMemoryReminder(context.agent.id);
|
||||
}
|
||||
|
||||
const MAX_COMMAND_REMINDERS_PER_TURN = 10;
|
||||
const MAX_TOOLSET_REMINDERS_PER_TURN = 5;
|
||||
const MAX_COMMAND_INPUT_CHARS = 2000;
|
||||
const MAX_COMMAND_OUTPUT_CHARS = 4000;
|
||||
const MAX_TOOL_LIST_CHARS = 3000;
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function truncate(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, maxChars)}... [truncated]`;
|
||||
}
|
||||
|
||||
function formatToolList(tools: string[]): string {
|
||||
const uniqueTools = Array.from(new Set(tools));
|
||||
if (uniqueTools.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
return truncate(uniqueTools.join(", "), MAX_TOOL_LIST_CHARS);
|
||||
}
|
||||
|
||||
async function buildCommandIoReminder(
|
||||
context: SharedReminderContext,
|
||||
): Promise<string | null> {
|
||||
if (context.state.pendingCommandIoReminders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queued = context.state.pendingCommandIoReminders.splice(0);
|
||||
const recent = queued.slice(-MAX_COMMAND_REMINDERS_PER_TURN);
|
||||
const dropped = queued.length - recent.length;
|
||||
|
||||
const commandBlocks = recent.map((entry) => {
|
||||
const status = entry.success ? "success" : "error";
|
||||
const safeInput = escapeXml(truncate(entry.input, MAX_COMMAND_INPUT_CHARS));
|
||||
const safeOutput = escapeXml(
|
||||
truncate(entry.output || "(no output)", MAX_COMMAND_OUTPUT_CHARS),
|
||||
);
|
||||
return `<user-command>
|
||||
<user-command-input>${safeInput}</user-command-input>
|
||||
<user-command-output>${safeOutput}</user-command-output>
|
||||
<user-command-status>${status}</user-command-status>
|
||||
</user-command>`;
|
||||
});
|
||||
|
||||
const droppedLine =
|
||||
dropped > 0 ? `\nOmitted ${dropped} older command event(s).` : "";
|
||||
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
The following slash commands were executed in the Letta Code harness since your last user message.
|
||||
Treat these as execution context from the CLI, not new user requests.${droppedLine}
|
||||
${commandBlocks.join("\n")}
|
||||
${SYSTEM_REMINDER_CLOSE}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
async function buildToolsetChangeReminder(
|
||||
context: SharedReminderContext,
|
||||
): Promise<string | null> {
|
||||
if (context.state.pendingToolsetChangeReminders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queued = context.state.pendingToolsetChangeReminders.splice(0);
|
||||
const recent = queued.slice(-MAX_TOOLSET_REMINDERS_PER_TURN);
|
||||
const dropped = queued.length - recent.length;
|
||||
|
||||
const changeBlocks = recent.map((entry) => {
|
||||
const source = escapeXml(entry.source);
|
||||
const previousToolset = escapeXml(entry.previousToolset ?? "unknown");
|
||||
const newToolset = escapeXml(entry.newToolset ?? "unknown");
|
||||
const previousTools = escapeXml(formatToolList(entry.previousTools));
|
||||
const newTools = escapeXml(formatToolList(entry.newTools));
|
||||
return `<toolset-change>
|
||||
<source>${source}</source>
|
||||
<previous-toolset>${previousToolset}</previous-toolset>
|
||||
<new-toolset>${newToolset}</new-toolset>
|
||||
<previous-tools>${previousTools}</previous-tools>
|
||||
<new-tools>${newTools}</new-tools>
|
||||
</toolset-change>`;
|
||||
});
|
||||
|
||||
const droppedLine =
|
||||
dropped > 0 ? `\nOmitted ${dropped} older toolset change event(s).` : "";
|
||||
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
The user just changed your toolset (specifically, client-side tools that are attached to the Letta Code harness, which may be a subset of your total tools).${droppedLine}
|
||||
${changeBlocks.join("\n")}
|
||||
${SYSTEM_REMINDER_CLOSE}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
export const sharedReminderProviders: Record<
|
||||
SharedReminderId,
|
||||
SharedReminderProvider
|
||||
@@ -252,6 +353,8 @@ export const sharedReminderProviders: Record<
|
||||
"plan-mode": buildPlanModeReminder,
|
||||
"reflection-step-count": buildReflectionStepReminder,
|
||||
"reflection-compaction": buildReflectionCompactionReminder,
|
||||
"command-io": buildCommandIoReminder,
|
||||
"toolset-change": buildToolsetChangeReminder,
|
||||
};
|
||||
|
||||
export function assertSharedReminderCoverage(): void {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { ContextTracker } from "../cli/helpers/contextTracker";
|
||||
import type { PermissionMode } from "../permissions/mode";
|
||||
|
||||
const MAX_PENDING_INTERACTION_REMINDERS = 25;
|
||||
|
||||
export interface CommandIoReminder {
|
||||
input: string;
|
||||
output: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface ToolsetChangeReminder {
|
||||
source: string;
|
||||
previousToolset: string | null;
|
||||
newToolset: string | null;
|
||||
previousTools: string[];
|
||||
newTools: string[];
|
||||
}
|
||||
|
||||
export interface SharedReminderState {
|
||||
hasSentSessionContext: boolean;
|
||||
hasInjectedSkillsReminder: boolean;
|
||||
@@ -10,6 +26,8 @@ export interface SharedReminderState {
|
||||
turnCount: number;
|
||||
pendingSkillsReinject: boolean;
|
||||
pendingReflectionTrigger: boolean;
|
||||
pendingCommandIoReminders: CommandIoReminder[];
|
||||
pendingToolsetChangeReminders: ToolsetChangeReminder[];
|
||||
}
|
||||
|
||||
export function createSharedReminderState(): SharedReminderState {
|
||||
@@ -22,6 +40,8 @@ export function createSharedReminderState(): SharedReminderState {
|
||||
turnCount: 0,
|
||||
pendingSkillsReinject: false,
|
||||
pendingReflectionTrigger: false,
|
||||
pendingCommandIoReminders: [],
|
||||
pendingToolsetChangeReminders: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,3 +62,25 @@ export function syncReminderStateFromContextTracker(
|
||||
contextTracker.pendingReflectionTrigger = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pushBounded<T>(items: T[], entry: T): void {
|
||||
items.push(entry);
|
||||
if (items.length <= MAX_PENDING_INTERACTION_REMINDERS) {
|
||||
return;
|
||||
}
|
||||
items.splice(0, items.length - MAX_PENDING_INTERACTION_REMINDERS);
|
||||
}
|
||||
|
||||
export function enqueueCommandIoReminder(
|
||||
state: SharedReminderState,
|
||||
reminder: CommandIoReminder,
|
||||
): void {
|
||||
pushBounded(state.pendingCommandIoReminders, reminder);
|
||||
}
|
||||
|
||||
export function enqueueToolsetChangeReminder(
|
||||
state: SharedReminderState,
|
||||
reminder: ToolsetChangeReminder,
|
||||
): void {
|
||||
pushBounded(state.pendingToolsetChangeReminders, reminder);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,27 @@ describe("commandRunner", () => {
|
||||
});
|
||||
expect(buffers.order).toEqual(["cmd-1"]);
|
||||
});
|
||||
|
||||
test("onCommandFinished fires once on running->finished transition", () => {
|
||||
const buffers = createBuffers();
|
||||
const buffersRef = { current: buffers };
|
||||
const finishedEvents: Array<{ input: string; output: string }> = [];
|
||||
const runner = createCommandRunner({
|
||||
buffersRef,
|
||||
refreshDerived: () => {},
|
||||
createId: () => "cmd-1",
|
||||
onCommandFinished: (event) => {
|
||||
finishedEvents.push({ input: event.input, output: event.output });
|
||||
},
|
||||
});
|
||||
|
||||
const cmd = runner.start("/model", "Opening model selector...");
|
||||
cmd.update({ output: "Still opening...", phase: "running" });
|
||||
cmd.finish("Switched", true);
|
||||
cmd.finish("Switched again", true);
|
||||
|
||||
expect(finishedEvents).toEqual([{ input: "/model", output: "Switched" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("command input preservation in handlers", () => {
|
||||
|
||||
35
src/tests/cli/interaction-reminder-wiring.test.ts
Normal file
35
src/tests/cli/interaction-reminder-wiring.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function readAppSource(): string {
|
||||
const appPath = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url));
|
||||
return readFileSync(appPath, "utf-8");
|
||||
}
|
||||
|
||||
describe("interaction reminder wiring", () => {
|
||||
test("command runner finish events are wired into shared reminder state", () => {
|
||||
const source = readAppSource();
|
||||
expect(source).toContain("const recordCommandReminder = useCallback(");
|
||||
expect(source).toContain(
|
||||
"enqueueCommandIoReminder(sharedReminderStateRef.current",
|
||||
);
|
||||
expect(source).toContain("onCommandFinished: recordCommandReminder");
|
||||
});
|
||||
|
||||
test("model/toolset handlers enqueue toolset change reminder snapshots", () => {
|
||||
const source = readAppSource();
|
||||
expect(source).toContain(
|
||||
"const maybeRecordToolsetChangeReminder = useCallback(",
|
||||
);
|
||||
expect(source).toContain(
|
||||
"const previousToolNamesSnapshot = getToolNames();",
|
||||
);
|
||||
expect(source).toContain('source: "/model (auto toolset)"');
|
||||
expect(source).toContain('source: "/model (manual toolset override)"');
|
||||
expect(source).toContain('source: "/toolset"');
|
||||
expect(source).toContain(
|
||||
"enqueueToolsetChangeReminder(sharedReminderStateRef.current",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,14 +18,29 @@ describe("shared reminder catalog", () => {
|
||||
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("every runtime mode has at least one reminder", () => {
|
||||
const modes: Array<
|
||||
"interactive" | "headless-one-shot" | "headless-bidirectional"
|
||||
> = ["interactive", "headless-one-shot", "headless-bidirectional"];
|
||||
|
||||
for (const mode of modes) {
|
||||
expect(
|
||||
SHARED_REMINDER_CATALOG.some((entry) => entry.modes.includes(mode)),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("command and toolset reminders are interactive-only", () => {
|
||||
const commandReminder = SHARED_REMINDER_CATALOG.find(
|
||||
(entry) => entry.id === "command-io",
|
||||
);
|
||||
const toolsetReminder = SHARED_REMINDER_CATALOG.find(
|
||||
(entry) => entry.id === "toolset-change",
|
||||
);
|
||||
expect(commandReminder?.modes).toEqual(["interactive"]);
|
||||
expect(toolsetReminder?.modes).toEqual(["interactive"]);
|
||||
});
|
||||
|
||||
test("provider ids and catalog ids stay in lockstep", () => {
|
||||
expect(Object.keys(sharedReminderProviders).sort()).toEqual(
|
||||
[...SHARED_REMINDER_IDS].sort(),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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 {
|
||||
SHARED_REMINDER_CATALOG,
|
||||
SHARED_REMINDER_IDS,
|
||||
type SharedReminderId,
|
||||
type SharedReminderMode,
|
||||
} from "../../reminders/catalog";
|
||||
import {
|
||||
buildSharedReminderParts,
|
||||
sharedReminderProviders,
|
||||
@@ -11,6 +16,12 @@ import { createSharedReminderState } from "../../reminders/state";
|
||||
const originalProviders = { ...sharedReminderProviders };
|
||||
const providerMap = sharedReminderProviders;
|
||||
|
||||
function reminderIdsForMode(mode: SharedReminderMode): SharedReminderId[] {
|
||||
return SHARED_REMINDER_CATALOG.filter((entry) =>
|
||||
entry.modes.includes(mode),
|
||||
).map((entry) => entry.id);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const reminderId of SHARED_REMINDER_IDS) {
|
||||
providerMap[reminderId] = originalProviders[reminderId];
|
||||
@@ -58,15 +69,23 @@ describe("shared reminder parity", () => {
|
||||
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(interactive.appliedReminderIds).toEqual(
|
||||
reminderIdsForMode("interactive"),
|
||||
);
|
||||
expect(oneShot.appliedReminderIds).toEqual(
|
||||
reminderIdsForMode("headless-one-shot"),
|
||||
);
|
||||
expect(bidirectional.appliedReminderIds).toEqual(
|
||||
reminderIdsForMode("headless-bidirectional"),
|
||||
);
|
||||
expect(interactive.parts.map((part) => part.text)).toEqual(
|
||||
reminderIdsForMode("interactive"),
|
||||
);
|
||||
expect(oneShot.parts.map((part) => part.text)).toEqual(
|
||||
reminderIdsForMode("headless-one-shot"),
|
||||
);
|
||||
expect(oneShot.parts.map((part) => part.text)).toEqual(SHARED_REMINDER_IDS);
|
||||
expect(bidirectional.parts.map((part) => part.text)).toEqual(
|
||||
SHARED_REMINDER_IDS,
|
||||
reminderIdsForMode("headless-bidirectional"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
90
src/tests/reminders/interaction-reminders.test.ts
Normal file
90
src/tests/reminders/interaction-reminders.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type SharedReminderContext,
|
||||
sharedReminderProviders,
|
||||
} from "../../reminders/engine";
|
||||
import {
|
||||
createSharedReminderState,
|
||||
enqueueCommandIoReminder,
|
||||
enqueueToolsetChangeReminder,
|
||||
type SharedReminderState,
|
||||
} from "../../reminders/state";
|
||||
|
||||
function baseContext(
|
||||
state: SharedReminderState,
|
||||
mode: SharedReminderContext["mode"] = "interactive",
|
||||
): SharedReminderContext {
|
||||
return {
|
||||
mode,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Agent 1",
|
||||
description: null,
|
||||
lastRunAt: null,
|
||||
},
|
||||
state,
|
||||
sessionContextReminderEnabled: false,
|
||||
reflectionSettings: {
|
||||
trigger: "off",
|
||||
behavior: "reminder",
|
||||
stepCount: 25,
|
||||
},
|
||||
skillSources: [],
|
||||
resolvePlanModeReminder: () => "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("interaction reminders", () => {
|
||||
test("command-io provider renders escaped command input/output and drains queue", async () => {
|
||||
const state = createSharedReminderState();
|
||||
enqueueCommandIoReminder(state, {
|
||||
input: '/model && echo "<unsafe>"',
|
||||
output: "Models dialog dismissed <ok>",
|
||||
success: true,
|
||||
});
|
||||
|
||||
const reminder = await sharedReminderProviders["command-io"](
|
||||
baseContext(state),
|
||||
);
|
||||
expect(reminder).toContain("<user-command-input>");
|
||||
expect(reminder).toContain("<unsafe>");
|
||||
expect(reminder).toContain("<user-command-output>");
|
||||
expect(reminder).toContain("<ok>");
|
||||
expect(state.pendingCommandIoReminders).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("toolset-change provider renders previous/new toolset and drains queue", async () => {
|
||||
const state = createSharedReminderState();
|
||||
enqueueToolsetChangeReminder(state, {
|
||||
source: "/toolset",
|
||||
previousToolset: "default",
|
||||
newToolset: "codex",
|
||||
previousTools: ["Read", "Write"],
|
||||
newTools: ["ReadFile", "ApplyPatch", "ShellCommand"],
|
||||
});
|
||||
|
||||
const reminder = await sharedReminderProviders["toolset-change"](
|
||||
baseContext(state),
|
||||
);
|
||||
expect(reminder).toContain("<source>/toolset</source>");
|
||||
expect(reminder).toContain("<previous-toolset>default</previous-toolset>");
|
||||
expect(reminder).toContain("<new-toolset>codex</new-toolset>");
|
||||
expect(reminder).toContain("<previous-tools>Read, Write</previous-tools>");
|
||||
expect(reminder).toContain(
|
||||
"<new-tools>ReadFile, ApplyPatch, ShellCommand</new-tools>",
|
||||
);
|
||||
expect(state.pendingToolsetChangeReminders).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("interaction reminder providers return null when there is no queued data", async () => {
|
||||
const state = createSharedReminderState();
|
||||
const commandReminder = await sharedReminderProviders["command-io"](
|
||||
baseContext(state),
|
||||
);
|
||||
const toolsetReminder = await sharedReminderProviders["toolset-change"](
|
||||
baseContext(state),
|
||||
);
|
||||
expect(commandReminder).toBeNull();
|
||||
expect(toolsetReminder).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user