feat: inject toolset swap and slash-command context reminders (#1022)
This commit is contained in:
@@ -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