- Implemented mutable session titles with audit tracking, including storage persistence for SQL and Redis backends. - Added comprehensive session management features such as idle recap triggers, incremental subagent yield submissions, and automated title refreshing. - Enhanced task tracking in the TodoTool with progress prioritization and improved session cleanup logic. - Introduced citation tag handling for OpenAI-compatible source markers and improved edit parsing.
203 lines
6.1 KiB
TypeScript
203 lines
6.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
|
|
function createAssistantMessage(): AssistantMessage {
|
|
return {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "done" }],
|
|
api: "anthropic-messages",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4-5",
|
|
usage: {
|
|
input: 200,
|
|
output: 10,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 210,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason: "stop",
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
function createContext(
|
|
options: {
|
|
editorText?: string;
|
|
goalObjective?: string;
|
|
isCompacting?: boolean;
|
|
isStreaming?: boolean;
|
|
runIdleCompaction?: () => void;
|
|
sessionName?: string;
|
|
showStatus?: (message: string, options?: { dim?: boolean }) => void;
|
|
todoPhases?: InteractiveModeContext["todoPhases"];
|
|
} = {},
|
|
): InteractiveModeContext {
|
|
const runIdleCompaction = options.runIdleCompaction ?? (() => {});
|
|
const goalState = options.goalObjective
|
|
? {
|
|
enabled: true,
|
|
mode: "active",
|
|
goal: {
|
|
id: "goal-test",
|
|
objective: options.goalObjective,
|
|
status: "active",
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
},
|
|
}
|
|
: undefined;
|
|
const context = {
|
|
isInitialized: true,
|
|
loadingAnimation: undefined,
|
|
streamingComponent: undefined,
|
|
streamingMessage: undefined,
|
|
pendingTools: new Map<string, unknown>(),
|
|
flushPendingModelSwitch: async () => {},
|
|
ui: { requestRender: vi.fn() },
|
|
chatContainer: { removeChild: vi.fn() },
|
|
statusContainer: { clear: vi.fn() },
|
|
statusLine: { invalidate: vi.fn() },
|
|
updateEditorTopBorder: vi.fn(),
|
|
editor: { getText: () => options.editorText ?? "" },
|
|
sessionManager: { getSessionName: () => options.sessionName },
|
|
todoPhases: options.todoPhases ?? [],
|
|
showStatus: options.showStatus ?? (() => {}),
|
|
session: {
|
|
isCompacting: options.isCompacting ?? false,
|
|
isStreaming: options.isStreaming ?? false,
|
|
runIdleCompaction,
|
|
getContextUsage: () => ({ tokens: 210 }),
|
|
getGoalModeState: () => goalState,
|
|
agent: { state: { messages: [createAssistantMessage()] } },
|
|
},
|
|
get viewSession() {
|
|
return (this as typeof context).session;
|
|
},
|
|
clearTransientSessionUi: () => {},
|
|
} as unknown as InteractiveModeContext;
|
|
return context;
|
|
}
|
|
|
|
describe("EventController idle compaction teardown", () => {
|
|
beforeEach(async () => {
|
|
await initTheme();
|
|
resetSettingsForTest();
|
|
await Settings.init({
|
|
inMemory: true,
|
|
overrides: {
|
|
"compaction.idleEnabled": true,
|
|
"compaction.idleThresholdTokens": 100,
|
|
"compaction.idleTimeoutSeconds": 60,
|
|
},
|
|
});
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
resetSettingsForTest();
|
|
});
|
|
|
|
it("cancels scheduled idle compaction when disposed", async () => {
|
|
const runIdleCompaction = vi.fn();
|
|
const context = createContext({ runIdleCompaction });
|
|
|
|
const controller = new EventController(context);
|
|
await controller.handleEvent({ type: "agent_end", messages: [createAssistantMessage()] });
|
|
controller.dispose();
|
|
vi.advanceTimersByTime(60_000);
|
|
|
|
expect(runIdleCompaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("emits an idle recap after the default four-minute delay", async () => {
|
|
resetSettingsForTest();
|
|
await Settings.init({
|
|
inMemory: true,
|
|
overrides: {
|
|
"compaction.idleEnabled": false,
|
|
"completion.notify": "off",
|
|
},
|
|
});
|
|
const showStatus = vi.fn((_: string, _options?: { dim?: boolean }) => {});
|
|
const context = createContext({
|
|
sessionName: "Fix login flow",
|
|
showStatus,
|
|
todoPhases: [{ name: "Work", tasks: [{ content: "Wire focused tests", status: "pending" }] }],
|
|
});
|
|
|
|
const controller = new EventController(context);
|
|
await controller.handleEvent({ type: "agent_end", messages: [createAssistantMessage()] });
|
|
vi.advanceTimersByTime(239_999);
|
|
expect(showStatus).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(1);
|
|
|
|
expect(showStatus).toHaveBeenCalledTimes(1);
|
|
const [message, options] = showStatus.mock.calls[0] ?? [];
|
|
expect(Bun.stripANSI(message ?? "")).toBe("※ recap: Goal: Fix login flow Next: Wire focused tests");
|
|
expect(options).toEqual({ dim: false });
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps the idle recap silent when disabled", async () => {
|
|
resetSettingsForTest();
|
|
await Settings.init({
|
|
inMemory: true,
|
|
overrides: {
|
|
"compaction.idleEnabled": false,
|
|
"completion.notify": "off",
|
|
"recap.enabled": false,
|
|
"recap.idleSeconds": 1,
|
|
},
|
|
});
|
|
const showStatus = vi.fn((_: string, _options?: { dim?: boolean }) => {});
|
|
const context = createContext({
|
|
sessionName: "Fix login flow",
|
|
showStatus,
|
|
todoPhases: [{ name: "Work", tasks: [{ content: "Wire focused tests", status: "pending" }] }],
|
|
});
|
|
|
|
const controller = new EventController(context);
|
|
await controller.handleEvent({ type: "agent_end", messages: [createAssistantMessage()] });
|
|
vi.advanceTimersByTime(1_000);
|
|
|
|
expect(showStatus).not.toHaveBeenCalled();
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps the idle recap silent while the editor has a draft", async () => {
|
|
resetSettingsForTest();
|
|
await Settings.init({
|
|
inMemory: true,
|
|
overrides: {
|
|
"compaction.idleEnabled": false,
|
|
"completion.notify": "off",
|
|
"recap.idleSeconds": 1,
|
|
},
|
|
});
|
|
const showStatus = vi.fn((_: string, _options?: { dim?: boolean }) => {});
|
|
const context = createContext({
|
|
editorText: "draft",
|
|
sessionName: "Fix login flow",
|
|
showStatus,
|
|
todoPhases: [{ name: "Work", tasks: [{ content: "Wire focused tests", status: "pending" }] }],
|
|
});
|
|
|
|
const controller = new EventController(context);
|
|
await controller.handleEvent({ type: "agent_end", messages: [createAssistantMessage()] });
|
|
vi.advanceTimersByTime(1_000);
|
|
|
|
expect(showStatus).not.toHaveBeenCalled();
|
|
controller.dispose();
|
|
});
|
|
});
|