Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ Runtime data lives outside the repo under `~/.pi/agent/memory/` (`MEMORY.md`, `S

## Testing Guidelines

- Enforce TDD for every behavior change: follow `red -> green -> refactor`.
- Start by establishing a verifiable baseline: run the relevant existing tests before edits, and record the exact command + outcome in the PR/commit notes.
- Add or update a failing test first that reproduces the bug or captures the new requirement; implement code only after the test fails for the expected reason.
- Keep tests green after implementation and after any refactor; do not merge with skipped failing tests.
- Every bug fix must include a regression test that fails before the fix and passes after it.
- Tests touch `~/.pi/agent/memory/`; ensure backups/restores remain intact and new tests don’t leak user data.
- Prefer behavior-focused assertions (tool availability, file contents, cross-session recall). Keep timeouts generous for model latency.

Expand Down
39 changes: 36 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,41 @@ function formatExitSummaryEntry(
return [`<!-- ${timestamp} [${sessionId}] -->`, header, "", summary.trim()].join("\n");
}

function getSessionBranch(ctx: ExtensionContext): SessionEntry[] | null {
const sessionManager = ctx.sessionManager as ExtensionContext["sessionManager"] & {
getBranch?: () => SessionEntry[];
};
if (typeof sessionManager?.getBranch !== "function") {
return null;
}
return sessionManager.getBranch();
}

async function resolveExitSummaryApiKey(ctx: ExtensionContext): Promise<string | undefined> {
if (!ctx.model) return undefined;

const modelRegistry = ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
getApiKey?: (model: NonNullable<ExtensionContext["model"]>) => Promise<string | undefined>;
getApiKeyForProvider?: (provider: string) => Promise<string | undefined>;
};

if (typeof modelRegistry?.getApiKey === "function") {
return modelRegistry.getApiKey(ctx.model);
}

if (typeof modelRegistry?.getApiKeyForProvider === "function") {
return modelRegistry.getApiKeyForProvider(ctx.model.provider);
}

return undefined;
}

async function generateExitSummary(ctx: ExtensionContext): Promise<ExitSummaryResult> {
const branch = ctx.sessionManager.getBranch();
const branch = getSessionBranch(ctx);
if (!branch) {
return { summary: null, error: "Session branch unavailable", hasMessages: false };
}

const messages = branch
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
.map((entry) => entry.message);
Expand All @@ -355,11 +388,11 @@ async function generateExitSummary(ctx: ExtensionContext): Promise<ExitSummaryRe
return { summary: null, error: "No active model", hasMessages: true };
}

const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
const apiKey = await resolveExitSummaryApiKey(ctx);
if (!apiKey) {
return {
summary: null,
error: `No API key for ${ctx.model.provider}/${ctx.model.id}`,
error: `API key resolution unavailable for ${ctx.model.provider}/${ctx.model.id}`,
hasMessages: true,
};
}
Expand Down
55 changes: 54 additions & 1 deletion test/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ function createMockCtx(sessionId = "abcdef1234567890") {
};
}

function createShutdownCtx(options?: {
sessionId?: string;
branch?: any[];
model?: { provider: string; id: string };
modelRegistry?: Record<string, unknown>;
}) {
const sessionId = options?.sessionId ?? "abcdef1234567890";
return {
sessionManager: {
getSessionId: () => sessionId,
getBranch: () => options?.branch ?? [],
},
model: options?.model,
modelRegistry: options?.modelRegistry ?? {},
hasUI: false,
ui: {
notify: mock(() => {}),
},
};
}

// We need to import the default export to register tools
import registerExtension from "../index.js";

Expand Down Expand Up @@ -983,7 +1004,7 @@ describe("lifecycle hooks", () => {
_setQmdAvailable(true);
scheduleQmdUpdate();
expect(_getUpdateTimer()).not.toBeNull();
await hooks.session_shutdown({}, {});
await hooks.session_shutdown({}, createShutdownCtx());
expect(_getUpdateTimer()).toBeNull();
});

Expand All @@ -993,6 +1014,38 @@ describe("lifecycle hooks", () => {
await hooks.session_shutdown({}, {});
});

test("session_shutdown writes fallback summary when getApiKey is unavailable", async () => {
const ctx = createShutdownCtx({
branch: [
{
type: "message",
message: {
role: "user",
content: [{ type: "text", text: "Please remember we chose SQLite." }],
timestamp: Date.now(),
},
},
{
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text: "Noted." }],
timestamp: Date.now(),
},
},
],
model: { provider: "openai", id: "gpt-4o-mini" },
modelRegistry: {},
});

await hooks.session_shutdown({}, ctx);

const content = fs.readFileSync(dailyPath(todayStr()), "utf-8");
expect(content).toContain("## Session Summary (auto, exit: session-end)");
expect(content).toContain("Auto-summary unavailable");
expect(content).toContain("API key resolution unavailable");
});

// -- session_before_compact --

test("session_before_compact appends handoff when scratchpad has open items", async () => {
Expand Down
Loading