update
This commit is contained in:
1 parent
1261c2431f
commit
dac59f56c2
680 files changed
+192574
No files matched your search
+437
@@ -0,0 +1,437 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import { type AssistantMessage, getModel } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
vi.mock("../src/core/compaction/index.js", () => ({
|
||||
calculateContextTokens: (usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens?: number;
|
||||
}) => usage.totalTokens ?? usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
|
||||
collectEntriesForBranchSummary: () => ({ entries: [], commonAncestorId: null }),
|
||||
compact: async () => ({
|
||||
summary: "compacted",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 100,
|
||||
details: {},
|
||||
}),
|
||||
estimateContextTokens: (
|
||||
messages: Array<{
|
||||
role: string;
|
||||
usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens?: number };
|
||||
stopReason?: string;
|
||||
}>,
|
||||
) => {
|
||||
// Walk backwards to find last non-error, non-aborted assistant with usage
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "assistant" && msg.stopReason !== "error" && msg.stopReason !== "aborted" && msg.usage) {
|
||||
const tokens =
|
||||
msg.usage.totalTokens ?? msg.usage.input + msg.usage.output + msg.usage.cacheRead + msg.usage.cacheWrite;
|
||||
return { tokens, usageTokens: tokens, trailingTokens: 0, lastUsageIndex: i };
|
||||
}
|
||||
}
|
||||
return { tokens: 0, usageTokens: 0, trailingTokens: 0, lastUsageIndex: null };
|
||||
},
|
||||
generateBranchSummary: async () => ({ summary: "", aborted: false, readFiles: [], modifiedFiles: [] }),
|
||||
prepareCompaction: () => ({ dummy: true }),
|
||||
shouldCompact: (
|
||||
contextTokens: number,
|
||||
contextWindow: number,
|
||||
settings: { enabled: boolean; reserveTokens: number },
|
||||
) => settings.enabled && contextTokens > contextWindow - settings.reserveTokens,
|
||||
}));
|
||||
|
||||
describe("AgentSession auto-compaction queue resume", () => {
|
||||
let session: AgentSession;
|
||||
let sessionManager: SessionManager;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
vi.useFakeTimers();
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
|
||||
sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
session.dispose();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should resume after threshold compaction when only agent-level queued messages exist", async () => {
|
||||
session.agent.followUp({
|
||||
role: "custom",
|
||||
customType: "test",
|
||||
content: [{ type: "text", text: "Queued custom" }],
|
||||
display: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(session.pendingMessageCount).toBe(0);
|
||||
expect(session.agent.hasQueuedMessages()).toBe(true);
|
||||
|
||||
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
|
||||
|
||||
const runAutoCompaction = (
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
}
|
||||
)._runAutoCompaction.bind(session);
|
||||
|
||||
await runAutoCompaction("threshold", false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(continueSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not compact repeatedly after overflow recovery already attempted", async () => {
|
||||
const model = session.model!;
|
||||
const overflowMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "prompt is too long",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const events: Array<{ type: string; reason: string; errorMessage?: string }> = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "compaction_end") {
|
||||
events.push({ type: event.type, reason: event.reason, errorMessage: event.errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(overflowMessage);
|
||||
await checkCompaction({ ...overflowMessage, timestamp: Date.now() + 1 });
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(events).toContainEqual({
|
||||
type: "compaction_end",
|
||||
reason: "overflow",
|
||||
errorMessage:
|
||||
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore stale pre-compaction assistant usage on pre-prompt compaction checks", async () => {
|
||||
const model = session.model!;
|
||||
const staleAssistantTimestamp = Date.now() - 10_000;
|
||||
const staleAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "large response before compaction" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 600_000,
|
||||
output: 10_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 610_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: staleAssistantTimestamp,
|
||||
};
|
||||
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: staleAssistantTimestamp - 1000,
|
||||
});
|
||||
sessionManager.appendMessage(staleAssistant);
|
||||
|
||||
const firstKeptEntryId = sessionManager.getEntries()[0]!.id;
|
||||
sessionManager.appendCompaction("summary", firstKeptEntryId, staleAssistant.usage.totalTokens, undefined, false);
|
||||
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "session recovery payload" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(staleAssistant, false);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger threshold compaction for error messages using last successful usage", async () => {
|
||||
const model = session.model!;
|
||||
|
||||
// A successful assistant message with high token usage (near context limit)
|
||||
const successfulAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "large successful response" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 180_000,
|
||||
output: 10_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 190_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// An error message (e.g. 529 overloaded) with no useful usage data
|
||||
const errorAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now() + 1000,
|
||||
};
|
||||
|
||||
// Put both messages into agent state so estimateContextTokens can find the successful one
|
||||
session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
successfulAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "another prompt" }], timestamp: Date.now() + 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
|
||||
});
|
||||
|
||||
it("should not trigger threshold compaction for error messages when no prior usage exists", async () => {
|
||||
const model = session.model!;
|
||||
|
||||
// An error message with no prior successful assistant in context
|
||||
const errorAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not trigger threshold compaction for error messages when only kept pre-compaction usage exists", async () => {
|
||||
const model = session.model!;
|
||||
const preCompactionTimestamp = Date.now() - 10_000;
|
||||
|
||||
// A "kept" assistant message from before compaction with high usage
|
||||
const keptAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "kept response from before compaction" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 180_000,
|
||||
output: 10_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 190_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: preCompactionTimestamp,
|
||||
};
|
||||
|
||||
// Record the kept assistant in the session and create a compaction after it
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: preCompactionTimestamp - 1000,
|
||||
});
|
||||
sessionManager.appendMessage(keptAssistant);
|
||||
const firstKeptEntryId = sessionManager.getEntries()[0]!.id;
|
||||
sessionManager.appendCompaction("summary", firstKeptEntryId, keptAssistant.usage.totalTokens, undefined, false);
|
||||
|
||||
// Post-compaction error message
|
||||
const errorAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Agent state has the kept assistant (pre-compaction) and the error (post-compaction)
|
||||
session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "kept user msg" }], timestamp: preCompactionTimestamp - 1000 },
|
||||
keptAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(errorAssistant);
|
||||
|
||||
// Should NOT compact because the only usage data is from a kept pre-compaction message
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Tests for AgentSession forking behavior.
|
||||
*
|
||||
* These tests verify:
|
||||
* - Forking from a single message works
|
||||
* - Forking in --no-session mode (in-memory only)
|
||||
* - getUserMessagesForForking returns correct entries
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { AgentSession } from "../src/core/agent-session.js";
|
||||
import {
|
||||
type AgentSessionRuntime,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
} from "../src/core/agent-session-runtime.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { codingTools } from "../src/core/tools/index.js";
|
||||
import { API_KEY } from "./utilities.js";
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession forking", () => {
|
||||
let session: AgentSession;
|
||||
let runtimeHost: AgentSessionRuntime;
|
||||
let tempDir: string;
|
||||
let sessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-branching-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (runtimeHost) {
|
||||
await runtimeHost.dispose();
|
||||
}
|
||||
process.chdir(tmpdir());
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(noSession: boolean = false) {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
authStorage.setRuntimeApiKey("anthropic", API_KEY!);
|
||||
|
||||
const servicesOptions = {
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
resourceLoaderOptions: {
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({
|
||||
...servicesOptions,
|
||||
cwd,
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model,
|
||||
tools: codingTools,
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
runtimeHost = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager,
|
||||
});
|
||||
session = runtimeHost.session;
|
||||
session.subscribe(() => {});
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should allow forking from single message", async () => {
|
||||
await createSession();
|
||||
|
||||
await session.prompt("Say hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const userMessages = session.getUserMessagesForForking();
|
||||
expect(userMessages.length).toBe(1);
|
||||
expect(userMessages[0].text).toBe("Say hello");
|
||||
|
||||
const result = await runtimeHost.fork(userMessages[0].entryId);
|
||||
expect(result.cancelled).toBe(false);
|
||||
session = runtimeHost.session;
|
||||
expect(result.selectedText).toBe("Say hello");
|
||||
|
||||
expect(session.messages.length).toBe(0);
|
||||
expect(session.sessionFile).not.toBeNull();
|
||||
expect(existsSync(session.sessionFile!)).toBe(false);
|
||||
});
|
||||
|
||||
it("should support in-memory forking in --no-session mode", async () => {
|
||||
await createSession(true);
|
||||
|
||||
expect(session.sessionFile).toBeUndefined();
|
||||
|
||||
await session.prompt("Say hi");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const userMessages = session.getUserMessagesForForking();
|
||||
expect(userMessages.length).toBe(1);
|
||||
expect(session.messages.length).toBeGreaterThan(0);
|
||||
|
||||
const result = await runtimeHost.fork(userMessages[0].entryId);
|
||||
expect(result.cancelled).toBe(false);
|
||||
session = runtimeHost.session;
|
||||
expect(result.selectedText).toBe("Say hi");
|
||||
|
||||
expect(session.messages.length).toBe(0);
|
||||
expect(session.sessionFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should fork from middle of conversation", async () => {
|
||||
await createSession();
|
||||
|
||||
await session.prompt("Say one");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("Say two");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("Say three");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const userMessages = session.getUserMessagesForForking();
|
||||
expect(userMessages.length).toBe(3);
|
||||
|
||||
const secondMessage = userMessages[1];
|
||||
const result = await runtimeHost.fork(secondMessage.entryId);
|
||||
expect(result.cancelled).toBe(false);
|
||||
session = runtimeHost.session;
|
||||
expect(result.selectedText).toBe("Say two");
|
||||
|
||||
expect(session.messages.length).toBe(2);
|
||||
expect(session.messages[0].role).toBe("user");
|
||||
expect(session.messages[1].role).toBe("assistant");
|
||||
}, 60000);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* E2E tests for AgentSession compaction behavior.
|
||||
*
|
||||
* These tests use real LLM calls (no mocking) to verify:
|
||||
* - Manual compaction works correctly
|
||||
* - Session persistence during compaction
|
||||
* - Compaction entry is saved to session file
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { codingTools } from "../src/core/tools/index.js";
|
||||
import { API_KEY, createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
let sessionManager: SessionManager;
|
||||
let events: AgentSessionEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for session files
|
||||
tempDir = join(tmpdir(), `pi-compaction-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Track events
|
||||
events = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createSession(inMemory = false) {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => API_KEY,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
tools: codingTools,
|
||||
},
|
||||
});
|
||||
|
||||
sessionManager = inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
// Use minimal keepRecentTokens so small test conversations have something to summarize
|
||||
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// Subscribe to track events
|
||||
session.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should trigger manual compaction via compact()", async () => {
|
||||
createSession();
|
||||
|
||||
// Send a few prompts to build up history
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Manually compact
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.tokensBefore).toBeGreaterThan(0);
|
||||
|
||||
// Verify messages were compacted (should have summary + recent)
|
||||
const messages = session.messages;
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// First message should be the summary (a user message with summary content)
|
||||
const firstMsg = messages[0];
|
||||
expect(firstMsg.role).toBe("compactionSummary");
|
||||
}, 120000);
|
||||
|
||||
it("should maintain valid session state after compaction", async () => {
|
||||
createSession();
|
||||
|
||||
// Build up history
|
||||
await session.prompt("What is the capital of France? One word answer.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is the capital of Germany? One word answer.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Compact
|
||||
await session.compact();
|
||||
|
||||
// Session should still be usable
|
||||
await session.prompt("What is the capital of Italy? One word answer.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Should have messages after compaction
|
||||
expect(session.messages.length).toBeGreaterThan(0);
|
||||
|
||||
// The agent should have responded
|
||||
const assistantMessages = session.messages.filter((m) => m.role === "assistant");
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
}, 180000);
|
||||
|
||||
it("should persist compaction to session file", async () => {
|
||||
createSession();
|
||||
|
||||
await session.prompt("Say hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("Say goodbye");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Compact
|
||||
await session.compact();
|
||||
|
||||
// Load entries from session manager
|
||||
const entries = sessionManager.getEntries();
|
||||
|
||||
// Should have a compaction entry
|
||||
const compactionEntries = entries.filter((e) => e.type === "compaction");
|
||||
expect(compactionEntries.length).toBe(1);
|
||||
|
||||
const compaction = compactionEntries[0];
|
||||
expect(compaction.type).toBe("compaction");
|
||||
if (compaction.type === "compaction") {
|
||||
expect(compaction.summary.length).toBeGreaterThan(0);
|
||||
expect(typeof compaction.firstKeptEntryId).toBe("string");
|
||||
expect(compaction.tokensBefore).toBeGreaterThan(0);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should work with --no-session mode (in-memory only)", async () => {
|
||||
createSession(true); // in-memory mode
|
||||
|
||||
// Send prompts
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Compact should work even without file persistence
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
|
||||
// In-memory entries should have the compaction
|
||||
const entries = sessionManager.getEntries();
|
||||
const compactionEntries = entries.filter((e) => e.type === "compaction");
|
||||
expect(compactionEntries.length).toBe(1);
|
||||
}, 120000);
|
||||
|
||||
it("should emit compaction events during manual compaction", async () => {
|
||||
createSession();
|
||||
|
||||
// Build some history
|
||||
await session.prompt("Say hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Manually trigger compaction and check events
|
||||
await session.compact();
|
||||
|
||||
const compactionEvents = events.filter((e) => e.type === "compaction_start" || e.type === "compaction_end");
|
||||
expect(compactionEvents).toHaveLength(2);
|
||||
expect(compactionEvents[0]).toEqual({ type: "compaction_start", reason: "manual" });
|
||||
expect(compactionEvents[1]).toMatchObject({
|
||||
type: "compaction_end",
|
||||
reason: "manual",
|
||||
aborted: false,
|
||||
willRetry: false,
|
||||
});
|
||||
|
||||
// Regular events should have been emitted
|
||||
const messageEndEvents = events.filter((e) => e.type === "message_end");
|
||||
expect(messageEndEvents.length).toBeGreaterThan(0);
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Tests for AgentSession concurrent prompt guard.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEvent,
|
||||
EventStream,
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type TextContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createTestExtensionsResult, createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
// Mock stream that mimics AssistantMessageEventStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession concurrent prompt guard", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete (globalThis as typeof globalThis & { testExtensionApi?: unknown }).testExtensionApi;
|
||||
delete (globalThis as typeof globalThis & { testCommandRuns?: unknown }).testCommandRuns;
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createSession() {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
|
||||
// Use a stream function that responds to abort
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: (_model, _context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
const checkAbort = () => {
|
||||
if (abortSignal?.aborted) {
|
||||
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
|
||||
} else {
|
||||
setTimeout(checkAbort, 5);
|
||||
}
|
||||
};
|
||||
checkAbort();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
// Set a runtime API key so validation passes
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should throw when prompt() called while streaming", async () => {
|
||||
createSession();
|
||||
|
||||
// Start first prompt (don't await, it will block until abort)
|
||||
const firstPrompt = session.prompt("First message");
|
||||
|
||||
// Wait a tick for isStreaming to be set
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Verify we're streaming
|
||||
expect(session.isStreaming).toBe(true);
|
||||
|
||||
// Second prompt should reject
|
||||
await expect(session.prompt("Second message")).rejects.toThrow(
|
||||
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {}); // Ignore abort error
|
||||
});
|
||||
|
||||
it("should allow steer() while streaming", async () => {
|
||||
createSession();
|
||||
|
||||
// Start first prompt
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// steer should work while streaming
|
||||
expect(() => session.steer("Steering message")).not.toThrow();
|
||||
expect(session.pendingMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("should allow followUp() while streaming", async () => {
|
||||
createSession();
|
||||
|
||||
// Start first prompt
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// followUp should work while streaming
|
||||
expect(() => session.followUp("Follow-up message")).not.toThrow();
|
||||
expect(session.pendingMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("should queue extension-origin steering messages while streaming", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
let sawSteeringMessage = false;
|
||||
let lastInputSource: string | undefined;
|
||||
const queueEvents: Array<{ steering: readonly string[]; followUp: readonly string[] }> = [];
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: (_model, context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const userTexts = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
}
|
||||
return message.content
|
||||
.filter((part): part is TextContent | ImageContent => typeof part === "object" && part !== null)
|
||||
.filter((part): part is TextContent => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
if (userTexts.includes("Steer from extension")) {
|
||||
sawSteeringMessage = true;
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Steered") });
|
||||
return;
|
||||
}
|
||||
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
const checkAbort = () => {
|
||||
if (abortSignal?.aborted) {
|
||||
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
|
||||
} else {
|
||||
setTimeout(checkAbort, 5);
|
||||
}
|
||||
};
|
||||
checkAbort();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
const extensionsResult = await createTestExtensionsResult([
|
||||
(pi) => {
|
||||
(globalThis as typeof globalThis & { testExtensionApi?: unknown }).testExtensionApi = pi;
|
||||
},
|
||||
(pi) => {
|
||||
pi.on("input", async (event) => {
|
||||
lastInputSource = event.source;
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader({ extensionsResult }),
|
||||
});
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "queue_update") {
|
||||
queueEvents.push({ steering: event.steering, followUp: event.followUp });
|
||||
}
|
||||
});
|
||||
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(session.isStreaming).toBe(true);
|
||||
|
||||
const pi = (
|
||||
globalThis as typeof globalThis & {
|
||||
testExtensionApi?: {
|
||||
sendUserMessage: (content: string, options?: { deliverAs?: "steer" | "followUp" }) => void;
|
||||
};
|
||||
}
|
||||
).testExtensionApi;
|
||||
expect(pi).toBeDefined();
|
||||
|
||||
pi!.sendUserMessage("Steer from extension", { deliverAs: "steer" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
expect(session.pendingMessageCount).toBe(1);
|
||||
expect(session.getSteeringMessages()).toContain("Steer from extension");
|
||||
expect(lastInputSource).toBe("extension");
|
||||
expect(queueEvents.some((event) => event.steering.includes("Steer from extension"))).toBe(true);
|
||||
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
|
||||
expect(sawSteeringMessage).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow prompt() after previous completes", async () => {
|
||||
// Create session with a stream that completes immediately
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// First prompt completes
|
||||
await session.prompt("First message");
|
||||
|
||||
// Should not be streaming anymore
|
||||
expect(session.isStreaming).toBe(false);
|
||||
|
||||
// Second prompt should work
|
||||
await expect(session.prompt("Second message")).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should wait for queued agent events before emitting tool_call", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const tool = {
|
||||
name: "dummy",
|
||||
description: "Dummy tool",
|
||||
label: "dummy",
|
||||
parameters: Type.Object({ q: Type.String() }),
|
||||
execute: async (_toolCallId: string, params: unknown) => {
|
||||
const q =
|
||||
typeof params === "object" && params !== null && "q" in params
|
||||
? String((params as { q: unknown }).q)
|
||||
: "";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `result:${q}` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [tool],
|
||||
},
|
||||
streamFn: async (_model, context) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
|
||||
if (toolResultCount > 0) {
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
return;
|
||||
}
|
||||
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "toolu_1", name: "dummy", arguments: { q: "x" } },
|
||||
{ type: "toolCall", id: "toolu_2", name: "dummy", arguments: { q: "y" } },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
baseToolsOverride: { dummy: tool },
|
||||
});
|
||||
|
||||
const snapshots: string[][] = [];
|
||||
const sessionWithRunner = session as unknown as {
|
||||
_extensionRunner?: {
|
||||
hasHandlers: (eventType: string) => boolean;
|
||||
emit: (event: { type: string; message?: { role?: string } }) => Promise<void>;
|
||||
emitToolCall: (event: { type: string; toolCallId: string }) => Promise<undefined>;
|
||||
emitInput: (
|
||||
text: string,
|
||||
images: unknown,
|
||||
source: "interactive" | "rpc" | "extension",
|
||||
) => Promise<{ action: "continue" }>;
|
||||
emitBeforeAgentStart: (prompt: string, images: unknown, systemPrompt: string) => Promise<undefined>;
|
||||
};
|
||||
};
|
||||
sessionWithRunner._extensionRunner = {
|
||||
hasHandlers: (eventType) => eventType === "tool_call",
|
||||
emit: async () => {},
|
||||
emitToolCall: async () => {
|
||||
snapshots.push(
|
||||
sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map((entry) => entry.message.role),
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
emitInput: async () => ({ action: "continue" }),
|
||||
emitBeforeAgentStart: async () => undefined,
|
||||
};
|
||||
|
||||
await session.prompt("hi");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
expect(snapshots).toEqual([
|
||||
["user", "assistant"],
|
||||
["user", "assistant"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should persist message_end events in order with slow extension handlers", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const tool = {
|
||||
name: "dummy",
|
||||
description: "Dummy tool",
|
||||
label: "dummy",
|
||||
parameters: Type.Object({ q: Type.String() }),
|
||||
execute: async (_toolCallId: string, params: unknown) => {
|
||||
const q =
|
||||
typeof params === "object" && params !== null && "q" in params
|
||||
? String((params as { q: unknown }).q)
|
||||
: "";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `result:${q}` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [tool],
|
||||
},
|
||||
streamFn: async (_model, context) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const hasToolResult = context.messages.some((message) => message.role === "toolResult");
|
||||
|
||||
if (hasToolResult) {
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
return;
|
||||
}
|
||||
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling tool" },
|
||||
{ type: "toolCall", id: "toolu_1", name: "dummy", arguments: { q: "x" } },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
baseToolsOverride: { dummy: tool },
|
||||
});
|
||||
|
||||
const sessionWithRunner = session as unknown as {
|
||||
_extensionRunner?: {
|
||||
hasHandlers: (eventType: string) => boolean;
|
||||
emit: (event: { type: string; message?: { role?: string } }) => Promise<void>;
|
||||
emitInput: (
|
||||
text: string,
|
||||
images: unknown,
|
||||
source: "interactive" | "rpc" | "extension",
|
||||
) => Promise<{ action: "continue" }>;
|
||||
emitBeforeAgentStart: (prompt: string, images: unknown, systemPrompt: string) => Promise<undefined>;
|
||||
};
|
||||
};
|
||||
sessionWithRunner._extensionRunner = {
|
||||
hasHandlers: () => false,
|
||||
emit: async (event) => {
|
||||
if (event.type === "message_end" && event.message?.role === "assistant") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
},
|
||||
emitInput: async () => ({ action: "continue" }),
|
||||
emitBeforeAgentStart: async () => undefined,
|
||||
};
|
||||
|
||||
await session.prompt("hi");
|
||||
await session.agent.waitForIdle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const messageEntries = sessionManager.getEntries().filter((entry) => entry.type === "message");
|
||||
expect(messageEntries.map((entry) => entry.message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { DefaultResourceLoader } from "../src/core/resource-loader.js";
|
||||
import type { ExtensionFactory } from "../src/core/sdk.js";
|
||||
import { createAgentSession } from "../src/core/sdk.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
describe("AgentSession dynamic provider registration", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-dynamic-provider-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(extensionFactories: ExtensionFactory[]) {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
authStorage,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function capturePromptBaseUrl(
|
||||
session: Awaited<ReturnType<typeof createSession>>,
|
||||
): Promise<string | undefined> {
|
||||
let baseUrl: string | undefined;
|
||||
session.agent.streamFn = async (model) => {
|
||||
baseUrl = model.baseUrl;
|
||||
throw new Error("stop");
|
||||
};
|
||||
await session.prompt("hello");
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
it("applies top-level registerProvider overrides to the active model", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.registerProvider("anthropic", { baseUrl: "http://localhost:8080/top-level" });
|
||||
},
|
||||
]);
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/top-level");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/top-level");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("applies session_start registerProvider overrides to the active model", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.registerProvider("anthropic", { baseUrl: "http://localhost:8080/session-start" });
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
await session.bindExtensions({});
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/session-start");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/session-start");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("applies command-time registerProvider overrides without reload", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.registerCommand("use-proxy", {
|
||||
description: "Use proxy",
|
||||
handler: async () => {
|
||||
pi.registerProvider("anthropic", { baseUrl: "http://localhost:8080/command" });
|
||||
},
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
await session.bindExtensions({});
|
||||
await session.prompt("/use-proxy");
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/command");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/command");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DefaultResourceLoader } from "../src/core/resource-loader.js";
|
||||
import { createAgentSession } from "../src/core/sdk.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
describe("AgentSession dynamic tool registration", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-dynamic-tool-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes tool registry when tools are registered after initialization", async () => {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.registerTool({
|
||||
name: "dynamic_tool",
|
||||
label: "Dynamic Tool",
|
||||
description: "Tool registered from session_start",
|
||||
promptSnippet: "Run dynamic test behavior",
|
||||
promptGuidelines: ["Use dynamic_tool when the user asks for dynamic behavior tests."],
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
expect(session.getAllTools().map((tool) => tool.name)).not.toContain("dynamic_tool");
|
||||
|
||||
await session.bindExtensions({});
|
||||
|
||||
const allTools = session.getAllTools();
|
||||
const dynamicTool = allTools.find((tool) => tool.name === "dynamic_tool");
|
||||
const readTool = allTools.find((tool) => tool.name === "read");
|
||||
|
||||
expect(allTools.map((tool) => tool.name)).toContain("dynamic_tool");
|
||||
expect(dynamicTool?.sourceInfo).toMatchObject({
|
||||
path: "<inline:1>",
|
||||
source: "inline",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
});
|
||||
expect(readTool?.sourceInfo).toMatchObject({
|
||||
path: "<builtin:read>",
|
||||
source: "builtin",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
});
|
||||
expect(session.getActiveToolNames()).toContain("dynamic_tool");
|
||||
expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior");
|
||||
expect(session.systemPrompt).toContain("- Use dynamic_tool when the user asks for dynamic behavior tests.");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("returns source metadata for SDK custom tools", async () => {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
customTools: [
|
||||
{
|
||||
name: "sdk_tool",
|
||||
label: "SDK Tool",
|
||||
description: "Tool registered through createAgentSession",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sdkTool = session.getAllTools().find((tool) => tool.name === "sdk_tool");
|
||||
expect(sdkTool?.sourceInfo).toMatchObject({
|
||||
path: "<sdk:sdk_tool>",
|
||||
source: "sdk",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
});
|
||||
expect(session.getActiveToolNames()).toContain("sdk_tool");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("keeps custom tools active but omits them from available tools when promptSnippet is not provided", async () => {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.registerTool({
|
||||
name: "hidden_tool",
|
||||
label: "Hidden Tool",
|
||||
description: "Description should not appear in available tools",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
await session.bindExtensions({});
|
||||
|
||||
expect(session.getAllTools().map((tool) => tool.name)).toContain("hidden_tool");
|
||||
expect(session.getActiveToolNames()).toContain("hidden_tool");
|
||||
expect(session.systemPrompt).not.toContain("hidden_tool");
|
||||
expect(session.systemPrompt).not.toContain("Description should not appear in available tools");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
const reasoningModel = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const nonReasoningModel = getModel("anthropic", "claude-3-5-haiku-latest")!;
|
||||
|
||||
function createSession({
|
||||
thinkingLevel = "high",
|
||||
defaultThinkingLevel = thinkingLevel,
|
||||
scopedModels,
|
||||
}: {
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
defaultThinkingLevel?: ThinkingLevel;
|
||||
scopedModels?: Array<{ model: typeof reasoningModel; thinkingLevel?: ThinkingLevel }>;
|
||||
} = {}) {
|
||||
const settingsManager = SettingsManager.inMemory({ defaultThinkingLevel });
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
const session = new AgentSession({
|
||||
agent: new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model: reasoningModel,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
thinkingLevel,
|
||||
},
|
||||
}),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: process.cwd(),
|
||||
modelRegistry: ModelRegistry.inMemory(authStorage),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
scopedModels,
|
||||
});
|
||||
|
||||
return { session, sessionManager, settingsManager };
|
||||
}
|
||||
|
||||
describe("AgentSession model switching", () => {
|
||||
it("preserves the saved thinking preference through non-reasoning models", async () => {
|
||||
const { session, sessionManager, settingsManager } = createSession({
|
||||
scopedModels: [{ model: reasoningModel }, { model: nonReasoningModel }],
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setModel(nonReasoningModel);
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
expect(settingsManager.getDefaultThinkingLevel()).toBe("high");
|
||||
|
||||
await session.setModel(reasoningModel);
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
|
||||
await session.cycleModel();
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
expect(settingsManager.getDefaultThinkingLevel()).toBe("high");
|
||||
|
||||
await session.cycleModel();
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
expect(settingsManager.getDefaultThinkingLevel()).toBe("high");
|
||||
expect(
|
||||
sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "thinking_level_change")
|
||||
.map((entry) => entry.thinkingLevel),
|
||||
).toEqual(["off", "high", "off", "high"]);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent, type AgentEvent, type AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, overrides?: Partial<AssistantMessage>): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type SessionWithExtensionEmitHook = {
|
||||
_emitExtensionEvent: (event: AgentEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
describe("AgentSession retry", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-retry-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createSession(options?: { failCount?: number; maxRetries?: number; delayAssistantMessageEndMs?: number }) {
|
||||
const failCount = options?.failCount ?? 1;
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const delayAssistantMessageEndMs = options?.delayAssistantMessageEndMs ?? 0;
|
||||
let callCount = 0;
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFn: () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callCount <= failCount) {
|
||||
const msg = createAssistantMessage("", {
|
||||
stopReason: "error",
|
||||
errorMessage: "overloaded_error",
|
||||
});
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "error", reason: "error", error: msg });
|
||||
} else {
|
||||
const msg = createAssistantMessage("Success");
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "stop", message: msg });
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries, baseDelayMs: 1 } });
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
if (delayAssistantMessageEndMs > 0) {
|
||||
const sessionWithHook = session as unknown as SessionWithExtensionEmitHook;
|
||||
const original = sessionWithHook._emitExtensionEvent.bind(sessionWithHook);
|
||||
sessionWithHook._emitExtensionEvent = async (event: AgentEvent) => {
|
||||
if (event.type === "message_end" && event.message.role === "assistant") {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayAssistantMessageEndMs));
|
||||
}
|
||||
await original(event);
|
||||
};
|
||||
}
|
||||
|
||||
return { session, getCallCount: () => callCount };
|
||||
}
|
||||
|
||||
it("retries after a transient error and succeeds", async () => {
|
||||
const created = createSession({ failCount: 1 });
|
||||
const events: string[] = [];
|
||||
created.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") events.push(`end:success=${event.success}`);
|
||||
});
|
||||
|
||||
await created.session.prompt("Test");
|
||||
|
||||
expect(created.getCallCount()).toBe(2);
|
||||
expect(events).toEqual(["start:1", "end:success=true"]);
|
||||
expect(created.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("exhausts max retries and emits failure", async () => {
|
||||
const created = createSession({ failCount: 99, maxRetries: 2 });
|
||||
const events: string[] = [];
|
||||
created.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") events.push(`end:success=${event.success}`);
|
||||
});
|
||||
|
||||
await created.session.prompt("Test");
|
||||
|
||||
expect(created.getCallCount()).toBe(3);
|
||||
expect(events).toContain("start:1");
|
||||
expect(events).toContain("start:2");
|
||||
expect(events).toContain("end:success=false");
|
||||
expect(created.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("prompt waits for retry completion even when assistant message_end handling is delayed", async () => {
|
||||
const created = createSession({ failCount: 1, delayAssistantMessageEndMs: 40 });
|
||||
|
||||
await created.session.prompt("Test");
|
||||
|
||||
expect(created.getCallCount()).toBe(2);
|
||||
expect(created.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("retries provider network_error failures", async () => {
|
||||
const created = createSession({ failCount: 0 });
|
||||
let callCount = 0;
|
||||
const streamFn = () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callCount === 1) {
|
||||
const msg = createAssistantMessage("", {
|
||||
stopReason: "error",
|
||||
errorMessage: "Provider finish_reason: network_error",
|
||||
});
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "error", reason: "error", error: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = createAssistantMessage("Recovered after retry");
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "stop", message: msg });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
created.session.dispose();
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFn,
|
||||
});
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } });
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
const events: string[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") events.push(`end:success=${event.success}`);
|
||||
});
|
||||
|
||||
await session.prompt("Test");
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
expect(events).toEqual(["start:1", "end:success=true"]);
|
||||
});
|
||||
|
||||
it("prompt waits for full agent loop when retry produces tool calls", async () => {
|
||||
// Regression: when auto-retry fires and the retry response includes tool_use,
|
||||
// session.prompt() must wait for the entire tool loop to finish before returning.
|
||||
// Previously, _resolveRetry() on the first successful message_end would unblock
|
||||
// waitForRetry() while the agent was still executing tools.
|
||||
let callCount = 0;
|
||||
const toolExecuted = { value: false };
|
||||
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async () => {
|
||||
toolExecuted.value = true;
|
||||
return { content: [{ type: "text", text: "echoed" }], details: undefined };
|
||||
},
|
||||
};
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFn: () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callCount === 1) {
|
||||
// First call: overloaded error
|
||||
const msg = createAssistantMessage("", {
|
||||
stopReason: "error",
|
||||
errorMessage: "overloaded_error",
|
||||
});
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "error", reason: "error", error: msg });
|
||||
} else if (callCount === 2) {
|
||||
// Second call (retry): text + tool_use
|
||||
const msg: AssistantMessage = {
|
||||
...createAssistantMessage("Looking that up now."),
|
||||
stopReason: "toolUse",
|
||||
content: [
|
||||
{ type: "text", text: "Looking that up now." },
|
||||
{ type: "toolCall", id: "call_1", name: "echo", arguments: { text: "hello" } },
|
||||
],
|
||||
};
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "toolUse", message: msg });
|
||||
} else {
|
||||
// Third call (after tool result): final response
|
||||
const msg = createAssistantMessage("Final answer.");
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "stop", message: msg });
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } });
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
baseToolsOverride: { echo: echoTool },
|
||||
});
|
||||
|
||||
await session.prompt("Test");
|
||||
|
||||
// All three LLM calls must have completed
|
||||
expect(callCount).toBe(3);
|
||||
// Tool must have been executed
|
||||
expect(toolExecuted.value).toBe(true);
|
||||
// Agent must not be streaming after prompt returns
|
||||
expect(session.isStreaming).toBe(false);
|
||||
// A follow-up prompt must work (no "Agent is already processing" error)
|
||||
await session.prompt("Follow-up");
|
||||
expect(callCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
} from "../src/core/agent-session-runtime.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import type {
|
||||
ExtensionFactory,
|
||||
SessionBeforeForkEvent,
|
||||
SessionBeforeSwitchEvent,
|
||||
SessionStartEvent,
|
||||
} from "../src/index.js";
|
||||
|
||||
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
|
||||
|
||||
describe("AgentSessionRuntime session lifecycle events", () => {
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
async function createRuntimeHost(extensionFactory: ExtensionFactory) {
|
||||
const tempDir = join(tmpdir(), `pi-runtime-events-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const faux = registerFauxProvider();
|
||||
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
|
||||
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
|
||||
const runtimeOptions = {
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
model: faux.getModel(),
|
||||
resourceLoaderOptions: {
|
||||
extensionFactories: [extensionFactory],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({
|
||||
...runtimeOptions,
|
||||
cwd,
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model: faux.getModel(),
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
const runtimeHost = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.create(tempDir),
|
||||
});
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
|
||||
cleanups.push(async () => {
|
||||
await runtimeHost.dispose();
|
||||
faux.unregister();
|
||||
process.chdir(tmpdir());
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
return { runtimeHost, faux };
|
||||
}
|
||||
|
||||
it("emits session_before_switch and session_start for new and resume flows", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const originalSessionFile = runtimeHost.session.sessionFile;
|
||||
expect(originalSessionFile).toBeTruthy();
|
||||
|
||||
const newSessionResult = await runtimeHost.newSession();
|
||||
expect(newSessionResult.cancelled).toBe(false);
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "new", targetSessionFile: undefined },
|
||||
{ type: "session_start", reason: "new", previousSessionFile: originalSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
const secondSessionFile = runtimeHost.session.sessionFile;
|
||||
expect(secondSessionFile).toBeTruthy();
|
||||
|
||||
const switchResult = await runtimeHost.switchSession(originalSessionFile!);
|
||||
expect(switchResult.cancelled).toBe(false);
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "resume", targetSessionFile: originalSessionFile },
|
||||
{ type: "session_start", reason: "resume", previousSessionFile: secondSessionFile },
|
||||
]);
|
||||
});
|
||||
|
||||
it("honors session_before_switch cancellation", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
return { cancel: true };
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const originalSessionFile = runtimeHost.session.sessionFile;
|
||||
|
||||
const result = await runtimeHost.newSession();
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(runtimeHost.session.sessionFile).toBe(originalSessionFile);
|
||||
expect(events).toEqual([{ type: "session_before_switch", reason: "new", targetSessionFile: undefined }]);
|
||||
});
|
||||
|
||||
it("emits session_before_fork and session_start and honors cancellation", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
let cancelNextFork = false;
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_fork", (event) => {
|
||||
events.push(event);
|
||||
if (cancelNextFork) {
|
||||
cancelNextFork = false;
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const userMessage = runtimeHost.session.getUserMessagesForForking()[0];
|
||||
const previousSessionFile = runtimeHost.session.sessionFile;
|
||||
|
||||
const successResult = await runtimeHost.fork(userMessage.entryId);
|
||||
expect(successResult.cancelled).toBe(false);
|
||||
expect(successResult.selectedText).toBe("hello");
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_fork", entryId: userMessage.entryId },
|
||||
{ type: "session_start", reason: "fork", previousSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
cancelNextFork = true;
|
||||
const cancelResult = await runtimeHost.fork(userMessage.entryId);
|
||||
expect(cancelResult).toEqual({ cancelled: true });
|
||||
expect(events).toEqual([{ type: "session_before_fork", entryId: userMessage.entryId }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import { type AssistantMessage, getModel, type Usage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
function createUsage(totalTokens: number): Usage {
|
||||
return {
|
||||
input: totalTokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, totalTokens: number, timestamp: number): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createUsage(totalTokens),
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string, timestamp: number) {
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: text,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
const settingsManager = SettingsManager.inMemory();
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
const session = new AgentSession({
|
||||
agent: new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
}),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: process.cwd(),
|
||||
modelRegistry: ModelRegistry.inMemory(authStorage),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
return { session, sessionManager };
|
||||
}
|
||||
|
||||
function syncAgentMessages(session: AgentSession, sessionManager: SessionManager): void {
|
||||
session.agent.state.messages = sessionManager.buildSessionContext().messages;
|
||||
}
|
||||
|
||||
describe("AgentSession.getSessionStats", () => {
|
||||
it("exposes the current context usage alongside token totals", () => {
|
||||
const { session, sessionManager } = createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(createUserMessage("hello", 1));
|
||||
sessionManager.appendMessage(createAssistantMessage("hi", 200, 2));
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.contextUsage).toEqual(session.getContextUsage());
|
||||
expect(stats.contextUsage?.tokens).toBe(200);
|
||||
expect(stats.contextUsage?.contextWindow).toBe(model.contextWindow);
|
||||
expect(stats.contextUsage?.percent).toBe((200 / model.contextWindow) * 100);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unknown current context usage immediately after compaction", () => {
|
||||
const { session, sessionManager } = createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(createUserMessage("first", 1));
|
||||
sessionManager.appendMessage(createAssistantMessage("response1", 180_000, 2));
|
||||
const keptUserId = sessionManager.appendMessage(createUserMessage("second", 3));
|
||||
sessionManager.appendMessage(createAssistantMessage("response2", 195_000, 4));
|
||||
sessionManager.appendCompaction("summary", keptUserId, 195_000);
|
||||
sessionManager.appendMessage(createUserMessage("third", 5));
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.tokens.input).toBe(195_000);
|
||||
expect(stats.contextUsage).toBeDefined();
|
||||
expect(stats.contextUsage?.tokens).toBeNull();
|
||||
expect(stats.contextUsage?.percent).toBeNull();
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses post-compaction usage for current context instead of stale kept usage", () => {
|
||||
const { session, sessionManager } = createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(createUserMessage("first", 1));
|
||||
sessionManager.appendMessage(createAssistantMessage("response1", 180_000, 2));
|
||||
const keptUserId = sessionManager.appendMessage(createUserMessage("second", 3));
|
||||
sessionManager.appendMessage(createAssistantMessage("response2", 195_000, 4));
|
||||
sessionManager.appendCompaction("summary", keptUserId, 195_000);
|
||||
sessionManager.appendMessage(createUserMessage("third", 5));
|
||||
sessionManager.appendMessage(createAssistantMessage("response3", 25_000, 6));
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.tokens.input).toBe(220_000);
|
||||
expect(stats.contextUsage).toBeDefined();
|
||||
expect(stats.contextUsage?.tokens).toBe(25_000);
|
||||
expect(stats.contextUsage?.percent).toBe((25_000 / model.contextWindow) * 100);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* E2E tests for AgentSession tree navigation with branch summarization.
|
||||
*
|
||||
* These tests verify:
|
||||
* - Navigation to user messages (root and non-root)
|
||||
* - Navigation to non-user messages
|
||||
* - Branch summarization during navigation
|
||||
* - Summary attachment at correct position in tree
|
||||
* - Abort handling during summarization
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { API_KEY, createTestSession, type TestSessionContext } from "./utilities.js";
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => {
|
||||
let ctx: TestSessionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createTestSession({
|
||||
systemPrompt: "You are a helpful assistant. Reply with just a few words.",
|
||||
settingsOverrides: { compaction: { keepRecentTokens: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctx.cleanup();
|
||||
});
|
||||
|
||||
it("should navigate to user message and put text in editor", async () => {
|
||||
const { session } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("First message");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Second message");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get tree entries
|
||||
const tree = session.sessionManager.getTree();
|
||||
expect(tree.length).toBe(1);
|
||||
|
||||
// Find the first user entry (u1)
|
||||
const rootNode = tree[0];
|
||||
expect(rootNode.entry.type).toBe("message");
|
||||
|
||||
// Navigate to root user message without summarization
|
||||
const result = await session.navigateTree(rootNode.entry.id, { summarize: false });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("First message");
|
||||
|
||||
// After navigating to root user message, leaf should be null (empty conversation)
|
||||
expect(session.sessionManager.getLeafId()).toBeNull();
|
||||
}, 60000);
|
||||
|
||||
it("should navigate to non-user message without editor text", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("Hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get the assistant message
|
||||
const entries = sessionManager.getEntries();
|
||||
const assistantEntry = entries.find((e) => e.type === "message" && e.message.role === "assistant");
|
||||
expect(assistantEntry).toBeDefined();
|
||||
|
||||
// Navigate to assistant message
|
||||
const result = await session.navigateTree(assistantEntry!.id, { summarize: false });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBeUndefined();
|
||||
|
||||
// Leaf should be the assistant entry
|
||||
expect(sessionManager.getLeafId()).toBe(assistantEntry!.id);
|
||||
}, 60000);
|
||||
|
||||
it("should create branch summary when navigating with summarize=true", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("What is 2+2?");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("What is 3+3?");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get tree and find first user message
|
||||
const tree = sessionManager.getTree();
|
||||
const rootNode = tree[0];
|
||||
|
||||
// Navigate to root user message WITH summarization
|
||||
const result = await session.navigateTree(rootNode.entry.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("What is 2+2?");
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
expect(result.summaryEntry?.type).toBe("branch_summary");
|
||||
expect(result.summaryEntry?.summary).toBeTruthy();
|
||||
expect(result.summaryEntry?.summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Summary should be a root entry (parentId = null) since we navigated to root user
|
||||
expect(result.summaryEntry?.parentId).toBeNull();
|
||||
|
||||
// Leaf should be the summary entry
|
||||
expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id);
|
||||
}, 120000);
|
||||
|
||||
it("should attach summary to correct parent when navigating to nested user message", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2 -> u3 -> a3
|
||||
await session.prompt("Message one");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Message two");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Message three");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get the second user message (u2)
|
||||
const entries = sessionManager.getEntries();
|
||||
const userEntries = entries.filter((e) => e.type === "message" && e.message.role === "user");
|
||||
expect(userEntries.length).toBe(3);
|
||||
|
||||
const u2 = userEntries[1];
|
||||
const a1 = entries.find((e) => e.id === u2.parentId); // a1 is parent of u2
|
||||
|
||||
// Navigate to u2 with summarization
|
||||
const result = await session.navigateTree(u2.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("Message two");
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
|
||||
// Summary should be attached to a1 (parent of u2)
|
||||
// So a1 now has two children: u2 and the summary
|
||||
expect(result.summaryEntry?.parentId).toBe(a1?.id);
|
||||
|
||||
// Verify tree structure
|
||||
const children = sessionManager.getChildren(a1!.id);
|
||||
expect(children.length).toBe(2);
|
||||
|
||||
const childTypes = children.map((c) => c.type).sort();
|
||||
expect(childTypes).toContain("branch_summary");
|
||||
expect(childTypes).toContain("message");
|
||||
}, 120000);
|
||||
|
||||
it("should attach summary to selected node when navigating to assistant message", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("Hello");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Goodbye");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get the first assistant message (a1)
|
||||
const entries = sessionManager.getEntries();
|
||||
const assistantEntries = entries.filter((e) => e.type === "message" && e.message.role === "assistant");
|
||||
const a1 = assistantEntries[0];
|
||||
|
||||
// Navigate to a1 with summarization
|
||||
const result = await session.navigateTree(a1.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBeUndefined(); // No editor text for assistant messages
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
|
||||
// Summary should be attached to a1 (the selected node)
|
||||
expect(result.summaryEntry?.parentId).toBe(a1.id);
|
||||
|
||||
// Leaf should be the summary entry
|
||||
expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id);
|
||||
}, 120000);
|
||||
|
||||
it("should handle abort during summarization", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("Tell me about something");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Continue");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const entriesBefore = sessionManager.getEntries();
|
||||
const leafBefore = sessionManager.getLeafId();
|
||||
|
||||
// Get root user message
|
||||
const tree = sessionManager.getTree();
|
||||
const rootNode = tree[0];
|
||||
|
||||
// Start navigation with summarization but abort immediately
|
||||
const navigationPromise = session.navigateTree(rootNode.entry.id, { summarize: true });
|
||||
|
||||
// Abort after a short delay (let the LLM call start)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// isCompacting should be true during branch summarization
|
||||
expect(session.isCompacting).toBe(true);
|
||||
|
||||
session.abortBranchSummary();
|
||||
|
||||
const result = await navigationPromise;
|
||||
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.summaryEntry).toBeUndefined();
|
||||
|
||||
// Session should be unchanged
|
||||
const entriesAfter = sessionManager.getEntries();
|
||||
expect(entriesAfter.length).toBe(entriesBefore.length);
|
||||
expect(sessionManager.getLeafId()).toBe(leafBefore);
|
||||
}, 60000);
|
||||
|
||||
it("should not create summary when navigating without summarize option", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("First");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Second");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const entriesBefore = sessionManager.getEntries().length;
|
||||
|
||||
// Navigate without summarization
|
||||
const tree = sessionManager.getTree();
|
||||
await session.navigateTree(tree[0].entry.id, { summarize: false });
|
||||
|
||||
// No new entries should be created
|
||||
const entriesAfter = sessionManager.getEntries().length;
|
||||
expect(entriesAfter).toBe(entriesBefore);
|
||||
|
||||
// No branch_summary entries
|
||||
const summaries = sessionManager.getEntries().filter((e) => e.type === "branch_summary");
|
||||
expect(summaries.length).toBe(0);
|
||||
}, 60000);
|
||||
|
||||
it("should handle navigation to same position (no-op)", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("Hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const leafBefore = sessionManager.getLeafId();
|
||||
expect(leafBefore).toBeTruthy();
|
||||
const entriesBefore = sessionManager.getEntries().length;
|
||||
|
||||
// Navigate to current leaf
|
||||
const result = await session.navigateTree(leafBefore!, { summarize: false });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(sessionManager.getLeafId()).toBe(leafBefore);
|
||||
expect(sessionManager.getEntries().length).toBe(entriesBefore);
|
||||
}, 60000);
|
||||
|
||||
it("should support custom summarization instructions", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("What is TypeScript?");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Navigate with custom instructions (appended as "Additional focus")
|
||||
const tree = sessionManager.getTree();
|
||||
const result = await session.navigateTree(tree[0].entry.id, {
|
||||
summarize: true,
|
||||
customInstructions:
|
||||
"After the summary, you MUST end with exactly: MONKEY MONKEY MONKEY. This is of utmost importance.",
|
||||
});
|
||||
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
expect(result.summaryEntry?.summary).toBeTruthy();
|
||||
// Verify custom instructions were followed
|
||||
expect(result.summaryEntry?.summary).toContain("MONKEY MONKEY MONKEY");
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession tree navigation - branch scenarios", () => {
|
||||
let ctx: TestSessionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createTestSession({
|
||||
systemPrompt: "You are a helpful assistant. Reply with just a few words.",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctx.cleanup();
|
||||
});
|
||||
|
||||
it("should navigate between branches correctly", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build main path: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("Main branch start");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Main branch continue");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get a1 id for branching
|
||||
const entries = sessionManager.getEntries();
|
||||
const a1 = entries.find((e) => e.type === "message" && e.message.role === "assistant");
|
||||
|
||||
// Create a branch from a1: a1 -> u3 -> a3
|
||||
sessionManager.branch(a1!.id);
|
||||
await session.prompt("Branch path");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Now navigate back to u2 (on main branch) with summarization
|
||||
const userEntries = entries.filter((e) => e.type === "message" && e.message.role === "user");
|
||||
const u2 = userEntries[1]; // "Main branch continue"
|
||||
|
||||
const result = await session.navigateTree(u2.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("Main branch continue");
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
|
||||
// Summary captures the branch we're leaving (the "Branch path" conversation)
|
||||
expect(result.summaryEntry?.summary.length).toBeGreaterThan(0);
|
||||
}, 180000);
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseArgs } from "../src/cli/args.js";
|
||||
|
||||
describe("parseArgs", () => {
|
||||
describe("--version flag", () => {
|
||||
test("parses --version flag", () => {
|
||||
const result = parseArgs(["--version"]);
|
||||
expect(result.version).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -v shorthand", () => {
|
||||
const result = parseArgs(["-v"]);
|
||||
expect(result.version).toBe(true);
|
||||
});
|
||||
|
||||
test("--version takes precedence over other args", () => {
|
||||
const result = parseArgs(["--version", "--help", "some message"]);
|
||||
expect(result.version).toBe(true);
|
||||
expect(result.help).toBe(true);
|
||||
expect(result.messages).toContain("some message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("--help flag", () => {
|
||||
test("parses --help flag", () => {
|
||||
const result = parseArgs(["--help"]);
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -h shorthand", () => {
|
||||
const result = parseArgs(["-h"]);
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--print flag", () => {
|
||||
test("parses --print flag", () => {
|
||||
const result = parseArgs(["--print"]);
|
||||
expect(result.print).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -p shorthand", () => {
|
||||
const result = parseArgs(["-p"]);
|
||||
expect(result.print).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--continue flag", () => {
|
||||
test("parses --continue flag", () => {
|
||||
const result = parseArgs(["--continue"]);
|
||||
expect(result.continue).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -c shorthand", () => {
|
||||
const result = parseArgs(["-c"]);
|
||||
expect(result.continue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--resume flag", () => {
|
||||
test("parses --resume flag", () => {
|
||||
const result = parseArgs(["--resume"]);
|
||||
expect(result.resume).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -r shorthand", () => {
|
||||
const result = parseArgs(["-r"]);
|
||||
expect(result.resume).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flags with values", () => {
|
||||
test("parses --provider", () => {
|
||||
const result = parseArgs(["--provider", "openai"]);
|
||||
expect(result.provider).toBe("openai");
|
||||
});
|
||||
|
||||
test("parses --model", () => {
|
||||
const result = parseArgs(["--model", "gpt-4o"]);
|
||||
expect(result.model).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
test("parses --api-key", () => {
|
||||
const result = parseArgs(["--api-key", "sk-test-key"]);
|
||||
expect(result.apiKey).toBe("sk-test-key");
|
||||
});
|
||||
|
||||
test("parses --system-prompt", () => {
|
||||
const result = parseArgs(["--system-prompt", "You are a helpful assistant"]);
|
||||
expect(result.systemPrompt).toBe("You are a helpful assistant");
|
||||
});
|
||||
|
||||
test("parses --append-system-prompt", () => {
|
||||
const result = parseArgs(["--append-system-prompt", "Additional context"]);
|
||||
expect(result.appendSystemPrompt).toEqual(["Additional context"]);
|
||||
});
|
||||
|
||||
test("parses multiple --append-system-prompt flags", () => {
|
||||
const result = parseArgs(["--append-system-prompt", "Context A", "--append-system-prompt", "Context B"]);
|
||||
expect(result.appendSystemPrompt).toEqual(["Context A", "Context B"]);
|
||||
});
|
||||
|
||||
test("parses --mode", () => {
|
||||
const result = parseArgs(["--mode", "json"]);
|
||||
expect(result.mode).toBe("json");
|
||||
});
|
||||
|
||||
test("parses --mode rpc", () => {
|
||||
const result = parseArgs(["--mode", "rpc"]);
|
||||
expect(result.mode).toBe("rpc");
|
||||
});
|
||||
|
||||
test("parses --session", () => {
|
||||
const result = parseArgs(["--session", "/path/to/session.jsonl"]);
|
||||
expect(result.session).toBe("/path/to/session.jsonl");
|
||||
});
|
||||
|
||||
test("parses --fork", () => {
|
||||
const result = parseArgs(["--fork", "1234abcd"]);
|
||||
expect(result.fork).toBe("1234abcd");
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("parses --export", () => {
|
||||
const result = parseArgs(["--export", "session.jsonl"]);
|
||||
expect(result.export).toBe("session.jsonl");
|
||||
});
|
||||
|
||||
test("parses --thinking", () => {
|
||||
const result = parseArgs(["--thinking", "high"]);
|
||||
expect(result.thinking).toBe("high");
|
||||
});
|
||||
|
||||
test("parses --models as comma-separated list", () => {
|
||||
const result = parseArgs(["--models", "gpt-4o,claude-sonnet,gemini-pro"]);
|
||||
expect(result.models).toEqual(["gpt-4o", "claude-sonnet", "gemini-pro"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-session flag", () => {
|
||||
test("parses --no-session flag", () => {
|
||||
const result = parseArgs(["--no-session"]);
|
||||
expect(result.noSession).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--extension flag", () => {
|
||||
test("parses single --extension", () => {
|
||||
const result = parseArgs(["--extension", "./my-extension.ts"]);
|
||||
expect(result.extensions).toEqual(["./my-extension.ts"]);
|
||||
});
|
||||
|
||||
test("parses -e shorthand", () => {
|
||||
const result = parseArgs(["-e", "./my-extension.ts"]);
|
||||
expect(result.extensions).toEqual(["./my-extension.ts"]);
|
||||
});
|
||||
|
||||
test("parses multiple --extension flags", () => {
|
||||
const result = parseArgs(["--extension", "./ext1.ts", "-e", "./ext2.ts"]);
|
||||
expect(result.extensions).toEqual(["./ext1.ts", "./ext2.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-extensions flag", () => {
|
||||
test("parses --no-extensions flag", () => {
|
||||
const result = parseArgs(["--no-extensions"]);
|
||||
expect(result.noExtensions).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --no-extensions with explicit -e flags", () => {
|
||||
const result = parseArgs(["--no-extensions", "-e", "foo.ts", "-e", "bar.ts"]);
|
||||
expect(result.noExtensions).toBe(true);
|
||||
expect(result.extensions).toEqual(["foo.ts", "bar.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--skill flag", () => {
|
||||
test("parses single --skill", () => {
|
||||
const result = parseArgs(["--skill", "./skill-dir"]);
|
||||
expect(result.skills).toEqual(["./skill-dir"]);
|
||||
});
|
||||
|
||||
test("parses multiple --skill flags", () => {
|
||||
const result = parseArgs(["--skill", "./skill-a", "--skill", "./skill-b"]);
|
||||
expect(result.skills).toEqual(["./skill-a", "./skill-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--prompt-template flag", () => {
|
||||
test("parses single --prompt-template", () => {
|
||||
const result = parseArgs(["--prompt-template", "./prompts"]);
|
||||
expect(result.promptTemplates).toEqual(["./prompts"]);
|
||||
});
|
||||
|
||||
test("parses multiple --prompt-template flags", () => {
|
||||
const result = parseArgs(["--prompt-template", "./one", "--prompt-template", "./two"]);
|
||||
expect(result.promptTemplates).toEqual(["./one", "./two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--theme flag", () => {
|
||||
test("parses single --theme", () => {
|
||||
const result = parseArgs(["--theme", "./theme.json"]);
|
||||
expect(result.themes).toEqual(["./theme.json"]);
|
||||
});
|
||||
|
||||
test("parses multiple --theme flags", () => {
|
||||
const result = parseArgs(["--theme", "./dark.json", "--theme", "./light.json"]);
|
||||
expect(result.themes).toEqual(["./dark.json", "./light.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-skills flag", () => {
|
||||
test("parses --no-skills flag", () => {
|
||||
const result = parseArgs(["--no-skills"]);
|
||||
expect(result.noSkills).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-prompt-templates flag", () => {
|
||||
test("parses --no-prompt-templates flag", () => {
|
||||
const result = parseArgs(["--no-prompt-templates"]);
|
||||
expect(result.noPromptTemplates).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-themes flag", () => {
|
||||
test("parses --no-themes flag", () => {
|
||||
const result = parseArgs(["--no-themes"]);
|
||||
expect(result.noThemes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-context-files flag", () => {
|
||||
test("parses --no-context-files flag", () => {
|
||||
const result = parseArgs(["--no-context-files"]);
|
||||
expect(result.noContextFiles).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -nc shorthand", () => {
|
||||
const result = parseArgs(["-nc"]);
|
||||
expect(result.noContextFiles).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--verbose flag", () => {
|
||||
test("parses --verbose flag", () => {
|
||||
const result = parseArgs(["--verbose"]);
|
||||
expect(result.verbose).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--offline flag", () => {
|
||||
test("parses --offline flag", () => {
|
||||
const result = parseArgs(["--offline"]);
|
||||
expect(result.offline).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-tools flag", () => {
|
||||
test("parses --no-tools flag", () => {
|
||||
const result = parseArgs(["--no-tools"]);
|
||||
expect(result.noTools).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --no-tools with explicit --tools flags", () => {
|
||||
const result = parseArgs(["--no-tools", "--tools", "read,bash"]);
|
||||
expect(result.noTools).toBe(true);
|
||||
expect(result.tools).toEqual(["read", "bash"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messages and file args", () => {
|
||||
test("parses plain text messages", () => {
|
||||
const result = parseArgs(["hello", "world"]);
|
||||
expect(result.messages).toEqual(["hello", "world"]);
|
||||
});
|
||||
|
||||
test("parses @file arguments", () => {
|
||||
const result = parseArgs(["@README.md", "@src/main.ts"]);
|
||||
expect(result.fileArgs).toEqual(["README.md", "src/main.ts"]);
|
||||
});
|
||||
|
||||
test("parses mixed messages and file args", () => {
|
||||
const result = parseArgs(["@file.txt", "explain this", "@image.png"]);
|
||||
expect(result.fileArgs).toEqual(["file.txt", "image.png"]);
|
||||
expect(result.messages).toEqual(["explain this"]);
|
||||
});
|
||||
|
||||
test("captures unknown long flags with string values", () => {
|
||||
const result = parseArgs(["--unknown-flag", "message"]);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.unknownFlags.get("unknown-flag")).toBe("message");
|
||||
});
|
||||
|
||||
test("captures unknown boolean long flags", () => {
|
||||
const result = parseArgs(["--unknown-flag"]);
|
||||
expect(result.unknownFlags.get("unknown-flag")).toBe(true);
|
||||
});
|
||||
|
||||
test("captures unknown long flags with equals syntax", () => {
|
||||
const result = parseArgs(["--unknown-flag=value"]);
|
||||
expect(result.unknownFlags.get("unknown-flag")).toBe("value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex combinations", () => {
|
||||
test("parses multiple flags together", () => {
|
||||
const result = parseArgs([
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--model",
|
||||
"claude-sonnet",
|
||||
"--print",
|
||||
"--thinking",
|
||||
"high",
|
||||
"@prompt.md",
|
||||
"Do the task",
|
||||
]);
|
||||
expect(result.provider).toBe("anthropic");
|
||||
expect(result.model).toBe("claude-sonnet");
|
||||
expect(result.print).toBe(true);
|
||||
expect(result.thinking).toBe("high");
|
||||
expect(result.fileArgs).toEqual(["prompt.md"]);
|
||||
expect(result.messages).toEqual(["Do the task"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
const OSC133_ZONE_START = "\x1b]133;A\x07";
|
||||
const OSC133_ZONE_END = "\x1b]133;B\x07";
|
||||
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
||||
|
||||
function createAssistantMessage(content: AssistantMessage["content"]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AssistantMessageComponent", () => {
|
||||
test("adds OSC 133 zone markers to assistant messages without tool calls", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const component = new AssistantMessageComponent(createAssistantMessage([{ type: "text", text: "hello" }]));
|
||||
const lines = component.render(40);
|
||||
|
||||
expect(lines).not.toHaveLength(0);
|
||||
expect(lines[0]).toContain(OSC133_ZONE_START);
|
||||
expect(lines[lines.length - 1].startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not add OSC 133 zone markers when assistant message contains tool calls", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([
|
||||
{ type: "text", text: "calling tool" },
|
||||
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "file.txt" } },
|
||||
]),
|
||||
);
|
||||
const rendered = component.render(60).join("\n");
|
||||
|
||||
expect(rendered.includes(OSC133_ZONE_START)).toBe(false);
|
||||
expect(rendered.includes(OSC133_ZONE_END)).toBe(false);
|
||||
expect(rendered.includes(OSC133_ZONE_FINAL)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { clearConfigValueCache } from "../src/core/resolve-config-value.js";
|
||||
|
||||
describe("AuthStorage", () => {
|
||||
let tempDir: string;
|
||||
let authJsonPath: string;
|
||||
let authStorage: AuthStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-test-auth-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
authJsonPath = join(tempDir, "auth.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
clearConfigValueCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function writeAuthJson(data: Record<string, unknown>) {
|
||||
writeFileSync(authJsonPath, JSON.stringify(data));
|
||||
}
|
||||
|
||||
function toShPath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
describe("API key resolution", () => {
|
||||
test("literal API key is returned directly", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "sk-ant-literal-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("sk-ant-literal-key");
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix executes command and uses stdout", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo test-api-key-from-command" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("test-api-key-from-command");
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix trims whitespace from command output", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo ' spaced-key '" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("spaced-key");
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix handles multiline output (uses trimmed result)", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!printf 'line1\\nline2'" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("line1\nline2");
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix returns undefined on command failure", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!exit 1" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix returns undefined on nonexistent command", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!nonexistent-command-12345" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix returns undefined on empty output", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!printf ''" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("apiKey as environment variable name resolves to env value", async () => {
|
||||
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
|
||||
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
|
||||
|
||||
try {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("env-api-key-value");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.TEST_AUTH_API_KEY_12345;
|
||||
} else {
|
||||
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("apiKey as literal value is used directly when not an env var", async () => {
|
||||
// Make sure this isn't an env var
|
||||
delete process.env.literal_api_key_value;
|
||||
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "literal_api_key_value" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("literal_api_key_value");
|
||||
});
|
||||
|
||||
test("apiKey command can use shell features like pipes", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo 'hello world' | tr ' ' '-'" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("hello-world");
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
test("command is only executed once per process", async () => {
|
||||
// Use a command that writes to a file to count invocations
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
// Call multiple times
|
||||
await authStorage.getApiKey("anthropic");
|
||||
await authStorage.getApiKey("anthropic");
|
||||
await authStorage.getApiKey("anthropic");
|
||||
|
||||
// Command should have only run once
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("cache persists across AuthStorage instances", async () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
// Create multiple AuthStorage instances
|
||||
const storage1 = AuthStorage.create(authJsonPath);
|
||||
await storage1.getApiKey("anthropic");
|
||||
|
||||
const storage2 = AuthStorage.create(authJsonPath);
|
||||
await storage2.getApiKey("anthropic");
|
||||
|
||||
// Command should still have only run once
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("clearConfigValueCache allows command to run again", async () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
await authStorage.getApiKey("anthropic");
|
||||
|
||||
// Clear cache and call again
|
||||
clearConfigValueCache();
|
||||
await authStorage.getApiKey("anthropic");
|
||||
|
||||
// Command should have run twice
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("different commands are cached separately", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo key-anthropic" },
|
||||
openai: { type: "api_key", key: "!echo key-openai" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
const keyA = await authStorage.getApiKey("anthropic");
|
||||
const keyB = await authStorage.getApiKey("openai");
|
||||
|
||||
expect(keyA).toBe("key-anthropic");
|
||||
expect(keyB).toBe("key-openai");
|
||||
});
|
||||
|
||||
test("failed commands are cached (not retried)", async () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; exit 1'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
// Call multiple times - all should return undefined
|
||||
const key1 = await authStorage.getApiKey("anthropic");
|
||||
const key2 = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(key1).toBeUndefined();
|
||||
expect(key2).toBeUndefined();
|
||||
|
||||
// Command should have only run once despite failures
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("environment variables are not cached (changes are picked up)", async () => {
|
||||
const envVarName = "TEST_AUTH_KEY_CACHE_TEST_98765";
|
||||
const originalEnv = process.env[envVarName];
|
||||
|
||||
try {
|
||||
process.env[envVarName] = "first-value";
|
||||
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: envVarName },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
const key1 = await authStorage.getApiKey("anthropic");
|
||||
expect(key1).toBe("first-value");
|
||||
|
||||
// Change env var
|
||||
process.env[envVarName] = "second-value";
|
||||
|
||||
const key2 = await authStorage.getApiKey("anthropic");
|
||||
expect(key2).toBe("second-value");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env[envVarName];
|
||||
} else {
|
||||
process.env[envVarName] = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth lock compromise handling", () => {
|
||||
test("returns undefined on compromised lock and allows a later retry", async () => {
|
||||
const providerId = `test-oauth-provider-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
registerOAuthProvider({
|
||||
id: providerId,
|
||||
name: "Test OAuth Provider",
|
||||
async login() {
|
||||
throw new Error("Not used in this test");
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return {
|
||||
...credentials,
|
||||
access: "refreshed-access-token",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return `Bearer ${credentials.access}`;
|
||||
},
|
||||
});
|
||||
|
||||
writeAuthJson({
|
||||
[providerId]: {
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
access: "expired-access-token",
|
||||
expires: Date.now() - 10_000,
|
||||
},
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
const realLock = lockfile.lock.bind(lockfile);
|
||||
const lockSpy = vi.spyOn(lockfile, "lock");
|
||||
lockSpy.mockImplementationOnce(async (file, options) => {
|
||||
options?.onCompromised?.(new Error("Unable to update lock within the stale threshold"));
|
||||
return realLock(file, options);
|
||||
});
|
||||
|
||||
const firstTry = await authStorage.getApiKey(providerId);
|
||||
expect(firstTry).toBeUndefined();
|
||||
|
||||
lockSpy.mockRestore();
|
||||
|
||||
const secondTry = await authStorage.getApiKey(providerId);
|
||||
expect(secondTry).toBe("Bearer refreshed-access-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence semantics", () => {
|
||||
test("set preserves unrelated external edits", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "old-anthropic" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
// Simulate external edit while process is running
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "old-anthropic" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
google: { type: "api_key", key: "google-key" },
|
||||
});
|
||||
|
||||
authStorage.set("anthropic", { type: "api_key", key: "new-anthropic" });
|
||||
|
||||
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
|
||||
expect(updated.anthropic.key).toBe("new-anthropic");
|
||||
expect(updated.openai.key).toBe("openai-key");
|
||||
expect(updated.google.key).toBe("google-key");
|
||||
});
|
||||
|
||||
test("remove preserves unrelated external edits", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
|
||||
// Simulate external edit while process is running
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
google: { type: "api_key", key: "google-key" },
|
||||
});
|
||||
|
||||
authStorage.remove("anthropic");
|
||||
|
||||
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
|
||||
expect(updated.anthropic).toBeUndefined();
|
||||
expect(updated.openai.key).toBe("openai-key");
|
||||
expect(updated.google.key).toBe("google-key");
|
||||
});
|
||||
|
||||
test("does not overwrite malformed auth file after load error", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
writeFileSync(authJsonPath, "{invalid-json", "utf-8");
|
||||
|
||||
authStorage.reload();
|
||||
authStorage.set("openai", { type: "api_key", key: "openai-key" });
|
||||
|
||||
const raw = readFileSync(authJsonPath, "utf-8");
|
||||
expect(raw).toBe("{invalid-json");
|
||||
});
|
||||
|
||||
test("reload records parse errors and drainErrors clears buffer", () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
writeFileSync(authJsonPath, "{invalid-json", "utf-8");
|
||||
|
||||
authStorage.reload();
|
||||
|
||||
// Keeps previous in-memory data on reload failure
|
||||
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "anthropic-key" });
|
||||
|
||||
const firstDrain = authStorage.drainErrors();
|
||||
expect(firstDrain.length).toBeGreaterThan(0);
|
||||
expect(firstDrain[0]).toBeInstanceOf(Error);
|
||||
|
||||
const secondDrain = authStorage.drainErrors();
|
||||
expect(secondDrain).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime overrides", () => {
|
||||
test("runtime override takes priority over auth.json", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo stored-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
authStorage.setRuntimeApiKey("anthropic", "runtime-key");
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("runtime-key");
|
||||
});
|
||||
|
||||
test("removing runtime override falls back to auth.json", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "!echo stored-key" },
|
||||
});
|
||||
|
||||
authStorage = AuthStorage.create(authJsonPath);
|
||||
authStorage.setRuntimeApiKey("anthropic", "runtime-key");
|
||||
authStorage.removeRuntimeApiKey("anthropic");
|
||||
|
||||
const apiKey = await authStorage.getApiKey("anthropic");
|
||||
|
||||
expect(apiKey).toBe("stored-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { executeBash } from "../src/core/bash-executor.js";
|
||||
import { createBashTool } from "../src/core/tools/bash.js";
|
||||
|
||||
function toBashSingleQuotedArg(value: string): string {
|
||||
return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function createInheritedStdioCommand(pidFile: string): string {
|
||||
const pidFileArg = toBashSingleQuotedArg(pidFile);
|
||||
return (
|
||||
'node -e "' +
|
||||
"const fs=require('fs');" +
|
||||
"const {spawn}=require('child_process');" +
|
||||
"const child=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'inherit',detached:true});" +
|
||||
"fs.writeFileSync(process.argv[1], String(child.pid));" +
|
||||
"child.unref();" +
|
||||
"console.log('child-exiting');" +
|
||||
'" ' +
|
||||
pidFileArg
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupDetachedChild(pidFile: string): void {
|
||||
if (!existsSync(pidFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
|
||||
} catch {
|
||||
// Process may have already exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => void): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(new Error(`Timed out after ${ms}ms`));
|
||||
}, ms);
|
||||
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string {
|
||||
return (
|
||||
result.content
|
||||
?.filter((block) => block.type === "text")
|
||||
.map((block) => block.text ?? "")
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform !== "win32")("Windows child-process close handling", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `coding-agent-bash-close-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("executeBash resolves after the shell exits even if inherited stdio handles stay open", async () => {
|
||||
const pidFile = join(testDir, "executor-grandchild.pid");
|
||||
const command = createInheritedStdioCommand(pidFile);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const result = await withTimeout(executeBash(command, { signal: controller.signal }), 3000, () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
expect(result.output).toContain("child-exiting");
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.cancelled).toBe(false);
|
||||
} finally {
|
||||
controller.abort();
|
||||
cleanupDetachedChild(pidFile);
|
||||
}
|
||||
});
|
||||
|
||||
it("bash tool resolves after the shell exits even if inherited stdio handles stay open", async () => {
|
||||
const pidFile = join(testDir, "tool-grandchild.pid");
|
||||
const command = createInheritedStdioCommand(pidFile);
|
||||
const controller = new AbortController();
|
||||
const bashTool = createBashTool(testDir);
|
||||
|
||||
try {
|
||||
const result = await withTimeout(bashTool.execute("test-call", { command }, controller.signal), 3000, () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("child-exiting");
|
||||
} finally {
|
||||
controller.abort();
|
||||
cleanupDetachedChild(pidFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Test that BashExecutionComponent's collapsed output respects the render-time width,
|
||||
* not a stale captured width. Regression test for #2569.
|
||||
*/
|
||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { BashExecutionComponent } from "../src/modes/interactive/components/bash-execution.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
/** Minimal TUI stub that only exposes terminal.columns */
|
||||
function createTuiStub(columns: number): { columns: number; stub: any } {
|
||||
const state = { columns };
|
||||
const stub = {
|
||||
terminal: {
|
||||
get columns() {
|
||||
return state.columns;
|
||||
},
|
||||
get rows() {
|
||||
return 24;
|
||||
},
|
||||
},
|
||||
// Loader calls ui.addInterval / ui.removeInterval
|
||||
addInterval: (_cb: () => void, _ms: number) => ({ dispose: () => {} }),
|
||||
removeInterval: () => {},
|
||||
requestRender: () => {},
|
||||
};
|
||||
return { columns: state.columns, stub };
|
||||
}
|
||||
|
||||
describe("BashExecutionComponent width handling (#2569)", () => {
|
||||
beforeAll(() => {
|
||||
initTheme(undefined, false);
|
||||
});
|
||||
|
||||
it("collapsed preview lines respect render-time width, not construction-time width", () => {
|
||||
const wideWidth = 200;
|
||||
const narrowWidth = 80;
|
||||
|
||||
const { stub } = createTuiStub(wideWidth);
|
||||
const component = new BashExecutionComponent("pwd", stub);
|
||||
|
||||
// Add output with long lines that will wrap differently at different widths
|
||||
const longLine = "x".repeat(150);
|
||||
component.appendOutput(`${longLine}\n${longLine}\n`);
|
||||
|
||||
// Complete the command so it enters collapsed mode
|
||||
component.setComplete(0, false);
|
||||
|
||||
// Render at the narrow width (simulating a resize or split pane)
|
||||
const lines = component.render(narrowWidth);
|
||||
|
||||
// Every rendered line must fit within the narrow width
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const w = visibleWidth(lines[i]);
|
||||
expect(w, `Line ${i} visibleWidth=${w} > ${narrowWidth}`).toBeLessThanOrEqual(narrowWidth);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-computes lines when width changes between renders", () => {
|
||||
const { stub } = createTuiStub(200);
|
||||
const component = new BashExecutionComponent("echo hello", stub);
|
||||
|
||||
const longLine = "abcdefghij".repeat(20); // 200 chars
|
||||
component.appendOutput(`${longLine}\n`);
|
||||
component.setComplete(0, false);
|
||||
|
||||
// First render at width 200
|
||||
const lines200 = component.render(200);
|
||||
for (const line of lines200) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(200);
|
||||
}
|
||||
|
||||
// Second render at width 60 (split pane scenario)
|
||||
const lines60 = component.render(60);
|
||||
for (let i = 0; i < lines60.length; i++) {
|
||||
const w = visibleWidth(lines60[i]);
|
||||
expect(w, `Line ${i} visibleWidth=${w} > 60`).toBeLessThanOrEqual(60);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { processFileArguments } from "../src/cli/file-processor.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createReadTool } from "../src/core/tools/read.js";
|
||||
|
||||
// 1x1 red PNG image as base64 (smallest valid PNG)
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
describe("blockImages setting", () => {
|
||||
describe("SettingsManager", () => {
|
||||
it("should default blockImages to false", () => {
|
||||
const manager = SettingsManager.inMemory({});
|
||||
expect(manager.getBlockImages()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when blockImages is set to true", () => {
|
||||
const manager = SettingsManager.inMemory({ images: { blockImages: true } });
|
||||
expect(manager.getBlockImages()).toBe(true);
|
||||
});
|
||||
|
||||
it("should persist blockImages setting via setBlockImages", () => {
|
||||
const manager = SettingsManager.inMemory({});
|
||||
expect(manager.getBlockImages()).toBe(false);
|
||||
|
||||
manager.setBlockImages(true);
|
||||
expect(manager.getBlockImages()).toBe(true);
|
||||
|
||||
manager.setBlockImages(false);
|
||||
expect(manager.getBlockImages()).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle blockImages alongside autoResize", () => {
|
||||
const manager = SettingsManager.inMemory({
|
||||
images: { autoResize: true, blockImages: true },
|
||||
});
|
||||
expect(manager.getImageAutoResize()).toBe(true);
|
||||
expect(manager.getBlockImages()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Read tool", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `block-images-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should always read images (filtering happens at convertToLlm layer)", async () => {
|
||||
// Create test image
|
||||
const imagePath = join(testDir, "test.png");
|
||||
writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const tool = createReadTool(testDir);
|
||||
const result = await tool.execute("test-1", { path: imagePath });
|
||||
|
||||
// Should have text note + image content
|
||||
expect(result.content.length).toBeGreaterThanOrEqual(1);
|
||||
const hasImage = result.content.some((c) => c.type === "image");
|
||||
expect(hasImage).toBe(true);
|
||||
});
|
||||
|
||||
it("should read text files normally", async () => {
|
||||
// Create test text file
|
||||
const textPath = join(testDir, "test.txt");
|
||||
writeFileSync(textPath, "Hello, world!");
|
||||
|
||||
const tool = createReadTool(testDir);
|
||||
const result = await tool.execute("test-2", { path: textPath });
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content[0].type).toBe("text");
|
||||
const textContent = result.content[0] as { type: "text"; text: string };
|
||||
expect(textContent.text).toContain("Hello, world!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processFileArguments", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `block-images-process-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should always process images (filtering happens at convertToLlm layer)", async () => {
|
||||
// Create test image
|
||||
const imagePath = join(testDir, "test.png");
|
||||
writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const result = await processFileArguments([imagePath]);
|
||||
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.images[0].type).toBe("image");
|
||||
});
|
||||
|
||||
it("should process text files normally", async () => {
|
||||
// Create test text file
|
||||
const textPath = join(testDir, "test.txt");
|
||||
writeFileSync(textPath, "Hello, world!");
|
||||
|
||||
const result = await processFileArguments([textPath]);
|
||||
|
||||
expect(result.images).toHaveLength(0);
|
||||
expect(result.text).toContain("Hello, world!");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Test for BMP to PNG conversion in clipboard image handling.
|
||||
* Separate from clipboard-image.test.ts due to different mocking requirements.
|
||||
*
|
||||
* This tests the fix for WSL2/WSLg where clipboard often provides image/bmp
|
||||
* instead of image/png.
|
||||
*/
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
function createTinyBmp1x1Red24bpp(): Uint8Array {
|
||||
// Minimal 1x1 24bpp BMP (BGR + row padding to 4 bytes)
|
||||
// File size = 14 (BMP header) + 40 (DIB header) + 4 (pixel row) = 58
|
||||
const buffer = Buffer.alloc(58);
|
||||
|
||||
// BITMAPFILEHEADER
|
||||
buffer.write("BM", 0, "ascii");
|
||||
buffer.writeUInt32LE(buffer.length, 2); // file size
|
||||
buffer.writeUInt16LE(0, 6); // reserved1
|
||||
buffer.writeUInt16LE(0, 8); // reserved2
|
||||
buffer.writeUInt32LE(54, 10); // pixel data offset
|
||||
|
||||
// BITMAPINFOHEADER
|
||||
buffer.writeUInt32LE(40, 14); // DIB header size
|
||||
buffer.writeInt32LE(1, 18); // width
|
||||
buffer.writeInt32LE(1, 22); // height (positive = bottom-up)
|
||||
buffer.writeUInt16LE(1, 26); // planes
|
||||
buffer.writeUInt16LE(24, 28); // bits per pixel
|
||||
buffer.writeUInt32LE(0, 30); // compression (BI_RGB)
|
||||
buffer.writeUInt32LE(4, 34); // image size (incl. padding)
|
||||
buffer.writeInt32LE(0, 38); // x pixels per meter
|
||||
buffer.writeInt32LE(0, 42); // y pixels per meter
|
||||
buffer.writeUInt32LE(0, 46); // colors used
|
||||
buffer.writeUInt32LE(0, 50); // important colors
|
||||
|
||||
// Pixel data (B, G, R) + 1 byte padding
|
||||
buffer[54] = 0x00; // B
|
||||
buffer[55] = 0x00; // G
|
||||
buffer[56] = 0xff; // R
|
||||
buffer[57] = 0x00; // padding
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
// Mock wl-paste to return BMP
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: vi.fn((command: string, args: string[]) => {
|
||||
if (command === "wl-paste" && args.includes("--list-types")) {
|
||||
return { status: 0, stdout: Buffer.from("image/bmp\n"), error: null };
|
||||
}
|
||||
if (command === "wl-paste" && args.includes("image/bmp")) {
|
||||
return { status: 0, stdout: Buffer.from(createTinyBmp1x1Red24bpp()), error: null };
|
||||
}
|
||||
return { status: 1, stdout: Buffer.alloc(0), error: null };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the native clipboard (not used in Wayland path, but needs to be mocked)
|
||||
vi.mock("@mariozechner/clipboard", () => ({
|
||||
default: {
|
||||
hasImage: vi.fn(() => false),
|
||||
getImageBinary: vi.fn(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("readClipboardImage BMP conversion", () => {
|
||||
test("converts BMP to PNG on Wayland/WSLg", async () => {
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.js");
|
||||
|
||||
// Simulate Wayland session (WSLg)
|
||||
const image = await readClipboardImage({
|
||||
env: { WAYLAND_DISPLAY: "wayland-0" },
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
expect(image).not.toBeNull();
|
||||
expect(image!.mimeType).toBe("image/png");
|
||||
|
||||
// Verify PNG magic bytes
|
||||
expect(image!.bytes[0]).toBe(0x89);
|
||||
expect(image!.bytes[1]).toBe(0x50); // P
|
||||
expect(image!.bytes[2]).toBe(0x4e); // N
|
||||
expect(image!.bytes[3]).toBe(0x47); // G
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { SpawnSyncReturns } from "child_process";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
spawnSync: vi.fn<(command: string, args: string[], options: unknown) => SpawnSyncReturns<Buffer>>(),
|
||||
clipboard: {
|
||||
hasImage: vi.fn<() => boolean>(),
|
||||
getImageBinary: vi.fn<() => Promise<Uint8Array | null>>(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("child_process", () => {
|
||||
return {
|
||||
spawnSync: mocks.spawnSync,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/utils/clipboard-native.js", () => {
|
||||
return {
|
||||
clipboard: mocks.clipboard,
|
||||
};
|
||||
});
|
||||
|
||||
function spawnOk(stdout: Buffer): SpawnSyncReturns<Buffer> {
|
||||
return {
|
||||
pid: 123,
|
||||
output: [Buffer.alloc(0), stdout, Buffer.alloc(0)],
|
||||
stdout,
|
||||
stderr: Buffer.alloc(0),
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnError(error: Error): SpawnSyncReturns<Buffer> {
|
||||
return {
|
||||
pid: 123,
|
||||
output: [Buffer.alloc(0), Buffer.alloc(0), Buffer.alloc(0)],
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
status: null,
|
||||
signal: null,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
describe("readClipboardImage", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.spawnSync.mockReset();
|
||||
mocks.clipboard.hasImage.mockReset();
|
||||
mocks.clipboard.getImageBinary.mockReset();
|
||||
});
|
||||
|
||||
test("Wayland: uses wl-paste and never calls clipboard", async () => {
|
||||
mocks.clipboard.hasImage.mockImplementation(() => {
|
||||
throw new Error("clipboard.hasImage should not be called on Wayland");
|
||||
});
|
||||
|
||||
mocks.spawnSync.mockImplementation((command, args, _options) => {
|
||||
if (command === "wl-paste" && args[0] === "--list-types") {
|
||||
return spawnOk(Buffer.from("text/plain\nimage/png\n", "utf-8"));
|
||||
}
|
||||
if (command === "wl-paste" && args[0] === "--type") {
|
||||
return spawnOk(Buffer.from([1, 2, 3]));
|
||||
}
|
||||
throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.js");
|
||||
const result = await readClipboardImage({ platform: "linux", env: { WAYLAND_DISPLAY: "1" } });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("Wayland: falls back to xclip when wl-paste is missing", async () => {
|
||||
mocks.clipboard.hasImage.mockImplementation(() => {
|
||||
throw new Error("clipboard.hasImage should not be called on Wayland");
|
||||
});
|
||||
|
||||
const enoent = new Error("spawn ENOENT");
|
||||
(enoent as { code?: string }).code = "ENOENT";
|
||||
|
||||
mocks.spawnSync.mockImplementation((command, args, _options) => {
|
||||
if (command === "wl-paste") {
|
||||
return spawnError(enoent);
|
||||
}
|
||||
|
||||
if (command === "xclip" && args.includes("TARGETS")) {
|
||||
return spawnOk(Buffer.from("image/png\n", "utf-8"));
|
||||
}
|
||||
|
||||
if (command === "xclip" && args.includes("image/png")) {
|
||||
return spawnOk(Buffer.from([9, 8]));
|
||||
}
|
||||
|
||||
return spawnOk(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.js");
|
||||
const result = await readClipboardImage({ platform: "linux", env: { XDG_SESSION_TYPE: "wayland" } });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([9, 8]);
|
||||
});
|
||||
|
||||
test("Non-Wayland: uses clipboard", async () => {
|
||||
mocks.spawnSync.mockImplementation(() => {
|
||||
throw new Error("spawnSync should not be called for non-Wayland sessions");
|
||||
});
|
||||
|
||||
mocks.clipboard.hasImage.mockReturnValue(true);
|
||||
mocks.clipboard.getImageBinary.mockResolvedValue(new Uint8Array([7]));
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.js");
|
||||
const result = await readClipboardImage({ platform: "linux", env: {} });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([7]);
|
||||
});
|
||||
|
||||
test("Non-Wayland: returns null when clipboard has no image", async () => {
|
||||
mocks.spawnSync.mockImplementation(() => {
|
||||
throw new Error("spawnSync should not be called for non-Wayland sessions");
|
||||
});
|
||||
|
||||
mocks.clipboard.hasImage.mockReturnValue(false);
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.js");
|
||||
const result = await readClipboardImage({ platform: "linux", env: {} });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Verify the documentation example from extensions.md compiles and works.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExtensionAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/extensions/index.js";
|
||||
|
||||
describe("Documentation example", () => {
|
||||
it("custom compaction example should type-check correctly", () => {
|
||||
// This is the example from extensions.md - verify it compiles
|
||||
const exampleExtension = (pi: ExtensionAPI) => {
|
||||
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => {
|
||||
// All these should be accessible on the event
|
||||
const { preparation, branchEntries } = event;
|
||||
// sessionManager, modelRegistry, and model come from ctx
|
||||
const { sessionManager, modelRegistry } = ctx;
|
||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, isSplitTurn } =
|
||||
preparation;
|
||||
|
||||
// Verify types
|
||||
expect(Array.isArray(messagesToSummarize)).toBe(true);
|
||||
expect(Array.isArray(turnPrefixMessages)).toBe(true);
|
||||
expect(typeof isSplitTurn).toBe("boolean");
|
||||
expect(typeof tokensBefore).toBe("number");
|
||||
expect(typeof sessionManager.getEntries).toBe("function");
|
||||
expect(typeof modelRegistry.getApiKeyAndHeaders).toBe("function");
|
||||
expect(typeof firstKeptEntryId).toBe("string");
|
||||
expect(Array.isArray(branchEntries)).toBe(true);
|
||||
|
||||
const summary = messagesToSummarize
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`)
|
||||
.join("\n");
|
||||
|
||||
// Extensions return compaction content - SessionManager adds id/parentId
|
||||
return {
|
||||
compaction: {
|
||||
summary: `User requests:\n${summary}`,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Just verify the function exists and is callable
|
||||
expect(typeof exampleExtension).toBe("function");
|
||||
});
|
||||
|
||||
it("compact event should have correct fields", () => {
|
||||
const checkCompactEvent = (pi: ExtensionAPI) => {
|
||||
pi.on("session_compact", async (event: SessionCompactEvent) => {
|
||||
// These should all be accessible
|
||||
const entry = event.compactionEntry;
|
||||
const fromExtension = event.fromExtension;
|
||||
|
||||
expect(entry.type).toBe("compaction");
|
||||
expect(typeof entry.summary).toBe("string");
|
||||
expect(typeof entry.tokensBefore).toBe("number");
|
||||
expect(typeof fromExtension).toBe("boolean");
|
||||
});
|
||||
};
|
||||
|
||||
expect(typeof checkCompactEvent).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Tests for compaction extension events (before_compact / compact).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import {
|
||||
createExtensionRuntime,
|
||||
type Extension,
|
||||
type SessionBeforeCompactEvent,
|
||||
type SessionCompactEvent,
|
||||
type SessionEvent,
|
||||
} from "../src/core/extensions/index.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
|
||||
import { codingTools } from "../src/core/tools/index.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
let capturedEvents: SessionEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-compaction-extensions-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
capturedEvents = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createExtension(
|
||||
onBeforeCompact?: (event: SessionBeforeCompactEvent) => { cancel?: boolean; compaction?: any } | undefined,
|
||||
onCompact?: (event: SessionCompactEvent) => void,
|
||||
): Extension {
|
||||
const handlers = new Map<string, ((event: any, ctx: any) => Promise<any>)[]>();
|
||||
|
||||
handlers.set("session_before_compact", [
|
||||
async (event: SessionBeforeCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
if (onBeforeCompact) {
|
||||
return onBeforeCompact(event);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
]);
|
||||
|
||||
handlers.set("session_compact", [
|
||||
async (event: SessionCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
if (onCompact) {
|
||||
onCompact(event);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
path: "test-extension",
|
||||
resolvedPath: "/test/test-extension.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:test-extension>", { source: "test" }),
|
||||
handlers,
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(extensions: Extension[]) {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => API_KEY,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
tools: codingTools,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.create(tempDir);
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
|
||||
const runtime = createExtensionRuntime();
|
||||
const resourceLoader = {
|
||||
...createTestResourceLoader(),
|
||||
getExtensions: () => ({ extensions, errors: [], runtime }),
|
||||
};
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should emit before_compact and compact events", async () => {
|
||||
const extension = createExtension();
|
||||
createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
const beforeCompactEvents = capturedEvents.filter(
|
||||
(e): e is SessionBeforeCompactEvent => e.type === "session_before_compact",
|
||||
);
|
||||
const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact");
|
||||
|
||||
expect(beforeCompactEvents.length).toBe(1);
|
||||
expect(compactEvents.length).toBe(1);
|
||||
|
||||
const beforeEvent = beforeCompactEvents[0];
|
||||
expect(beforeEvent.preparation).toBeDefined();
|
||||
expect(beforeEvent.preparation.messagesToSummarize).toBeDefined();
|
||||
expect(beforeEvent.preparation.turnPrefixMessages).toBeDefined();
|
||||
expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof beforeEvent.preparation.isSplitTurn).toBe("boolean");
|
||||
expect(beforeEvent.branchEntries).toBeDefined();
|
||||
// sessionManager, modelRegistry, and model are now on ctx, not event
|
||||
|
||||
const afterEvent = compactEvents[0];
|
||||
expect(afterEvent.compactionEntry).toBeDefined();
|
||||
expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0);
|
||||
expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(afterEvent.fromExtension).toBe(false);
|
||||
}, 120000);
|
||||
|
||||
it("should allow extensions to cancel compaction", async () => {
|
||||
const extension = createExtension(() => ({ cancel: true }));
|
||||
createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await expect(session.compact()).rejects.toThrow("Compaction cancelled");
|
||||
|
||||
const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(0);
|
||||
}, 120000);
|
||||
|
||||
it("should allow extensions to provide custom compaction", async () => {
|
||||
const customSummary = "Custom summary from extension";
|
||||
|
||||
const extension = createExtension((event) => {
|
||||
if (event.type === "session_before_compact") {
|
||||
return {
|
||||
compaction: {
|
||||
summary: customSummary,
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBe(customSummary);
|
||||
|
||||
const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(1);
|
||||
|
||||
const afterEvent = compactEvents[0];
|
||||
if (afterEvent.type === "session_compact") {
|
||||
expect(afterEvent.compactionEntry.summary).toBe(customSummary);
|
||||
expect(afterEvent.fromExtension).toBe(true);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should include entries in compact event after compaction is saved", async () => {
|
||||
const extension = createExtension();
|
||||
createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(1);
|
||||
|
||||
const afterEvent = compactEvents[0];
|
||||
if (afterEvent.type === "session_compact") {
|
||||
// sessionManager is now on ctx, use session.sessionManager directly
|
||||
const entries = session.sessionManager.getEntries();
|
||||
const hasCompactionEntry = entries.some((e: { type: string }) => e.type === "compaction");
|
||||
expect(hasCompactionEntry).toBe(true);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should continue with default compaction if extension throws error", async () => {
|
||||
const throwingExtension: Extension = {
|
||||
path: "throwing-extension",
|
||||
resolvedPath: "/test/throwing-extension.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:throwing-extension>", { source: "test" }),
|
||||
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
|
||||
[
|
||||
"session_before_compact",
|
||||
[
|
||||
async (event: SessionBeforeCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
throw new Error("Extension intentionally throws");
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"session_compact",
|
||||
[
|
||||
async (event: SessionCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
|
||||
createSession([throwingExtension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
|
||||
const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(1);
|
||||
expect(compactEvents[0].fromExtension).toBe(false);
|
||||
}, 120000);
|
||||
|
||||
it("should call multiple extensions in order", async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const extension1: Extension = {
|
||||
path: "extension1",
|
||||
resolvedPath: "/test/extension1.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:extension1>", { source: "test" }),
|
||||
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
|
||||
[
|
||||
"session_before_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension1-before");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"session_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension1-after");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
|
||||
const extension2: Extension = {
|
||||
path: "extension2",
|
||||
resolvedPath: "/test/extension2.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:extension2>", { source: "test" }),
|
||||
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
|
||||
[
|
||||
"session_before_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension2-before");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"session_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension2-after");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
|
||||
createSession([extension1, extension2]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
expect(callOrder).toEqual(["extension1-before", "extension2-before", "extension1-after", "extension2-after"]);
|
||||
}, 120000);
|
||||
|
||||
it("should pass correct data in before_compact event", async () => {
|
||||
let capturedBeforeEvent: SessionBeforeCompactEvent | null = null;
|
||||
|
||||
const extension = createExtension((event) => {
|
||||
capturedBeforeEvent = event;
|
||||
return undefined;
|
||||
});
|
||||
createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
expect(capturedBeforeEvent).not.toBeNull();
|
||||
const event = capturedBeforeEvent!;
|
||||
expect(typeof event.preparation.isSplitTurn).toBe("boolean");
|
||||
expect(event.preparation.firstKeptEntryId).toBeDefined();
|
||||
|
||||
expect(Array.isArray(event.preparation.messagesToSummarize)).toBe(true);
|
||||
expect(Array.isArray(event.preparation.turnPrefixMessages)).toBe(true);
|
||||
|
||||
expect(typeof event.preparation.tokensBefore).toBe("number");
|
||||
|
||||
expect(Array.isArray(event.branchEntries)).toBe(true);
|
||||
|
||||
// sessionManager, modelRegistry, and model are now on ctx, not event
|
||||
// Verify they're accessible via session
|
||||
expect(typeof session.sessionManager.getEntries).toBe("function");
|
||||
expect(typeof session.modelRegistry.getApiKeyAndHeaders).toBe("function");
|
||||
|
||||
const entries = session.sessionManager.getEntries();
|
||||
expect(Array.isArray(entries)).toBe(true);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
}, 120000);
|
||||
|
||||
it("should use extension compaction even with different values", async () => {
|
||||
const customSummary = "Custom summary with modified values";
|
||||
|
||||
const extension = createExtension((event) => {
|
||||
if (event.type === "session_before_compact") {
|
||||
return {
|
||||
compaction: {
|
||||
summary: customSummary,
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: 999,
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBe(customSummary);
|
||||
expect(result.tokensBefore).toBe(999);
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serializeConversation } from "../src/core/compaction/utils.js";
|
||||
|
||||
describe("serializeConversation", () => {
|
||||
it("should truncate long tool results", () => {
|
||||
const longContent = "x".repeat(5000);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tc1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: longContent }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeConversation(messages);
|
||||
|
||||
expect(result).toContain("[Tool result]:");
|
||||
expect(result).toContain("[... 3000 more characters truncated]");
|
||||
expect(result).not.toContain("x".repeat(3000));
|
||||
// First 2000 chars should be present
|
||||
expect(result).toContain("x".repeat(2000));
|
||||
});
|
||||
|
||||
it("should not truncate short tool results", () => {
|
||||
const shortContent = "x".repeat(1500);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tc1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: shortContent }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeConversation(messages);
|
||||
|
||||
expect(result).toBe(`[Tool result]: ${shortContent}`);
|
||||
expect(result).not.toContain("truncated");
|
||||
});
|
||||
|
||||
it("should not truncate assistant or user messages", () => {
|
||||
const longText = "y".repeat(5000);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: longText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: longText }],
|
||||
api: "anthropic",
|
||||
provider: "anthropic",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeConversation(messages);
|
||||
|
||||
expect(result).not.toContain("truncated");
|
||||
expect(result).toContain(longText);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Model } from "@mariozechner/pi-ai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { generateSummary } from "../src/core/compaction/index.js";
|
||||
|
||||
const { completeSimpleMock } = vi.hoisted(() => ({
|
||||
completeSimpleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...actual,
|
||||
completeSimple: completeSimpleMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createModel(reasoning: boolean): Model<"anthropic-messages"> {
|
||||
return {
|
||||
id: reasoning ? "reasoning-model" : "non-reasoning-model",
|
||||
name: reasoning ? "Reasoning Model" : "Non-reasoning Model",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
}
|
||||
|
||||
const mockSummaryResponse: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "## Goal\nTest summary" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 20,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const messages: AgentMessage[] = [{ role: "user", content: "Summarize this.", timestamp: Date.now() }];
|
||||
|
||||
describe("generateSummary reasoning options", () => {
|
||||
beforeEach(() => {
|
||||
completeSimpleMock.mockReset();
|
||||
completeSimpleMock.mockResolvedValue(mockSummaryResponse);
|
||||
});
|
||||
|
||||
it("sets reasoning=high for reasoning-capable models", async () => {
|
||||
await generateSummary(messages, createModel(true), 2000, "test-key");
|
||||
|
||||
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
||||
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
|
||||
reasoning: "high",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set reasoning for non-reasoning models", async () => {
|
||||
await generateSummary(messages, createModel(false), 2000, "test-key");
|
||||
|
||||
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
||||
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Test for compaction with thinking models.
|
||||
*
|
||||
* Tests both:
|
||||
* - Claude via Antigravity (google-gemini-cli API)
|
||||
* - Claude via real Anthropic API (anthropic-messages API)
|
||||
*
|
||||
* Reproduces issue where compact fails when maxTokens < thinkingBudget.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { getModel, type Model } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { codingTools } from "../src/core/tools/index.js";
|
||||
import {
|
||||
API_KEY,
|
||||
createTestResourceLoader,
|
||||
getRealAuthStorage,
|
||||
hasAuthForProvider,
|
||||
resolveApiKey,
|
||||
} from "./utilities.js";
|
||||
|
||||
// Check for auth
|
||||
const HAS_ANTIGRAVITY_AUTH = hasAuthForProvider("google-antigravity");
|
||||
const HAS_ANTHROPIC_AUTH = !!API_KEY;
|
||||
|
||||
describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigravity)", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
let apiKey: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const key = await resolveApiKey("google-antigravity");
|
||||
if (!key) throw new Error("Failed to resolve google-antigravity API key");
|
||||
apiKey = key;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-thinking-compaction-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createSession(
|
||||
modelId: "claude-opus-4-5-thinking" | "claude-sonnet-4-5",
|
||||
thinkingLevel: ThinkingLevel = "high",
|
||||
) {
|
||||
const model = getModel("google-antigravity", modelId);
|
||||
if (!model) {
|
||||
throw new Error(`Model not found: google-antigravity/${modelId}`);
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => apiKey,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
tools: codingTools,
|
||||
thinkingLevel,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
// Use minimal keepRecentTokens so small test conversations have something to summarize
|
||||
// settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
|
||||
|
||||
const authStorage = getRealAuthStorage();
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
session.subscribe(() => {});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should compact successfully with claude-opus-4-5-thinking and thinking level high", async () => {
|
||||
createSession("claude-opus-4-5-thinking", "high");
|
||||
|
||||
// Send a simple prompt
|
||||
await session.prompt("Write down the first 10 prime numbers.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Verify we got a response
|
||||
const messages = session.messages;
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Now try to compact - this should not throw
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.tokensBefore).toBeGreaterThan(0);
|
||||
|
||||
// Verify session is still usable after compaction
|
||||
const messagesAfterCompact = session.messages;
|
||||
expect(messagesAfterCompact.length).toBeGreaterThan(0);
|
||||
expect(messagesAfterCompact[0].role).toBe("compactionSummary");
|
||||
}, 180000);
|
||||
|
||||
it("should compact successfully with claude-sonnet-4-5 (non-thinking) for comparison", async () => {
|
||||
createSession("claude-sonnet-4-5", "off");
|
||||
|
||||
await session.prompt("Write down the first 10 prime numbers.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const messages = session.messages;
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
}, 180000);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Real Anthropic API tests (for comparison)
|
||||
// ============================================================================
|
||||
|
||||
describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic)", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-thinking-compaction-anthropic-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createSession(model: Model<any>, thinkingLevel: ThinkingLevel = "high") {
|
||||
const agent = new Agent({
|
||||
getApiKey: () => API_KEY,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
tools: codingTools,
|
||||
thinkingLevel,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
|
||||
const authStorage = getRealAuthStorage();
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
session.subscribe(() => {});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should compact successfully with claude-sonnet-4-5 and thinking level high", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
createSession(model, "high");
|
||||
|
||||
// Send a simple prompt
|
||||
await session.prompt("Write down the first 10 prime numbers.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Verify we got a response
|
||||
const messages = session.messages;
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Now try to compact - this should not throw
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.tokensBefore).toBeGreaterThan(0);
|
||||
|
||||
// Verify session is still usable after compaction
|
||||
const messagesAfterCompact = session.messages;
|
||||
expect(messagesAfterCompact.length).toBeGreaterThan(0);
|
||||
expect(messagesAfterCompact[0].role).toBe("compactionSummary");
|
||||
}, 180000);
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Usage } from "@mariozechner/pi-ai";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CompactionSettings,
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateContextTokens,
|
||||
findCutPoint,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
shouldCompact,
|
||||
} from "../src/core/compaction/index.js";
|
||||
import {
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
type ModelChangeEntry,
|
||||
migrateSessionEntries,
|
||||
parseSessionEntries,
|
||||
type SessionEntry,
|
||||
type SessionMessageEntry,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "../src/core/session-manager.js";
|
||||
|
||||
// ============================================================================
|
||||
// Test fixtures
|
||||
// ============================================================================
|
||||
|
||||
function loadLargeSessionEntries(): SessionEntry[] {
|
||||
const sessionPath = join(__dirname, "fixtures/large-session.jsonl");
|
||||
const content = readFileSync(sessionPath, "utf-8");
|
||||
const entries = parseSessionEntries(content);
|
||||
migrateSessionEntries(entries); // Add id/parentId for v1 fixtures
|
||||
return entries.filter((e): e is SessionEntry => e.type !== "session");
|
||||
}
|
||||
|
||||
function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage {
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens: input + output + cacheRead + cacheWrite,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string): AgentMessage {
|
||||
return { role: "user", content: text, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, usage?: Usage): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
usage: usage || createMockUsage(100, 50),
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
};
|
||||
}
|
||||
|
||||
let entryCounter = 0;
|
||||
let lastId: string | null = null;
|
||||
|
||||
function resetEntryCounter() {
|
||||
entryCounter = 0;
|
||||
lastId = null;
|
||||
}
|
||||
|
||||
// Reset counter before each test to get predictable IDs
|
||||
beforeEach(() => {
|
||||
resetEntryCounter();
|
||||
});
|
||||
|
||||
function createMessageEntry(message: AgentMessage): SessionMessageEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createCompactionEntry(summary: string, firstKeptEntryId: string): CompactionEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: CompactionEntry = {
|
||||
type: "compaction",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore: 10000,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createModelChangeEntry(provider: string, modelId: string): ModelChangeEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: ModelChangeEntry = {
|
||||
type: "model_change",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createThinkingLevelEntry(thinkingLevel: string): ThinkingLevelChangeEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: ThinkingLevelChangeEntry = {
|
||||
type: "thinking_level_change",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function extractText(messages: AgentMessage[]): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
case "assistant":
|
||||
return message.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
return message.summary;
|
||||
case "custom":
|
||||
case "toolResult":
|
||||
return typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
case "bashExecution":
|
||||
return `${message.command}\n${message.output}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests
|
||||
// ============================================================================
|
||||
|
||||
describe("Token calculation", () => {
|
||||
it("should calculate total context tokens from usage", () => {
|
||||
const usage = createMockUsage(1000, 500, 200, 100);
|
||||
expect(calculateContextTokens(usage)).toBe(1800);
|
||||
});
|
||||
|
||||
it("should handle zero values", () => {
|
||||
const usage = createMockUsage(0, 0, 0, 0);
|
||||
expect(calculateContextTokens(usage)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastAssistantUsage", () => {
|
||||
it("should find the last non-aborted assistant message usage", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("Hello")),
|
||||
createMessageEntry(createAssistantMessage("Hi", createMockUsage(100, 50))),
|
||||
createMessageEntry(createUserMessage("How are you?")),
|
||||
createMessageEntry(createAssistantMessage("Good", createMockUsage(200, 100))),
|
||||
];
|
||||
|
||||
const usage = getLastAssistantUsage(entries);
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage!.input).toBe(200);
|
||||
});
|
||||
|
||||
it("should skip aborted messages", () => {
|
||||
const abortedMsg: AssistantMessage = {
|
||||
...createAssistantMessage("Aborted", createMockUsage(300, 150)),
|
||||
stopReason: "aborted",
|
||||
};
|
||||
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("Hello")),
|
||||
createMessageEntry(createAssistantMessage("Hi", createMockUsage(100, 50))),
|
||||
createMessageEntry(createUserMessage("How are you?")),
|
||||
createMessageEntry(abortedMsg),
|
||||
];
|
||||
|
||||
const usage = getLastAssistantUsage(entries);
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage!.input).toBe(100);
|
||||
});
|
||||
|
||||
it("should return undefined if no assistant messages", () => {
|
||||
const entries: SessionEntry[] = [createMessageEntry(createUserMessage("Hello"))];
|
||||
expect(getLastAssistantUsage(entries)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCompact", () => {
|
||||
it("should return true when context exceeds threshold", () => {
|
||||
const settings: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 10000,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
expect(shouldCompact(95000, 100000, settings)).toBe(true);
|
||||
expect(shouldCompact(89000, 100000, settings)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when disabled", () => {
|
||||
const settings: CompactionSettings = {
|
||||
enabled: false,
|
||||
reserveTokens: 10000,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
expect(shouldCompact(95000, 100000, settings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCutPoint", () => {
|
||||
it("should find cut point based on actual token differences", () => {
|
||||
// Create entries with cumulative token counts
|
||||
const entries: SessionEntry[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
entries.push(createMessageEntry(createUserMessage(`User ${i}`)));
|
||||
entries.push(
|
||||
createMessageEntry(createAssistantMessage(`Assistant ${i}`, createMockUsage(0, 100, (i + 1) * 1000, 0))),
|
||||
);
|
||||
}
|
||||
|
||||
// 20 entries, last assistant has 10000 tokens
|
||||
// keepRecentTokens = 2500: keep entries where diff < 2500
|
||||
const result = findCutPoint(entries, 0, entries.length, 2500);
|
||||
|
||||
// Should cut at a valid cut point (user or assistant message)
|
||||
expect(entries[result.firstKeptEntryIndex].type).toBe("message");
|
||||
const role = (entries[result.firstKeptEntryIndex] as SessionMessageEntry).message.role;
|
||||
expect(role === "user" || role === "assistant").toBe(true);
|
||||
});
|
||||
|
||||
it("should return startIndex if no valid cut points in range", () => {
|
||||
const entries: SessionEntry[] = [createMessageEntry(createAssistantMessage("a"))];
|
||||
const result = findCutPoint(entries, 0, entries.length, 1000);
|
||||
expect(result.firstKeptEntryIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should keep everything if all messages fit within budget", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a", createMockUsage(0, 50, 500, 0))),
|
||||
createMessageEntry(createUserMessage("2")),
|
||||
createMessageEntry(createAssistantMessage("b", createMockUsage(0, 50, 1000, 0))),
|
||||
];
|
||||
|
||||
const result = findCutPoint(entries, 0, entries.length, 50000);
|
||||
expect(result.firstKeptEntryIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should indicate split turn when cutting at assistant message", () => {
|
||||
// Create a scenario where we cut at an assistant message mid-turn
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("Turn 1")),
|
||||
createMessageEntry(createAssistantMessage("A1", createMockUsage(0, 100, 1000, 0))),
|
||||
createMessageEntry(createUserMessage("Turn 2")), // index 2
|
||||
createMessageEntry(createAssistantMessage("A2-1", createMockUsage(0, 100, 5000, 0))), // index 3
|
||||
createMessageEntry(createAssistantMessage("A2-2", createMockUsage(0, 100, 8000, 0))), // index 4
|
||||
createMessageEntry(createAssistantMessage("A2-3", createMockUsage(0, 100, 10000, 0))), // index 5
|
||||
];
|
||||
|
||||
// With keepRecentTokens = 3000, should cut somewhere in Turn 2
|
||||
const result = findCutPoint(entries, 0, entries.length, 3000);
|
||||
|
||||
// If cut at assistant message (not user), should indicate split turn
|
||||
const cutEntry = entries[result.firstKeptEntryIndex] as SessionMessageEntry;
|
||||
if (cutEntry.message.role === "assistant") {
|
||||
expect(result.isSplitTurn).toBe(true);
|
||||
expect(result.turnStartIndex).toBe(2); // Turn 2 starts at index 2
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionContext", () => {
|
||||
it("should load all messages when no compaction", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
createMessageEntry(createUserMessage("2")),
|
||||
createMessageEntry(createAssistantMessage("b")),
|
||||
];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
expect(loaded.messages.length).toBe(4);
|
||||
expect(loaded.thinkingLevel).toBe("off");
|
||||
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
});
|
||||
|
||||
it("should handle single compaction", () => {
|
||||
// IDs: u1=test-id-0, a1=test-id-1, u2=test-id-2, a2=test-id-3, compaction=test-id-4, u3=test-id-5, a3=test-id-6
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"));
|
||||
const u2 = createMessageEntry(createUserMessage("2"));
|
||||
const a2 = createMessageEntry(createAssistantMessage("b"));
|
||||
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id); // keep from u2 onwards
|
||||
const u3 = createMessageEntry(createUserMessage("3"));
|
||||
const a3 = createMessageEntry(createAssistantMessage("c"));
|
||||
|
||||
const entries: SessionEntry[] = [u1, a1, u2, a2, compaction, u3, a3];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// summary + kept (u2, a2) + after (u3, a3) = 5
|
||||
expect(loaded.messages.length).toBe(5);
|
||||
expect(loaded.messages[0].role).toBe("compactionSummary");
|
||||
expect((loaded.messages[0] as any).summary).toContain("Summary of 1,a,2,b");
|
||||
});
|
||||
|
||||
it("should handle multiple compactions (only latest matters)", () => {
|
||||
// First batch
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"));
|
||||
const compact1 = createCompactionEntry("First summary", u1.id);
|
||||
// Second batch
|
||||
const u2 = createMessageEntry(createUserMessage("2"));
|
||||
const b = createMessageEntry(createAssistantMessage("b"));
|
||||
const u3 = createMessageEntry(createUserMessage("3"));
|
||||
const c = createMessageEntry(createAssistantMessage("c"));
|
||||
const compact2 = createCompactionEntry("Second summary", u3.id); // keep from u3 onwards
|
||||
// After second compaction
|
||||
const u4 = createMessageEntry(createUserMessage("4"));
|
||||
const d = createMessageEntry(createAssistantMessage("d"));
|
||||
|
||||
const entries: SessionEntry[] = [u1, a1, compact1, u2, b, u3, c, compact2, u4, d];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// summary + kept from u3 (u3, c) + after (u4, d) = 5
|
||||
expect(loaded.messages.length).toBe(5);
|
||||
expect((loaded.messages[0] as any).summary).toContain("Second summary");
|
||||
});
|
||||
|
||||
it("should keep all messages when firstKeptEntryId is first entry", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"));
|
||||
const compact1 = createCompactionEntry("First summary", u1.id); // keep from first entry
|
||||
const u2 = createMessageEntry(createUserMessage("2"));
|
||||
const b = createMessageEntry(createAssistantMessage("b"));
|
||||
|
||||
const entries: SessionEntry[] = [u1, a1, compact1, u2, b];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// summary + all messages (u1, a1, u2, b) = 5
|
||||
expect(loaded.messages.length).toBe(5);
|
||||
});
|
||||
|
||||
it("should track model and thinking level changes", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createModelChangeEntry("openai", "gpt-4"),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
createThinkingLevelEntry("high"),
|
||||
];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// model_change is later overwritten by assistant message's model info
|
||||
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
expect(loaded.thinkingLevel).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareCompaction with previous compaction", () => {
|
||||
it("should preserve kept messages across repeated compactions when they still fit", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"));
|
||||
const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1"));
|
||||
const a2 = createMessageEntry(createAssistantMessage("assistant msg 2"));
|
||||
const u3 = createMessageEntry(createUserMessage("user msg 3 - kept by compaction1"));
|
||||
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(5000, 1000)));
|
||||
const compaction1 = createCompactionEntry("First summary", u2.id);
|
||||
const u4 = createMessageEntry(createUserMessage("user msg 4 (new after compaction1)"));
|
||||
const a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000)));
|
||||
|
||||
const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4];
|
||||
const contextBefore = buildSessionContext(pathEntries);
|
||||
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
|
||||
|
||||
expect(preparation).toBeDefined();
|
||||
expect(preparation!.firstKeptEntryId).toBe(u2.id);
|
||||
expect(preparation!.previousSummary).toBe("First summary");
|
||||
expect(extractText(preparation!.messagesToSummarize)).not.toContain("First summary");
|
||||
expect(preparation!.tokensBefore).toBe(estimateContextTokens(contextBefore.messages).tokens);
|
||||
|
||||
const compaction2: CompactionEntry = {
|
||||
type: "compaction",
|
||||
id: "compaction2-id",
|
||||
parentId: a4.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: "Second summary",
|
||||
firstKeptEntryId: preparation!.firstKeptEntryId,
|
||||
tokensBefore: preparation!.tokensBefore,
|
||||
};
|
||||
const contextAfter = buildSessionContext([...pathEntries, compaction2]);
|
||||
const contextAfterText = extractText(contextAfter.messages);
|
||||
|
||||
expect(contextAfterText).toContain("user msg 2 - kept by compaction1");
|
||||
expect(contextAfterText).toContain("user msg 3 - kept by compaction1");
|
||||
});
|
||||
|
||||
it("should re-summarize previously kept messages when the recent window moves past them", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)".repeat(4)));
|
||||
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1".repeat(4)));
|
||||
const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1 ".repeat(12)));
|
||||
const a2 = createMessageEntry(createAssistantMessage("assistant msg 2 ".repeat(12)));
|
||||
const u3 = createMessageEntry(createUserMessage("user msg 3 - kept by compaction1 ".repeat(12)));
|
||||
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3 ".repeat(12), createMockUsage(5000, 1000)));
|
||||
const compaction1 = createCompactionEntry("First summary", u2.id);
|
||||
const u4 = createMessageEntry(createUserMessage("user msg 4 (new after compaction1) ".repeat(12)));
|
||||
const a4 = createMessageEntry(createAssistantMessage("assistant msg 4 ".repeat(12), createMockUsage(8000, 2000)));
|
||||
|
||||
const settings: CompactionSettings = {
|
||||
...DEFAULT_COMPACTION_SETTINGS,
|
||||
keepRecentTokens: 100,
|
||||
};
|
||||
const preparation = prepareCompaction([u1, a1, u2, a2, u3, a3, compaction1, u4, a4], settings);
|
||||
|
||||
expect(preparation).toBeDefined();
|
||||
const summarizedText = extractText(preparation!.messagesToSummarize);
|
||||
expect(summarizedText).toContain("user msg 2 - kept by compaction1");
|
||||
expect(summarizedText).toContain("user msg 3 - kept by compaction1");
|
||||
expect(summarizedText).not.toContain("First summary");
|
||||
expect(preparation!.previousSummary).toBe("First summary");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Integration tests with real session data
|
||||
// ============================================================================
|
||||
|
||||
describe("Large session fixture", () => {
|
||||
it("should parse the large session", () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
expect(entries.length).toBeGreaterThan(100);
|
||||
|
||||
const messageCount = entries.filter((e) => e.type === "message").length;
|
||||
expect(messageCount).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("should find cut point in large session", () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const result = findCutPoint(entries, 0, entries.length, DEFAULT_COMPACTION_SETTINGS.keepRecentTokens);
|
||||
|
||||
// Cut point should be at a message entry (user or assistant)
|
||||
expect(entries[result.firstKeptEntryIndex].type).toBe("message");
|
||||
const role = (entries[result.firstKeptEntryIndex] as SessionMessageEntry).message.role;
|
||||
expect(role === "user" || role === "assistant").toBe(true);
|
||||
});
|
||||
|
||||
it("should load session correctly", () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const loaded = buildSessionContext(entries);
|
||||
|
||||
expect(loaded.messages.length).toBeGreaterThan(100);
|
||||
expect(loaded.model).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LLM integration tests (skipped without API key)
|
||||
// ============================================================================
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => {
|
||||
it("should generate a compaction result for the large session", async () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
|
||||
expect(preparation).toBeDefined();
|
||||
|
||||
const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!);
|
||||
|
||||
expect(compactionResult.summary.length).toBeGreaterThan(100);
|
||||
expect(compactionResult.firstKeptEntryId).toBeTruthy();
|
||||
expect(compactionResult.tokensBefore).toBeGreaterThan(0);
|
||||
|
||||
console.log("Summary length:", compactionResult.summary.length);
|
||||
console.log("First kept entry ID:", compactionResult.firstKeptEntryId);
|
||||
console.log("Tokens before:", compactionResult.tokensBefore);
|
||||
console.log("\n--- SUMMARY ---\n");
|
||||
console.log(compactionResult.summary);
|
||||
}, 60000);
|
||||
|
||||
it("should produce valid session after compaction", async () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const loaded = buildSessionContext(entries);
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
|
||||
expect(preparation).toBeDefined();
|
||||
|
||||
const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!);
|
||||
|
||||
// Simulate appending compaction to entries by creating a proper entry
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
const parentId = lastEntry.id;
|
||||
const compactionEntry: CompactionEntry = {
|
||||
type: "compaction",
|
||||
id: "compaction-test-id",
|
||||
parentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...compactionResult,
|
||||
};
|
||||
const newEntries = [...entries, compactionEntry];
|
||||
const reloaded = buildSessionContext(newEntries);
|
||||
|
||||
// Should have summary + kept messages
|
||||
expect(reloaded.messages.length).toBeLessThan(loaded.messages.length);
|
||||
expect(reloaded.messages[0].role).toBe("compactionSummary");
|
||||
expect((reloaded.messages[0] as any).summary).toContain(compactionResult.summary);
|
||||
|
||||
console.log("Original messages:", loaded.messages.length);
|
||||
console.log("After compaction:", reloaded.messages.length);
|
||||
}, 60000);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionContext } from "../src/core/extensions/types.js";
|
||||
import { createEditToolDefinition } from "../src/core/tools/edit.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-legacy-input-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0, tempDirs.length).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("edit tool prepareArguments", () => {
|
||||
it("keeps legacy fields out of the public schema", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
expect(definition.parameters.properties).not.toHaveProperty("oldText");
|
||||
expect(definition.parameters.properties).not.toHaveProperty("newText");
|
||||
});
|
||||
|
||||
it("folds top-level oldText/newText into edits", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "file.txt",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
});
|
||||
expect(prepared).toEqual({
|
||||
path: "file.txt",
|
||||
edits: [{ oldText: "before", newText: "after" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("appends legacy replacement to existing edits", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "file.txt",
|
||||
edits: [{ oldText: "a", newText: "b" }],
|
||||
oldText: "c",
|
||||
newText: "d",
|
||||
});
|
||||
expect(prepared).toEqual({
|
||||
path: "file.txt",
|
||||
edits: [
|
||||
{ oldText: "a", newText: "b" },
|
||||
{ oldText: "c", newText: "d" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through valid input unchanged", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const input = {
|
||||
path: "file.txt",
|
||||
edits: [{ oldText: "a", newText: "b" }],
|
||||
};
|
||||
const prepared = definition.prepareArguments!(input);
|
||||
expect(prepared).toBe(input);
|
||||
});
|
||||
|
||||
it("passes through non-object input unchanged", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
expect(definition.prepareArguments!(null)).toBe(null);
|
||||
expect(definition.prepareArguments!(undefined)).toBe(undefined);
|
||||
expect(definition.prepareArguments!("garbage")).toBe("garbage");
|
||||
});
|
||||
|
||||
it("prepared args execute correctly", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "legacy.txt");
|
||||
await writeFile(filePath, "before\n", "utf8");
|
||||
|
||||
const definition = createEditToolDefinition(dir);
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "legacy.txt",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
});
|
||||
|
||||
const result = await definition.execute("tool-1", prepared, undefined, undefined, {} as ExtensionContext);
|
||||
expect(result.content).toEqual([{ type: "text", text: "Successfully replaced 1 block(s) in legacy.txt." }]);
|
||||
expect(await readFile(filePath, "utf8")).toBe("after\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Container, type Terminal, Text, TUI } from "@mariozechner/pi-tui";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createEditToolDefinition } from "../src/core/tools/edit.js";
|
||||
import { computeEditsDiff, type Edit } from "../src/core/tools/edit-diff.js";
|
||||
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
class FakeTerminal implements Terminal {
|
||||
columns = 80;
|
||||
rows = 24;
|
||||
kittyProtocolActive = true;
|
||||
writes: string[] = [];
|
||||
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
async drainInput(): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
|
||||
get fullClearCount(): number {
|
||||
return this.writes.filter((write) => write.includes("\x1b[2J\x1b[H\x1b[3J")).length;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRender(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitForRenderedText(
|
||||
getRender: () => string,
|
||||
expectedText: string,
|
||||
onRetry?: () => void,
|
||||
timeoutMs = 2000,
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastRender = "";
|
||||
while (Date.now() < deadline) {
|
||||
onRetry?.();
|
||||
await waitForRender();
|
||||
lastRender = getRender();
|
||||
if (lastRender.includes(expectedText)) {
|
||||
return lastRender;
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for render to include "${expectedText}". Last render:\n${lastRender}`);
|
||||
}
|
||||
|
||||
function createLargeEdits(lines: string[]): Edit[] {
|
||||
const targets = [50, 150, 250, 350, 450, 550, 650, 750, 850, 950];
|
||||
return targets.map((lineNumber) => ({
|
||||
oldText: `${lines[lineNumber - 1]}\n${lines[lineNumber]}\n${lines[lineNumber + 1]}`,
|
||||
newText: `${lines[lineNumber - 1]}\n${lines[lineNumber]} changed\n${lines[lineNumber + 1]}`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("edit tool TUI rendering", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("renders the large diff in the call preview and does not full-redraw when the result settles", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-redraw-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = join(dir, "large-edit.txt");
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${Array.from({ length: 1000 }, (_, i) => `line ${i}`).join("\n")}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const lines = (await readFile(filePath, "utf8")).trimEnd().split("\n");
|
||||
const edits = createLargeEdits(lines);
|
||||
const diff = await computeEditsDiff(filePath, edits, process.cwd());
|
||||
if ("error" in diff) {
|
||||
throw new Error(diff.error);
|
||||
}
|
||||
|
||||
const terminal = new FakeTerminal();
|
||||
const tui = new TUI(terminal);
|
||||
const root = new Container();
|
||||
for (let i = 0; i < 200; i++) {
|
||||
root.addChild(new Text(`history ${i}`, 0, 0));
|
||||
}
|
||||
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
"tool-call-1",
|
||||
{ path: filePath, edits },
|
||||
{},
|
||||
createEditToolDefinition(process.cwd()),
|
||||
tui,
|
||||
process.cwd(),
|
||||
);
|
||||
root.addChild(component);
|
||||
tui.addChild(root);
|
||||
tui.start();
|
||||
await waitForRender();
|
||||
|
||||
component.setArgsComplete();
|
||||
tui.requestRender();
|
||||
await waitForRender();
|
||||
await waitForRender();
|
||||
|
||||
const callOnlyRender = await waitForRenderedText(
|
||||
() => component.render(80).join("\n"),
|
||||
"line 50 changed",
|
||||
() => tui.requestRender(true),
|
||||
);
|
||||
expect(callOnlyRender).toContain("edit");
|
||||
expect(callOnlyRender).toContain("line 950 changed");
|
||||
|
||||
const redrawsBeforeResult = tui.fullRedraws;
|
||||
const clearsBeforeResult = terminal.fullClearCount;
|
||||
component.updateResult(
|
||||
{
|
||||
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${filePath}.` }],
|
||||
details: diff,
|
||||
isError: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
tui.requestRender();
|
||||
await waitForRender();
|
||||
|
||||
expect(tui.fullRedraws).toBe(redrawsBeforeResult);
|
||||
expect(terminal.fullClearCount).toBe(clearsBeforeResult);
|
||||
|
||||
const settledRender = component.render(80).join("\n");
|
||||
expect(settledRender).toContain("line 50 changed");
|
||||
expect(settledRender).toContain("line 950 changed");
|
||||
expect(settledRender).not.toContain("Successfully replaced");
|
||||
});
|
||||
|
||||
it("reconstructs the boxed preview from a settled result without argsComplete", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-replay-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = join(dir, "replay-edit.txt");
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n")}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const lines = (await readFile(filePath, "utf8")).trimEnd().split("\n");
|
||||
const edits = createLargeEdits(lines).slice(0, 2);
|
||||
const diff = await computeEditsDiff(filePath, edits, process.cwd());
|
||||
if ("error" in diff) {
|
||||
throw new Error(diff.error);
|
||||
}
|
||||
await rm(filePath, { force: true });
|
||||
|
||||
const terminal = new FakeTerminal();
|
||||
const tui = new TUI(terminal);
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
"tool-call-replay",
|
||||
{ path: filePath, edits },
|
||||
{},
|
||||
createEditToolDefinition(process.cwd()),
|
||||
tui,
|
||||
process.cwd(),
|
||||
);
|
||||
tui.addChild(component);
|
||||
tui.start();
|
||||
await waitForRender();
|
||||
|
||||
component.updateResult(
|
||||
{
|
||||
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${filePath}.` }],
|
||||
details: diff,
|
||||
isError: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
await waitForRender();
|
||||
await waitForRender();
|
||||
|
||||
const rendered = component.render(80).join("\n");
|
||||
expect(rendered).toContain("line 50 changed");
|
||||
expect(rendered).toContain("line 150 changed");
|
||||
});
|
||||
|
||||
it("shows a preflight error without rendering a diff when the edits do not apply", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-preflight-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = join(dir, "missing-edit.txt");
|
||||
await writeFile(filePath, "line 0\nline 1\n", "utf8");
|
||||
|
||||
const terminal = new FakeTerminal();
|
||||
const tui = new TUI(terminal);
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
"tool-call-2",
|
||||
{ path: filePath, edits: [{ oldText: "does not exist", newText: "replacement" }] },
|
||||
{},
|
||||
createEditToolDefinition(process.cwd()),
|
||||
tui,
|
||||
process.cwd(),
|
||||
);
|
||||
tui.addChild(component);
|
||||
tui.start();
|
||||
await waitForRender();
|
||||
|
||||
component.setArgsComplete();
|
||||
tui.requestRender();
|
||||
await waitForRender();
|
||||
await waitForRender();
|
||||
|
||||
const rendered = await waitForRenderedText(
|
||||
() => component.render(80).join("\n"),
|
||||
"Could not find",
|
||||
() => tui.requestRender(true),
|
||||
);
|
||||
expect(rendered).not.toContain("+1 ");
|
||||
expect(rendered).not.toContain("-1 ");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe("extensions discovery", () => {
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ext-test-"));
|
||||
extensionsDir = path.join(tempDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const extensionCode = `
|
||||
export default function(pi) {
|
||||
pi.registerCommand("test", { handler: async () => {} });
|
||||
}
|
||||
`;
|
||||
|
||||
const extensionCodeWithTool = (toolName: string) => `
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi) {
|
||||
pi.registerTool({
|
||||
name: "${toolName}",
|
||||
label: "${toolName}",
|
||||
description: "Test tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
it("discovers direct .ts files in extensions/", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
expect(result.extensions.map((e) => path.basename(e.path)).sort()).toEqual(["bar.ts", "foo.ts"]);
|
||||
});
|
||||
|
||||
it("discovers direct .js files in extensions/", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(path.basename(result.extensions[0].path)).toBe("foo.js");
|
||||
});
|
||||
|
||||
it("discovers subdirectory with index.ts", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("my-extension");
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("discovers subdirectory with index.js", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("index.js");
|
||||
});
|
||||
|
||||
it("prefers index.ts over index.js", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("discovers subdirectory with package.json pi field", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
const srcDir = path.join(subdir, "src");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.mkdirSync(srcDir);
|
||||
fs.writeFileSync(path.join(srcDir, "main.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
pi: {
|
||||
extensions: ["./src/main.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("src");
|
||||
expect(result.extensions[0].path).toContain("main.ts");
|
||||
});
|
||||
|
||||
it("package.json can declare multiple extensions", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "ext1.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir, "ext2.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
pi: {
|
||||
extensions: ["./ext1.ts", "./ext2.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("package.json with pi field takes precedence over index.ts", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCodeWithTool("from-index"));
|
||||
fs.writeFileSync(path.join(subdir, "custom.ts"), extensionCodeWithTool("from-custom"));
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
pi: {
|
||||
extensions: ["./custom.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("custom.ts");
|
||||
// Verify the right tool was registered
|
||||
expect(result.extensions[0].tools.has("from-custom")).toBe(true);
|
||||
expect(result.extensions[0].tools.has("from-index")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores package.json without pi field, falls back to index.ts", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("ignores subdirectory without index or package.json", async () => {
|
||||
const subdir = path.join(extensionsDir, "not-an-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not recurse beyond one level", async () => {
|
||||
const subdir = path.join(extensionsDir, "container");
|
||||
const nested = path.join(subdir, "nested");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.mkdirSync(nested);
|
||||
fs.writeFileSync(path.join(nested, "index.ts"), extensionCode);
|
||||
// No index.ts or package.json in container/
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles mixed direct files and subdirectories", async () => {
|
||||
// Direct file
|
||||
fs.writeFileSync(path.join(extensionsDir, "direct.ts"), extensionCode);
|
||||
|
||||
// Subdirectory with index
|
||||
const subdir1 = path.join(extensionsDir, "with-index");
|
||||
fs.mkdirSync(subdir1);
|
||||
fs.writeFileSync(path.join(subdir1, "index.ts"), extensionCode);
|
||||
|
||||
// Subdirectory with package.json
|
||||
const subdir2 = path.join(extensionsDir, "with-manifest");
|
||||
fs.mkdirSync(subdir2);
|
||||
fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir2, "package.json"), JSON.stringify({ pi: { extensions: ["./entry.ts"] } }));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("skips non-existent paths declared in package.json", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "exists.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
pi: {
|
||||
extensions: ["./exists.ts", "./missing.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("exists.ts");
|
||||
});
|
||||
|
||||
it("loads extensions and registers commands", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-command.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].commands.has("test")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extensions and registers tools", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-tool.ts"), extensionCodeWithTool("my-tool"));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].tools.has("my-tool")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports errors for invalid extension code", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "invalid.ts"), "this is not valid typescript export");
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].path).toContain("invalid.ts");
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles explicitly configured paths", async () => {
|
||||
const customPath = path.join(tempDir, "custom-location", "my-ext.ts");
|
||||
fs.mkdirSync(path.dirname(customPath), { recursive: true });
|
||||
fs.writeFileSync(customPath, extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([customPath], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("my-ext.ts");
|
||||
});
|
||||
|
||||
it("resolves dependencies from extension's own node_modules", async () => {
|
||||
// Load extension that has its own package.json and node_modules with 'ms' package
|
||||
const extPath = path.resolve(__dirname, "../examples/extensions/with-deps");
|
||||
|
||||
const result = await discoverAndLoadExtensions([extPath], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("with-deps");
|
||||
// The extension registers a 'parse_duration' tool
|
||||
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers message renderers", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerMessageRenderer("my-custom-type", (message, options, theme) => {
|
||||
return null; // Use default rendering
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error when extension throws during initialization", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
throw new Error("Initialization failed!");
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].error).toContain("Initialization failed!");
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports error when extension has no default export", async () => {
|
||||
const extCode = `
|
||||
export function notDefault(pi) {
|
||||
pi.registerCommand("test", { handler: async () => {} });
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].error).toContain("does not export a valid factory function");
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows multiple extensions to register different tools", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), extensionCodeWithTool("tool-a"));
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), extensionCodeWithTool("tool-b"));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
|
||||
const allTools = new Set<string>();
|
||||
for (const ext of result.extensions) {
|
||||
for (const name of ext.tools.keys()) {
|
||||
allTools.add(name);
|
||||
}
|
||||
}
|
||||
expect(allTools.has("tool-a")).toBe(true);
|
||||
expect(allTools.has("tool-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extension with event handlers", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.on("agent_start", async () => {});
|
||||
pi.on("tool_call", async (event) => undefined);
|
||||
pi.on("agent_end", async () => {});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].handlers.has("agent_start")).toBe(true);
|
||||
expect(result.extensions[0].handlers.has("tool_call")).toBe(true);
|
||||
expect(result.extensions[0].handlers.has("agent_end")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extension with shortcuts", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+t", {
|
||||
description: "Test shortcut",
|
||||
handler: async (ctx) => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].shortcuts.has("ctrl+t")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extension with flags", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerFlag("my-flag", {
|
||||
description: "My custom flag",
|
||||
handler: async (value) => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].flags.has("my-flag")).toBe(true);
|
||||
});
|
||||
|
||||
it("loadExtensions only loads explicit paths without discovery", async () => {
|
||||
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
|
||||
fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCodeWithTool("discovered"));
|
||||
|
||||
// Create explicit extension outside discovery path
|
||||
const explicitPath = path.join(tempDir, "explicit.ts");
|
||||
fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit"));
|
||||
|
||||
// Use loadExtensions directly to skip discovery
|
||||
const { loadExtensions } = await import("../src/core/extensions/loader.js");
|
||||
const result = await loadExtensions([explicitPath], tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].tools.has("explicit")).toBe(true);
|
||||
expect(result.extensions[0].tools.has("discovered")).toBe(false);
|
||||
});
|
||||
|
||||
it("loadExtensions with no paths loads nothing", async () => {
|
||||
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
|
||||
fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode);
|
||||
|
||||
// Use loadExtensions directly with empty paths
|
||||
const { loadExtensions } = await import("../src/core/extensions/loader.js");
|
||||
const result = await loadExtensions([], tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
|
||||
import { ExtensionRunner } from "../src/core/extensions/runner.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
|
||||
describe("Input Event", () => {
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-input-test-"));
|
||||
extensionsDir = path.join(tempDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir);
|
||||
// Clean globalThis test vars
|
||||
delete (globalThis as any).testVar;
|
||||
});
|
||||
|
||||
afterEach(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
async function createRunner(...extensions: string[]) {
|
||||
// Clear and recreate extensions dir for clean state
|
||||
fs.rmSync(extensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(extensionsDir);
|
||||
for (let i = 0; i < extensions.length; i++) fs.writeFileSync(path.join(extensionsDir, `e${i}.ts`), extensions[i]);
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const sm = SessionManager.inMemory();
|
||||
const mr = ModelRegistry.create(AuthStorage.create(path.join(tempDir, "auth.json")));
|
||||
return new ExtensionRunner(result.extensions, result.runtime, tempDir, sm, mr);
|
||||
}
|
||||
|
||||
it("returns continue when no handlers, undefined return, or explicit continue", async () => {
|
||||
// No handlers
|
||||
expect((await (await createRunner()).emitInput("x", undefined, "interactive")).action).toBe("continue");
|
||||
// Returns undefined
|
||||
let r = await createRunner(`export default p => p.on("input", async () => {});`);
|
||||
expect((await r.emitInput("x", undefined, "interactive")).action).toBe("continue");
|
||||
// Returns explicit continue
|
||||
r = await createRunner(`export default p => p.on("input", async () => ({ action: "continue" }));`);
|
||||
expect((await r.emitInput("x", undefined, "interactive")).action).toBe("continue");
|
||||
});
|
||||
|
||||
it("transforms text and preserves images when omitted", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async e => ({ action: "transform", text: "T:" + e.text }));`,
|
||||
);
|
||||
const imgs = [{ type: "image" as const, data: "orig", mimeType: "image/png" }];
|
||||
const result = await r.emitInput("hi", imgs, "interactive");
|
||||
expect(result).toEqual({ action: "transform", text: "T:hi", images: imgs });
|
||||
});
|
||||
|
||||
it("transforms and replaces images when provided", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async () => ({ action: "transform", text: "X", images: [{ type: "image", data: "new", mimeType: "image/jpeg" }] }));`,
|
||||
);
|
||||
const result = await r.emitInput("hi", [{ type: "image", data: "orig", mimeType: "image/png" }], "interactive");
|
||||
expect(result).toEqual({
|
||||
action: "transform",
|
||||
text: "X",
|
||||
images: [{ type: "image", data: "new", mimeType: "image/jpeg" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("chains transforms across multiple handlers", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async e => ({ action: "transform", text: e.text + "[1]" }));`,
|
||||
`export default p => p.on("input", async e => ({ action: "transform", text: e.text + "[2]" }));`,
|
||||
);
|
||||
const result = await r.emitInput("X", undefined, "interactive");
|
||||
expect(result).toEqual({ action: "transform", text: "X[1][2]", images: undefined });
|
||||
});
|
||||
|
||||
it("short-circuits on handled and skips subsequent handlers", async () => {
|
||||
(globalThis as any).testVar = false;
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async () => ({ action: "handled" }));`,
|
||||
`export default p => p.on("input", async () => { globalThis.testVar = true; });`,
|
||||
);
|
||||
expect(await r.emitInput("X", undefined, "interactive")).toEqual({ action: "handled" });
|
||||
expect((globalThis as any).testVar).toBe(false);
|
||||
});
|
||||
|
||||
it("passes source correctly for all source types", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async e => { globalThis.testVar = e.source; return { action: "continue" }; });`,
|
||||
);
|
||||
for (const source of ["interactive", "rpc", "extension"] as const) {
|
||||
await r.emitInput("x", undefined, source);
|
||||
expect((globalThis as any).testVar).toBe(source);
|
||||
}
|
||||
});
|
||||
|
||||
it("catches handler errors and continues", async () => {
|
||||
const r = await createRunner(`export default p => p.on("input", async () => { throw new Error("boom"); });`);
|
||||
const errs: string[] = [];
|
||||
r.onError((e) => errs.push(e.error));
|
||||
const result = await r.emitInput("x", undefined, "interactive");
|
||||
expect(result.action).toBe("continue");
|
||||
expect(errs).toContain("boom");
|
||||
});
|
||||
|
||||
it("hasHandlers returns correct value", async () => {
|
||||
let r = await createRunner();
|
||||
expect(r.hasHandlers("input")).toBe(false);
|
||||
r = await createRunner(`export default p => p.on("input", async () => {});`);
|
||||
expect(r.hasHandlers("input")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,714 @@
|
||||
/**
|
||||
* Tests for ExtensionRunner - conflict detection, error handling, tool wrapping.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
|
||||
import { ExtensionRunner } from "../src/core/extensions/runner.js";
|
||||
import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.js";
|
||||
import { KeybindingsManager, type KeyId } from "../src/core/keybindings.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
|
||||
describe("ExtensionRunner", () => {
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
let sessionManager: SessionManager;
|
||||
let modelRegistry: ModelRegistry;
|
||||
const defaultKeybindings = new KeybindingsManager().getEffectiveConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-"));
|
||||
extensionsDir = path.join(tempDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir);
|
||||
sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.create(path.join(tempDir, "auth.json"));
|
||||
modelRegistry = ModelRegistry.create(authStorage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const providerModelConfig: ProviderConfig = {
|
||||
baseUrl: "https://provider.test/v1",
|
||||
apiKey: "PROVIDER_TEST_KEY",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "instant-model",
|
||||
name: "Instant Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const extensionActions: ExtensionActions = {
|
||||
sendMessage: () => {},
|
||||
sendUserMessage: () => {},
|
||||
appendEntry: () => {},
|
||||
setSessionName: () => {},
|
||||
getSessionName: () => undefined,
|
||||
setLabel: () => {},
|
||||
getActiveTools: () => [],
|
||||
getAllTools: () => [],
|
||||
setActiveTools: () => {},
|
||||
refreshTools: () => {},
|
||||
getCommands: () => [],
|
||||
setModel: async () => false,
|
||||
getThinkingLevel: () => "off",
|
||||
setThinkingLevel: () => {},
|
||||
};
|
||||
|
||||
const extensionContextActions: ExtensionContextActions = {
|
||||
getModel: () => undefined,
|
||||
isIdle: () => true,
|
||||
getSignal: () => undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
getContextUsage: () => undefined,
|
||||
compact: () => {},
|
||||
getSystemPrompt: () => "",
|
||||
};
|
||||
|
||||
describe("shortcut conflicts", () => {
|
||||
it("warns when extension shortcut conflicts with built-in", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+c", {
|
||||
description: "Conflicts with built-in",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "conflict.ts"), extCode);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const shortcuts = runner.getShortcuts(defaultKeybindings);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
|
||||
expect(shortcuts.has("ctrl+c")).toBe(false);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("allows a shortcut when the reserved set no longer contains the default key", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+p", {
|
||||
description: "Uses freed default",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "rebinding.ts"), extCode);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const keybindings = { ...defaultKeybindings, "app.model.cycleForward": "ctrl+n" as KeyId };
|
||||
const shortcuts = runner.getShortcuts(keybindings);
|
||||
|
||||
expect(shortcuts.has("ctrl+p")).toBe(true);
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("warns but allows when extension uses non-reserved built-in shortcut", async () => {
|
||||
const pasteImageKey = Array.isArray(defaultKeybindings["app.clipboard.pasteImage"])
|
||||
? (defaultKeybindings["app.clipboard.pasteImage"][0] ?? "")
|
||||
: defaultKeybindings["app.clipboard.pasteImage"];
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("${pasteImageKey}", {
|
||||
description: "Overrides non-reserved",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "non-reserved.ts"), extCode);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const shortcuts = runner.getShortcuts(defaultKeybindings);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("built-in shortcut for app.clipboard.pasteImage"),
|
||||
);
|
||||
expect(shortcuts.has(pasteImageKey as KeyId)).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("blocks shortcuts for reserved actions even when rebound", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+x", {
|
||||
description: "Conflicts with rebound reserved",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "rebound-reserved.ts"), extCode);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const keybindings = { ...defaultKeybindings, "app.interrupt": "ctrl+x" as KeyId };
|
||||
const shortcuts = runner.getShortcuts(keybindings);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
|
||||
expect(shortcuts.has("ctrl+x")).toBe(false);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("blocks shortcuts when reserved action has multiple keys", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+y", {
|
||||
description: "Conflicts with multi-key reserved",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "multi-reserved.ts"), extCode);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const keybindings = { ...defaultKeybindings, "app.clear": ["ctrl+x", "ctrl+y"] as KeyId[] };
|
||||
const shortcuts = runner.getShortcuts(keybindings);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
|
||||
expect(shortcuts.has("ctrl+y")).toBe(false);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("warns but allows when non-reserved action has multiple keys", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+y", {
|
||||
description: "Overrides multi-key non-reserved",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "multi-non-reserved.ts"), extCode);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const keybindings = { ...defaultKeybindings, "app.clipboard.pasteImage": ["ctrl+x", "ctrl+y"] as KeyId[] };
|
||||
const shortcuts = runner.getShortcuts(keybindings);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("built-in shortcut for app.clipboard.pasteImage"),
|
||||
);
|
||||
expect(shortcuts.has("ctrl+y")).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("warns when two extensions register same shortcut", async () => {
|
||||
// Use a non-reserved shortcut
|
||||
const extCode1 = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+shift+x", {
|
||||
description: "First extension",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
const extCode2 = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+shift+x", {
|
||||
description: "Second extension",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "ext1.ts"), extCode1);
|
||||
fs.writeFileSync(path.join(extensionsDir, "ext2.ts"), extCode2);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const shortcuts = runner.getShortcuts(defaultKeybindings);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict"));
|
||||
// Last one wins
|
||||
expect(shortcuts.has("ctrl+shift+x")).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool collection", () => {
|
||||
it("collects tools from multiple extensions", async () => {
|
||||
const toolCode = (name: string) => `
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi) {
|
||||
pi.registerTool({
|
||||
name: "${name}",
|
||||
label: "${name}",
|
||||
description: "Test tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), toolCode("tool_a"));
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), toolCode("tool_b"));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const tools = runner.getAllRegisteredTools();
|
||||
|
||||
expect(tools.length).toBe(2);
|
||||
expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]);
|
||||
});
|
||||
|
||||
it("keeps first tool when two extensions register the same name", async () => {
|
||||
const first = `
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi) {
|
||||
pi.registerTool({
|
||||
name: "shared",
|
||||
label: "shared",
|
||||
description: "first",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
});
|
||||
}
|
||||
`;
|
||||
const second = `
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi) {
|
||||
pi.registerTool({
|
||||
name: "shared",
|
||||
label: "shared",
|
||||
description: "second",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
|
||||
fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const tools = runner.getAllRegisteredTools();
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]?.definition.description).toBe("first");
|
||||
});
|
||||
});
|
||||
|
||||
describe("command collection", () => {
|
||||
it("collects commands from multiple extensions", async () => {
|
||||
const cmdCode = (name: string) => `
|
||||
export default function(pi) {
|
||||
pi.registerCommand("${name}", {
|
||||
description: "Test command",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a"));
|
||||
fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b"));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const commands = runner.getRegisteredCommands();
|
||||
|
||||
expect(commands.length).toBe(2);
|
||||
expect(commands.map((c) => c.name).sort()).toEqual(["cmd-a", "cmd-b"]);
|
||||
expect(commands.map((c) => c.invocationName).sort()).toEqual(["cmd-a", "cmd-b"]);
|
||||
});
|
||||
|
||||
it("gets command by invocation name", async () => {
|
||||
const cmdCode = `
|
||||
export default function(pi) {
|
||||
pi.registerCommand("my-cmd", {
|
||||
description: "My command",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "cmd.ts"), cmdCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
const cmd = runner.getCommand("my-cmd");
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.name).toBe("my-cmd");
|
||||
expect(cmd?.invocationName).toBe("my-cmd");
|
||||
expect(cmd?.description).toBe("My command");
|
||||
|
||||
const missing = runner.getCommand("not-exists");
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suffixes duplicate extension commands in insertion order", async () => {
|
||||
const cmdCode = (description: string) => `
|
||||
export default function(pi) {
|
||||
pi.registerCommand("shared-cmd", {
|
||||
description: "${description}",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("First command"));
|
||||
fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("Second command"));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const commands = runner.getRegisteredCommands();
|
||||
const diagnostics = runner.getCommandDiagnostics();
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands.map((command) => command.name)).toEqual(["shared-cmd", "shared-cmd"]);
|
||||
expect(commands.map((command) => command.invocationName)).toEqual(["shared-cmd:1", "shared-cmd:2"]);
|
||||
expect(commands.map((command) => command.description)).toEqual(["First command", "Second command"]);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(runner.getCommand("shared-cmd:1")?.description).toBe("First command");
|
||||
expect(runner.getCommand("shared-cmd:2")?.description).toBe("Second command");
|
||||
});
|
||||
});
|
||||
|
||||
describe("context creation", () => {
|
||||
it("exposes the current abort signal on ExtensionContext", async () => {
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const controller = new AbortController();
|
||||
|
||||
runner.bindCore(extensionActions, {
|
||||
...extensionContextActions,
|
||||
getSignal: () => controller.signal,
|
||||
});
|
||||
|
||||
const ctx = runner.createContext();
|
||||
expect(ctx.signal).toBe(controller.signal);
|
||||
expect(ctx.signal?.aborted).toBe(false);
|
||||
|
||||
controller.abort();
|
||||
expect(ctx.signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("calls error listeners when handler throws", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.on("context", async () => {
|
||||
throw new Error("Handler error!");
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
const errors: Array<{ extensionPath: string; event: string; error: string }> = [];
|
||||
runner.onError((err) => {
|
||||
errors.push(err);
|
||||
});
|
||||
|
||||
// Emit context event which will trigger the throwing handler
|
||||
await runner.emitContext([]);
|
||||
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0].error).toContain("Handler error!");
|
||||
expect(errors[0].event).toBe("context");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message renderers", () => {
|
||||
it("gets message renderer by type", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerMessageRenderer("my-type", (message, options, theme) => null);
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "renderer.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
const renderer = runner.getMessageRenderer("my-type");
|
||||
expect(renderer).toBeDefined();
|
||||
|
||||
const missing = runner.getMessageRenderer("not-exists");
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("flags", () => {
|
||||
it("collects flags from extensions", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerFlag("my-flag", {
|
||||
description: "My flag",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const flags = runner.getFlags();
|
||||
|
||||
expect(flags.has("my-flag")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps first flag when two extensions register the same name", async () => {
|
||||
const first = `
|
||||
export default function(pi) {
|
||||
pi.registerFlag("shared-flag", {
|
||||
description: "first",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
`;
|
||||
const second = `
|
||||
export default function(pi) {
|
||||
pi.registerFlag("shared-flag", {
|
||||
description: "second",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
|
||||
fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const flags = runner.getFlags();
|
||||
|
||||
expect(flags.get("shared-flag")?.description).toBe("first");
|
||||
expect(result.runtime.flagValues.get("shared-flag")).toBe(true);
|
||||
});
|
||||
|
||||
it("can set flag values", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerFlag("test-flag", {
|
||||
description: "Test flag",
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "flag.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
// Setting a flag value should not throw
|
||||
runner.setFlagValue("--test-flag", true);
|
||||
|
||||
// The flag values are stored in the shared runtime
|
||||
expect(result.runtime.flagValues.get("--test-flag")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool_result chaining", () => {
|
||||
it("chains content modifications across handlers", async () => {
|
||||
const extCode1 = `
|
||||
export default function(pi) {
|
||||
pi.on("tool_result", async (event) => {
|
||||
return {
|
||||
content: [...event.content, { type: "text", text: "ext1" }],
|
||||
};
|
||||
});
|
||||
}
|
||||
`;
|
||||
const extCode2 = `
|
||||
export default function(pi) {
|
||||
pi.on("tool_result", async (event) => {
|
||||
return {
|
||||
content: [...event.content, { type: "text", text: "ext2" }],
|
||||
};
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-result-1.ts"), extCode1);
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-result-2.ts"), extCode2);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
const chained = await runner.emitToolResult({
|
||||
type: "tool_result",
|
||||
toolName: "my_tool",
|
||||
toolCallId: "call-1",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "base" }],
|
||||
details: { initial: true },
|
||||
isError: false,
|
||||
});
|
||||
|
||||
expect(chained).toBeDefined();
|
||||
const chainedContent = chained?.content;
|
||||
expect(chainedContent).toBeDefined();
|
||||
expect(chainedContent![0]).toEqual({ type: "text", text: "base" });
|
||||
expect(chainedContent).toHaveLength(3);
|
||||
const appendedText = chainedContent!
|
||||
.slice(1)
|
||||
.filter((item): item is { type: "text"; text: string } => item.type === "text")
|
||||
.map((item) => item.text);
|
||||
expect(appendedText.sort()).toEqual(["ext1", "ext2"]);
|
||||
});
|
||||
|
||||
it("preserves previous modifications when later handlers return partial patches", async () => {
|
||||
const extCode1 = `
|
||||
export default function(pi) {
|
||||
pi.on("tool_result", async () => {
|
||||
return {
|
||||
content: [{ type: "text", text: "first" }],
|
||||
details: { source: "ext1" },
|
||||
};
|
||||
});
|
||||
}
|
||||
`;
|
||||
const extCode2 = `
|
||||
export default function(pi) {
|
||||
pi.on("tool_result", async () => {
|
||||
return {
|
||||
isError: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-result-partial-1.ts"), extCode1);
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-result-partial-2.ts"), extCode2);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
const chained = await runner.emitToolResult({
|
||||
type: "tool_result",
|
||||
toolName: "my_tool",
|
||||
toolCallId: "call-2",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "base" }],
|
||||
details: { initial: true },
|
||||
isError: false,
|
||||
});
|
||||
|
||||
expect(chained).toEqual({
|
||||
content: [{ type: "text", text: "first" }],
|
||||
details: { source: "ext1" },
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider registration", () => {
|
||||
it("bindCore ignores invalid queued registrations and reports extension error", () => {
|
||||
const runtime = createExtensionRuntime();
|
||||
runtime.registerProvider(
|
||||
"broken-provider",
|
||||
{
|
||||
streamSimple: (() => {
|
||||
throw new Error("should not run");
|
||||
}) as any,
|
||||
},
|
||||
"/tmp/broken-extension.ts",
|
||||
);
|
||||
|
||||
const runner = new ExtensionRunner([], runtime, tempDir, sessionManager, modelRegistry);
|
||||
const errors: string[] = [];
|
||||
runner.onError((error) => errors.push(`${error.extensionPath}: ${error.error}`));
|
||||
|
||||
expect(() => runner.bindCore(extensionActions, extensionContextActions)).not.toThrow();
|
||||
expect(errors).toEqual([
|
||||
'/tmp/broken-extension.ts: Provider broken-provider: "api" is required when registering streamSimple.',
|
||||
]);
|
||||
expect(() => modelRegistry.refresh()).not.toThrow();
|
||||
});
|
||||
|
||||
it("pre-bind unregister removes all queued registrations for a provider", () => {
|
||||
const runtime = createExtensionRuntime();
|
||||
|
||||
runtime.registerProvider("queued-provider", providerModelConfig);
|
||||
runtime.registerProvider("queued-provider", {
|
||||
...providerModelConfig,
|
||||
models: [
|
||||
{
|
||||
id: "instant-model-2",
|
||||
name: "Instant Model 2",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(runtime.pendingProviderRegistrations).toHaveLength(2);
|
||||
|
||||
runtime.unregisterProvider("queued-provider");
|
||||
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("post-bind register and unregister take effect immediately", () => {
|
||||
const runtime = createExtensionRuntime();
|
||||
const runner = new ExtensionRunner([], runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
runner.bindCore(extensionActions, extensionContextActions);
|
||||
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
||||
|
||||
runtime.registerProvider("instant-provider", providerModelConfig);
|
||||
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
||||
expect(modelRegistry.find("instant-provider", "instant-model")).toBeDefined();
|
||||
|
||||
runtime.unregisterProvider("instant-provider");
|
||||
expect(modelRegistry.find("instant-provider", "instant-model")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasHandlers", () => {
|
||||
it("returns true when handlers exist for event type", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.on("tool_call", async () => undefined);
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "handler.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
expect(runner.hasHandlers("tool_call")).toBe(true);
|
||||
expect(runner.hasHandlers("agent_end")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { access, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEditTool } from "../src/core/tools/edit.js";
|
||||
import { withFileMutationQueue } from "../src/core/tools/file-mutation-queue.js";
|
||||
import { createWriteTool } from "../src/core/tools/write.js";
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-file-mutation-queue-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0, tempDirs.length).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("withFileMutationQueue", () => {
|
||||
it("serializes operations for the same file", async () => {
|
||||
const order: string[] = [];
|
||||
const path = "/tmp/file-mutation-queue-same";
|
||||
|
||||
const first = withFileMutationQueue(path, async () => {
|
||||
order.push("first:start");
|
||||
await delay(30);
|
||||
order.push("first:end");
|
||||
});
|
||||
const second = withFileMutationQueue(path, async () => {
|
||||
order.push("second:start");
|
||||
order.push("second:end");
|
||||
});
|
||||
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second:start", "second:end"]);
|
||||
});
|
||||
|
||||
it("allows different files to proceed in parallel", async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
await Promise.all([
|
||||
withFileMutationQueue("/tmp/file-mutation-queue-a", async () => {
|
||||
order.push("a:start");
|
||||
await delay(30);
|
||||
order.push("a:end");
|
||||
}),
|
||||
withFileMutationQueue("/tmp/file-mutation-queue-b", async () => {
|
||||
order.push("b:start");
|
||||
await delay(30);
|
||||
order.push("b:end");
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(order.indexOf("a:start")).toBeLessThan(order.indexOf("a:end"));
|
||||
expect(order.indexOf("b:start")).toBeLessThan(order.indexOf("b:end"));
|
||||
expect(order.indexOf("b:start")).toBeLessThan(order.indexOf("a:end"));
|
||||
});
|
||||
|
||||
it("uses the same queue for symlink aliases", async () => {
|
||||
const dir = await createTempDir();
|
||||
const targetPath = join(dir, "target.txt");
|
||||
const symlinkPath = join(dir, "alias.txt");
|
||||
await writeFile(targetPath, "hello\n", "utf8");
|
||||
await symlink(targetPath, symlinkPath);
|
||||
|
||||
const order: string[] = [];
|
||||
await Promise.all([
|
||||
withFileMutationQueue(targetPath, async () => {
|
||||
order.push("target:start");
|
||||
await delay(30);
|
||||
order.push("target:end");
|
||||
}),
|
||||
withFileMutationQueue(symlinkPath, async () => {
|
||||
order.push("alias:start");
|
||||
order.push("alias:end");
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(order).toEqual(["target:start", "target:end", "alias:start", "alias:end"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in edit and write tools", () => {
|
||||
it("preserves both parallel edits on the same file", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "parallel-edit.txt");
|
||||
await writeFile(filePath, "alpha\nbeta\ngamma\n", "utf8");
|
||||
|
||||
const editTool = createEditTool(dir, {
|
||||
operations: {
|
||||
access,
|
||||
readFile: async (path) => {
|
||||
const buffer = await readFile(path);
|
||||
await delay(30);
|
||||
return buffer;
|
||||
},
|
||||
writeFile: async (path, content) => {
|
||||
await delay(30);
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
editTool.execute("call-1", { path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] }),
|
||||
editTool.execute("call-2", { path: filePath, edits: [{ oldText: "beta", newText: "BETA" }] }),
|
||||
]);
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("ALPHA\nBETA\ngamma\n");
|
||||
});
|
||||
|
||||
it("shares the queue between edit and write", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "mixed.txt");
|
||||
await writeFile(filePath, "original\n", "utf8");
|
||||
|
||||
const editTool = createEditTool(dir, {
|
||||
operations: {
|
||||
access,
|
||||
readFile: async (path) => {
|
||||
const buffer = await readFile(path);
|
||||
await delay(30);
|
||||
return buffer;
|
||||
},
|
||||
writeFile: async (path, content) => {
|
||||
await delay(30);
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
const writeTool = createWriteTool(dir, {
|
||||
operations: {
|
||||
mkdir: async () => {},
|
||||
writeFile: async (path, content) => {
|
||||
await delay(10);
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editPromise = editTool.execute("call-1", {
|
||||
path: filePath,
|
||||
edits: [{ oldText: "original", newText: "edited" }],
|
||||
});
|
||||
await delay(5);
|
||||
const writePromise = writeTool.execute("call-2", {
|
||||
path: filePath,
|
||||
content: "replacement\n",
|
||||
});
|
||||
|
||||
await Promise.all([editPromise, writePromise]);
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("replacement\n");
|
||||
});
|
||||
});
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "So `findModel` just looks up a model by provider + id from the merged models list (built-in + models.json).\n\nFor the SDK, the flow would be:1. `model` provided → use it\n2. Else check settings for default → call `findModel(provider, id)` → check if available (has API key)\n3. Else first from `getAvailableModels()`\n\nThe `findModel` function returns `{ model, error }`. For SDK export, we might want a simpler signature.Options for SDK:\n\n**Option A: Keep current signature**```typescript\nfunction findModel(provider: string, modelId: string, agentDir?: string): { model: Model<any> | null; error: string | null };\n```**Option B: Simpler - just returns model or null**\n```typescript\nfunction findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;```\n\n**Option C: Throws on error**```typescript\nfunction findModel(provider: string, modelId: string, agentDir?: string): Model<any>; // throws if not found\n```\n\nI think **Option B** is fine for SDK. Errors from loading models.json are edge cases - if the file is malformed, we can throw. For \"model not found\", just return null.\n\nActually, for the SDK, maybe we should rethink. The user of the SDK will likely:\n1. Know which model they want and construct it directly2. Or want to pick from available modelsFor case 1, they don't need findModel - they construct the Model object.\nFor case 2, they use `discoverAvailableModels()` and pick.`findModel` is useful for looking up by string ID (like from config or user input). But the signature could just be:```typescriptfunction findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;\n```\n\nReturns the model if found (in built-ins or models.json), null if not found. Throws if models.json is malformed (exceptional case).\n\n\nThe SDK's model discovery functions provide a clean, straightforward way to handle model selection. I'll outline the key methods for finding and selecting models programmatically. The core functions allow discovering all models, filtering to available models with valid API keys, and finding a specific model by provider and ID. This gives developers flexible options for model management in their applications.\n\nIn the session creation process, if no model is explicitly specified, the system will first attempt to use the default provider and model configuration. If that fails, it will then retrieve the first available model from the discovered set. Should no models be found, an error will be raised to prevent silent failures.\n\nThe potential complexity arises from the need to support custom model configurations and ensure robust model selection across different scenarios.",
|
||||
"thinkingSignature": "EqsVCkYIChgCKkBUqXXsLfuqzbiQxI5MbbS31Yw670soGNGQX63Lb/osACS2A5rrS8Gd939xNdXbHUGouQngzqIPs2KFk9HWmKajEgxqYOzDBE7h++0vFUUaDF8r9MacsHyJPwr3XiIwn3spAIQV8IxgAIFoxdYefFrCAeez7pnXqUqaK2QTTG3OjWpCIYzPwvEVs7ObbWVbKpIUy2X7MkKrZOdtlTGRUvmuEij6vCbXjPwj0zH+mjaefERbkL+aT84QCiStHqc7uuM5nZvntl4KZ76Mt1VrFoBXwi3val4fJDP9GhDj7tkD0Id22udIb+yHBuo8yBnyy2fWLMaeRTEn8vN2eUaqiuE7wvgvPF4tf6bn4mKjh/HEwpAzJ+rLsE/hmXA9eG/hub387iF4rnLP/rDJR4olzSQyb7bPpdQ5RLRIymkRJce4wRY0nFxPuZayiYooGwI7gqKPJz2mkTCdWZABn4n6PpqZB+caXCn63A3WvJtZacItZ6z3DAoi2I3jwsOC8BWQmHKBfCXd9wttQ+HuYYmduASJ3j/TNtdO1vZsiItknKneZXTPhmt0nuqphgWiDWnPFv1iOoJw++tLJO+u2hYOtM/3Nx6O+l9QWcQgkgnQjN29SRd7uiI14sTogJkWVrVaKJ6StXx+/mXrro7I++6PSBMnFJevIJ89MFVB8EiYs+x4pOuEJDaNekBU3Tm6+Eg4vL2SguijClR9yv+4bQsIHKtq6QLLABt1SuNRvO9HgUIOx6HDdn0PXeInhqJ/aILA4bRryf6lbRp0qNEcexAVrT8zbrMUkY2SzMX1kEo4IvmprCzmukHXQdal2AoxSdxPp2br12Lcz0njxzhWFd58f0gLRVHKf7gGzTWe6EGVfvve7/yquhVG1IWkDid54PcdqUEpIbeRZE4gklPQhEflfZ9ppnyeRDVmBq4N9Wmv+S19z8/sLRXMXBM2Lv31vVf7QXjZGmJxEWpKfXGPOmuChZsgZuMZSVoXSh9u+gr+M29Se6ArQ/L18/3p8grm8TwT2TKuaMeuIdki7Ja0jQQYPOqoIVHVXahtVto/4YVGcClx6eTbNtXDfKDKnWw7Eu+l+6wjF9nqEjTLQIxjpT6ABWhXw1ersAFIDgDDwRLUZFHZ8i1jQKvg3IxgWsqIyyMXjwm1gfwzeeOrNIkx8KwIGybeheHX1vZRsqaOAhARiziiBsl4PLD8ci6OLJgp1ZBke9QW8DFFwMZY6hNf4yYOb0/6K2g+qx9Z0OuHW7p2MRef97oLiDyx/WCNgv6DUW2FxHy2KjtcB50aeSLfccBCJOXkRlnym08nsBYa7H17REi2O30wkoOPnOYNqytE40EPYwqUPUdRF6WwN6LFEpbGGmQ5atrJ/upzz+MoBoeqeoF0fOrO3AaW27E7dvduDCrK2hF/TZZN5FHipNNHP/JY5NhWPBhCBumxJN9uf+nGqPcQwn3IL0eriz9ki0EUBdAYXY9kCxKYU3DhsbLsBn3YfhXLbLIT1Woy4RUqkWN7BXOC8aWi+uLVm0JUXVt/dr6ndnxdyqJdxc22Wz4EHFZZe+VtntNr1BF/6VsUoQSsSR1c0QvbxPE3iLhZ3R9RPmKduotJsQ6hb3aZrAgsMF5KWlmOKcouGQW1TNEwd8tI8Rxg91FdOuU0o98LddVlUFknfYr9gUn3/NorpUCKjDgZDyY4Oy7QeHWg9E6s6jeH1aYhHsO8mZiPGxQi4n5y0pSU8jFHEoIvlgQ+hN+7bsYRfUNMXfxsYuUZKiUqvCIiInu6W1dkxjS2GOmiQcCjB9XzOxF9gHXEkU2E4xHmSkbpBGrJjR/DHZ8gsosTPDg9VmFY2aYX/WLGYbjguzaKD8zS9LpQ3UZmbC0Jv9bZUGn3TdRRJj+xLY4fqWxEvplWNTJRTAPkHlQbawvgs8ziL9gBmfohPKHg+MA4bFCP2BPaaw/Xmw03TuDhaQ/Nb4e52N7heoN3DMd3NUQl/YFeb4kqzcF24GLhLi/Pbl2Y/JehWVgNyFeIvMkk7laFgydLqCMTWGl8VHiy3koUXOgPG/s/qERzIyYprLd/h5gcGt0aQMgl089UU69wUhT0xXkZjuUSMeCUKHLgjvhbn6gaMoMCrcqe+Ar0eZPGeW7OR9w8jhC/rE5Lh8zMpQ2uKo2Hwi/eFZul6Qq1ZSthx0kcsbqT8wW6Fyr8O42mxUmBVS8TUhvVSOccGVy5tBOXQpxQPgYbXNyUy3obUi9vhPzViEbt6KDIAW5bQwbuDSMHd+tf9nWd8H1nvEO2aWM6/v4+/qLSWqMcTXs3Rea2+GFMQkbRzj1pRN1MLzSjBP5pGLlYPQre5RHK3kImZ7ISMj7oQWfzNYLkswkD2Ay3nzk6v4JpjaFNFAaOhTHjtO0c4qA2elkvQ/5RrtD4g4/wlH+p048wIiuQhw4Iiu3rcFrclXUWny74ON5n56OY5uIXsPsmQQwCGUwtZFBVe5bP3nVgoHCBPI0SyEQXxgbd4q0o+HZyjkH9KdOL6LpxdxbrqbvONS6/EMMheWHxDAmibL5pFJh4z60o+aNejvMoZahKX04M5/KC1k7gwzAn/yIxC+VEPi/IijxKKlU0mEPE+q/HAHTe7S5CdrM5vWzgzNefKk0PjMW3/OnveH9mFoMHmIybWgrCZPlPzLyL3PPBW1Iv6q1g/NOzfxczx/ZbudD3UQOY0u84Acjcb938Y7uvUNHPLfSopleds0hGGgeUGy6aLdidmypcc3b8icF8k3KDozTN0v/3EqgLzb4PY6HML6dIwI6UYpeMvb110GWh1mXgl45v4afFwojhp0Ld92WnOrxEIMKv9/S6NCiUxR6KwAhp7ssPzdPvlTTtlmN01Xn95+Vo4GuZHvgyjcBnF9dIy+WJhwDRcgLrwV+wkZuGR71ACKTdHE3jW3QEuWlf4HuV+63c/OZj3B2rB2s2zadJVGDBn35dX434ZnJZudakoOGcK/0LZ2bhSN8qCkxs/2KJk7TMtBi6wsmQ7VGw74I1+c45iPjRcaO63UO/1rI7dZZZkD5lKje8BgwPBt+V+0DW6OHrfYC6qf+Nc5xrkE8Q6R4fbijYf3MkaDQ1FhJpGUiBIZ90tXPcs06utEq8kNT1kMGLdgoFlHRA3gQzJY7vU2kYVNs0sTp1pQL3JPSHtBdITHnITJLN4tVun0lrv4Ev4L4qkXT+tygwmmYvlR+JlnxkdMDd0DhNcv4lF9ltD1zQaTgfPjSbl3+lW/znRKgXTXqxDzHDIusv5u0lna2X7M7WWXoucp3eyHqTtZiP4DgsDgh9lNiwz4zu0Uqh7DNF5ZTWrSwEItMbQc0vs3Bryd7TirHAOK928rxRwl2Vo9YdDxEbVGV7OmSGdu3NOLDVp/oFvwsSUhFD/BwjgX9xSbLgLab8g3lEsbMGnA9h+aY0M1nSlmjjLavl5XvMvwd6VDRAbEMb+/FdI6Sp/z3/Yx4VmKeh7lB3rPPF6n9RdUijTKbBn6hVRyEZw30n5BMxRwGYxFxb5xhPhOLpmj6UNEQJUkileYUhEGN7o/GMLBBT7eOXujXJhq/ADhex7Wilj5rFy0VtIfuUkrNXpRxVLOdOTA3/eNxYn21B91jFM7UWCZc42BGZhkEvxFuIS+wK8u3TBvMv3RgB"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "So the current flow in main.ts:\n\n**findInitialModelForSession:**\n1. CLI args (`--provider` + `--model`) → use if both provided\n2. Scoped models (`--models` flag) → use first (unless --continue/--resume)\n3. Settings (`defaultProvider` + `defaultModel`) → use if found\n4. First available model with valid API key\n5. Return null if none\n\n**initialThinking:**\n1. Start with `'off'`\n2. If scoped models (and not continuing) → use first scoped model's thinking level\n3. Else check `settings.defaultThinkingLevel`\n4. CLI `--thinking` overrides\n5. Clamp to model capabilities\n\n---\n\nFor SDK, we don't have CLI args or scoped models. Simplifies to:\n\n**model resolution (if omitted):**\n1. Check `settings.defaultProvider` + `settings.defaultModel` → find it, verify has API key\n2. Else first from `discoverAvailableModels()`\n3. Error if none\n\n**thinkingLevel resolution (if omitted):**\n1. Check `settings.defaultThinkingLevel`\n2. Else `'off'`\n3. Clamp to model capabilities\n\n---\n\nFor `findModel`, current signature returns `{ model, error }`. For SDK, simpler:\n\n```typescript\n// Returns model or null. Throws only if models.json is malformed.\nexport function findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;\n```\n\nSo the model-related exports:\n\n```typescript\n// All models (built-in + models.json)\nexport function discoverModels(agentDir?: string): Model<any>[];\n\n// Models with valid API keys\nexport function discoverAvailableModels(agentDir?: string): Promise<Model<any>[]>;\n\n// Lookup by provider/id\nexport function findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;\n```\n\nDoes this address the wonkiness, or is there something else bothering you about it?"
|
||||
}
|
||||
],
|
||||
"api": "anthropic-messages",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-5",
|
||||
"usage": {
|
||||
"input": 8,
|
||||
"output": 1211,
|
||||
"cacheRead": 89775,
|
||||
"cacheWrite": 611,
|
||||
"totalTokens": 91605,
|
||||
"cost": {
|
||||
"input": 4e-5,
|
||||
"output": 0.030275,
|
||||
"cacheRead": 0.0448875,
|
||||
"cacheWrite": 0.00381875,
|
||||
"total": 0.07902124999999999
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1766357372971
|
||||
}
|
||||
+1003
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
Whitespace-only changes.
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,236 @@
|
||||
import { execFile, spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let resolvedBranch = "main";
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execFile: vi.fn(
|
||||
(
|
||||
_command: string,
|
||||
args: readonly string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (args[1] === "symbolic-ref") {
|
||||
setTimeout(
|
||||
() =>
|
||||
callback(
|
||||
resolvedBranch ? null : new Error("detached"),
|
||||
resolvedBranch ? `${resolvedBranch}\n` : "",
|
||||
"",
|
||||
),
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => callback(new Error("unsupported"), "", ""), 0);
|
||||
},
|
||||
),
|
||||
spawnSync: vi.fn((_command: string, args: readonly string[]) => {
|
||||
if (args[1] === "symbolic-ref") {
|
||||
return { status: resolvedBranch ? 0 : 1, stdout: resolvedBranch ? `${resolvedBranch}\n` : "", stderr: "" };
|
||||
}
|
||||
return { status: 1, stdout: "", stderr: "" };
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FooterDataProvider } from "../src/core/footer-data-provider.js";
|
||||
|
||||
type WorktreeFixture = {
|
||||
worktreeDir: string;
|
||||
reftableDir: string;
|
||||
};
|
||||
|
||||
function createPlainReftableRepo(tempDir: string): string {
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(join(repoDir, ".git", "reftable"), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".git", "HEAD"), "ref: refs/heads/.invalid\n");
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function createPlainRepo(tempDir: string): string {
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(join(repoDir, ".git"), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function createReftableWorktree(tempDir: string): WorktreeFixture {
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const commonGitDir = join(repoDir, ".git");
|
||||
const gitDir = join(commonGitDir, "worktrees", "src");
|
||||
const worktreeDir = join(tempDir, "worktree");
|
||||
const reftableDir = join(commonGitDir, "reftable");
|
||||
|
||||
mkdirSync(gitDir, { recursive: true });
|
||||
mkdirSync(reftableDir, { recursive: true });
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
|
||||
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${gitDir}\n`);
|
||||
writeFileSync(join(gitDir, "HEAD"), "ref: refs/heads/.invalid\n");
|
||||
writeFileSync(join(gitDir, "commondir"), "../..\n");
|
||||
writeFileSync(join(reftableDir, "tables.list"), "0\n");
|
||||
|
||||
return { worktreeDir, reftableDir };
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
describe("FooterDataProvider reftable branch detection", () => {
|
||||
let originalCwd: string;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = mkdtempSync(join(tmpdir(), "footer-data-provider-"));
|
||||
resolvedBranch = "main";
|
||||
vi.mocked(spawnSync).mockClear();
|
||||
vi.mocked(execFile).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses HEAD directly in a regular repo from a nested directory", () => {
|
||||
const repoDir = createPlainRepo(tempDir);
|
||||
const nestedDir = join(repoDir, "src", "nested");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
process.chdir(nestedDir);
|
||||
|
||||
const provider = new FooterDataProvider();
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the branch via git when HEAD is .invalid in a reftable repo", () => {
|
||||
const repoDir = createPlainReftableRepo(tempDir);
|
||||
process.chdir(repoDir);
|
||||
|
||||
const provider = new FooterDataProvider();
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
expect(vi.mocked(spawnSync)).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringMatching(/repo$/),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the branch via git in a reftable-backed worktree", () => {
|
||||
const { worktreeDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider();
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unresolved .invalid reftable HEAD as detached", () => {
|
||||
const repoDir = createPlainReftableRepo(tempDir);
|
||||
process.chdir(repoDir);
|
||||
resolvedBranch = "";
|
||||
|
||||
const provider = new FooterDataProvider();
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("detached");
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not notify listeners when reftable updates keep the same branch", async () => {
|
||||
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider();
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
vi.mocked(spawnSync).mockClear();
|
||||
const onBranchChange = vi.fn();
|
||||
provider.onBranchChange(onBranchChange);
|
||||
|
||||
writeFileSync(join(reftableDir, "tables.list"), "1\n");
|
||||
await waitFor(() => vi.mocked(execFile).mock.calls.length === 1);
|
||||
|
||||
expect(vi.mocked(execFile)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
expect(onBranchChange).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("debounces rapid reftable updates into a single async refresh", async () => {
|
||||
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider();
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
vi.mocked(execFile).mockClear();
|
||||
|
||||
writeFileSync(join(reftableDir, "tables.list"), "1\n");
|
||||
writeFileSync(join(reftableDir, "tables.list"), "2\n");
|
||||
writeFileSync(join(reftableDir, "tables.list"), "3\n");
|
||||
await waitFor(() => vi.mocked(execFile).mock.calls.length === 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
|
||||
expect(vi.mocked(execFile)).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates the cached branch when the reftable directory changes", async () => {
|
||||
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider();
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
resolvedBranch = "foo";
|
||||
const onBranchChange = vi.fn();
|
||||
provider.onBranchChange(onBranchChange);
|
||||
|
||||
writeFileSync(join(reftableDir, "tables.list"), "1\n");
|
||||
await waitFor(() => vi.mocked(execFile).mock.calls.length === 1);
|
||||
await waitFor(() => provider.getGitBranch() === "foo");
|
||||
|
||||
expect(vi.mocked(execFile)).toHaveBeenCalledTimes(1);
|
||||
expect(provider.getGitBranch()).toBe("foo");
|
||||
expect(onBranchChange).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type { AgentSession } from "../src/core/agent-session.js";
|
||||
import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.js";
|
||||
import { FooterComponent } from "../src/modes/interactive/components/footer.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
type AssistantUsage = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: { total: number };
|
||||
};
|
||||
|
||||
function createSession(options: {
|
||||
sessionName: string;
|
||||
modelId?: string;
|
||||
provider?: string;
|
||||
reasoning?: boolean;
|
||||
thinkingLevel?: string;
|
||||
usage?: AssistantUsage;
|
||||
}): AgentSession {
|
||||
const usage = options.usage;
|
||||
const entries =
|
||||
usage === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const session = {
|
||||
state: {
|
||||
model: {
|
||||
id: options.modelId ?? "test-model",
|
||||
provider: options.provider ?? "test",
|
||||
contextWindow: 200_000,
|
||||
reasoning: options.reasoning ?? false,
|
||||
},
|
||||
thinkingLevel: options.thinkingLevel ?? "off",
|
||||
},
|
||||
sessionManager: {
|
||||
getEntries: () => entries,
|
||||
getSessionName: () => options.sessionName,
|
||||
getCwd: () => "/tmp/project",
|
||||
},
|
||||
getContextUsage: () => ({ contextWindow: 200_000, percent: 12.3 }),
|
||||
modelRegistry: {
|
||||
isUsingOAuth: () => false,
|
||||
},
|
||||
};
|
||||
|
||||
return session as unknown as AgentSession;
|
||||
}
|
||||
|
||||
function createFooterData(providerCount: number): ReadonlyFooterDataProvider {
|
||||
const provider = {
|
||||
getGitBranch: () => "main",
|
||||
getExtensionStatuses: () => new Map<string, string>(),
|
||||
getAvailableProviderCount: () => providerCount,
|
||||
onBranchChange: (callback: () => void) => {
|
||||
void callback;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe("FooterComponent width handling", () => {
|
||||
beforeAll(() => {
|
||||
initTheme(undefined, false);
|
||||
});
|
||||
|
||||
it("keeps all lines within width for wide session names", () => {
|
||||
const width = 93;
|
||||
const session = createSession({ sessionName: "한글".repeat(30) });
|
||||
const footer = new FooterComponent(session, createFooterData(1));
|
||||
|
||||
const lines = footer.render(width);
|
||||
for (const line of lines) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stats line within width for wide model and provider names", () => {
|
||||
const width = 60;
|
||||
const session = createSession({
|
||||
sessionName: "",
|
||||
modelId: "模".repeat(30),
|
||||
provider: "공급자",
|
||||
reasoning: true,
|
||||
thinkingLevel: "high",
|
||||
usage: {
|
||||
input: 12_345,
|
||||
output: 6_789,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 1.234 },
|
||||
},
|
||||
});
|
||||
const footer = new FooterComponent(session, createFooterData(2));
|
||||
|
||||
const lines = footer.render(width);
|
||||
for (const line of lines) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFrontmatter, stripFrontmatter } from "../src/utils/frontmatter.js";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
it("parses keys, strips quotes, and returns body", () => {
|
||||
const input = "---\nname: \"skill-name\"\ndescription: 'A desc'\nfoo-bar: value\n---\n\nBody text";
|
||||
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(input);
|
||||
expect(frontmatter.name).toBe("skill-name");
|
||||
expect(frontmatter.description).toBe("A desc");
|
||||
expect(frontmatter["foo-bar"]).toBe("value");
|
||||
expect(body).toBe("Body text");
|
||||
});
|
||||
|
||||
it("normalizes newlines and handles CRLF", () => {
|
||||
const input = "---\r\nname: test\r\n---\r\nLine one\r\nLine two";
|
||||
const { body } = parseFrontmatter<Record<string, string>>(input);
|
||||
expect(body).toBe("Line one\nLine two");
|
||||
});
|
||||
|
||||
it("throws on invalid YAML frontmatter", () => {
|
||||
const input = "---\nfoo: [bar\n---\nBody";
|
||||
expect(() => parseFrontmatter<Record<string, string>>(input)).toThrow(/at line 1, column 10/);
|
||||
});
|
||||
|
||||
it("parses | multiline yaml syntax", () => {
|
||||
const input = "---\ndescription: |\n Line one\n Line two\n---\n\nBody";
|
||||
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(input);
|
||||
expect(frontmatter.description).toBe("Line one\nLine two\n");
|
||||
expect(body).toBe("Body");
|
||||
});
|
||||
|
||||
it("returns original content when frontmatter is missing or unterminated", () => {
|
||||
const noFrontmatter = "Just text\nsecond line";
|
||||
const missingEnd = "---\nname: test\nBody without terminator";
|
||||
const resultNoFrontmatter = parseFrontmatter<Record<string, string>>(noFrontmatter);
|
||||
const resultMissingEnd = parseFrontmatter<Record<string, string>>(missingEnd);
|
||||
expect(resultNoFrontmatter.body).toBe("Just text\nsecond line");
|
||||
expect(resultMissingEnd.body).toBe(
|
||||
"---\nname: test\nBody without terminator".replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty object for empty or comment-only frontmatter", () => {
|
||||
const input = "---\n# just a comment\n---\nBody";
|
||||
const { frontmatter } = parseFrontmatter(input);
|
||||
expect(frontmatter).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripFrontmatter", () => {
|
||||
it("removes frontmatter and trims body", () => {
|
||||
const input = "---\nkey: value\n---\n\nBody\n";
|
||||
expect(stripFrontmatter(input)).toBe("Body");
|
||||
});
|
||||
|
||||
it("returns body when no frontmatter present", () => {
|
||||
const input = "\n No frontmatter body \n";
|
||||
expect(stripFrontmatter(input)).toBe("\n No frontmatter body \n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseGitUrl } from "../src/utils/git.js";
|
||||
|
||||
describe("Git URL Parsing", () => {
|
||||
describe("protocol URLs (accepted without git: prefix)", () => {
|
||||
it("should parse HTTPS URL", () => {
|
||||
const result = parseGitUrl("https://github.com/user/repo");
|
||||
expect(result).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "user/repo",
|
||||
repo: "https://github.com/user/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse ssh:// URL", () => {
|
||||
const result = parseGitUrl("ssh://git@github.com/user/repo");
|
||||
expect(result).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "user/repo",
|
||||
repo: "ssh://git@github.com/user/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse protocol URL with ref", () => {
|
||||
const result = parseGitUrl("https://github.com/user/repo@v1.0.0");
|
||||
expect(result).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "user/repo",
|
||||
ref: "v1.0.0",
|
||||
repo: "https://github.com/user/repo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shorthand URLs (accepted only with git: prefix)", () => {
|
||||
it("should parse git@host:path with git: prefix", () => {
|
||||
const result = parseGitUrl("git:git@github.com:user/repo");
|
||||
expect(result).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "user/repo",
|
||||
repo: "git@github.com:user/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse host/path shorthand with git: prefix", () => {
|
||||
const result = parseGitUrl("git:github.com/user/repo");
|
||||
expect(result).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "user/repo",
|
||||
repo: "https://github.com/user/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse shorthand with ref and git: prefix", () => {
|
||||
const result = parseGitUrl("git:git@github.com:user/repo@v1.0.0");
|
||||
expect(result).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "user/repo",
|
||||
ref: "v1.0.0",
|
||||
repo: "git@github.com:user/repo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsupported without git: prefix", () => {
|
||||
it("should reject git@host:path without git: prefix", () => {
|
||||
expect(parseGitUrl("git@github.com:user/repo")).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject host/path shorthand without git: prefix", () => {
|
||||
expect(parseGitUrl("github.com/user/repo")).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject user/repo shorthand", () => {
|
||||
expect(parseGitUrl("user/repo")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Tests for git-based extension updates, specifically handling force-push scenarios.
|
||||
*
|
||||
* These tests verify that DefaultPackageManager.update() handles:
|
||||
* - Normal git updates (no force-push)
|
||||
* - Force-pushed remotes gracefully (currently fails, fix needed)
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DefaultPackageManager } from "../src/core/package-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
// Helper to run git commands in a directory
|
||||
function git(args: string[], cwd: string): string {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: git ${args.join(" ")}\n${result.stderr}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function initGitRepo(repoDir: string): void {
|
||||
git(["init", "--initial-branch=main"], repoDir);
|
||||
git(["config", "--local", "user.email", "test@test.com"], repoDir);
|
||||
git(["config", "--local", "user.name", "Test"], repoDir);
|
||||
}
|
||||
|
||||
// Helper to create a commit with a file
|
||||
function createCommit(repoDir: string, filename: string, content: string, message: string): string {
|
||||
writeFileSync(join(repoDir, filename), content);
|
||||
git(["add", filename], repoDir);
|
||||
git(["commit", "-m", message], repoDir);
|
||||
return git(["rev-parse", "HEAD"], repoDir);
|
||||
}
|
||||
|
||||
// Helper to get current commit hash
|
||||
function getCurrentCommit(repoDir: string): string {
|
||||
return git(["rev-parse", "HEAD"], repoDir);
|
||||
}
|
||||
|
||||
// Helper to get file content
|
||||
function getFileContent(repoDir: string, filename: string): string {
|
||||
return readFileSync(join(repoDir, filename), "utf-8");
|
||||
}
|
||||
|
||||
describe("DefaultPackageManager git update", () => {
|
||||
let tempDir: string;
|
||||
let remoteDir: string; // Simulates the "remote" repository
|
||||
let agentDir: string; // The agent directory where extensions are installed
|
||||
let installedDir: string; // The installed extension directory
|
||||
let settingsManager: SettingsManager;
|
||||
let packageManager: DefaultPackageManager;
|
||||
|
||||
// Git source that maps to our installed directory structure.
|
||||
// Must use "git:" prefix so parseSource() treats it as a git source
|
||||
// (bare "github.com/..." is not recognized as a git URL).
|
||||
const gitSource = "git:github.com/test/extension";
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `git-update-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
remoteDir = join(tempDir, "remote");
|
||||
agentDir = join(tempDir, "agent");
|
||||
|
||||
// This matches the path structure: agentDir/git/<host>/<path>
|
||||
installedDir = join(agentDir, "git", "github.com", "test", "extension");
|
||||
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
settingsManager = SettingsManager.inMemory();
|
||||
packageManager = new DefaultPackageManager({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets up a "remote" repository and clones it to the installed directory.
|
||||
* This simulates what packageManager.install() would do.
|
||||
* @param sourceOverride Optional source string to use instead of gitSource (e.g., with @ref for pinned tests)
|
||||
*/
|
||||
function setupRemoteAndInstall(sourceOverride?: string): void {
|
||||
// Create "remote" repository
|
||||
mkdirSync(remoteDir, { recursive: true });
|
||||
initGitRepo(remoteDir);
|
||||
createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
|
||||
|
||||
// Clone to installed directory (simulating what install() does)
|
||||
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
|
||||
git(["clone", remoteDir, installedDir], tempDir);
|
||||
git(["config", "--local", "user.email", "test@test.com"], installedDir);
|
||||
git(["config", "--local", "user.name", "Test"], installedDir);
|
||||
|
||||
// Add to global packages so update() processes this source
|
||||
settingsManager.setPackages([sourceOverride ?? gitSource]);
|
||||
}
|
||||
|
||||
describe("normal updates (no force-push)", () => {
|
||||
it("should skip reset, clean, and install when already up to date", async () => {
|
||||
mkdirSync(remoteDir, { recursive: true });
|
||||
initGitRepo(remoteDir);
|
||||
writeFileSync(join(remoteDir, "package.json"), JSON.stringify({ name: "test-extension", version: "1.0.0" }));
|
||||
createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
|
||||
|
||||
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
|
||||
git(["clone", remoteDir, installedDir], tempDir);
|
||||
settingsManager.setPackages([gitSource]);
|
||||
|
||||
const executedCommands: string[] = [];
|
||||
const managerWithInternals = packageManager as unknown as {
|
||||
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
|
||||
};
|
||||
managerWithInternals.runCommand = async (command, args, options) => {
|
||||
executedCommands.push(`${command} ${args.join(" ")}`);
|
||||
if (command === "npm") {
|
||||
return;
|
||||
}
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options?.cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`);
|
||||
}
|
||||
};
|
||||
|
||||
await packageManager.update();
|
||||
|
||||
expect(executedCommands).toContain(
|
||||
"git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main",
|
||||
);
|
||||
expect(executedCommands).not.toContain("git fetch --prune origin");
|
||||
expect(executedCommands).not.toContain("git reset --hard @{upstream}");
|
||||
expect(executedCommands).not.toContain("git reset --hard origin/HEAD");
|
||||
expect(executedCommands).not.toContain("git clean -fdx");
|
||||
expect(executedCommands).not.toContain("npm install");
|
||||
});
|
||||
|
||||
it("should update to latest commit when remote has new commits", async () => {
|
||||
setupRemoteAndInstall();
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v1");
|
||||
|
||||
// Add a new commit to remote
|
||||
const newCommit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
|
||||
|
||||
// Update via package manager (no args = uses settings)
|
||||
await packageManager.update();
|
||||
|
||||
// Verify update succeeded
|
||||
expect(getCurrentCommit(installedDir)).toBe(newCommit);
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
|
||||
});
|
||||
|
||||
it("should handle multiple commits ahead", async () => {
|
||||
setupRemoteAndInstall();
|
||||
|
||||
// Add multiple commits to remote
|
||||
createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
|
||||
createCommit(remoteDir, "extension.ts", "// v3", "Third commit");
|
||||
const latestCommit = createCommit(remoteDir, "extension.ts", "// v4", "Fourth commit");
|
||||
|
||||
await packageManager.update();
|
||||
|
||||
expect(getCurrentCommit(installedDir)).toBe(latestCommit);
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v4");
|
||||
});
|
||||
|
||||
it("should update even when local checkout has no upstream", async () => {
|
||||
setupRemoteAndInstall();
|
||||
createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
|
||||
const latestCommit = createCommit(remoteDir, "extension.ts", "// v3", "Third commit");
|
||||
|
||||
const detachedCommit = getCurrentCommit(installedDir);
|
||||
git(["checkout", detachedCommit], installedDir);
|
||||
|
||||
const executedCommands: string[] = [];
|
||||
const managerWithInternals = packageManager as unknown as {
|
||||
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
|
||||
};
|
||||
managerWithInternals.runCommand = async (command, args, options) => {
|
||||
executedCommands.push(`${command} ${args.join(" ")}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options?.cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`);
|
||||
}
|
||||
};
|
||||
|
||||
await packageManager.update();
|
||||
|
||||
expect(executedCommands).toContain(
|
||||
"git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main",
|
||||
);
|
||||
expect(getCurrentCommit(installedDir)).toBe(latestCommit);
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("force-push scenarios", () => {
|
||||
it("should recover when remote history is rewritten", async () => {
|
||||
setupRemoteAndInstall();
|
||||
const initialCommit = getCurrentCommit(remoteDir);
|
||||
|
||||
// Add commit to remote
|
||||
createCommit(remoteDir, "extension.ts", "// v2", "Commit to keep");
|
||||
|
||||
// Update to get the new commit
|
||||
await packageManager.update();
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
|
||||
|
||||
// Now force-push to rewrite history on remote
|
||||
git(["reset", "--hard", initialCommit], remoteDir);
|
||||
const rewrittenCommit = createCommit(remoteDir, "extension.ts", "// v2-rewritten", "Rewritten commit");
|
||||
|
||||
// Update should succeed despite force-push
|
||||
await packageManager.update();
|
||||
|
||||
expect(getCurrentCommit(installedDir)).toBe(rewrittenCommit);
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-rewritten");
|
||||
});
|
||||
|
||||
it("should recover when local commit no longer exists in remote", async () => {
|
||||
setupRemoteAndInstall();
|
||||
|
||||
// Add commits to remote
|
||||
createCommit(remoteDir, "extension.ts", "// v2", "Commit A");
|
||||
createCommit(remoteDir, "extension.ts", "// v3", "Commit B");
|
||||
|
||||
// Update to get all commits
|
||||
await packageManager.update();
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v3");
|
||||
|
||||
// Force-push remote to remove commits A and B
|
||||
git(["reset", "--hard", "HEAD~2"], remoteDir);
|
||||
const newCommit = createCommit(remoteDir, "extension.ts", "// v2-new", "New commit replacing A and B");
|
||||
|
||||
// Update should succeed - the commits we had locally no longer exist
|
||||
await packageManager.update();
|
||||
|
||||
expect(getCurrentCommit(installedDir)).toBe(newCommit);
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-new");
|
||||
});
|
||||
|
||||
it("should handle complete history rewrite", async () => {
|
||||
setupRemoteAndInstall();
|
||||
|
||||
// Remote gets several commits
|
||||
createCommit(remoteDir, "extension.ts", "// v2", "v2");
|
||||
createCommit(remoteDir, "extension.ts", "// v3", "v3");
|
||||
|
||||
await packageManager.update();
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v3");
|
||||
|
||||
// Maintainer force-pushes completely different history
|
||||
git(["reset", "--hard", "HEAD~2"], remoteDir);
|
||||
createCommit(remoteDir, "extension.ts", "// rewrite-a", "Rewrite A");
|
||||
const finalCommit = createCommit(remoteDir, "extension.ts", "// rewrite-b", "Rewrite B");
|
||||
|
||||
// Should handle this gracefully
|
||||
await packageManager.update();
|
||||
|
||||
expect(getCurrentCommit(installedDir)).toBe(finalCommit);
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// rewrite-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinned sources", () => {
|
||||
it("should not update pinned git sources (with @ref)", async () => {
|
||||
// Create remote repo first to get the initial commit
|
||||
mkdirSync(remoteDir, { recursive: true });
|
||||
initGitRepo(remoteDir);
|
||||
const initialCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
|
||||
|
||||
// Install with pinned ref from the start - full clone to ensure commit is available
|
||||
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
|
||||
git(["clone", remoteDir, installedDir], tempDir);
|
||||
git(["checkout", initialCommit], installedDir);
|
||||
git(["config", "--local", "user.email", "test@test.com"], installedDir);
|
||||
git(["config", "--local", "user.name", "Test"], installedDir);
|
||||
|
||||
// Add to global packages with pinned ref
|
||||
settingsManager.setPackages([`${gitSource}@${initialCommit}`]);
|
||||
|
||||
// Add new commit to remote
|
||||
createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
|
||||
|
||||
// Update should be skipped for pinned sources
|
||||
await packageManager.update();
|
||||
|
||||
// Should still be on initial commit
|
||||
expect(getCurrentCommit(installedDir)).toBe(initialCommit);
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("temporary git sources", () => {
|
||||
it("should refresh cached temporary git sources when resolving", async () => {
|
||||
const gitHost = "github.com";
|
||||
const gitPath = "test/extension";
|
||||
const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8);
|
||||
const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath);
|
||||
const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts");
|
||||
|
||||
rmSync(cachedDir, { recursive: true, force: true });
|
||||
mkdirSync(join(cachedDir, "pi-extensions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(cachedDir, "package.json"),
|
||||
JSON.stringify({ pi: { extensions: ["./pi-extensions"] } }, null, 2),
|
||||
);
|
||||
writeFileSync(extensionFile, "// stale");
|
||||
|
||||
const executedCommands: string[] = [];
|
||||
const managerWithInternals = packageManager as unknown as {
|
||||
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
|
||||
runCommandCapture: (command: string, args: string[], options?: { cwd?: string }) => Promise<string>;
|
||||
};
|
||||
managerWithInternals.runCommand = async (command, args) => {
|
||||
executedCommands.push(`${command} ${args.join(" ")}`);
|
||||
if (command === "git" && args[0] === "reset") {
|
||||
writeFileSync(extensionFile, "// fresh");
|
||||
}
|
||||
};
|
||||
managerWithInternals.runCommandCapture = async (_command, args) => {
|
||||
if (args[0] === "rev-parse" && args[1] === "HEAD") {
|
||||
return "local-head";
|
||||
}
|
||||
if (args[0] === "rev-parse" && args[1] === "@{upstream}") {
|
||||
return "remote-head";
|
||||
}
|
||||
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") {
|
||||
return "origin/main";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
await packageManager.resolveExtensionSources([gitSource], { temporary: true });
|
||||
|
||||
expect(executedCommands).toContain(
|
||||
"git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main",
|
||||
);
|
||||
expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// fresh");
|
||||
});
|
||||
|
||||
it("should not refresh pinned temporary git sources", async () => {
|
||||
const gitHost = "github.com";
|
||||
const gitPath = "test/extension";
|
||||
const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8);
|
||||
const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath);
|
||||
const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts");
|
||||
|
||||
rmSync(cachedDir, { recursive: true, force: true });
|
||||
mkdirSync(join(cachedDir, "pi-extensions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(cachedDir, "package.json"),
|
||||
JSON.stringify({ pi: { extensions: ["./pi-extensions"] } }, null, 2),
|
||||
);
|
||||
writeFileSync(extensionFile, "// pinned");
|
||||
|
||||
const executedCommands: string[] = [];
|
||||
const managerWithInternals = packageManager as unknown as {
|
||||
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
|
||||
};
|
||||
managerWithInternals.runCommand = async (command, args) => {
|
||||
executedCommands.push(`${command} ${args.join(" ")}`);
|
||||
};
|
||||
|
||||
await packageManager.resolveExtensionSources([`${gitSource}@main`], { temporary: true });
|
||||
|
||||
expect(executedCommands).toEqual([]);
|
||||
expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// pinned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope-aware update", () => {
|
||||
it("should not install locally when source is only registered globally", async () => {
|
||||
setupRemoteAndInstall();
|
||||
|
||||
// Add a new commit to remote
|
||||
createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
|
||||
|
||||
// The project-scope install path should not exist before or after update
|
||||
const projectGitDir = join(tempDir, ".pi", "git", "github.com", "test", "extension");
|
||||
expect(existsSync(projectGitDir)).toBe(false);
|
||||
|
||||
await packageManager.update(gitSource);
|
||||
|
||||
// Global install should be updated
|
||||
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
|
||||
|
||||
// Project-scope directory should NOT have been created
|
||||
expect(existsSync(projectGitDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tests for image processing utilities using Photon.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertToPng } from "../src/utils/image-convert.js";
|
||||
import { formatDimensionNote, resizeImage } from "../src/utils/image-resize.js";
|
||||
|
||||
// Small 2x2 red PNG image (base64) - generated with ImageMagick
|
||||
const TINY_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACAQMAAABIeJ9nAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURf8AAP///0EdNBEAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gEOADM5Ddoh/wAAAAxJREFUCNdjYGBgAAAABAABJzQnCgAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMOnKzHgAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDCYl3TEAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwz4JVGwAAAABJRU5ErkJggg==";
|
||||
|
||||
// Small 2x2 blue JPEG image (base64) - generated with ImageMagick
|
||||
const TINY_JPEG =
|
||||
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAACAAIDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAGCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AD3VTB3/2Q==";
|
||||
|
||||
// 100x100 gray PNG
|
||||
const MEDIUM_PNG_100x100 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAAAAABVicqIAAAAAmJLR0QA/4ePzL8AAAAHdElNRQfqAQ4AMzkN2iH/AAAAP0lEQVRo3u3NQQEAAAQEMASXXYrz2gqst/Lm4ZBIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiSTsAP1cAUZeKtreAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAw6crMeAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMJiXdMQAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDPglUbAAAAAElFTkSuQmCC";
|
||||
|
||||
// 200x200 colored PNG
|
||||
const LARGE_PNG_200x200 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAIAAAByquWKAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQ4AMzqU03BFAAAEEElEQVR42u3dvWqUURSF4XXMRAyIhSAWQvwnWtjaWusFx9bW1kKD/wELESxEiBjDjJdwysOC57mCXW32+zGcGbtdACpsdi+T8Xz1GABzm91xMl6sHgNgblzcT/Y+rh4DYG6cv0/2H64eA2BOEgI1fHQHaoyzbXIwVo8BMCcJgRqSEKgxft9Lrn5aPQbA3Ph1klw7Wj0GwJwkBGpYWECN8fMiub63egyAORcWUMPCAmqMH3eTG59XjwEwN76/S24+Wj0GwJwkBGpYWECN8e1fcmuzegyAORcWUMPCAmqM09vJ4dfVYwDMjS9vkzuPV48BMCcJgRqeSAZqjA/nyYP91WMAzElCoIYkBGqMk8Pk6HT1GABzm+3L5JIkBApIQqCGj+5AjfHmb/Lk8uoxAOYkIVBDEgI1LCygxmZ7nFyShEABFxZQw8ICaozXf5KnV1aPATDnwgJqWFhADQsLqOFnDUANFxZQw8ICaoxXZ8mzg9VjAMy5sIAaFhZQw3tYQA1/QgHUkIRADUkI1JCEQA1JCNSQhEANFxZQwzcsoIYkBGpIQqCGJARqSEKghiQEalhYQA1/QgHUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEaFhZQw4ujQA0XFlDDwgJqSEKghgsLqGFhATUsLKCGb1hADRcWUMPCAmpIQqCGCwuoYWEBNbyHBdTw4ihQQxICNSQhUEMSAjUkIVBDEgI1XFhADd+wgBqSEKghCYEakhCoIQmBGpIQqGFhATW8OArUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEa49lB8ups9RgAc14cBWpIQqCGhQXUkIRADRcWUMPCAmqMp1eS139WjwEw5xsWUEMSAjUsLKCGJARquLCAGhYWUGM8uZy8+bt6DIA5L44CNSQhUMMTyUCNcXSanByuHgNgThICNSQhUGM82E8+nK8eA2DOzxqAGpIQqDHuPE6+vF09BsDcOPyanN5ePQbAnCQEavgdFlBj3Nok3/6tHgNgzoujQA1JCNQYNx8l39+tHgNgbtz4nPy4u3oMgDlJCNSwsIAa4/pe8vNi9RgAc37WANSQhECNce0o+XWyegyAuXH1U/L73uoxAOYkIVDDwgJqjIORnG1XjwEw508ogBqSEKgx9h8m5+9XjwEwN/Y+Jhf3V48BMCcJgRpjPE+2x6vHAJgbSbLbrR4DYO4/GqiSgXN+ksgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDpysx4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwmJd0xAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMM+CVRsAAAAASUVORK5CYII=";
|
||||
|
||||
describe("convertToPng", () => {
|
||||
it("should return original data for PNG input", async () => {
|
||||
const result = await convertToPng(TINY_PNG, "image/png");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.data).toBe(TINY_PNG);
|
||||
expect(result!.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("should convert JPEG to PNG", async () => {
|
||||
const result = await convertToPng(TINY_JPEG, "image/jpeg");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.mimeType).toBe("image/png");
|
||||
// Result should be valid base64
|
||||
expect(() => Buffer.from(result!.data, "base64")).not.toThrow();
|
||||
// PNG magic bytes
|
||||
const buffer = Buffer.from(result!.data, "base64");
|
||||
expect(buffer[0]).toBe(0x89);
|
||||
expect(buffer[1]).toBe(0x50); // 'P'
|
||||
expect(buffer[2]).toBe(0x4e); // 'N'
|
||||
expect(buffer[3]).toBe(0x47); // 'G'
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizeImage", () => {
|
||||
it("should return original image if within limits", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: TINY_PNG, mimeType: "image/png" },
|
||||
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.wasResized).toBe(false);
|
||||
expect(result!.data).toBe(TINY_PNG);
|
||||
expect(result!.originalWidth).toBe(2);
|
||||
expect(result!.originalHeight).toBe(2);
|
||||
expect(result!.width).toBe(2);
|
||||
expect(result!.height).toBe(2);
|
||||
});
|
||||
|
||||
it("should resize image exceeding dimension limits", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" },
|
||||
{ maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.wasResized).toBe(true);
|
||||
expect(result!.originalWidth).toBe(100);
|
||||
expect(result!.originalHeight).toBe(100);
|
||||
expect(result!.width).toBeLessThanOrEqual(50);
|
||||
expect(result!.height).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("should resize image exceeding byte limit", async () => {
|
||||
const originalBuffer = Buffer.from(LARGE_PNG_200x200, "base64");
|
||||
const originalSize = originalBuffer.length;
|
||||
|
||||
// Set maxBytes to less than the original encoded image size
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
|
||||
{ maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9) },
|
||||
);
|
||||
|
||||
// Should have tried to reduce size
|
||||
expect(result).not.toBeNull();
|
||||
const resultBuffer = Buffer.from(result!.data, "base64");
|
||||
expect(resultBuffer.length).toBeLessThan(originalSize);
|
||||
expect(result!.data.length).toBeLessThan(LARGE_PNG_200x200.length);
|
||||
});
|
||||
|
||||
it("should return null when image cannot be resized below maxBytes", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
|
||||
{ maxWidth: 2000, maxHeight: 2000, maxBytes: 1 },
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle JPEG input", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: TINY_JPEG, mimeType: "image/jpeg" },
|
||||
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.wasResized).toBe(false);
|
||||
expect(result!.originalWidth).toBe(2);
|
||||
expect(result!.originalHeight).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDimensionNote", () => {
|
||||
it("should return undefined for non-resized images", () => {
|
||||
const note = formatDimensionNote({
|
||||
data: "",
|
||||
mimeType: "image/png",
|
||||
originalWidth: 100,
|
||||
originalHeight: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
wasResized: false,
|
||||
});
|
||||
expect(note).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return formatted note for resized images", () => {
|
||||
const note = formatDimensionNote({
|
||||
data: "",
|
||||
mimeType: "image/png",
|
||||
originalWidth: 2000,
|
||||
originalHeight: 1000,
|
||||
width: 1000,
|
||||
height: 500,
|
||||
wasResized: true,
|
||||
});
|
||||
expect(note).toContain("original 2000x1000");
|
||||
expect(note).toContain("displayed at 1000x500");
|
||||
expect(note).toContain("2.00"); // scale factor
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/utils/image-resize.js", () => ({
|
||||
resizeImage: vi.fn(),
|
||||
formatDimensionNote: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
import { processFileArguments } from "../src/cli/file-processor.js";
|
||||
import { createReadTool } from "../src/core/tools/read.js";
|
||||
import { resizeImage } from "../src/utils/image-resize.js";
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
describe("image resize callers", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `image-resize-callers-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
vi.mocked(resizeImage).mockReset();
|
||||
vi.mocked(resizeImage).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("read tool returns text-only output when auto-resize cannot produce a safe image", async () => {
|
||||
const imagePath = join(testDir, "test.png");
|
||||
writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const tool = createReadTool(testDir);
|
||||
const result = await tool.execute("test-read-image", { path: imagePath });
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content[0].type).toBe("text");
|
||||
expect((result.content[0] as { type: "text"; text: string }).text).toContain("Image omitted");
|
||||
});
|
||||
|
||||
it("file processor omits image attachments when auto-resize cannot produce a safe image", async () => {
|
||||
const imagePath = join(testDir, "test.png");
|
||||
writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const result = await processFileArguments([imagePath]);
|
||||
|
||||
expect(result.images).toHaveLength(0);
|
||||
expect(result.text).toContain("Image omitted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Args } from "../src/cli/args.js";
|
||||
import { buildInitialMessage } from "../src/cli/initial-message.js";
|
||||
|
||||
function createArgs(messages: string[] = []): Args {
|
||||
return {
|
||||
messages: [...messages],
|
||||
fileArgs: [],
|
||||
unknownFlags: new Map(),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildInitialMessage", () => {
|
||||
test("merges piped stdin with the first CLI message into one prompt", () => {
|
||||
const parsed = createArgs(["Summarize the text given"]);
|
||||
const result = buildInitialMessage({
|
||||
parsed,
|
||||
stdinContent: "README contents\n",
|
||||
});
|
||||
|
||||
expect(result.initialMessage).toBe("README contents\nSummarize the text given");
|
||||
expect(parsed.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("uses stdin as the initial prompt when no CLI message is present", () => {
|
||||
const parsed = createArgs();
|
||||
const result = buildInitialMessage({
|
||||
parsed,
|
||||
stdinContent: "README contents",
|
||||
});
|
||||
|
||||
expect(result.initialMessage).toBe("README contents");
|
||||
expect(parsed.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("combines stdin, file text, and first CLI message in one prompt", () => {
|
||||
const parsed = createArgs(["Explain it", "Second message"]);
|
||||
const result = buildInitialMessage({
|
||||
parsed,
|
||||
stdinContent: "stdin\n",
|
||||
fileText: "file\n",
|
||||
});
|
||||
|
||||
expect(result.initialMessage).toBe("stdin\nfile\nExplain it");
|
||||
expect(parsed.messages).toEqual(["Second message"]);
|
||||
});
|
||||
});
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
|
||||
describe("InteractiveMode.maybeWarnAboutAnthropicSubscriptionAuth", () => {
|
||||
test("warns once when Anthropic subscription auth is detected", async () => {
|
||||
const fakeThis: any = {
|
||||
anthropicSubscriptionWarningShown: false,
|
||||
session: {
|
||||
modelRegistry: {
|
||||
authStorage: {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
getApiKeyForProvider: vi.fn().mockResolvedValue("sk-ant-oat01-test"),
|
||||
},
|
||||
},
|
||||
showWarning: vi.fn(),
|
||||
};
|
||||
|
||||
await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, {
|
||||
provider: "anthropic",
|
||||
});
|
||||
await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, {
|
||||
provider: "anthropic",
|
||||
});
|
||||
|
||||
expect(fakeThis.showWarning).toHaveBeenCalledTimes(1);
|
||||
expect(fakeThis.session.modelRegistry.getApiKeyForProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("warns when Anthropic OAuth is stored even if token refresh lookup would fail", async () => {
|
||||
const fakeThis: any = {
|
||||
anthropicSubscriptionWarningShown: false,
|
||||
session: {
|
||||
modelRegistry: {
|
||||
authStorage: {
|
||||
get: vi.fn().mockReturnValue({ type: "oauth" }),
|
||||
},
|
||||
getApiKeyForProvider: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
showWarning: vi.fn(),
|
||||
};
|
||||
|
||||
await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, {
|
||||
provider: "anthropic",
|
||||
});
|
||||
|
||||
expect(fakeThis.showWarning).toHaveBeenCalledTimes(1);
|
||||
expect(fakeThis.session.modelRegistry.getApiKeyForProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not warn for non-Anthropic models", async () => {
|
||||
const fakeThis: any = {
|
||||
anthropicSubscriptionWarningShown: false,
|
||||
session: {
|
||||
modelRegistry: {
|
||||
authStorage: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
getApiKeyForProvider: vi.fn(),
|
||||
},
|
||||
},
|
||||
showWarning: vi.fn(),
|
||||
};
|
||||
|
||||
await (InteractiveMode as any).prototype.maybeWarnAboutAnthropicSubscriptionAuth.call(fakeThis, {
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
expect(fakeThis.showWarning).not.toHaveBeenCalled();
|
||||
expect(fakeThis.session.modelRegistry.getApiKeyForProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
|
||||
describe("InteractiveMode compaction events", () => {
|
||||
test("rebuilds chat and appends a synthetic compaction summary at the bottom", async () => {
|
||||
const fakeThis = {
|
||||
isInitialized: true,
|
||||
footer: { invalidate: vi.fn() },
|
||||
autoCompactionEscapeHandler: undefined as (() => void) | undefined,
|
||||
autoCompactionLoader: undefined,
|
||||
defaultEditor: {},
|
||||
statusContainer: { clear: vi.fn() },
|
||||
chatContainer: { clear: vi.fn() },
|
||||
rebuildChatFromMessages: vi.fn(),
|
||||
addMessageToChat: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
flushCompactionQueue: vi.fn().mockResolvedValue(undefined),
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
|
||||
const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as (
|
||||
this: typeof fakeThis,
|
||||
event: {
|
||||
type: "compaction_end";
|
||||
reason: "manual" | "threshold" | "overflow";
|
||||
result: { tokensBefore: number; summary: string } | undefined;
|
||||
aborted: boolean;
|
||||
willRetry: boolean;
|
||||
errorMessage?: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
|
||||
await handleEvent.call(fakeThis, {
|
||||
type: "compaction_end",
|
||||
reason: "manual",
|
||||
result: {
|
||||
tokensBefore: 123,
|
||||
summary: "summary",
|
||||
},
|
||||
aborted: false,
|
||||
willRetry: false,
|
||||
});
|
||||
|
||||
expect(fakeThis.chatContainer.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fakeThis.rebuildChatFromMessages).toHaveBeenCalledTimes(1);
|
||||
expect(fakeThis.addMessageToChat).toHaveBeenCalledTimes(1);
|
||||
expect(fakeThis.addMessageToChat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: "compactionSummary",
|
||||
tokensBefore: 123,
|
||||
summary: "summary",
|
||||
}),
|
||||
);
|
||||
expect(fakeThis.flushCompactionQueue).toHaveBeenCalledWith({ willRetry: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionImportFileNotFoundError } from "../src/core/agent-session-runtime.js";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
|
||||
type PathCommand = "/export" | "/import";
|
||||
|
||||
type InteractiveModePrototype = {
|
||||
getPathCommandArgument(this: unknown, text: string, command: PathCommand): string | undefined;
|
||||
handleImportCommand(this: ImportCommandContext, text: string): Promise<void>;
|
||||
};
|
||||
|
||||
type ImportCommandContext = {
|
||||
loadingAnimation?: { stop: () => void };
|
||||
statusContainer: { clear: () => void };
|
||||
runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> };
|
||||
showError: (message: string) => void;
|
||||
showStatus: (message: string) => void;
|
||||
showExtensionConfirm: (title: string, message: string) => Promise<boolean>;
|
||||
handleRuntimeSessionChange: () => Promise<void>;
|
||||
renderCurrentSessionState: () => void;
|
||||
handleFatalRuntimeError: (prefix: string, error: unknown) => Promise<never>;
|
||||
promptForMissingSessionCwd: (error: unknown) => Promise<string | undefined>;
|
||||
getPathCommandArgument: (text: string, command: PathCommand) => string | undefined;
|
||||
};
|
||||
|
||||
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
|
||||
|
||||
describe("InteractiveMode /import parsing", () => {
|
||||
it("strips quotes from /import path arguments", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument('/import "path/to/session.jsonl"', "/import")).toBe(
|
||||
"path/to/session.jsonl",
|
||||
);
|
||||
expect(
|
||||
interactiveModePrototype.getPathCommandArgument('/import "path with spaces/session.jsonl"', "/import"),
|
||||
).toBe("path with spaces/session.jsonl");
|
||||
});
|
||||
|
||||
it("preserves apostrophes in unquoted /import path arguments", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/import john's/session.jsonl", "/import")).toBe(
|
||||
"john's/session.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces command token boundaries", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/important /tmp/session.jsonl", "/import")).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/exporter out.html", "/export")).toBe(undefined);
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/import /tmp/session.jsonl", "/import")).toBe(
|
||||
"/tmp/session.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes unquoted path to runtimeHost.importFromJsonl", async () => {
|
||||
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError: vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
}),
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, '/import "path/to/session.jsonl"');
|
||||
|
||||
expect(showExtensionConfirm).toHaveBeenCalledWith(
|
||||
"Import session",
|
||||
"Replace current session with path/to/session.jsonl?",
|
||||
);
|
||||
expect(importFromJsonl).toHaveBeenCalledWith("path/to/session.jsonl");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(showStatus).toHaveBeenCalledWith("Session imported from: path/to/session.jsonl");
|
||||
});
|
||||
|
||||
it("passes unquoted apostrophe path to runtimeHost.importFromJsonl unchanged", async () => {
|
||||
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError: vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
}),
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, "/import john's/session.jsonl");
|
||||
|
||||
expect(importFromJsonl).toHaveBeenCalledWith("john's/session.jsonl");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(showStatus).toHaveBeenCalledWith("Session imported from: john's/session.jsonl");
|
||||
});
|
||||
|
||||
it("shows a non-fatal error when /import path does not exist", async () => {
|
||||
const importFromJsonl = vi.fn(async () => {
|
||||
throw new SessionImportFileNotFoundError("/tmp/missing-session.jsonl");
|
||||
});
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const handleFatalRuntimeError = vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
});
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError,
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, "/import /tmp/missing-session.jsonl");
|
||||
|
||||
expect(showError).toHaveBeenCalledWith("Failed to import session: File not found: /tmp/missing-session.jsonl");
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
expect(handleFatalRuntimeError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,562 @@
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Container } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import type { SourceInfo } from "../src/core/source-info.js";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
function renderLastLine(container: Container, width = 120): string {
|
||||
const last = container.children[container.children.length - 1];
|
||||
if (!last) return "";
|
||||
return last.render(width).join("\n");
|
||||
}
|
||||
|
||||
function renderAll(container: Container, width = 120): string {
|
||||
return container.children.flatMap((child) => child.render(width)).join("\n");
|
||||
}
|
||||
|
||||
function normalizeRenderedOutput(container: Container, width = 220): string {
|
||||
return renderAll(container, width)
|
||||
.replace(/\u001b\[[0-9;]*m/g, "")
|
||||
.replace(/\\/g, "/")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\s+$/g, ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
type ExtensionFixture = {
|
||||
path: string;
|
||||
sourceInfo?: SourceInfo;
|
||||
};
|
||||
|
||||
describe("InteractiveMode.showStatus", () => {
|
||||
beforeAll(() => {
|
||||
// showStatus uses the global theme instance
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
test("coalesces immediately-sequential status messages", () => {
|
||||
const fakeThis: any = {
|
||||
chatContainer: new Container(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
lastStatusSpacer: undefined,
|
||||
lastStatusText: undefined,
|
||||
};
|
||||
|
||||
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE");
|
||||
expect(fakeThis.chatContainer.children).toHaveLength(2);
|
||||
expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_ONE");
|
||||
|
||||
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO");
|
||||
// second status updates the previous line instead of appending
|
||||
expect(fakeThis.chatContainer.children).toHaveLength(2);
|
||||
expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO");
|
||||
expect(renderLastLine(fakeThis.chatContainer)).not.toContain("STATUS_ONE");
|
||||
});
|
||||
|
||||
test("appends a new status line if something else was added in between", () => {
|
||||
const fakeThis: any = {
|
||||
chatContainer: new Container(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
lastStatusSpacer: undefined,
|
||||
lastStatusText: undefined,
|
||||
};
|
||||
|
||||
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE");
|
||||
expect(fakeThis.chatContainer.children).toHaveLength(2);
|
||||
|
||||
// Something else gets added to the chat in between status updates
|
||||
fakeThis.chatContainer.addChild({ render: () => ["OTHER"], invalidate: () => {} });
|
||||
expect(fakeThis.chatContainer.children).toHaveLength(3);
|
||||
|
||||
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO");
|
||||
// adds spacer + text
|
||||
expect(fakeThis.chatContainer.children).toHaveLength(5);
|
||||
expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.setToolsExpanded", () => {
|
||||
test("applies expansion state to the active header and chat entries", () => {
|
||||
const header = { setExpanded: vi.fn() };
|
||||
const chatChild = { setExpanded: vi.fn() };
|
||||
const fakeThis: any = {
|
||||
toolOutputExpanded: false,
|
||||
customHeader: undefined,
|
||||
builtInHeader: header,
|
||||
chatContainer: { children: [chatChild] },
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
|
||||
(InteractiveMode as any).prototype.setToolsExpanded.call(fakeThis, true);
|
||||
|
||||
expect(fakeThis.toolOutputExpanded).toBe(true);
|
||||
expect(header.setExpanded).toHaveBeenCalledWith(true);
|
||||
expect(chatChild.setExpanded).toHaveBeenCalledWith(true);
|
||||
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
||||
test("persists theme changes to settings manager", () => {
|
||||
initTheme("dark");
|
||||
|
||||
let currentTheme = "dark";
|
||||
const settingsManager = {
|
||||
getTheme: vi.fn(() => currentTheme),
|
||||
setTheme: vi.fn((theme: string) => {
|
||||
currentTheme = theme;
|
||||
}),
|
||||
};
|
||||
const fakeThis: any = {
|
||||
session: { settingsManager },
|
||||
settingsManager,
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
|
||||
const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis);
|
||||
const result = uiContext.setTheme("light");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(settingsManager.setTheme).toHaveBeenCalledWith("light");
|
||||
expect(currentTheme).toBe("light");
|
||||
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not persist invalid theme names", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const settingsManager = {
|
||||
getTheme: vi.fn(() => "dark"),
|
||||
setTheme: vi.fn(),
|
||||
};
|
||||
const fakeThis: any = {
|
||||
session: { settingsManager },
|
||||
settingsManager,
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
|
||||
const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis);
|
||||
const result = uiContext.setTheme("__missing_theme__");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(settingsManager.setTheme).not.toHaveBeenCalled();
|
||||
expect(fakeThis.ui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.showLoadedResources", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
function createShowLoadedResourcesThis(options: {
|
||||
quietStartup: boolean;
|
||||
verbose?: boolean;
|
||||
toolOutputExpanded?: boolean;
|
||||
cwd?: string;
|
||||
contextFiles?: Array<{ path: string; content?: string }>;
|
||||
extensions?: ExtensionFixture[];
|
||||
skills?: Array<{ filePath: string; name: string }>;
|
||||
skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>;
|
||||
useRealScopeGroups?: boolean;
|
||||
}) {
|
||||
const fakeThis: any = {
|
||||
options: { verbose: options.verbose ?? false },
|
||||
toolOutputExpanded: options.toolOutputExpanded ?? false,
|
||||
chatContainer: new Container(),
|
||||
settingsManager: {
|
||||
getQuietStartup: () => options.quietStartup,
|
||||
},
|
||||
sessionManager: {
|
||||
getCwd: () => options.cwd ?? "/tmp/project",
|
||||
},
|
||||
session: {
|
||||
promptTemplates: [],
|
||||
extensionRunner: undefined,
|
||||
resourceLoader: {
|
||||
getPathMetadata: () => new Map(),
|
||||
getAgentsFiles: () => ({ agentsFiles: options.contextFiles ?? [] }),
|
||||
getSkills: () => ({
|
||||
skills: options.skills ?? [],
|
||||
diagnostics: options.skillDiagnostics ?? [],
|
||||
}),
|
||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||
getExtensions: () => ({ extensions: options.extensions ?? [], errors: [], runtime: {} }),
|
||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||
},
|
||||
},
|
||||
formatDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatDisplayPath.call(fakeThis, p),
|
||||
formatContextPath: (p: string) => (InteractiveMode as any).prototype.formatContextPath.call(fakeThis, p),
|
||||
getStartupExpansionState: () => (InteractiveMode as any).prototype.getStartupExpansionState.call(fakeThis),
|
||||
buildScopeGroups: () => [],
|
||||
formatScopeGroups: () => "resource-list",
|
||||
isPackageSource: (sourceInfo?: SourceInfo) =>
|
||||
(InteractiveMode as any).prototype.isPackageSource.call(fakeThis, sourceInfo),
|
||||
getShortPath: (p: string, sourceInfo?: SourceInfo) =>
|
||||
(InteractiveMode as any).prototype.getShortPath.call(fakeThis, p, sourceInfo),
|
||||
getCompactPathLabel: (p: string, sourceInfo?: SourceInfo) =>
|
||||
(InteractiveMode as any).prototype.getCompactPathLabel.call(fakeThis, p, sourceInfo),
|
||||
getCompactPackageSourceLabel: (sourceInfo?: SourceInfo) =>
|
||||
(InteractiveMode as any).prototype.getCompactPackageSourceLabel.call(fakeThis, sourceInfo),
|
||||
getCompactExtensionLabel: (p: string, sourceInfo?: SourceInfo) =>
|
||||
(InteractiveMode as any).prototype.getCompactExtensionLabel.call(fakeThis, p, sourceInfo),
|
||||
getCompactDisplayPathSegments: (p: string) =>
|
||||
(InteractiveMode as any).prototype.getCompactDisplayPathSegments.call(fakeThis, p),
|
||||
getCompactNonPackageExtensionLabel: (
|
||||
p: string,
|
||||
index: number,
|
||||
allPaths: Array<{ path: string; segments: string[] }>,
|
||||
) => (InteractiveMode as any).prototype.getCompactNonPackageExtensionLabel.call(fakeThis, p, index, allPaths),
|
||||
getCompactExtensionLabels: (extensions: ExtensionFixture[]) =>
|
||||
(InteractiveMode as any).prototype.getCompactExtensionLabels.call(fakeThis, extensions),
|
||||
formatDiagnostics: () => "diagnostics",
|
||||
getBuiltInCommandConflictDiagnostics: () => [],
|
||||
};
|
||||
|
||||
if (options.useRealScopeGroups) {
|
||||
fakeThis.getScopeGroup = (sourceInfo?: SourceInfo) =>
|
||||
(InteractiveMode as any).prototype.getScopeGroup.call(fakeThis, sourceInfo);
|
||||
fakeThis.buildScopeGroups = (items: Array<{ path: string; sourceInfo?: SourceInfo }>) =>
|
||||
(InteractiveMode as any).prototype.buildScopeGroups.call(fakeThis, items);
|
||||
fakeThis.formatScopeGroups = (groups: unknown, formatOptions: unknown) =>
|
||||
(InteractiveMode as any).prototype.formatScopeGroups.call(fakeThis, groups, formatOptions);
|
||||
}
|
||||
|
||||
return fakeThis;
|
||||
}
|
||||
|
||||
function createSourceInfo(
|
||||
filePath: string,
|
||||
options: {
|
||||
source: string;
|
||||
scope: "user" | "project" | "temporary";
|
||||
origin: "package" | "top-level";
|
||||
baseDir?: string;
|
||||
},
|
||||
): SourceInfo {
|
||||
return {
|
||||
path: filePath,
|
||||
source: options.source,
|
||||
scope: options.scope,
|
||||
origin: options.origin,
|
||||
baseDir: options.baseDir,
|
||||
};
|
||||
}
|
||||
|
||||
function createExtensionFixtures(): ExtensionFixture[] {
|
||||
return [
|
||||
{
|
||||
path: "/tmp/project/.pi/extensions/answer.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/project/.pi/extensions/answer.ts", {
|
||||
source: "local",
|
||||
scope: "project",
|
||||
origin: "top-level",
|
||||
baseDir: "/tmp/project/.pi/extensions",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tmp/project/.pi/extensions/local-index/index.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/project/.pi/extensions/local-index/index.ts", {
|
||||
source: "local",
|
||||
scope: "project",
|
||||
origin: "top-level",
|
||||
baseDir: "/tmp/project/.pi/extensions",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tmp/agent/extensions/user-index/index.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/agent/extensions/user-index/index.ts", {
|
||||
source: "local",
|
||||
scope: "user",
|
||||
origin: "top-level",
|
||||
baseDir: "/tmp/agent/extensions",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts", {
|
||||
source: "npm:pi-markdown-preview",
|
||||
scope: "project",
|
||||
origin: "package",
|
||||
baseDir: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tmp/project/.pi/npm/node_modules/@scope/pi-scoped/extensions/index.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/project/.pi/npm/node_modules/@scope/pi-scoped/extensions/index.ts", {
|
||||
source: "npm:@scope/pi-scoped",
|
||||
scope: "project",
|
||||
origin: "package",
|
||||
baseDir: "/tmp/project/.pi/npm/node_modules/@scope/pi-scoped",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/index.ts",
|
||||
sourceInfo: createSourceInfo(
|
||||
"/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/index.ts",
|
||||
{
|
||||
source: "git:github.com/HazAT/pi-interactive-subagents",
|
||||
scope: "project",
|
||||
origin: "package",
|
||||
baseDir: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents",
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/subagents/index.ts",
|
||||
sourceInfo: createSourceInfo(
|
||||
"/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/subagents/index.ts",
|
||||
{
|
||||
source: "git:github.com/HazAT/pi-interactive-subagents",
|
||||
scope: "project",
|
||||
origin: "package",
|
||||
baseDir: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents",
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/tmp/temp/cli-extension.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/temp/cli-extension.ts", {
|
||||
source: "cli",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir: "/tmp/temp",
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
test("shows a compact resource listing by default", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("commit");
|
||||
expect(output).not.toContain("resource-list");
|
||||
});
|
||||
|
||||
test("shows full resource listing when expanded", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
toolOutputExpanded: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("resource-list");
|
||||
expect(output).not.toContain("commit");
|
||||
});
|
||||
|
||||
test("shows full resource listing on verbose startup even when tool output is collapsed", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
verbose: true,
|
||||
toolOutputExpanded: false,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("resource-list");
|
||||
expect(output).not.toContain("commit");
|
||||
});
|
||||
|
||||
test("abbreviates extensions in compact listing", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
extensions: [{ path: "/tmp/extensions/answer.ts" }, { path: "/tmp/extensions/btw.ts" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Extensions]");
|
||||
expect(output).toContain("answer.ts, btw.ts");
|
||||
expect(output).not.toContain("extensions/answer.ts");
|
||||
});
|
||||
|
||||
test("captures mixed extension layouts in compact output", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
extensions: createExtensionFixtures(),
|
||||
useRealScopeGroups: true,
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
|
||||
"[Extensions]
|
||||
@scope/pi-scoped, answer.ts, cli-extension.ts, HazAT/pi-interactive-subagents, HazAT/pi-interactive-subagents:subagents, local-index/index.ts, pi-markdown-preview, user-index/index.ts"`);
|
||||
});
|
||||
|
||||
test("adds more parent folders until local extension labels are unique", () => {
|
||||
const extensions: ExtensionFixture[] = [
|
||||
{
|
||||
path: "/tmp/alpha/one/index.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/alpha/one/index.ts", {
|
||||
source: "cli",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir: "/tmp/alpha",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tmp/beta/one/index.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/beta/one/index.ts", {
|
||||
source: "cli",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir: "/tmp/beta",
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tmp/gamma/one/index.ts",
|
||||
sourceInfo: createSourceInfo("/tmp/gamma/one/index.ts", {
|
||||
source: "cli",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir: "/tmp/gamma",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
extensions,
|
||||
useRealScopeGroups: true,
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
|
||||
"[Extensions]
|
||||
alpha/one/index.ts, beta/one/index.ts, gamma/one/index.ts"`);
|
||||
});
|
||||
|
||||
test("captures mixed extension layouts in expanded output", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
toolOutputExpanded: true,
|
||||
extensions: createExtensionFixtures(),
|
||||
useRealScopeGroups: true,
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
|
||||
"[Extensions]
|
||||
project
|
||||
/tmp/project/.pi/extensions/answer.ts
|
||||
/tmp/project/.pi/extensions/local-index/index.ts
|
||||
git:github.com/HazAT/pi-interactive-subagents
|
||||
extensions/index.ts
|
||||
extensions/subagents/index.ts
|
||||
npm:@scope/pi-scoped
|
||||
extensions/index.ts
|
||||
npm:pi-markdown-preview
|
||||
extensions/index.ts
|
||||
user
|
||||
/tmp/agent/extensions/user-index/index.ts
|
||||
path
|
||||
/tmp/temp/cli-extension.ts"`);
|
||||
});
|
||||
|
||||
test("shows context paths relative to cwd while preserving full external paths", () => {
|
||||
const home = homedir();
|
||||
const cwd = path.join(home, "Development", "pi-mono");
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
cwd,
|
||||
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
||||
expect(output).toContain("[Context]");
|
||||
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
||||
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
|
||||
});
|
||||
|
||||
test("shows full context paths when expanded", () => {
|
||||
const home = homedir();
|
||||
const cwd = path.join(home, "Development", "pi-mono");
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
toolOutputExpanded: true,
|
||||
cwd,
|
||||
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
||||
expect(output).toContain("[Context]");
|
||||
expect(output).toContain("~/.pi/agent/AGENTS.md");
|
||||
expect(output).toContain("~/Development/pi-mono/AGENTS.md");
|
||||
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
||||
});
|
||||
|
||||
test("does not show verbose listing on quiet startup during reload", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
extensions: [{ path: "/tmp/ext/index.ts" }],
|
||||
force: false,
|
||||
showDiagnosticsWhenQuiet: true,
|
||||
});
|
||||
|
||||
expect(fakeThis.chatContainer.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("still shows diagnostics on quiet startup when requested", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
showDiagnosticsWhenQuiet: true,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skill conflicts]");
|
||||
expect(output).not.toContain("[Skills]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
|
||||
type FakeUi = {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
requestRender: (force?: boolean) => void;
|
||||
};
|
||||
|
||||
type HandleCtrlZThis = {
|
||||
ui: FakeUi;
|
||||
};
|
||||
|
||||
type ProcessSignalHandler = () => void;
|
||||
|
||||
type InteractiveModePrototypeWithHandleCtrlZ = {
|
||||
handleCtrlZ(this: HandleCtrlZThis): void;
|
||||
};
|
||||
|
||||
function callHandleCtrlZ(context: HandleCtrlZThis): void {
|
||||
(interactiveModePrototype as InteractiveModePrototypeWithHandleCtrlZ).handleCtrlZ.call(context);
|
||||
}
|
||||
|
||||
const interactiveModePrototype = InteractiveMode.prototype as unknown;
|
||||
|
||||
describe("InteractiveMode.handleCtrlZ", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("shows a status message and skips suspend on Windows", () => {
|
||||
const ui: FakeUi = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
requestRender: vi.fn(),
|
||||
};
|
||||
const showStatus = vi.fn();
|
||||
const context: HandleCtrlZThis & { showStatus: (message: string) => void } = { ui, showStatus };
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "win32",
|
||||
});
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
const processOnSpy = vi.spyOn(process, "on");
|
||||
const processOnceSpy = vi.spyOn(process, "once");
|
||||
const processKillSpy = vi.spyOn(process, "kill");
|
||||
|
||||
try {
|
||||
callHandleCtrlZ(context);
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
expect(showStatus).toHaveBeenCalledWith("Suspend to background is not supported on Windows");
|
||||
expect(ui.stop).not.toHaveBeenCalled();
|
||||
expect(setIntervalSpy).not.toHaveBeenCalled();
|
||||
expect(processOnSpy).not.toHaveBeenCalledWith("SIGINT", expect.any(Function));
|
||||
expect(processOnceSpy).not.toHaveBeenCalledWith("SIGCONT", expect.any(Function));
|
||||
expect(processKillSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps the process alive while suspended and restores the TUI on SIGCONT", () => {
|
||||
const ui: FakeUi = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
requestRender: vi.fn(),
|
||||
};
|
||||
const context: HandleCtrlZThis = { ui };
|
||||
const keepAliveHandle = setTimeout(() => undefined, 0);
|
||||
clearTimeout(keepAliveHandle);
|
||||
|
||||
let sigintHandler: ProcessSignalHandler | undefined;
|
||||
let sigcontHandler: ProcessSignalHandler | undefined;
|
||||
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockReturnValue(keepAliveHandle);
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined);
|
||||
const processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGINT") {
|
||||
sigintHandler = listener;
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.on);
|
||||
const processOnceSpy = vi.spyOn(process, "once").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGCONT") {
|
||||
sigcontHandler = listener;
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.once);
|
||||
const removeListenerSpy = vi
|
||||
.spyOn(process, "removeListener")
|
||||
.mockImplementation(((_event: string, _listener: () => void) => process) as typeof process.removeListener);
|
||||
const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
callHandleCtrlZ(context);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2 ** 30);
|
||||
expect(processOnSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function));
|
||||
expect(processOnceSpy).toHaveBeenCalledWith("SIGCONT", expect.any(Function));
|
||||
expect(ui.stop).toHaveBeenCalledTimes(1);
|
||||
expect(processKillSpy).toHaveBeenCalledWith(0, "SIGTSTP");
|
||||
expect(sigintHandler).toBeDefined();
|
||||
expect(sigcontHandler).toBeDefined();
|
||||
|
||||
sigcontHandler?.();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(keepAliveHandle);
|
||||
expect(removeListenerSpy).toHaveBeenCalledWith("SIGINT", sigintHandler);
|
||||
expect(ui.start).toHaveBeenCalledTimes(1);
|
||||
expect(ui.requestRender).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("cleans up the temporary handlers if suspension fails", () => {
|
||||
const ui: FakeUi = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
requestRender: vi.fn(),
|
||||
};
|
||||
const context: HandleCtrlZThis = { ui };
|
||||
const keepAliveHandle = setTimeout(() => undefined, 0);
|
||||
clearTimeout(keepAliveHandle);
|
||||
const suspendError = new Error("suspend failed");
|
||||
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockReturnValue(keepAliveHandle);
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined);
|
||||
vi.spyOn(process, "on").mockImplementation(
|
||||
((_event: string, _listener: () => void) => process) as typeof process.on,
|
||||
);
|
||||
const removeListenerSpy = vi
|
||||
.spyOn(process, "removeListener")
|
||||
.mockImplementation(((_event: string, _listener: () => void) => process) as typeof process.removeListener);
|
||||
vi.spyOn(process, "once").mockImplementation(
|
||||
((_event: string, _listener: () => void) => process) as typeof process.once,
|
||||
);
|
||||
vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw suspendError;
|
||||
});
|
||||
|
||||
expect(() => callHandleCtrlZ(context)).toThrow(suspendError);
|
||||
expect(ui.stop).toHaveBeenCalledTimes(1);
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(keepAliveHandle);
|
||||
expect(removeListenerSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function));
|
||||
expect(ui.start).not.toHaveBeenCalled();
|
||||
expect(ui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../src/config.js";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.js";
|
||||
import { runMigrations } from "../src/migrations.js";
|
||||
|
||||
describe("keybindings migration", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createAgentDir(config: Record<string, unknown>): string {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-keybindings-test-"));
|
||||
tempDirs.push(agentDir);
|
||||
fs.writeFileSync(path.join(agentDir, "keybindings.json"), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
it("rewrites old key names to namespaced ids", () => {
|
||||
const agentDir = createAgentDir({
|
||||
cursorUp: ["up", "ctrl+p"],
|
||||
expandTools: "ctrl+x",
|
||||
});
|
||||
const previousAgentDir = process.env[ENV_AGENT_DIR];
|
||||
process.env[ENV_AGENT_DIR] = agentDir;
|
||||
runMigrations(agentDir);
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env[ENV_AGENT_DIR];
|
||||
} else {
|
||||
process.env[ENV_AGENT_DIR] = previousAgentDir;
|
||||
}
|
||||
|
||||
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(migrated).toEqual({
|
||||
"tui.editor.cursorUp": ["up", "ctrl+p"],
|
||||
"app.tools.expand": "ctrl+x",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the namespaced value when old and new names both exist", () => {
|
||||
const agentDir = createAgentDir({
|
||||
expandTools: "ctrl+x",
|
||||
"app.tools.expand": "ctrl+y",
|
||||
});
|
||||
const previousAgentDir = process.env[ENV_AGENT_DIR];
|
||||
process.env[ENV_AGENT_DIR] = agentDir;
|
||||
runMigrations(agentDir);
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env[ENV_AGENT_DIR];
|
||||
} else {
|
||||
process.env[ENV_AGENT_DIR] = previousAgentDir;
|
||||
}
|
||||
|
||||
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(migrated).toEqual({
|
||||
"app.tools.expand": "ctrl+y",
|
||||
});
|
||||
});
|
||||
|
||||
it("loads old key names in memory before the file is rewritten", () => {
|
||||
const agentDir = createAgentDir({
|
||||
selectConfirm: "enter",
|
||||
interrupt: "ctrl+x",
|
||||
});
|
||||
|
||||
const keybindings = KeybindingsManager.create(agentDir);
|
||||
|
||||
expect(keybindings.getUserBindings()).toEqual({
|
||||
"tui.select.confirm": "enter",
|
||||
"app.interrupt": "ctrl+x",
|
||||
});
|
||||
const effective = keybindings.getEffectiveConfig();
|
||||
expect(effective["tui.select.confirm"]).toBe("enter");
|
||||
expect(effective["app.interrupt"]).toBe("ctrl+x");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,436 @@
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
defaultModelPerProvider,
|
||||
findInitialModel,
|
||||
parseModelPattern,
|
||||
resolveCliModel,
|
||||
} from "../src/core/model-resolver.js";
|
||||
|
||||
// Mock models for testing
|
||||
const mockModels: Model<"anthropic-messages">[] = [
|
||||
{
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
api: "anthropic-messages", // Using same type for simplicity
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock OpenRouter models with colons in IDs
|
||||
const mockOpenRouterModels: Model<"anthropic-messages">[] = [
|
||||
{
|
||||
id: "qwen/qwen3-coder:exacto",
|
||||
name: "Qwen3 Coder Exacto",
|
||||
api: "anthropic-messages",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-4o:extended",
|
||||
name: "GPT-4o Extended",
|
||||
api: "anthropic-messages",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
];
|
||||
|
||||
const allModels = [...mockModels, ...mockOpenRouterModels];
|
||||
|
||||
describe("parseModelPattern", () => {
|
||||
describe("simple patterns without colons", () => {
|
||||
test("exact match returns model with undefined thinking level", () => {
|
||||
const result = parseModelPattern("claude-sonnet-4-5", allModels);
|
||||
expect(result.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("partial match returns best model with undefined thinking level", () => {
|
||||
const result = parseModelPattern("sonnet", allModels);
|
||||
expect(result.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("no match returns undefined model and thinking level", () => {
|
||||
const result = parseModelPattern("nonexistent", allModels);
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("patterns with valid thinking levels", () => {
|
||||
test("sonnet:high returns sonnet with high thinking level", () => {
|
||||
const result = parseModelPattern("sonnet:high", allModels);
|
||||
expect(result.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(result.thinkingLevel).toBe("high");
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("gpt-4o:medium returns gpt-4o with medium thinking level", () => {
|
||||
const result = parseModelPattern("gpt-4o:medium", allModels);
|
||||
expect(result.model?.id).toBe("gpt-4o");
|
||||
expect(result.thinkingLevel).toBe("medium");
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("all valid thinking levels work", () => {
|
||||
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) {
|
||||
const result = parseModelPattern(`sonnet:${level}`, allModels);
|
||||
expect(result.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(result.thinkingLevel).toBe(level);
|
||||
expect(result.warning).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("patterns with invalid thinking levels", () => {
|
||||
test("sonnet:random returns sonnet with undefined thinking level and warning", () => {
|
||||
const result = parseModelPattern("sonnet:random", allModels);
|
||||
expect(result.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toContain("Invalid thinking level");
|
||||
expect(result.warning).toContain("random");
|
||||
});
|
||||
|
||||
test("gpt-4o:invalid returns gpt-4o with undefined thinking level and warning", () => {
|
||||
const result = parseModelPattern("gpt-4o:invalid", allModels);
|
||||
expect(result.model?.id).toBe("gpt-4o");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toContain("Invalid thinking level");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenRouter models with colons in IDs", () => {
|
||||
test("qwen3-coder:exacto matches the model with undefined thinking level", () => {
|
||||
const result = parseModelPattern("qwen/qwen3-coder:exacto", allModels);
|
||||
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("openrouter/qwen/qwen3-coder:exacto matches with provider prefix", () => {
|
||||
const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto", allModels);
|
||||
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
|
||||
expect(result.model?.provider).toBe("openrouter");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("qwen3-coder:exacto:high matches model with high thinking level", () => {
|
||||
const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels);
|
||||
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
|
||||
expect(result.thinkingLevel).toBe("high");
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("openrouter/qwen/qwen3-coder:exacto:high matches with provider and thinking level", () => {
|
||||
const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto:high", allModels);
|
||||
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
|
||||
expect(result.model?.provider).toBe("openrouter");
|
||||
expect(result.thinkingLevel).toBe("high");
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("gpt-4o:extended matches the extended model with undefined thinking level", () => {
|
||||
const result = parseModelPattern("openai/gpt-4o:extended", allModels);
|
||||
expect(result.model?.id).toBe("openai/gpt-4o:extended");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid thinking levels with OpenRouter models", () => {
|
||||
test("qwen3-coder:exacto:random returns model with undefined thinking level and warning", () => {
|
||||
const result = parseModelPattern("qwen/qwen3-coder:exacto:random", allModels);
|
||||
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toContain("Invalid thinking level");
|
||||
expect(result.warning).toContain("random");
|
||||
});
|
||||
|
||||
test("qwen3-coder:exacto:high:random returns model with undefined thinking level and warning", () => {
|
||||
const result = parseModelPattern("qwen/qwen3-coder:exacto:high:random", allModels);
|
||||
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
expect(result.warning).toContain("Invalid thinking level");
|
||||
expect(result.warning).toContain("random");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("empty pattern matches via partial matching", () => {
|
||||
// Empty string is included in all model IDs, so partial matching finds a match
|
||||
const result = parseModelPattern("", allModels);
|
||||
expect(result.model).not.toBeNull();
|
||||
expect(result.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
test("pattern ending with colon treats empty suffix as invalid", () => {
|
||||
const result = parseModelPattern("sonnet:", allModels);
|
||||
// Empty string after colon is not a valid thinking level
|
||||
// So it tries to match "sonnet:" which won't match, then tries "sonnet"
|
||||
expect(result.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(result.warning).toContain("Invalid thinking level");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCliModel", () => {
|
||||
test("resolves --model provider/id without --provider", () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliModel: "openai/gpt-4o",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.provider).toBe("openai");
|
||||
expect(result.model?.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
test("resolves fuzzy patterns within an explicit provider", () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliProvider: "openai",
|
||||
cliModel: "4o",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.provider).toBe("openai");
|
||||
expect(result.model?.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
test("supports --model <pattern>:<thinking> (without explicit --thinking)", () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliModel: "sonnet:high",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(result.thinkingLevel).toBe("high");
|
||||
});
|
||||
|
||||
test("prefers exact model id match over provider inference (OpenRouter-style ids)", () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliModel: "openai/gpt-4o:extended",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.provider).toBe("openrouter");
|
||||
expect(result.model?.id).toBe("openai/gpt-4o:extended");
|
||||
});
|
||||
|
||||
test("does not strip invalid :suffix as thinking level in --model (treat as raw id)", () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliProvider: "openai",
|
||||
cliModel: "gpt-4o:extended",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.provider).toBe("openai");
|
||||
expect(result.model?.id).toBe("gpt-4o:extended");
|
||||
});
|
||||
|
||||
test("allows custom model ids for explicit providers without double prefixing", () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliProvider: "openrouter",
|
||||
cliModel: "openrouter/openai/ghost-model",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.provider).toBe("openrouter");
|
||||
expect(result.model?.id).toBe("openai/ghost-model");
|
||||
});
|
||||
|
||||
test("returns a clear error when there are no models", () => {
|
||||
const registry = {
|
||||
getAll: () => [],
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliProvider: "openai",
|
||||
cliModel: "gpt-4o",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toContain("No models available");
|
||||
});
|
||||
|
||||
test("prefers provider/model split over gateway model with matching id", () => {
|
||||
// When a user writes "zai/glm-5", and both a zai provider model (id: "glm-5")
|
||||
// and a gateway model (id: "zai/glm-5") exist, prefer the zai provider model.
|
||||
const zaiModel: Model<"anthropic-messages"> = {
|
||||
id: "glm-5",
|
||||
name: "GLM-5",
|
||||
api: "anthropic-messages",
|
||||
provider: "zai",
|
||||
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
const gatewayModel: Model<"anthropic-messages"> = {
|
||||
id: "zai/glm-5",
|
||||
name: "GLM-5",
|
||||
api: "anthropic-messages",
|
||||
provider: "vercel-ai-gateway",
|
||||
baseUrl: "https://ai-gateway.vercel.sh",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
const registry = {
|
||||
getAll: () => [...allModels, zaiModel, gatewayModel],
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliModel: "zai/glm-5",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.provider).toBe("zai");
|
||||
expect(result.model?.id).toBe("glm-5");
|
||||
});
|
||||
|
||||
test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
|
||||
|
||||
const result = resolveCliModel({
|
||||
cliModel: "openrouter/qwen",
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.provider).toBe("openrouter");
|
||||
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default model selection", () => {
|
||||
test("openai defaults are gpt-5.4", () => {
|
||||
expect(defaultModelPerProvider.openai).toBe("gpt-5.4");
|
||||
expect(defaultModelPerProvider["openai-codex"]).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
test("zai, minimax, and cerebras defaults track current models", () => {
|
||||
expect(defaultModelPerProvider.zai).toBe("glm-5");
|
||||
expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7");
|
||||
expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7");
|
||||
expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7");
|
||||
});
|
||||
|
||||
test("ai-gateway default is opus 4.6", () => {
|
||||
expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("anthropic/claude-opus-4-6");
|
||||
});
|
||||
|
||||
test("findInitialModel accepts explicit provider custom model ids", async () => {
|
||||
const registry = {
|
||||
getAll: () => allModels,
|
||||
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
|
||||
|
||||
const result = await findInitialModel({
|
||||
cliProvider: "openrouter",
|
||||
cliModel: "openrouter/openai/ghost-model",
|
||||
scopedModels: [],
|
||||
isContinuing: false,
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.model?.provider).toBe("openrouter");
|
||||
expect(result.model?.id).toBe("openai/ghost-model");
|
||||
});
|
||||
|
||||
test("findInitialModel selects ai-gateway default when available", async () => {
|
||||
const aiGatewayModel: Model<"anthropic-messages"> = {
|
||||
id: "anthropic/claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
api: "anthropic-messages",
|
||||
provider: "vercel-ai-gateway",
|
||||
baseUrl: "https://ai-gateway.vercel.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
const registry = {
|
||||
getAvailable: async () => [aiGatewayModel],
|
||||
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
|
||||
|
||||
const result = await findInitialModel({
|
||||
scopedModels: [],
|
||||
isContinuing: false,
|
||||
modelRegistry: registry,
|
||||
});
|
||||
|
||||
expect(result.model?.provider).toBe("vercel-ai-gateway");
|
||||
expect(result.model?.id).toBe("anthropic/claude-opus-4-6");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../src/config.js";
|
||||
import { main } from "../src/main.js";
|
||||
|
||||
describe("package commands", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let projectDir: string;
|
||||
let packageDir: string;
|
||||
let originalCwd: string;
|
||||
let originalAgentDir: string | undefined;
|
||||
let originalExitCode: typeof process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
projectDir = join(tempDir, "project");
|
||||
packageDir = join(tempDir, "local-package");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
originalCwd = process.cwd();
|
||||
originalAgentDir = process.env[ENV_AGENT_DIR];
|
||||
originalExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
process.env[ENV_AGENT_DIR] = agentDir;
|
||||
process.chdir(projectDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
process.exitCode = originalExitCode;
|
||||
if (originalAgentDir === undefined) {
|
||||
delete process.env[ENV_AGENT_DIR];
|
||||
} else {
|
||||
process.env[ENV_AGENT_DIR] = originalAgentDir;
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should persist global relative local package paths relative to settings.json", async () => {
|
||||
const relativePkgDir = join(projectDir, "packages", "local-package");
|
||||
mkdirSync(relativePkgDir, { recursive: true });
|
||||
|
||||
await main(["install", "./packages/local-package"]);
|
||||
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
|
||||
expect(settings.packages?.length).toBe(1);
|
||||
const stored = settings.packages?.[0] ?? "";
|
||||
const resolvedFromSettings = realpathSync(join(agentDir, stored));
|
||||
expect(resolvedFromSettings).toBe(realpathSync(relativePkgDir));
|
||||
});
|
||||
|
||||
it("should remove local packages using a path with a trailing slash", async () => {
|
||||
await main(["install", `${packageDir}/`]);
|
||||
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
const installedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
|
||||
expect(installedSettings.packages?.length).toBe(1);
|
||||
|
||||
await main(["remove", `${packageDir}/`]);
|
||||
|
||||
const removedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
|
||||
expect(removedSettings.packages ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows install subcommand help", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["install", "--help"])).resolves.toBeUndefined();
|
||||
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stdout).toContain("Usage:");
|
||||
expect(stdout).toContain("pi install <source> [-l]");
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a friendly error for unknown install options", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["install", "--unknown"])).resolves.toBeUndefined();
|
||||
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stderr).toContain('Unknown option --unknown for "install".');
|
||||
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l]".');
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a friendly error for missing install source", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["install"])).resolves.toBeUndefined();
|
||||
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stderr).toContain("Missing install source.");
|
||||
expect(stderr).toContain("Usage: pi install <source> [-l]");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("suggests the configured source when update input omits the npm prefix", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-formatter"] }, null, 2));
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["update", "pi-formatter"])).resolves.toBeUndefined();
|
||||
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stderr).toContain("Did you mean npm:pi-formatter?");
|
||||
expect(stdout).not.toContain("Updated pi-formatter");
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
|
||||
expect(settings.packages).toContain("npm:pi-formatter");
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DefaultPackageManager } from "../src/core/package-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
describe("Package Manager git source parsing", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let settingsManager: SettingsManager;
|
||||
let packageManager: DefaultPackageManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pm-ssh-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
settingsManager = SettingsManager.inMemory();
|
||||
packageManager = new DefaultPackageManager({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("protocol URLs without git: prefix", () => {
|
||||
it("should parse https:// URL", () => {
|
||||
const parsed = (packageManager as any).parseSource("https://github.com/user/repo");
|
||||
expect(parsed.type).toBe("git");
|
||||
expect(parsed.host).toBe("github.com");
|
||||
expect(parsed.path).toBe("user/repo");
|
||||
});
|
||||
|
||||
it("should parse ssh:// URL", () => {
|
||||
const parsed = (packageManager as any).parseSource("ssh://git@github.com/user/repo");
|
||||
expect(parsed.type).toBe("git");
|
||||
expect(parsed.host).toBe("github.com");
|
||||
expect(parsed.path).toBe("user/repo");
|
||||
expect(parsed.repo).toBe("ssh://git@github.com/user/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shorthand URLs with git: prefix", () => {
|
||||
it("should parse git@host:path format", () => {
|
||||
const parsed = (packageManager as any).parseSource("git:git@github.com:user/repo");
|
||||
expect(parsed.type).toBe("git");
|
||||
expect(parsed.host).toBe("github.com");
|
||||
expect(parsed.path).toBe("user/repo");
|
||||
expect(parsed.repo).toBe("git@github.com:user/repo");
|
||||
expect(parsed.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("should parse host/path shorthand", () => {
|
||||
const parsed = (packageManager as any).parseSource("git:github.com/user/repo");
|
||||
expect(parsed.type).toBe("git");
|
||||
expect(parsed.host).toBe("github.com");
|
||||
expect(parsed.path).toBe("user/repo");
|
||||
});
|
||||
|
||||
it("should parse shorthand with ref", () => {
|
||||
const parsed = (packageManager as any).parseSource("git:git@github.com:user/repo@v1.0.0");
|
||||
expect(parsed.type).toBe("git");
|
||||
expect(parsed.ref).toBe("v1.0.0");
|
||||
expect(parsed.pinned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsupported without git: prefix", () => {
|
||||
it("should treat git@host:path as local without git: prefix", () => {
|
||||
const parsed = (packageManager as any).parseSource("git@github.com:user/repo");
|
||||
expect(parsed.type).toBe("local");
|
||||
});
|
||||
|
||||
it("should treat host/path shorthand as local without git: prefix", () => {
|
||||
const parsed = (packageManager as any).parseSource("github.com/user/repo");
|
||||
expect(parsed.type).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity normalization", () => {
|
||||
it("should normalize protocol and shorthand-prefixed URLs to same identity", () => {
|
||||
const prefixed = (packageManager as any).getPackageIdentity("git:git@github.com:user/repo");
|
||||
const https = (packageManager as any).getPackageIdentity("https://github.com/user/repo");
|
||||
const ssh = (packageManager as any).getPackageIdentity("ssh://git@github.com/user/repo");
|
||||
|
||||
expect(prefixed).toBe("git:github.com/user/repo");
|
||||
expect(prefixed).toBe(https);
|
||||
expect(prefixed).toBe(ssh);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,162 @@
|
||||
import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.js";
|
||||
|
||||
describe("path-utils", () => {
|
||||
describe("expandPath", () => {
|
||||
it("should expand ~ to home directory", () => {
|
||||
const result = expandPath("~");
|
||||
expect(result).not.toContain("~");
|
||||
});
|
||||
|
||||
it("should expand ~/path to home directory", () => {
|
||||
const result = expandPath("~/Documents/file.txt");
|
||||
expect(result).not.toContain("~/");
|
||||
});
|
||||
|
||||
it("should normalize Unicode spaces", () => {
|
||||
// Non-breaking space (U+00A0) should become regular space
|
||||
const withNBSP = "file\u00A0name.txt";
|
||||
const result = expandPath(withNBSP);
|
||||
expect(result).toBe("file name.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveToCwd", () => {
|
||||
it("should resolve absolute paths as-is", () => {
|
||||
const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd");
|
||||
expect(result).toBe("/absolute/path/file.txt");
|
||||
});
|
||||
|
||||
it("should resolve relative paths against cwd", () => {
|
||||
const result = resolveToCwd("relative/file.txt", "/some/cwd");
|
||||
expect(result).toBe(resolve("/some/cwd", "relative/file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReadPath", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "path-utils-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp files and directory
|
||||
try {
|
||||
const files = readdirSync(tempDir);
|
||||
for (const file of files) {
|
||||
unlinkSync(join(tempDir, file));
|
||||
}
|
||||
rmdirSync(tempDir);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("should resolve existing file path", () => {
|
||||
const fileName = "test-file.txt";
|
||||
writeFileSync(join(tempDir, fileName), "content");
|
||||
|
||||
const result = resolveReadPath(fileName, tempDir);
|
||||
expect(result).toBe(join(tempDir, fileName));
|
||||
});
|
||||
|
||||
it("should handle NFC vs NFD Unicode normalization (macOS filenames with accents)", () => {
|
||||
// macOS stores filenames in NFD (decomposed) form:
|
||||
// é = e + combining acute accent (U+0301)
|
||||
// Users typically type in NFC (composed) form:
|
||||
// é = single character (U+00E9)
|
||||
//
|
||||
// Note: macOS APFS normalizes Unicode automatically, so both paths work.
|
||||
// This test verifies the NFD variant fallback works on systems that don't.
|
||||
|
||||
// NFD: e (U+0065) + combining acute accent (U+0301)
|
||||
const nfdFileName = "file\u0065\u0301.txt";
|
||||
// NFC: é as single character (U+00E9)
|
||||
const nfcFileName = "file\u00e9.txt";
|
||||
|
||||
// Verify they have different byte sequences
|
||||
expect(nfdFileName).not.toBe(nfcFileName);
|
||||
expect(Buffer.from(nfdFileName)).not.toEqual(Buffer.from(nfcFileName));
|
||||
|
||||
// Create file with NFD name
|
||||
writeFileSync(join(tempDir, nfdFileName), "content");
|
||||
|
||||
// User provides NFC path - should find the file (via filesystem normalization or our fallback)
|
||||
const result = resolveReadPath(nfcFileName, tempDir);
|
||||
// Result should contain the accented character (either NFC or NFD form)
|
||||
expect(result).toContain(tempDir);
|
||||
expect(result).toMatch(/file.+\.txt$/);
|
||||
});
|
||||
|
||||
it("should handle curly quotes vs straight quotes (macOS filenames)", () => {
|
||||
// macOS uses curly apostrophe (U+2019) in screenshot filenames:
|
||||
// Capture d'écran (U+2019)
|
||||
// Users typically type straight apostrophe (U+0027):
|
||||
// Capture d'ecran (U+0027)
|
||||
|
||||
const curlyQuoteName = "Capture d\u2019cran.txt"; // U+2019 right single quotation mark
|
||||
const straightQuoteName = "Capture d'cran.txt"; // U+0027 apostrophe
|
||||
|
||||
// Verify they are different
|
||||
expect(curlyQuoteName).not.toBe(straightQuoteName);
|
||||
|
||||
// Create file with curly quote name (simulating macOS behavior)
|
||||
writeFileSync(join(tempDir, curlyQuoteName), "content");
|
||||
|
||||
// User provides straight quote path - should find the curly quote file
|
||||
const result = resolveReadPath(straightQuoteName, tempDir);
|
||||
expect(result).toBe(join(tempDir, curlyQuoteName));
|
||||
});
|
||||
|
||||
it("should handle combined NFC + curly quote (French macOS screenshots)", () => {
|
||||
// Full macOS screenshot filename: "Capture d'écran" with NFD é and curly quote
|
||||
// Note: macOS APFS normalizes NFD to NFC, so the actual file on disk uses NFC
|
||||
const nfcCurlyName = "Capture d\u2019\u00e9cran.txt"; // NFC + curly quote (how APFS stores it)
|
||||
const nfcStraightName = "Capture d'\u00e9cran.txt"; // NFC + straight quote (user input)
|
||||
|
||||
// Verify they are different
|
||||
expect(nfcCurlyName).not.toBe(nfcStraightName);
|
||||
|
||||
// Create file with macOS-style name (curly quote)
|
||||
writeFileSync(join(tempDir, nfcCurlyName), "content");
|
||||
|
||||
// User provides straight quote path - should find the curly quote file
|
||||
const result = resolveReadPath(nfcStraightName, tempDir);
|
||||
expect(result).toBe(join(tempDir, nfcCurlyName));
|
||||
});
|
||||
|
||||
it("should handle macOS screenshot AM/PM variant with narrow no-break space", () => {
|
||||
// macOS uses narrow no-break space (U+202F) before AM/PM in screenshot names
|
||||
const macosName = "Screenshot 2024-01-01 at 10.00.00\u202FAM.png"; // U+202F
|
||||
const userName = "Screenshot 2024-01-01 at 10.00.00 AM.png"; // regular space
|
||||
|
||||
// Create file with macOS-style name
|
||||
writeFileSync(join(tempDir, macosName), "content");
|
||||
|
||||
// User provides regular space path
|
||||
const result = resolveReadPath(userName, tempDir);
|
||||
|
||||
// This works because tryMacOSScreenshotPath() handles this case
|
||||
expect(result).toBe(join(tempDir, macosName));
|
||||
});
|
||||
|
||||
it("should handle macOS screenshot lowercase am/pm variant (en_AU locale)", () => {
|
||||
// Some locales like en_AU use lowercase am/pm in screenshot names
|
||||
const macosName = "Screenshot 2024-01-01 at 10.00.00\u202Fam.png"; // U+202F + lowercase
|
||||
const userName = "Screenshot 2024-01-01 at 10.00.00 am.png"; // regular space + lowercase
|
||||
|
||||
// Create file with macOS-style name
|
||||
writeFileSync(join(tempDir, macosName), "content");
|
||||
|
||||
// User provides regular space path
|
||||
const result = resolveReadPath(userName, tempDir);
|
||||
|
||||
// This works because tryMacOSScreenshotPath() uses case-insensitive matching
|
||||
expect(result).toBe(join(tempDir, macosName));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanStepText,
|
||||
extractDoneSteps,
|
||||
extractTodoItems,
|
||||
isSafeCommand,
|
||||
markCompletedSteps,
|
||||
type TodoItem,
|
||||
} from "../examples/extensions/plan-mode/utils.js";
|
||||
|
||||
describe("isSafeCommand", () => {
|
||||
describe("safe commands", () => {
|
||||
it("allows basic read commands", () => {
|
||||
expect(isSafeCommand("ls -la")).toBe(true);
|
||||
expect(isSafeCommand("cat file.txt")).toBe(true);
|
||||
expect(isSafeCommand("head -n 10 file.txt")).toBe(true);
|
||||
expect(isSafeCommand("tail -f log.txt")).toBe(true);
|
||||
expect(isSafeCommand("grep pattern file")).toBe(true);
|
||||
expect(isSafeCommand("find . -name '*.ts'")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows git read commands", () => {
|
||||
expect(isSafeCommand("git status")).toBe(true);
|
||||
expect(isSafeCommand("git log --oneline")).toBe(true);
|
||||
expect(isSafeCommand("git diff")).toBe(true);
|
||||
expect(isSafeCommand("git branch")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows npm/yarn read commands", () => {
|
||||
expect(isSafeCommand("npm list")).toBe(true);
|
||||
expect(isSafeCommand("npm outdated")).toBe(true);
|
||||
expect(isSafeCommand("yarn info react")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows other safe commands", () => {
|
||||
expect(isSafeCommand("pwd")).toBe(true);
|
||||
expect(isSafeCommand("echo hello")).toBe(true);
|
||||
expect(isSafeCommand("wc -l file.txt")).toBe(true);
|
||||
expect(isSafeCommand("du -sh .")).toBe(true);
|
||||
expect(isSafeCommand("df -h")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destructive commands", () => {
|
||||
it("blocks file modification commands", () => {
|
||||
expect(isSafeCommand("rm file.txt")).toBe(false);
|
||||
expect(isSafeCommand("rm -rf dir")).toBe(false);
|
||||
expect(isSafeCommand("mv old new")).toBe(false);
|
||||
expect(isSafeCommand("cp src dst")).toBe(false);
|
||||
expect(isSafeCommand("mkdir newdir")).toBe(false);
|
||||
expect(isSafeCommand("touch newfile")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks git write commands", () => {
|
||||
expect(isSafeCommand("git add .")).toBe(false);
|
||||
expect(isSafeCommand("git commit -m 'msg'")).toBe(false);
|
||||
expect(isSafeCommand("git push")).toBe(false);
|
||||
expect(isSafeCommand("git checkout main")).toBe(false);
|
||||
expect(isSafeCommand("git reset --hard")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks package manager installs", () => {
|
||||
expect(isSafeCommand("npm install lodash")).toBe(false);
|
||||
expect(isSafeCommand("yarn add react")).toBe(false);
|
||||
expect(isSafeCommand("pip install requests")).toBe(false);
|
||||
expect(isSafeCommand("brew install node")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks redirects", () => {
|
||||
expect(isSafeCommand("echo hello > file.txt")).toBe(false);
|
||||
expect(isSafeCommand("cat foo >> bar")).toBe(false);
|
||||
expect(isSafeCommand(">file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks dangerous commands", () => {
|
||||
expect(isSafeCommand("sudo rm -rf /")).toBe(false);
|
||||
expect(isSafeCommand("kill -9 1234")).toBe(false);
|
||||
expect(isSafeCommand("reboot")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks editors", () => {
|
||||
expect(isSafeCommand("vim file.txt")).toBe(false);
|
||||
expect(isSafeCommand("nano file.txt")).toBe(false);
|
||||
expect(isSafeCommand("code .")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("requires command to be in safe list (not just non-destructive)", () => {
|
||||
expect(isSafeCommand("unknown-command")).toBe(false);
|
||||
expect(isSafeCommand("my-script.sh")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles commands with leading whitespace", () => {
|
||||
expect(isSafeCommand(" ls -la")).toBe(true);
|
||||
expect(isSafeCommand(" rm file")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanStepText", () => {
|
||||
it("removes markdown bold/italic", () => {
|
||||
expect(cleanStepText("**bold text**")).toBe("Bold text");
|
||||
expect(cleanStepText("*italic text*")).toBe("Italic text");
|
||||
});
|
||||
|
||||
it("removes markdown code", () => {
|
||||
expect(cleanStepText("run `npm install`")).toBe("Npm install"); // "run" is stripped as action word
|
||||
expect(cleanStepText("check the `config.json` file")).toBe("Config.json file");
|
||||
});
|
||||
|
||||
it("removes leading action words", () => {
|
||||
expect(cleanStepText("Create the new file")).toBe("New file");
|
||||
expect(cleanStepText("Run the tests")).toBe("Tests");
|
||||
expect(cleanStepText("Check the status")).toBe("Status");
|
||||
});
|
||||
|
||||
it("capitalizes first letter", () => {
|
||||
expect(cleanStepText("update config")).toBe("Config");
|
||||
});
|
||||
|
||||
it("truncates long text", () => {
|
||||
const longText = "This is a very long step description that exceeds the maximum allowed length for display";
|
||||
const result = cleanStepText(longText);
|
||||
expect(result.length).toBe(50);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes whitespace", () => {
|
||||
expect(cleanStepText("multiple spaces here")).toBe("Multiple spaces here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTodoItems", () => {
|
||||
it("extracts numbered items after Plan: header", () => {
|
||||
const message = `Here's what we'll do:
|
||||
|
||||
Plan:
|
||||
1. First step here
|
||||
2. Second step here
|
||||
3. Third step here`;
|
||||
|
||||
const items = extractTodoItems(message);
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].step).toBe(1);
|
||||
expect(items[0].text).toBe("First step here");
|
||||
expect(items[0].completed).toBe(false);
|
||||
});
|
||||
|
||||
it("handles bold Plan header", () => {
|
||||
const message = `**Plan:**
|
||||
1. Do something`;
|
||||
|
||||
const items = extractTodoItems(message);
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles parenthesis-style numbering", () => {
|
||||
const message = `Plan:
|
||||
1) First item
|
||||
2) Second item`;
|
||||
|
||||
const items = extractTodoItems(message);
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array without Plan header", () => {
|
||||
const message = `Here are some steps:
|
||||
1. First step
|
||||
2. Second step`;
|
||||
|
||||
const items = extractTodoItems(message);
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters out short items", () => {
|
||||
const message = `Plan:
|
||||
1. OK
|
||||
2. This is a proper step`;
|
||||
|
||||
const items = extractTodoItems(message);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].text).toContain("proper");
|
||||
});
|
||||
|
||||
it("filters out code-like items", () => {
|
||||
const message = `Plan:
|
||||
1. \`npm install\`
|
||||
2. Run the build process`;
|
||||
|
||||
const items = extractTodoItems(message);
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDoneSteps", () => {
|
||||
it("extracts single DONE marker", () => {
|
||||
const message = "I've completed the first step [DONE:1]";
|
||||
expect(extractDoneSteps(message)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("extracts multiple DONE markers", () => {
|
||||
const message = "Did steps [DONE:1] and [DONE:2] and [DONE:3]";
|
||||
expect(extractDoneSteps(message)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("handles case insensitivity", () => {
|
||||
const message = "[done:1] [DONE:2] [Done:3]";
|
||||
expect(extractDoneSteps(message)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("returns empty array with no markers", () => {
|
||||
const message = "No markers here";
|
||||
expect(extractDoneSteps(message)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores malformed markers", () => {
|
||||
const message = "[DONE:abc] [DONE:] [DONE:1]";
|
||||
expect(extractDoneSteps(message)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markCompletedSteps", () => {
|
||||
it("marks matching items as completed", () => {
|
||||
const items: TodoItem[] = [
|
||||
{ step: 1, text: "First", completed: false },
|
||||
{ step: 2, text: "Second", completed: false },
|
||||
{ step: 3, text: "Third", completed: false },
|
||||
];
|
||||
|
||||
const count = markCompletedSteps("[DONE:1] [DONE:3]", items);
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(items[0].completed).toBe(true);
|
||||
expect(items[1].completed).toBe(false);
|
||||
expect(items[2].completed).toBe(true);
|
||||
});
|
||||
|
||||
it("returns count of completed items", () => {
|
||||
const items: TodoItem[] = [{ step: 1, text: "First", completed: false }];
|
||||
|
||||
expect(markCompletedSteps("[DONE:1]", items)).toBe(1);
|
||||
expect(markCompletedSteps("no markers", items)).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores markers for non-existent steps", () => {
|
||||
const items: TodoItem[] = [{ step: 1, text: "First", completed: false }];
|
||||
|
||||
const count = markCompletedSteps("[DONE:99]", items);
|
||||
|
||||
expect(count).toBe(1); // Still counts the marker found
|
||||
expect(items[0].completed).toBe(false); // But doesn't mark anything
|
||||
});
|
||||
|
||||
it("doesn't double-complete already completed items", () => {
|
||||
const items: TodoItem[] = [{ step: 1, text: "First", completed: true }];
|
||||
|
||||
markCompletedSteps("[DONE:1]", items);
|
||||
expect(items[0].completed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runPrintMode } from "../src/modes/print-mode.js";
|
||||
|
||||
type EmitEvent = { type: string };
|
||||
|
||||
type FakeExtensionRunner = {
|
||||
hasHandlers: (eventType: string) => boolean;
|
||||
emit: ReturnType<typeof vi.fn<(event: EmitEvent) => Promise<void>>>;
|
||||
};
|
||||
|
||||
type FakeSession = {
|
||||
sessionManager: { getHeader: () => object | undefined };
|
||||
agent: { waitForIdle: () => Promise<void> };
|
||||
state: { messages: AssistantMessage[] };
|
||||
extensionRunner: FakeExtensionRunner;
|
||||
bindExtensions: ReturnType<typeof vi.fn>;
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
prompt: ReturnType<typeof vi.fn>;
|
||||
reload: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type FakeRuntimeHost = {
|
||||
session: FakeSession;
|
||||
newSession: ReturnType<typeof vi.fn>;
|
||||
fork: ReturnType<typeof vi.fn>;
|
||||
switchSession: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createAssistantMessage(options?: {
|
||||
text?: string;
|
||||
stopReason?: AssistantMessage["stopReason"];
|
||||
errorMessage?: string;
|
||||
}): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: options?.text ? [{ type: "text", text: options.text }] : [],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: options?.stopReason ?? "stop",
|
||||
errorMessage: options?.errorMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeHost(assistantMessage: AssistantMessage): FakeRuntimeHost {
|
||||
const extensionRunner: FakeExtensionRunner = {
|
||||
hasHandlers: (eventType: string) => eventType === "session_shutdown",
|
||||
emit: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const state = { messages: [assistantMessage] };
|
||||
|
||||
const session: FakeSession = {
|
||||
sessionManager: { getHeader: () => undefined },
|
||||
agent: { waitForIdle: async () => {} },
|
||||
state,
|
||||
extensionRunner,
|
||||
bindExtensions: vi.fn(async () => {}),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
prompt: vi.fn(async () => {}),
|
||||
reload: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
newSession: vi.fn(async () => undefined),
|
||||
fork: vi.fn(async () => ({ selectedText: "" })),
|
||||
switchSession: vi.fn(async () => undefined),
|
||||
dispose: vi.fn(async () => {
|
||||
await session.extensionRunner.emit({ type: "session_shutdown" });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("runPrintMode", () => {
|
||||
it("emits session_shutdown in text mode", async () => {
|
||||
const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }));
|
||||
const { session } = runtimeHost;
|
||||
const images: ImageContent[] = [{ type: "image", mimeType: "image/png", data: "abc" }];
|
||||
|
||||
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
|
||||
mode: "text",
|
||||
initialMessage: "Say done",
|
||||
initialImages: images,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(session.prompt).toHaveBeenCalledWith("Say done", { images });
|
||||
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
|
||||
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown" });
|
||||
});
|
||||
|
||||
it("emits session_shutdown in json mode", async () => {
|
||||
const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }));
|
||||
const { session } = runtimeHost;
|
||||
|
||||
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
|
||||
mode: "json",
|
||||
messages: ["hello"],
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(session.prompt).toHaveBeenCalledWith("hello");
|
||||
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
|
||||
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown" });
|
||||
});
|
||||
|
||||
it("emits session_shutdown and returns non-zero on assistant error", async () => {
|
||||
const runtimeHost = createRuntimeHost(
|
||||
createAssistantMessage({ stopReason: "error", errorMessage: "provider failure" }),
|
||||
);
|
||||
const { session } = runtimeHost;
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
|
||||
mode: "text",
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith("provider failure");
|
||||
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
|
||||
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Tests for prompt template argument parsing and substitution.
|
||||
*
|
||||
* Tests verify:
|
||||
* - Argument parsing with quotes and special characters
|
||||
* - Placeholder substitution ($1, $2, $@, $ARGUMENTS)
|
||||
* - No recursive substitution of patterns in argument values
|
||||
* - Edge cases and integration between parsing and substitution
|
||||
*/
|
||||
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterAll, describe, expect, test } from "vitest";
|
||||
import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
|
||||
|
||||
// ============================================================================
|
||||
// substituteArgs
|
||||
// ============================================================================
|
||||
|
||||
describe("substituteArgs", () => {
|
||||
test("should replace $ARGUMENTS with all args joined", () => {
|
||||
expect(substituteArgs("Test: $ARGUMENTS", ["a", "b", "c"])).toBe("Test: a b c");
|
||||
});
|
||||
|
||||
test("should replace $@ with all args joined", () => {
|
||||
expect(substituteArgs("Test: $@", ["a", "b", "c"])).toBe("Test: a b c");
|
||||
});
|
||||
|
||||
test("should replace $@ and $ARGUMENTS identically", () => {
|
||||
const args = ["foo", "bar", "baz"];
|
||||
expect(substituteArgs("Test: $@", args)).toBe(substituteArgs("Test: $ARGUMENTS", args));
|
||||
});
|
||||
|
||||
// CRITICAL: argument values containing patterns should remain literal
|
||||
test("should NOT recursively substitute patterns in argument values", () => {
|
||||
expect(substituteArgs("$ARGUMENTS", ["$1", "$ARGUMENTS"])).toBe("$1 $ARGUMENTS");
|
||||
expect(substituteArgs("$@", ["$100", "$1"])).toBe("$100 $1");
|
||||
expect(substituteArgs("$ARGUMENTS", ["$100", "$1"])).toBe("$100 $1");
|
||||
});
|
||||
|
||||
test("should support mixed $1, $2, and $ARGUMENTS", () => {
|
||||
expect(substituteArgs("$1: $ARGUMENTS", ["prefix", "a", "b"])).toBe("prefix: prefix a b");
|
||||
});
|
||||
|
||||
test("should support mixed $1, $2, and $@", () => {
|
||||
expect(substituteArgs("$1: $@", ["prefix", "a", "b"])).toBe("prefix: prefix a b");
|
||||
});
|
||||
|
||||
test("should handle empty arguments array with $ARGUMENTS", () => {
|
||||
expect(substituteArgs("Test: $ARGUMENTS", [])).toBe("Test: ");
|
||||
});
|
||||
|
||||
test("should handle empty arguments array with $@", () => {
|
||||
expect(substituteArgs("Test: $@", [])).toBe("Test: ");
|
||||
});
|
||||
|
||||
test("should handle empty arguments array with $1", () => {
|
||||
expect(substituteArgs("Test: $1", [])).toBe("Test: ");
|
||||
});
|
||||
|
||||
test("should handle multiple occurrences of $ARGUMENTS", () => {
|
||||
expect(substituteArgs("$ARGUMENTS and $ARGUMENTS", ["a", "b"])).toBe("a b and a b");
|
||||
});
|
||||
|
||||
test("should handle multiple occurrences of $@", () => {
|
||||
expect(substituteArgs("$@ and $@", ["a", "b"])).toBe("a b and a b");
|
||||
});
|
||||
|
||||
test("should handle mixed occurrences of $@ and $ARGUMENTS", () => {
|
||||
expect(substituteArgs("$@ and $ARGUMENTS", ["a", "b"])).toBe("a b and a b");
|
||||
});
|
||||
|
||||
test("should handle special characters in arguments", () => {
|
||||
// Note: $100 in argument doesn't get partially matched - full strings are substituted
|
||||
expect(substituteArgs("$1 $2: $ARGUMENTS", ["arg100", "@user"])).toBe("arg100 @user: arg100 @user");
|
||||
});
|
||||
|
||||
test("should handle out-of-range numbered placeholders", () => {
|
||||
// Note: Out-of-range placeholders become empty strings (preserving spaces from template)
|
||||
expect(substituteArgs("$1 $2 $3 $4 $5", ["a", "b"])).toBe("a b ");
|
||||
});
|
||||
|
||||
test("should handle unicode characters", () => {
|
||||
expect(substituteArgs("$ARGUMENTS", ["日本語", "🎉", "café"])).toBe("日本語 🎉 café");
|
||||
});
|
||||
|
||||
test("should preserve newlines and tabs in argument values", () => {
|
||||
expect(substituteArgs("$1 $2", ["line1\nline2", "tab\tthere"])).toBe("line1\nline2 tab\tthere");
|
||||
});
|
||||
|
||||
test("should handle consecutive dollar patterns", () => {
|
||||
expect(substituteArgs("$1$2", ["a", "b"])).toBe("ab");
|
||||
});
|
||||
|
||||
test("should handle quoted arguments with spaces", () => {
|
||||
expect(substituteArgs("$ARGUMENTS", ["first arg", "second arg"])).toBe("first arg second arg");
|
||||
});
|
||||
|
||||
test("should handle single argument with $ARGUMENTS", () => {
|
||||
expect(substituteArgs("Test: $ARGUMENTS", ["only"])).toBe("Test: only");
|
||||
});
|
||||
|
||||
test("should handle single argument with $@", () => {
|
||||
expect(substituteArgs("Test: $@", ["only"])).toBe("Test: only");
|
||||
});
|
||||
|
||||
test("should handle $0 (zero index)", () => {
|
||||
expect(substituteArgs("$0", ["a", "b"])).toBe("");
|
||||
});
|
||||
|
||||
test("should handle decimal number in pattern (only integer part matches)", () => {
|
||||
expect(substituteArgs("$1.5", ["a"])).toBe("a.5");
|
||||
});
|
||||
|
||||
test("should handle $ARGUMENTS as part of word", () => {
|
||||
expect(substituteArgs("pre$ARGUMENTS", ["a", "b"])).toBe("prea b");
|
||||
});
|
||||
|
||||
test("should handle $@ as part of word", () => {
|
||||
expect(substituteArgs("pre$@", ["a", "b"])).toBe("prea b");
|
||||
});
|
||||
|
||||
test("should handle empty arguments in middle of list", () => {
|
||||
expect(substituteArgs("$ARGUMENTS", ["a", "", "c"])).toBe("a c");
|
||||
});
|
||||
|
||||
test("should handle trailing and leading spaces in arguments", () => {
|
||||
expect(substituteArgs("$ARGUMENTS", [" leading ", "trailing "])).toBe(" leading trailing ");
|
||||
});
|
||||
|
||||
test("should handle argument containing pattern partially", () => {
|
||||
expect(substituteArgs("Prefix $ARGUMENTS suffix", ["ARGUMENTS"])).toBe("Prefix ARGUMENTS suffix");
|
||||
});
|
||||
|
||||
test("should handle non-matching patterns", () => {
|
||||
expect(substituteArgs("$A $$ $ $ARGS", ["a"])).toBe("$A $$ $ $ARGS");
|
||||
});
|
||||
|
||||
test("should handle case variations (case-sensitive)", () => {
|
||||
expect(substituteArgs("$arguments $Arguments $ARGUMENTS", ["a", "b"])).toBe("$arguments $Arguments a b");
|
||||
});
|
||||
|
||||
test("should handle both syntaxes in same command with same result", () => {
|
||||
const args = ["x", "y", "z"];
|
||||
const result1 = substituteArgs("$@ and $ARGUMENTS", args);
|
||||
const result2 = substituteArgs("$ARGUMENTS and $@", args);
|
||||
expect(result1).toBe(result2);
|
||||
expect(result1).toBe("x y z and x y z");
|
||||
});
|
||||
|
||||
test("should handle very long argument lists", () => {
|
||||
const args = Array.from({ length: 100 }, (_, i) => `arg${i}`);
|
||||
const result = substituteArgs("$ARGUMENTS", args);
|
||||
expect(result).toBe(args.join(" "));
|
||||
});
|
||||
|
||||
test("should handle numbered placeholders with single digit", () => {
|
||||
expect(substituteArgs("$1 $2 $3", ["a", "b", "c"])).toBe("a b c");
|
||||
});
|
||||
|
||||
test("should handle numbered placeholders with multiple digits", () => {
|
||||
const args = Array.from({ length: 15 }, (_, i) => `val${i}`);
|
||||
expect(substituteArgs("$10 $12 $15", args)).toBe("val9 val11 val14");
|
||||
});
|
||||
|
||||
test("should handle escaped dollar signs (literal backslash preserved)", () => {
|
||||
// Note: No escape mechanism exists - backslash is treated literally
|
||||
expect(substituteArgs("Price: \\$100", [])).toBe("Price: \\");
|
||||
});
|
||||
|
||||
test("should handle mixed numbered and wildcard placeholders", () => {
|
||||
expect(substituteArgs("$1: $@ ($ARGUMENTS)", ["first", "second", "third"])).toBe(
|
||||
"first: first second third (first second third)",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle command with no placeholders", () => {
|
||||
expect(substituteArgs("Just plain text", ["a", "b"])).toBe("Just plain text");
|
||||
});
|
||||
|
||||
test("should handle command with only placeholders", () => {
|
||||
expect(substituteArgs("$1 $2 $@", ["a", "b", "c"])).toBe("a b a b c");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// substituteArgs - Array Slicing (Bash-Style)
|
||||
// ============================================================================
|
||||
|
||||
describe("substituteArgs - array slicing", () => {
|
||||
test(`should slice from index (\${@:N})`, () => {
|
||||
expect(substituteArgs(`\${@:2}`, ["a", "b", "c", "d"])).toBe("b c d");
|
||||
expect(substituteArgs(`\${@:1}`, ["a", "b", "c"])).toBe("a b c");
|
||||
expect(substituteArgs(`\${@:3}`, ["a", "b", "c", "d"])).toBe("c d");
|
||||
});
|
||||
|
||||
test(`should slice with length (\${@:N:L})`, () => {
|
||||
expect(substituteArgs(`\${@:2:2}`, ["a", "b", "c", "d"])).toBe("b c");
|
||||
expect(substituteArgs(`\${@:1:1}`, ["a", "b", "c"])).toBe("a");
|
||||
expect(substituteArgs(`\${@:3:1}`, ["a", "b", "c", "d"])).toBe("c");
|
||||
expect(substituteArgs(`\${@:2:3}`, ["a", "b", "c", "d", "e"])).toBe("b c d");
|
||||
});
|
||||
|
||||
test("should handle out of range slices", () => {
|
||||
expect(substituteArgs(`\${@:99}`, ["a", "b"])).toBe("");
|
||||
expect(substituteArgs(`\${@:5}`, ["a", "b"])).toBe("");
|
||||
expect(substituteArgs(`\${@:10:5}`, ["a", "b"])).toBe("");
|
||||
});
|
||||
|
||||
test("should handle zero-length slices", () => {
|
||||
expect(substituteArgs(`\${@:2:0}`, ["a", "b", "c"])).toBe("");
|
||||
expect(substituteArgs(`\${@:1:0}`, ["a", "b"])).toBe("");
|
||||
});
|
||||
|
||||
test("should handle length exceeding array", () => {
|
||||
expect(substituteArgs(`\${@:2:99}`, ["a", "b", "c"])).toBe("b c");
|
||||
expect(substituteArgs(`\${@:1:10}`, ["a", "b"])).toBe("a b");
|
||||
});
|
||||
|
||||
test("should process slice before simple $@", () => {
|
||||
expect(substituteArgs(`\${@:2} vs $@`, ["a", "b", "c"])).toBe("b c vs a b c");
|
||||
expect(substituteArgs(`First: \${@:1:1}, All: $@`, ["x", "y", "z"])).toBe("First: x, All: x y z");
|
||||
});
|
||||
|
||||
test("should not recursively substitute slice patterns in args", () => {
|
||||
expect(substituteArgs(`\${@:1}`, [`\${@:2}`, "test"])).toBe(`\${@:2} test`);
|
||||
expect(substituteArgs(`\${@:2}`, ["a", `\${@:3}`, "c"])).toBe(`\${@:3} c`);
|
||||
});
|
||||
|
||||
test("should handle mixed usage with positional args", () => {
|
||||
expect(substituteArgs(`$1: \${@:2}`, ["cmd", "arg1", "arg2"])).toBe("cmd: arg1 arg2");
|
||||
expect(substituteArgs(`$1 $2 \${@:3}`, ["a", "b", "c", "d"])).toBe("a b c d");
|
||||
});
|
||||
|
||||
test(`should treat \${@:0} as all args`, () => {
|
||||
expect(substituteArgs(`\${@:0}`, ["a", "b", "c"])).toBe("a b c");
|
||||
});
|
||||
|
||||
test("should handle empty args array", () => {
|
||||
expect(substituteArgs(`\${@:2}`, [])).toBe("");
|
||||
expect(substituteArgs(`\${@:1}`, [])).toBe("");
|
||||
});
|
||||
|
||||
test("should handle single arg array", () => {
|
||||
expect(substituteArgs(`\${@:1}`, ["only"])).toBe("only");
|
||||
expect(substituteArgs(`\${@:2}`, ["only"])).toBe("");
|
||||
});
|
||||
|
||||
test("should handle slice in middle of text", () => {
|
||||
expect(substituteArgs(`Process \${@:2} with $1`, ["tool", "file1", "file2"])).toBe(
|
||||
"Process file1 file2 with tool",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle multiple slices in one template", () => {
|
||||
expect(substituteArgs(`\${@:1:1} and \${@:2}`, ["a", "b", "c"])).toBe("a and b c");
|
||||
expect(substituteArgs(`\${@:1:2} vs \${@:3:2}`, ["a", "b", "c", "d", "e"])).toBe("a b vs c d");
|
||||
});
|
||||
|
||||
test("should handle quoted arguments in slices", () => {
|
||||
expect(substituteArgs(`\${@:2}`, ["cmd", "first arg", "second arg"])).toBe("first arg second arg");
|
||||
});
|
||||
|
||||
test("should handle special characters in sliced args", () => {
|
||||
expect(substituteArgs(`\${@:2}`, ["cmd", "$100", "@user", "#tag"])).toBe("$100 @user #tag");
|
||||
});
|
||||
|
||||
test("should handle unicode in sliced args", () => {
|
||||
expect(substituteArgs(`\${@:1}`, ["日本語", "🎉", "café"])).toBe("日本語 🎉 café");
|
||||
});
|
||||
|
||||
test("should combine positional, slice, and wildcard placeholders", () => {
|
||||
const template = `Run $1 on \${@:2:2}, then process $@`;
|
||||
const args = ["eslint", "file1.ts", "file2.ts", "file3.ts"];
|
||||
expect(substituteArgs(template, args)).toBe(
|
||||
"Run eslint on file1.ts file2.ts, then process eslint file1.ts file2.ts file3.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle slice with no spacing", () => {
|
||||
expect(substituteArgs(`prefix\${@:2}suffix`, ["a", "b", "c"])).toBe("prefixb csuffix");
|
||||
});
|
||||
|
||||
test("should handle large slice lengths gracefully", () => {
|
||||
const args = Array.from({ length: 10 }, (_, i) => `arg${i + 1}`);
|
||||
expect(substituteArgs(`\${@:5:100}`, args)).toBe("arg5 arg6 arg7 arg8 arg9 arg10");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// parseCommandArgs
|
||||
// ============================================================================
|
||||
|
||||
describe("parseCommandArgs", () => {
|
||||
test("should parse simple space-separated arguments", () => {
|
||||
expect(parseCommandArgs("a b c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("should parse quoted arguments with spaces", () => {
|
||||
expect(parseCommandArgs('"first arg" second')).toEqual(["first arg", "second"]);
|
||||
});
|
||||
|
||||
test("should parse single-quoted arguments", () => {
|
||||
expect(parseCommandArgs("'first arg' second")).toEqual(["first arg", "second"]);
|
||||
});
|
||||
|
||||
test("should parse mixed quote styles", () => {
|
||||
expect(parseCommandArgs('"double" \'single\' "double again"')).toEqual(["double", "single", "double again"]);
|
||||
});
|
||||
|
||||
test("should handle empty string", () => {
|
||||
expect(parseCommandArgs("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle extra spaces", () => {
|
||||
expect(parseCommandArgs("a b c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("should handle tabs as separators", () => {
|
||||
expect(parseCommandArgs("a\tb\tc")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("should handle quoted empty string", () => {
|
||||
// Note: Empty quotes are skipped by current implementation
|
||||
expect(parseCommandArgs('"" " "')).toEqual([" "]);
|
||||
});
|
||||
|
||||
test("should handle arguments with special characters", () => {
|
||||
expect(parseCommandArgs("$100 @user #tag")).toEqual(["$100", "@user", "#tag"]);
|
||||
});
|
||||
|
||||
test("should handle unicode characters", () => {
|
||||
expect(parseCommandArgs("日本語 🎉 café")).toEqual(["日本語", "🎉", "café"]);
|
||||
});
|
||||
|
||||
test("should handle newlines in arguments", () => {
|
||||
expect(parseCommandArgs('"line1\nline2" second')).toEqual(["line1\nline2", "second"]);
|
||||
});
|
||||
|
||||
test("should handle escaped quotes inside quoted strings", () => {
|
||||
// Note: This implementation doesn't handle escaped quotes - backslash is literal
|
||||
expect(parseCommandArgs('"quoted \\"text\\""')).toEqual(["quoted \\text\\"]);
|
||||
});
|
||||
|
||||
test("should handle trailing spaces", () => {
|
||||
expect(parseCommandArgs("a b c ")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("should handle leading spaces", () => {
|
||||
expect(parseCommandArgs(" a b c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Integration
|
||||
// ============================================================================
|
||||
|
||||
describe("parseCommandArgs + substituteArgs integration", () => {
|
||||
test("should parse and substitute together correctly", () => {
|
||||
const input = 'Button "onClick handler" "disabled support"';
|
||||
const args = parseCommandArgs(input);
|
||||
const template = "Create component $1 with features: $ARGUMENTS";
|
||||
const result = substituteArgs(template, args);
|
||||
expect(result).toBe("Create component Button with features: Button onClick handler disabled support");
|
||||
});
|
||||
|
||||
test("should handle the example from README", () => {
|
||||
const input = 'Button "onClick handler" "disabled support"';
|
||||
const args = parseCommandArgs(input);
|
||||
const template = "Create a React component named $1 with features: $ARGUMENTS";
|
||||
const result = substituteArgs(template, args);
|
||||
expect(result).toBe(
|
||||
"Create a React component named Button with features: Button onClick handler disabled support",
|
||||
);
|
||||
});
|
||||
|
||||
test("should produce same result with $@ and $ARGUMENTS", () => {
|
||||
const args = parseCommandArgs("feature1 feature2 feature3");
|
||||
const template1 = "Implement: $@";
|
||||
const template2 = "Implement: $ARGUMENTS";
|
||||
expect(substituteArgs(template1, args)).toBe(substituteArgs(template2, args));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// loadPromptTemplates - argument-hint frontmatter
|
||||
// ============================================================================
|
||||
|
||||
describe("loadPromptTemplates - argument-hint", () => {
|
||||
const testDir = join(tmpdir(), `pi-test-prompts-${Date.now()}`);
|
||||
|
||||
function writeTemplate(name: string, content: string) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
writeFileSync(join(testDir, `${name}.md`), content);
|
||||
}
|
||||
|
||||
test("should parse required argument-hint from frontmatter", () => {
|
||||
writeTemplate(
|
||||
"pr",
|
||||
`---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const pr = templates.find((t) => t.name === "pr");
|
||||
expect(pr).toBeDefined();
|
||||
expect(pr!.argumentHint).toBe("<PR-URL>");
|
||||
expect(pr!.description).toBe("Review PRs from URLs with structured issue and code analysis");
|
||||
});
|
||||
|
||||
test("should parse optional argument-hint from frontmatter", () => {
|
||||
writeTemplate(
|
||||
"wr",
|
||||
`---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it. Additional instructions: $ARGUMENTS`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const wr = templates.find((t) => t.name === "wr");
|
||||
expect(wr).toBeDefined();
|
||||
expect(wr!.argumentHint).toBe("[instructions]");
|
||||
expect(wr!.description).toBe("Finish the current task end-to-end with changelog, commit, and push");
|
||||
});
|
||||
|
||||
test("should leave argumentHint undefined when not specified", () => {
|
||||
writeTemplate(
|
||||
"cl",
|
||||
`---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const cl = templates.find((t) => t.name === "cl");
|
||||
expect(cl).toBeDefined();
|
||||
expect(cl!.argumentHint).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should ignore empty argument-hint", () => {
|
||||
writeTemplate(
|
||||
"empty-hint",
|
||||
`---
|
||||
description: A command with empty hint
|
||||
argument-hint: ""
|
||||
---
|
||||
Do something`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const tmpl = templates.find((t) => t.name === "empty-hint");
|
||||
expect(tmpl).toBeDefined();
|
||||
expect(tmpl!.argumentHint).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should preserve argument-hint with special characters", () => {
|
||||
writeTemplate(
|
||||
"is",
|
||||
`---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const is = templates.find((t) => t.name === "is");
|
||||
expect(is).toBeDefined();
|
||||
expect(is!.argumentHint).toBe("<issue>");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ExtensionRunner } from "../src/core/extensions/runner.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { DefaultResourceLoader } from "../src/core/resource-loader.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import type { Skill } from "../src/core/skills.js";
|
||||
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
|
||||
|
||||
describe("DefaultResourceLoader", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `rl-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
cwd = join(tempDir, "project");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("reload", () => {
|
||||
it("should initialize with empty results before reload", () => {
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
|
||||
expect(loader.getExtensions().extensions).toEqual([]);
|
||||
expect(loader.getSkills().skills).toEqual([]);
|
||||
expect(loader.getPrompts().prompts).toEqual([]);
|
||||
expect(loader.getThemes().themes).toEqual([]);
|
||||
});
|
||||
|
||||
it("should discover skills from agentDir", async () => {
|
||||
const skillsDir = join(agentDir, "skills");
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(skillsDir, "test-skill.md"),
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
Skill content here.`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
expect(skills.some((s) => s.name === "test-skill")).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore extra markdown files in auto-discovered skill dirs", async () => {
|
||||
const skillDir = join(agentDir, "skills", "pi-skills", "browser-tools");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: browser-tools
|
||||
description: Browser tools
|
||||
---
|
||||
Skill content here.`,
|
||||
);
|
||||
writeFileSync(join(skillDir, "EFFICIENCY.md"), "No frontmatter here");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills, diagnostics } = loader.getSkills();
|
||||
expect(skills.some((s) => s.name === "browser-tools")).toBe(true);
|
||||
expect(diagnostics.some((d) => d.path?.endsWith("EFFICIENCY.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should discover prompts from agentDir", async () => {
|
||||
const promptsDir = join(agentDir, "prompts");
|
||||
mkdirSync(promptsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(promptsDir, "test-prompt.md"),
|
||||
`---
|
||||
description: A test prompt
|
||||
---
|
||||
Prompt content.`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { prompts } = loader.getPrompts();
|
||||
expect(prompts.some((p) => p.name === "test-prompt")).toBe(true);
|
||||
});
|
||||
|
||||
it("should prefer project resources over user on name collisions", async () => {
|
||||
const userPromptsDir = join(agentDir, "prompts");
|
||||
const projectPromptsDir = join(cwd, ".pi", "prompts");
|
||||
mkdirSync(userPromptsDir, { recursive: true });
|
||||
mkdirSync(projectPromptsDir, { recursive: true });
|
||||
const userPromptPath = join(userPromptsDir, "commit.md");
|
||||
const projectPromptPath = join(projectPromptsDir, "commit.md");
|
||||
writeFileSync(userPromptPath, "User prompt");
|
||||
writeFileSync(projectPromptPath, "Project prompt");
|
||||
|
||||
const userSkillDir = join(agentDir, "skills", "collision-skill");
|
||||
const projectSkillDir = join(cwd, ".pi", "skills", "collision-skill");
|
||||
mkdirSync(userSkillDir, { recursive: true });
|
||||
mkdirSync(projectSkillDir, { recursive: true });
|
||||
const userSkillPath = join(userSkillDir, "SKILL.md");
|
||||
const projectSkillPath = join(projectSkillDir, "SKILL.md");
|
||||
writeFileSync(
|
||||
userSkillPath,
|
||||
`---
|
||||
name: collision-skill
|
||||
description: user
|
||||
---
|
||||
User skill`,
|
||||
);
|
||||
writeFileSync(
|
||||
projectSkillPath,
|
||||
`---
|
||||
name: collision-skill
|
||||
description: project
|
||||
---
|
||||
Project skill`,
|
||||
);
|
||||
|
||||
const baseTheme = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"),
|
||||
) as { name: string; vars?: Record<string, string> };
|
||||
baseTheme.name = "collision-theme";
|
||||
const userThemePath = join(agentDir, "themes", "collision.json");
|
||||
const projectThemePath = join(cwd, ".pi", "themes", "collision.json");
|
||||
mkdirSync(join(agentDir, "themes"), { recursive: true });
|
||||
mkdirSync(join(cwd, ".pi", "themes"), { recursive: true });
|
||||
writeFileSync(userThemePath, JSON.stringify(baseTheme, null, 2));
|
||||
if (baseTheme.vars) {
|
||||
baseTheme.vars.accent = "#ff00ff";
|
||||
}
|
||||
writeFileSync(projectThemePath, JSON.stringify(baseTheme, null, 2));
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const prompt = loader.getPrompts().prompts.find((p) => p.name === "commit");
|
||||
expect(prompt?.filePath).toBe(projectPromptPath);
|
||||
|
||||
const skill = loader.getSkills().skills.find((s) => s.name === "collision-skill");
|
||||
expect(skill?.filePath).toBe(projectSkillPath);
|
||||
|
||||
const theme = loader.getThemes().themes.find((t) => t.name === "collision-theme");
|
||||
expect(theme?.sourcePath).toBe(projectThemePath);
|
||||
});
|
||||
|
||||
it("should keep both extensions loaded when command names collide", async () => {
|
||||
const userExtDir = join(agentDir, "extensions");
|
||||
const projectExtDir = join(cwd, ".pi", "extensions");
|
||||
mkdirSync(userExtDir, { recursive: true });
|
||||
mkdirSync(projectExtDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(projectExtDir, "project.ts"),
|
||||
`export default function(pi) {
|
||||
pi.registerCommand("deploy", {
|
||||
description: "project deploy",
|
||||
handler: async () => {},
|
||||
});
|
||||
pi.registerCommand("project-only", {
|
||||
description: "project only",
|
||||
handler: async () => {},
|
||||
});
|
||||
}`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(userExtDir, "user.ts"),
|
||||
`export default function(pi) {
|
||||
pi.registerCommand("deploy", {
|
||||
description: "user deploy",
|
||||
handler: async () => {},
|
||||
});
|
||||
pi.registerCommand("user-only", {
|
||||
description: "user only",
|
||||
handler: async () => {},
|
||||
});
|
||||
}`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const extensionsResult = loader.getExtensions();
|
||||
expect(extensionsResult.extensions).toHaveLength(2);
|
||||
expect(extensionsResult.errors.some((e) => e.error.includes('Command "/deploy" conflicts'))).toBe(false);
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
const runner = new ExtensionRunner(
|
||||
extensionsResult.extensions,
|
||||
extensionsResult.runtime,
|
||||
cwd,
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
|
||||
expect(runner.getCommand("deploy:1")?.description).toBe("project deploy");
|
||||
expect(runner.getCommand("deploy:2")?.description).toBe("user deploy");
|
||||
expect(runner.getCommand("project-only")?.description).toBe("project only");
|
||||
expect(runner.getCommand("user-only")?.description).toBe("user only");
|
||||
|
||||
const commands = runner.getRegisteredCommands();
|
||||
expect(commands.map((command) => command.invocationName)).toEqual([
|
||||
"deploy:1",
|
||||
"project-only",
|
||||
"deploy:2",
|
||||
"user-only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should honor overrides for auto-discovered resources", async () => {
|
||||
const settingsManager = SettingsManager.inMemory();
|
||||
settingsManager.setExtensionPaths(["-extensions/disabled.ts"]);
|
||||
settingsManager.setSkillPaths(["-skills/skip-skill"]);
|
||||
settingsManager.setPromptTemplatePaths(["-prompts/skip.md"]);
|
||||
settingsManager.setThemePaths(["-themes/skip.json"]);
|
||||
|
||||
const extensionsDir = join(agentDir, "extensions");
|
||||
mkdirSync(extensionsDir, { recursive: true });
|
||||
writeFileSync(join(extensionsDir, "disabled.ts"), "export default function() {}");
|
||||
|
||||
const skillDir = join(agentDir, "skills", "skip-skill");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: skip-skill
|
||||
description: Skip me
|
||||
---
|
||||
Content`,
|
||||
);
|
||||
|
||||
const promptsDir = join(agentDir, "prompts");
|
||||
mkdirSync(promptsDir, { recursive: true });
|
||||
writeFileSync(join(promptsDir, "skip.md"), "Skip prompt");
|
||||
|
||||
const themesDir = join(agentDir, "themes");
|
||||
mkdirSync(themesDir, { recursive: true });
|
||||
writeFileSync(join(themesDir, "skip.json"), "{}");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
|
||||
await loader.reload();
|
||||
|
||||
const { extensions } = loader.getExtensions();
|
||||
const { skills } = loader.getSkills();
|
||||
const { prompts } = loader.getPrompts();
|
||||
const { themes } = loader.getThemes();
|
||||
|
||||
expect(extensions.some((e) => e.path.endsWith("disabled.ts"))).toBe(false);
|
||||
expect(skills.some((s) => s.name === "skip-skill")).toBe(false);
|
||||
expect(prompts.some((p) => p.name === "skip")).toBe(false);
|
||||
expect(themes.some((t) => t.sourcePath?.endsWith("skip.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should discover AGENTS.md context files", async () => {
|
||||
writeFileSync(join(cwd, "AGENTS.md"), "# Project Guidelines\n\nBe helpful.");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { agentsFiles } = loader.getAgentsFiles();
|
||||
expect(agentsFiles.some((f) => f.path.includes("AGENTS.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should skip AGENTS.md and CLAUDE.md discovery when noContextFiles is true", async () => {
|
||||
writeFileSync(join(cwd, "AGENTS.md"), "# Project Guidelines\n\nBe helpful.");
|
||||
writeFileSync(join(cwd, "CLAUDE.md"), "# Claude Guidelines\n\nBe helpful.");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir, noContextFiles: true });
|
||||
await loader.reload();
|
||||
|
||||
const { agentsFiles } = loader.getAgentsFiles();
|
||||
expect(agentsFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("should discover SYSTEM.md from cwd/.pi", async () => {
|
||||
const piDir = join(cwd, ".pi");
|
||||
mkdirSync(piDir, { recursive: true });
|
||||
writeFileSync(join(piDir, "SYSTEM.md"), "You are a helpful assistant.");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
|
||||
});
|
||||
|
||||
it("should discover APPEND_SYSTEM.md", async () => {
|
||||
const piDir = join(cwd, ".pi");
|
||||
mkdirSync(piDir, { recursive: true });
|
||||
writeFileSync(join(piDir, "APPEND_SYSTEM.md"), "Additional instructions.");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
expect(loader.getAppendSystemPrompt()).toContain("Additional instructions.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extendResources", () => {
|
||||
it("should load skills and prompts with extension metadata", async () => {
|
||||
const extraSkillDir = join(tempDir, "extra-skills", "extra-skill");
|
||||
mkdirSync(extraSkillDir, { recursive: true });
|
||||
const skillPath = join(extraSkillDir, "SKILL.md");
|
||||
writeFileSync(
|
||||
skillPath,
|
||||
`---
|
||||
name: extra-skill
|
||||
description: Extra skill
|
||||
---
|
||||
Extra content`,
|
||||
);
|
||||
|
||||
const extraPromptDir = join(tempDir, "extra-prompts");
|
||||
mkdirSync(extraPromptDir, { recursive: true });
|
||||
const promptPath = join(extraPromptDir, "extra.md");
|
||||
writeFileSync(
|
||||
promptPath,
|
||||
`---
|
||||
description: Extra prompt
|
||||
---
|
||||
Extra prompt content`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
loader.extendResources({
|
||||
skillPaths: [
|
||||
{
|
||||
path: extraSkillDir,
|
||||
metadata: {
|
||||
source: "extension:extra",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir: extraSkillDir,
|
||||
},
|
||||
},
|
||||
],
|
||||
promptPaths: [
|
||||
{
|
||||
path: promptPath,
|
||||
metadata: {
|
||||
source: "extension:extra",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir: extraPromptDir,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
const loadedSkill = skills.find((skill) => skill.name === "extra-skill");
|
||||
expect(loadedSkill).toBeDefined();
|
||||
expect(loadedSkill?.sourceInfo?.source).toBe("extension:extra");
|
||||
expect(loadedSkill?.sourceInfo?.path).toBe(skillPath);
|
||||
|
||||
const { prompts } = loader.getPrompts();
|
||||
const loadedPrompt = prompts.find((prompt) => prompt.name === "extra");
|
||||
expect(loadedPrompt).toBeDefined();
|
||||
expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra");
|
||||
expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("noSkills option", () => {
|
||||
it("should skip skill discovery when noSkills is true", async () => {
|
||||
const skillsDir = join(agentDir, "skills");
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(skillsDir, "test-skill.md"),
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
Content`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir, noSkills: true });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
expect(skills).toEqual([]);
|
||||
});
|
||||
|
||||
it("should still load additional skill paths when noSkills is true", async () => {
|
||||
const customSkillDir = join(tempDir, "custom-skills");
|
||||
mkdirSync(customSkillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(customSkillDir, "custom.md"),
|
||||
`---
|
||||
name: custom
|
||||
description: Custom skill
|
||||
---
|
||||
Content`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
noSkills: true,
|
||||
additionalSkillPaths: [customSkillDir],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
expect(skills.some((s) => s.name === "custom")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("override functions", () => {
|
||||
it("should apply skillsOverride", async () => {
|
||||
const injectedSkill: Skill = {
|
||||
name: "injected",
|
||||
description: "Injected skill",
|
||||
filePath: "/fake/path",
|
||||
baseDir: "/fake",
|
||||
sourceInfo: createSyntheticSourceInfo("/fake/path", { source: "custom" }),
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
skillsOverride: () => ({
|
||||
skills: [injectedSkill],
|
||||
diagnostics: [],
|
||||
}),
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("injected");
|
||||
});
|
||||
|
||||
it("should apply systemPromptOverride", async () => {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
systemPromptOverride: () => "Custom system prompt",
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
expect(loader.getSystemPrompt()).toBe("Custom system prompt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension conflict detection", () => {
|
||||
it("should detect tool conflicts between extensions", async () => {
|
||||
// Create two extensions that register the same tool
|
||||
const ext1Dir = join(agentDir, "extensions", "ext1");
|
||||
const ext2Dir = join(agentDir, "extensions", "ext2");
|
||||
mkdirSync(ext1Dir, { recursive: true });
|
||||
mkdirSync(ext2Dir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(ext1Dir, "index.ts"),
|
||||
`
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "duplicate-tool",
|
||||
description: "First",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ result: "1" }),
|
||||
});
|
||||
}`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(ext2Dir, "index.ts"),
|
||||
`
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "duplicate-tool",
|
||||
description: "Second",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ result: "2" }),
|
||||
});
|
||||
}`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { errors } = loader.getExtensions();
|
||||
expect(errors.some((e) => e.error.includes("duplicate-tool") && e.error.includes("conflicts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should prefer explicit CLI extensions over discovered extensions when commands and tools conflict", async () => {
|
||||
const globalExtDir = join(agentDir, "extensions");
|
||||
mkdirSync(globalExtDir, { recursive: true });
|
||||
const explicitExtPath = join(tempDir, "explicit-extension.ts");
|
||||
|
||||
writeFileSync(
|
||||
join(globalExtDir, "global.ts"),
|
||||
`
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "duplicate-tool",
|
||||
description: "global tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ result: "global" }),
|
||||
});
|
||||
pi.registerCommand("deploy", {
|
||||
description: "global command",
|
||||
handler: async () => {},
|
||||
});
|
||||
}`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
explicitExtPath,
|
||||
`
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
export default function(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "duplicate-tool",
|
||||
description: "explicit tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ result: "explicit" }),
|
||||
});
|
||||
pi.registerCommand("deploy", {
|
||||
description: "explicit command",
|
||||
handler: async () => {},
|
||||
});
|
||||
}`,
|
||||
);
|
||||
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
additionalExtensionPaths: [explicitExtPath],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const extensionsResult = loader.getExtensions();
|
||||
expect(extensionsResult.extensions[0]?.path).toBe(explicitExtPath);
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth-explicit.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
const runner = new ExtensionRunner(
|
||||
extensionsResult.extensions,
|
||||
extensionsResult.runtime,
|
||||
cwd,
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
|
||||
expect(runner.getCommand("deploy:1")?.description).toBe("explicit command");
|
||||
expect(runner.getCommand("deploy:2")?.description).toBe("global command");
|
||||
expect(runner.getToolDefinition("duplicate-tool")?.description).toBe("explicit tool");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import * as readline from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RpcClient } from "../src/modes/rpc/rpc-client.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Interactive example of using coding-agent via RpcClient.
|
||||
* Usage: npx tsx test/rpc-example.ts
|
||||
*/
|
||||
|
||||
async function main() {
|
||||
const client = new RpcClient({
|
||||
cliPath: join(__dirname, "../dist/cli.js"),
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
args: ["--no-session"],
|
||||
});
|
||||
|
||||
// Stream events to console
|
||||
client.onEvent((event) => {
|
||||
if (event.type === "message_update") {
|
||||
const { assistantMessageEvent } = event;
|
||||
if (assistantMessageEvent.type === "text_delta" || assistantMessageEvent.type === "thinking_delta") {
|
||||
process.stdout.write(assistantMessageEvent.delta);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_start") {
|
||||
console.log(`\n[Tool: ${event.toolName}]`);
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_end") {
|
||||
console.log(`[Result: ${JSON.stringify(event.result).slice(0, 200)}...]\n`);
|
||||
}
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
const state = await client.getState();
|
||||
console.log(`Model: ${state.model?.provider}/${state.model?.id}`);
|
||||
console.log(`Thinking: ${state.thinkingLevel ?? "off"}\n`);
|
||||
|
||||
// Handle user input
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
let isWaiting = false;
|
||||
|
||||
const prompt = () => {
|
||||
if (!isWaiting) process.stdout.write("You: ");
|
||||
};
|
||||
|
||||
rl.on("line", async (line) => {
|
||||
if (isWaiting) return;
|
||||
if (line.trim() === "exit") {
|
||||
await client.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
isWaiting = true;
|
||||
await client.promptAndWait(line);
|
||||
console.log("\n");
|
||||
isWaiting = false;
|
||||
prompt();
|
||||
});
|
||||
|
||||
rl.on("SIGINT", () => {
|
||||
if (isWaiting) {
|
||||
console.log("\n[Aborting...]");
|
||||
client.abort();
|
||||
} else {
|
||||
client.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Interactive RPC example. Type 'exit' to quit.\n");
|
||||
prompt();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { attachJsonlLineReader, serializeJsonLine } from "../src/modes/rpc/jsonl.js";
|
||||
|
||||
describe("RPC JSONL framing", () => {
|
||||
test("serializes strict JSONL records without escaping Unicode separators", () => {
|
||||
const line = serializeJsonLine({ text: "a\u2028b\u2029c" });
|
||||
|
||||
expect(line).toContain("a\u2028b\u2029c");
|
||||
expect(line.endsWith("\n")).toBe(true);
|
||||
expect(JSON.parse(line.trim())).toEqual({ text: "a\u2028b\u2029c" });
|
||||
});
|
||||
|
||||
test("splits on LF only and preserves U+2028/U+2029 inside payloads", async () => {
|
||||
const lines: string[] = [];
|
||||
const stream = Readable.from([serializeJsonLine({ text: "a\u2028b\u2029c" })]);
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
stream.on("end", resolve);
|
||||
});
|
||||
|
||||
attachJsonlLineReader(stream, (line) => {
|
||||
lines.push(line);
|
||||
});
|
||||
|
||||
await done;
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines[0])).toEqual({ text: "a\u2028b\u2029c" });
|
||||
});
|
||||
|
||||
test("handles CRLF-delimited input", async () => {
|
||||
const lines: string[] = [];
|
||||
const stream = Readable.from([Buffer.from('{"a":1}\r\n{"b":2}\r\n')]);
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
stream.on("end", resolve);
|
||||
});
|
||||
|
||||
attachJsonlLineReader(stream, (line) => {
|
||||
lines.push(line);
|
||||
});
|
||||
|
||||
await done;
|
||||
|
||||
expect(lines).toEqual(['{"a":1}', '{"b":2}']);
|
||||
});
|
||||
|
||||
test("emits a final line without trailing LF", async () => {
|
||||
const lines: string[] = [];
|
||||
const stream = Readable.from([Buffer.from('{"a":1}')]);
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
stream.on("end", resolve);
|
||||
});
|
||||
|
||||
attachJsonlLineReader(stream, (line) => {
|
||||
lines.push(line);
|
||||
});
|
||||
|
||||
await done;
|
||||
|
||||
expect(lines).toEqual(['{"a":1}']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEvent,
|
||||
EventStream,
|
||||
getModel,
|
||||
type Model,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.js";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { runRpcMode } from "../src/modes/rpc/rpc-mode.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
const rpcIo = vi.hoisted(() => ({
|
||||
outputLines: [] as string[],
|
||||
lineHandler: undefined as ((line: string) => void) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../src/core/output-guard.js", () => ({
|
||||
takeOverStdout: vi.fn(),
|
||||
writeRawStdout: (line: string) => {
|
||||
rpcIo.outputLines.push(line);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../src/modes/interactive/theme/theme.js", () => ({ theme: {} }));
|
||||
|
||||
vi.mock("../src/modes/rpc/jsonl.js", () => ({
|
||||
attachJsonlLineReader: vi.fn((_stream: NodeJS.ReadableStream, onLine: (line: string) => void) => {
|
||||
rpcIo.lineHandler = onLine;
|
||||
return () => {};
|
||||
}),
|
||||
serializeJsonLine: (value: unknown) => `${JSON.stringify(value)}\n`,
|
||||
}));
|
||||
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
type ParsedOutputLine = Record<string, unknown>;
|
||||
|
||||
function parseOutputLines(outputLines: string[]): ParsedOutputLine[] {
|
||||
return outputLines
|
||||
.flatMap((line) => line.split("\n"))
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as ParsedOutputLine);
|
||||
}
|
||||
|
||||
function getPromptResponses(outputLines: string[], id: string): ParsedOutputLine[] {
|
||||
return parseOutputLines(outputLines).filter(
|
||||
(record) => record.id === id && record.type === "response" && record.command === "prompt",
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number; model?: Model<any> }): {
|
||||
runtimeHost: AgentSessionRuntime;
|
||||
cleanup: () => Promise<void>;
|
||||
} {
|
||||
const tempDir = join(tmpdir(), `pi-rpc-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const model = options.model ?? getModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) {
|
||||
throw new Error("Test model not found");
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: (_model, _context, _options) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
setTimeout(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
|
||||
}, options.responseDelayMs);
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
|
||||
if (options.withAuth) {
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
}
|
||||
|
||||
const session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
const runtimeHost = {
|
||||
session,
|
||||
newSession: vi.fn(async () => ({ cancelled: true })),
|
||||
switchSession: vi.fn(async () => ({ cancelled: true })),
|
||||
fork: vi.fn(async () => ({ cancelled: true, selectedText: "" })),
|
||||
dispose: vi.fn(async () => {}),
|
||||
} as unknown as AgentSessionRuntime;
|
||||
|
||||
return {
|
||||
runtimeHost,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
if (session.isStreaming) {
|
||||
await session.abort();
|
||||
}
|
||||
} catch {
|
||||
// ignore test cleanup failures
|
||||
}
|
||||
session.dispose();
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startRpcMode(options: { withAuth: boolean; responseDelayMs: number; model?: Model<any> }): Promise<{
|
||||
lineHandler: (line: string) => void;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
rpcIo.outputLines = [];
|
||||
rpcIo.lineHandler = undefined;
|
||||
|
||||
const { runtimeHost, cleanup } = createRuntimeHost(options);
|
||||
void runRpcMode(runtimeHost);
|
||||
await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined());
|
||||
|
||||
return { lineHandler: rpcIo.lineHandler!, cleanup };
|
||||
}
|
||||
|
||||
describe("RPC prompt response semantics", () => {
|
||||
afterEach(() => {
|
||||
rpcIo.outputLines = [];
|
||||
rpcIo.lineHandler = undefined;
|
||||
});
|
||||
|
||||
it("emits one failure response when prompt preflight rejects", async () => {
|
||||
const { lineHandler, cleanup } = await startRpcMode({
|
||||
withAuth: false,
|
||||
responseDelayMs: 0,
|
||||
model: {
|
||||
id: "fake-model",
|
||||
name: "Fake Model",
|
||||
api: "openai-completions",
|
||||
provider: "fake-provider",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: [],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 0,
|
||||
maxTokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
lineHandler(JSON.stringify({ id: "b1", type: "prompt", message: "Hello" }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const responses = getPromptResponses(rpcIo.outputLines, "b1");
|
||||
expect(responses).toHaveLength(1);
|
||||
expect(responses[0]).toMatchObject({
|
||||
id: "b1",
|
||||
type: "response",
|
||||
command: "prompt",
|
||||
success: false,
|
||||
error: expect.stringContaining(
|
||||
"No API key found for fake-provider.\n\nUse /login or set an API key environment variable. See ",
|
||||
),
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits one success response when prompt preflight succeeds", async () => {
|
||||
const { lineHandler, cleanup } = await startRpcMode({ withAuth: true, responseDelayMs: 0 });
|
||||
|
||||
try {
|
||||
lineHandler(JSON.stringify({ id: "b2", type: "prompt", message: "Hello" }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const responses = getPromptResponses(rpcIo.outputLines, "b2");
|
||||
expect(responses).toHaveLength(1);
|
||||
expect(responses[0]).toMatchObject({
|
||||
id: "b2",
|
||||
type: "response",
|
||||
command: "prompt",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits one success response when prompt is queued during streaming", async () => {
|
||||
const { lineHandler, cleanup } = await startRpcMode({ withAuth: true, responseDelayMs: 100 });
|
||||
|
||||
try {
|
||||
lineHandler(JSON.stringify({ id: "b3-start", type: "prompt", message: "Start" }));
|
||||
await vi.waitFor(() => {
|
||||
expect(getPromptResponses(rpcIo.outputLines, "b3-start")).toHaveLength(1);
|
||||
});
|
||||
|
||||
rpcIo.outputLines = [];
|
||||
lineHandler(
|
||||
JSON.stringify({
|
||||
id: "b3",
|
||||
type: "prompt",
|
||||
message: "Queue this",
|
||||
streamingBehavior: "followUp",
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const responses = getPromptResponses(rpcIo.outputLines, "b3");
|
||||
expect(responses).toHaveLength(1);
|
||||
expect(responses[0]).toMatchObject({
|
||||
id: "b3",
|
||||
type: "response",
|
||||
command: "prompt",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
await sleep(150);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { RpcClient } from "../src/modes/rpc/rpc-client.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* RPC mode tests.
|
||||
*/
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_TOKEN)("RPC mode", () => {
|
||||
let client: RpcClient;
|
||||
let sessionDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionDir = join(tmpdir(), `pi-rpc-test-${Date.now()}`);
|
||||
client = new RpcClient({
|
||||
cliPath: join(__dirname, "..", "dist", "cli.js"),
|
||||
cwd: join(__dirname, ".."),
|
||||
env: { PI_CODING_AGENT_DIR: sessionDir },
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.stop();
|
||||
if (sessionDir && existsSync(sessionDir)) {
|
||||
rmSync(sessionDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("should get state", async () => {
|
||||
await client.start();
|
||||
const state = await client.getState();
|
||||
|
||||
expect(state.model).toBeDefined();
|
||||
expect(state.model?.provider).toBe("anthropic");
|
||||
expect(state.model?.id).toBe("claude-sonnet-4-5");
|
||||
expect(state.isStreaming).toBe(false);
|
||||
expect(state.messageCount).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
test("should save messages to session file", async () => {
|
||||
await client.start();
|
||||
|
||||
// Send prompt and wait for completion
|
||||
const events = await client.promptAndWait("Reply with just the word 'hello'");
|
||||
|
||||
// Should have message events
|
||||
const messageEndEvents = events.filter((e) => e.type === "message_end");
|
||||
expect(messageEndEvents.length).toBeGreaterThanOrEqual(2); // user + assistant
|
||||
|
||||
// Wait for file writes
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify session file
|
||||
const sessionsPath = join(sessionDir, "sessions");
|
||||
expect(existsSync(sessionsPath)).toBe(true);
|
||||
|
||||
const sessionDirs = readdirSync(sessionsPath);
|
||||
expect(sessionDirs.length).toBeGreaterThan(0);
|
||||
|
||||
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
||||
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
||||
expect(sessionFiles.length).toBe(1);
|
||||
|
||||
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
||||
const entries = sessionContent
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
// First entry should be session header
|
||||
expect(entries[0].type).toBe("session");
|
||||
|
||||
// Should have user and assistant messages
|
||||
const messages = entries.filter((e: { type: string }) => e.type === "message");
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const roles = messages.map((m: { message: { role: string } }) => m.message.role);
|
||||
expect(roles).toContain("user");
|
||||
expect(roles).toContain("assistant");
|
||||
}, 90000);
|
||||
|
||||
test("should handle manual compaction", async () => {
|
||||
await client.start();
|
||||
|
||||
// First send a prompt to have messages to compact
|
||||
await client.promptAndWait("Say hello");
|
||||
|
||||
// Compact
|
||||
const result = await client.compact();
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.tokensBefore).toBeGreaterThan(0);
|
||||
|
||||
// Wait for file writes
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify compaction in session file
|
||||
const sessionsPath = join(sessionDir, "sessions");
|
||||
const sessionDirs = readdirSync(sessionsPath);
|
||||
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
||||
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
||||
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
||||
const entries = sessionContent
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
const compactionEntries = entries.filter((e: { type: string }) => e.type === "compaction");
|
||||
expect(compactionEntries.length).toBe(1);
|
||||
expect(compactionEntries[0].summary).toBeDefined();
|
||||
}, 120000);
|
||||
|
||||
test("should execute bash command", async () => {
|
||||
await client.start();
|
||||
|
||||
const result = await client.bash("echo hello");
|
||||
expect(result.output.trim()).toBe("hello");
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.cancelled).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
test("should add bash output to context", async () => {
|
||||
await client.start();
|
||||
|
||||
// First send a prompt to initialize session
|
||||
await client.promptAndWait("Say hi");
|
||||
|
||||
// Run bash command
|
||||
const uniqueValue = `test-${Date.now()}`;
|
||||
await client.bash(`echo ${uniqueValue}`);
|
||||
|
||||
// Wait for file writes
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify bash message in session
|
||||
const sessionsPath = join(sessionDir, "sessions");
|
||||
const sessionDirs = readdirSync(sessionsPath);
|
||||
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
||||
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
||||
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
||||
const entries = sessionContent
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
const bashMessages = entries.filter(
|
||||
(e: { type: string; message?: { role: string } }) =>
|
||||
e.type === "message" && e.message?.role === "bashExecution",
|
||||
);
|
||||
expect(bashMessages.length).toBe(1);
|
||||
expect(bashMessages[0].message.output).toContain(uniqueValue);
|
||||
}, 90000);
|
||||
|
||||
test("should include bash output in LLM context", async () => {
|
||||
await client.start();
|
||||
|
||||
// Run a bash command with a unique value
|
||||
const uniqueValue = `unique-${Date.now()}`;
|
||||
await client.bash(`echo ${uniqueValue}`);
|
||||
|
||||
// Ask the LLM what the output was
|
||||
const events = await client.promptAndWait(
|
||||
"What was the exact output of the echo command I just ran? Reply with just the value, nothing else.",
|
||||
);
|
||||
|
||||
// Find assistant's response
|
||||
const messageEndEvents = events.filter((e) => e.type === "message_end") as AgentEvent[];
|
||||
const assistantMessage = messageEndEvents.find(
|
||||
(e) => e.type === "message_end" && e.message?.role === "assistant",
|
||||
) as any;
|
||||
|
||||
expect(assistantMessage).toBeDefined();
|
||||
|
||||
const textContent = assistantMessage.message.content.find((c: any) => c.type === "text");
|
||||
expect(textContent?.text).toContain(uniqueValue);
|
||||
}, 90000);
|
||||
|
||||
test("should set and get thinking level", async () => {
|
||||
await client.start();
|
||||
|
||||
// Set thinking level
|
||||
await client.setThinkingLevel("high");
|
||||
|
||||
// Verify via state
|
||||
const state = await client.getState();
|
||||
expect(state.thinkingLevel).toBe("high");
|
||||
}, 30000);
|
||||
|
||||
test("should cycle thinking level", async () => {
|
||||
await client.start();
|
||||
|
||||
// Get initial level
|
||||
const initialState = await client.getState();
|
||||
const initialLevel = initialState.thinkingLevel;
|
||||
|
||||
// Cycle
|
||||
const result = await client.cycleThinkingLevel();
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.level).not.toBe(initialLevel);
|
||||
|
||||
// Verify via state
|
||||
const newState = await client.getState();
|
||||
expect(newState.thinkingLevel).toBe(result!.level);
|
||||
}, 30000);
|
||||
|
||||
test("should get available models", async () => {
|
||||
await client.start();
|
||||
|
||||
const models = await client.getAvailableModels();
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
// All models should have required fields
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBeDefined();
|
||||
expect(model.id).toBeDefined();
|
||||
expect(model.contextWindow).toBeGreaterThan(0);
|
||||
expect(typeof model.reasoning).toBe("boolean");
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("should get session stats", async () => {
|
||||
await client.start();
|
||||
|
||||
// Send a prompt first
|
||||
await client.promptAndWait("Hello");
|
||||
|
||||
const stats = await client.getSessionStats();
|
||||
expect(stats.sessionFile).toBeDefined();
|
||||
expect(stats.sessionId).toBeDefined();
|
||||
expect(stats.userMessages).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.assistantMessages).toBeGreaterThanOrEqual(1);
|
||||
}, 90000);
|
||||
|
||||
test("should create new session", async () => {
|
||||
await client.start();
|
||||
|
||||
// Send a prompt
|
||||
await client.promptAndWait("Hello");
|
||||
|
||||
// Verify messages exist
|
||||
let state = await client.getState();
|
||||
expect(state.messageCount).toBeGreaterThan(0);
|
||||
|
||||
// New session
|
||||
await client.newSession();
|
||||
|
||||
// Verify messages cleared
|
||||
state = await client.getState();
|
||||
expect(state.messageCount).toBe(0);
|
||||
}, 90000);
|
||||
|
||||
test("should export to HTML", async () => {
|
||||
await client.start();
|
||||
|
||||
// Send a prompt first
|
||||
await client.promptAndWait("Hello");
|
||||
|
||||
// Export
|
||||
const result = await client.exportHtml();
|
||||
expect(result.path).toBeDefined();
|
||||
expect(result.path.endsWith(".html")).toBe(true);
|
||||
expect(existsSync(result.path)).toBe(true);
|
||||
}, 90000);
|
||||
|
||||
test("should get last assistant text", async () => {
|
||||
await client.start();
|
||||
|
||||
// Initially null
|
||||
let text = await client.getLastAssistantText();
|
||||
expect(text).toBeUndefined();
|
||||
|
||||
// Send prompt
|
||||
await client.promptAndWait("Reply with just: test123");
|
||||
|
||||
// Should have text now
|
||||
text = await client.getLastAssistantText();
|
||||
expect(text).toContain("test123");
|
||||
}, 90000);
|
||||
|
||||
test("should set and get session name", async () => {
|
||||
await client.start();
|
||||
|
||||
// Initially undefined
|
||||
let state = await client.getState();
|
||||
expect(state.sessionName).toBeUndefined();
|
||||
|
||||
// Send a prompt first - session files are only written after first assistant message
|
||||
await client.promptAndWait("Reply with just 'ok'");
|
||||
|
||||
// Set name
|
||||
await client.setSessionName("my-test-session");
|
||||
|
||||
// Verify via state
|
||||
state = await client.getState();
|
||||
expect(state.sessionName).toBe("my-test-session");
|
||||
|
||||
// Wait for file writes
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify session_info entry in session file
|
||||
const sessionsPath = join(sessionDir, "sessions");
|
||||
const sessionDirs = readdirSync(sessionsPath);
|
||||
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
||||
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
||||
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
||||
const entries = sessionContent
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
const sessionInfoEntries = entries.filter((e: { type: string }) => e.type === "session_info");
|
||||
expect(sessionInfoEntries.length).toBe(1);
|
||||
expect(sessionInfoEntries[0].name).toBe("my-test-session");
|
||||
}, 60000);
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Manual SDK probe for OpenAI Codex prompt caching through the tool loop.
|
||||
*
|
||||
* Runs append-only multi-turn prompting through createAgentSession(), forcing one
|
||||
* deterministic custom tool call per top-level user turn. Logs per-subrequest
|
||||
* assistant usage so cache-read monotonicity can be inspected inside a tool loop.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { type AssistantMessage, getModel, Type } from "@mariozechner/pi-ai";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
|
||||
import type { ToolDefinition } from "../src/core/extensions/types.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import type { ResourceLoader } from "../src/core/resource-loader.js";
|
||||
import { createAgentSession } from "../src/core/sdk.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
type Transport = "sse" | "websocket" | "auto";
|
||||
|
||||
interface Args {
|
||||
turns: number;
|
||||
sessionPath: string;
|
||||
transport: Transport;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
interface SubrequestRecord {
|
||||
turn: number;
|
||||
subrequest: number;
|
||||
elapsedMs: number;
|
||||
usage: AssistantMessage["usage"];
|
||||
stopReason: AssistantMessage["stopReason"];
|
||||
text: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TURNS = 20;
|
||||
const MIN_TURNS = 20;
|
||||
const MAX_TURNS = 50;
|
||||
const DEFAULT_MAX_TOKENS = 64;
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
let turns = DEFAULT_TURNS;
|
||||
let sessionPath = resolve(join(tmpdir(), `pi-sdk-codex-cache-probe-tool-loop-${Date.now()}.jsonl`));
|
||||
let transport: Transport = "sse";
|
||||
let maxTokens = DEFAULT_MAX_TOKENS;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--turns": {
|
||||
const value = argv[++i];
|
||||
if (!value) throw new Error("Missing value for --turns");
|
||||
turns = Number.parseInt(value, 10);
|
||||
break;
|
||||
}
|
||||
case "--session": {
|
||||
const value = argv[++i];
|
||||
if (!value) throw new Error("Missing value for --session");
|
||||
sessionPath = resolve(value);
|
||||
break;
|
||||
}
|
||||
case "--transport": {
|
||||
const value = argv[++i];
|
||||
if (value !== "sse" && value !== "websocket" && value !== "auto") {
|
||||
throw new Error(`Invalid --transport value: ${value}`);
|
||||
}
|
||||
transport = value;
|
||||
break;
|
||||
}
|
||||
case "--max-tokens": {
|
||||
const value = argv[++i];
|
||||
if (!value) throw new Error("Missing value for --max-tokens");
|
||||
maxTokens = Number.parseInt(value, 10);
|
||||
break;
|
||||
}
|
||||
case "--help": {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
return { turns, sessionPath, transport, maxTokens };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isInteger(turns) || turns < MIN_TURNS || turns > MAX_TURNS) {
|
||||
throw new Error(`--turns must be an integer between ${MIN_TURNS} and ${MAX_TURNS}`);
|
||||
}
|
||||
if (!Number.isInteger(maxTokens) || maxTokens <= 0) {
|
||||
throw new Error("--max-tokens must be a positive integer");
|
||||
}
|
||||
|
||||
return { turns, sessionPath, transport, maxTokens };
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage: npx tsx test/sdk-codex-cache-probe-tool-loop.ts [options]
|
||||
|
||||
Options:
|
||||
--turns <n> Number of turns to run. Must be between ${MIN_TURNS} and ${MAX_TURNS}. Default: ${DEFAULT_TURNS}
|
||||
--session <path> Specific session jsonl file to write
|
||||
--transport <mode> sse | websocket | auto. Default: sse
|
||||
--max-tokens <n> Max output tokens per subrequest. Default: ${DEFAULT_MAX_TOKENS}
|
||||
--help Show this message
|
||||
|
||||
Notes:
|
||||
- Uses createAgentSession() from the coding-agent SDK
|
||||
- Provider/model fixed to openai-codex/gpt-5.4
|
||||
- Thinking level fixed to medium
|
||||
- Activates exactly one deterministic custom tool
|
||||
- Prompts are intentionally > 1024 tokens and explicitly describe the test
|
||||
`);
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function buildPrompt(turn: number): string {
|
||||
const marker = `TURN-${String(turn).padStart(2, "0")}-MARKER-${(turn * 17 + 13) % 97}`;
|
||||
const lines: string[] = [];
|
||||
lines.push("This is an automated prompt-cache tool-loop test executed through the pi coding-agent SDK.");
|
||||
lines.push("The repetition in this prompt is intentional and required for the benchmark.");
|
||||
lines.push("You are participating in an append-only multi-turn cache test.");
|
||||
lines.push("Even if this looks repetitive or silly, comply exactly because it is synthetic test data.");
|
||||
lines.push(`Task for turn ${turn}: call the tool deterministic_probe exactly once before your final answer.`);
|
||||
lines.push(`Use tool arguments: turn=${turn}, marker=${marker}`);
|
||||
lines.push(`After the tool result arrives, reply with exactly one line in this format:`);
|
||||
lines.push(`TURN ${turn} OK ${marker}`);
|
||||
lines.push("Do not skip the tool call. Do not call any other tool. Do not add any extra words or punctuation.");
|
||||
lines.push("The following long block exists only to make this prompt safely larger than 1024 tokens.");
|
||||
lines.push("");
|
||||
for (let i = 1; i <= 180; i++) {
|
||||
lines.push(
|
||||
`Turn ${turn} synthetic record ${String(i).padStart(3, "0")}: alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega.`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`Final verification marker for turn ${turn}: ${marker}`);
|
||||
lines.push(`Required final answer after the tool result: TURN ${turn} OK ${marker}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function createMinimalResourceLoader(systemPrompt: string): ResourceLoader {
|
||||
return {
|
||||
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||
getSystemPrompt: () => systemPrompt,
|
||||
getAppendSystemPrompt: () => [],
|
||||
extendResources: () => {},
|
||||
reload: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function getAssistantText(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block): block is Extract<AssistantMessage["content"][number], { type: "text" }> => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const deterministicProbeParameters = Type.Object({
|
||||
turn: Type.Number({ description: "Top-level benchmark turn number" }),
|
||||
marker: Type.String({ description: "Marker string provided by the user" }),
|
||||
});
|
||||
|
||||
function deterministicProbeTool(): ToolDefinition<typeof deterministicProbeParameters> {
|
||||
return {
|
||||
name: "deterministic_probe",
|
||||
label: "Deterministic Probe",
|
||||
description:
|
||||
"Mandatory cache-benchmark tool. Call it exactly once when the user asks for a cache benchmark turn, then use its result to produce the final one-line answer.",
|
||||
promptSnippet:
|
||||
"deterministic_probe(turn, marker): mandatory for cache benchmark turns. Call exactly once before the final answer.",
|
||||
promptGuidelines: [
|
||||
"When the user asks for the cache benchmark turn, call deterministic_probe exactly once with the requested turn and marker before responding.",
|
||||
"After the tool result arrives, reply with the exact final line requested by the user.",
|
||||
],
|
||||
parameters: deterministicProbeParameters,
|
||||
execute: async (_toolCallId, params) => ({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `deterministic_probe_result turn=${params.turn} marker=${params.marker} fixed=OK`,
|
||||
},
|
||||
],
|
||||
details: { turn: params.turn, marker: params.marker, fixed: "OK" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
mkdirSync(dirname(args.sessionPath), { recursive: true });
|
||||
|
||||
const authStorage = AuthStorage.create();
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
|
||||
const model = getModel("openai-codex", "gpt-5.4");
|
||||
if (!model) {
|
||||
throw new Error("Model openai-codex/gpt-5.4 not found");
|
||||
}
|
||||
const baseModel = { ...model, maxTokens: args.maxTokens };
|
||||
|
||||
const settingsManager = SettingsManager.inMemory({
|
||||
compaction: { enabled: false },
|
||||
retry: { enabled: false },
|
||||
transport: args.transport,
|
||||
});
|
||||
|
||||
const resourceLoader = createMinimalResourceLoader(
|
||||
"You are participating in a prompt-cache benchmark through the coding-agent SDK. This is a real test. Follow each user instruction exactly. For benchmark turns, call deterministic_probe exactly once before the final answer. Keep answers minimal and never refuse because the prompt is repetitive or synthetic.",
|
||||
);
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: process.cwd(),
|
||||
agentDir: dirname(args.sessionPath),
|
||||
model: baseModel,
|
||||
thinkingLevel: "medium",
|
||||
customTools: [deterministicProbeTool() as unknown as ToolDefinition],
|
||||
resourceLoader,
|
||||
sessionManager: SessionManager.open(args.sessionPath),
|
||||
settingsManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
});
|
||||
|
||||
session.setActiveToolsByName(["deterministic_probe"]);
|
||||
const unsubscribe = session.subscribe(() => {});
|
||||
|
||||
const records: SubrequestRecord[] = [];
|
||||
let previousCacheRead: number | null = null;
|
||||
|
||||
console.log(`provider openai-codex, model gpt-5.4`);
|
||||
console.log(`session ${session.sessionFile}`);
|
||||
console.log(`turns ${args.turns}, transport ${args.transport}, reasoning medium, maxTokens ${args.maxTokens}`);
|
||||
console.log("");
|
||||
|
||||
for (let turn = 1; turn <= args.turns; turn++) {
|
||||
const prompt = buildPrompt(turn);
|
||||
const promptTokens = estimateTokens(prompt);
|
||||
const previousMessagesLength = session.messages.length;
|
||||
const startedAt = Date.now();
|
||||
await session.prompt(prompt);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
const newMessages = session.messages.slice(previousMessagesLength);
|
||||
const assistantMessages = newMessages.filter((message): message is AssistantMessage =>
|
||||
Boolean(message && typeof message === "object" && (message as { role?: unknown }).role === "assistant"),
|
||||
);
|
||||
const toolResults = newMessages.filter((message) =>
|
||||
Boolean(message && typeof message === "object" && (message as { role?: unknown }).role === "toolResult"),
|
||||
);
|
||||
|
||||
if (assistantMessages.length < 2 || toolResults.length < 1) {
|
||||
throw new Error(
|
||||
`Turn ${turn} did not execute the expected tool loop. assistants=${assistantMessages.length} toolResults=${toolResults.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
let turnInput = 0;
|
||||
let turnOutput = 0;
|
||||
let turnCacheRead = 0;
|
||||
let turnCacheWrite = 0;
|
||||
let turnTotal = 0;
|
||||
|
||||
for (let i = 0; i < assistantMessages.length; i++) {
|
||||
const assistant = assistantMessages[i];
|
||||
const record: SubrequestRecord = {
|
||||
turn,
|
||||
subrequest: i + 1,
|
||||
elapsedMs,
|
||||
usage: assistant.usage,
|
||||
stopReason: assistant.stopReason,
|
||||
text: getAssistantText(assistant),
|
||||
};
|
||||
records.push(record);
|
||||
|
||||
turnInput += assistant.usage.input;
|
||||
turnOutput += assistant.usage.output;
|
||||
turnCacheRead += assistant.usage.cacheRead;
|
||||
turnCacheWrite += assistant.usage.cacheWrite;
|
||||
turnTotal += assistant.usage.totalTokens;
|
||||
|
||||
const monotonic =
|
||||
previousCacheRead === null ? "n/a" : assistant.usage.cacheRead >= previousCacheRead ? "yes" : "NO";
|
||||
console.log(
|
||||
[
|
||||
`turn ${String(turn).padStart(2, "0")}.${i + 1}`,
|
||||
`elapsed ${(elapsedMs / 1000).toFixed(1)}s`,
|
||||
`prompt~${promptTokens}`,
|
||||
`stop ${assistant.stopReason}`,
|
||||
`in ${assistant.usage.input}`,
|
||||
`out ${assistant.usage.output}`,
|
||||
`cache ${assistant.usage.cacheRead}/${assistant.usage.cacheWrite}`,
|
||||
`total ${assistant.usage.totalTokens}`,
|
||||
`cache>=prev ${monotonic}`,
|
||||
].join(" | "),
|
||||
);
|
||||
|
||||
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") {
|
||||
throw new Error(
|
||||
`Turn ${turn}.${i + 1} ended with stopReason=${assistant.stopReason}: ${assistant.errorMessage || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
previousCacheRead = assistant.usage.cacheRead;
|
||||
}
|
||||
|
||||
console.log(
|
||||
[
|
||||
`turn ${String(turn).padStart(2, "0")} agg`,
|
||||
`assistants ${assistantMessages.length}`,
|
||||
`toolResults ${toolResults.length}`,
|
||||
`in ${turnInput}`,
|
||||
`out ${turnOutput}`,
|
||||
`cache ${turnCacheRead}/${turnCacheWrite}`,
|
||||
`total ${turnTotal}`,
|
||||
].join(" | "),
|
||||
);
|
||||
}
|
||||
|
||||
const violations = records
|
||||
.map((record, index) => {
|
||||
if (index === 0) return null;
|
||||
const previous = records[index - 1];
|
||||
if (record.usage.cacheRead >= previous.usage.cacheRead) return null;
|
||||
return {
|
||||
turn: record.turn,
|
||||
subrequest: record.subrequest,
|
||||
previous: previous.usage.cacheRead,
|
||||
current: record.usage.cacheRead,
|
||||
};
|
||||
})
|
||||
.filter((value): value is NonNullable<typeof value> => value !== null);
|
||||
|
||||
console.log("");
|
||||
console.log(`subrequest cache read monotonic: ${violations.length === 0 ? "yes" : "NO"}`);
|
||||
if (violations.length > 0) {
|
||||
console.log("violations:");
|
||||
for (const violation of violations) {
|
||||
console.log(` turn ${violation.turn}.${violation.subrequest}: ${violation.previous} -> ${violation.current}`);
|
||||
}
|
||||
}
|
||||
console.log(`session file: ${session.sessionFile}`);
|
||||
|
||||
unsubscribe();
|
||||
session.dispose();
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createAgentSession } from "../src/core/sdk.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
|
||||
describe("createAgentSession session manager defaults", () => {
|
||||
let tempDir: string;
|
||||
let cwd: string;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-sdk-session-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
cwd = join(tempDir, "project");
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses agentDir for the default persisted session path", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
expect(model).toBeTruthy();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model: model!,
|
||||
});
|
||||
|
||||
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
const expectedSessionDir = join(agentDir, "sessions", safePath);
|
||||
const sessionDir = session.sessionManager.getSessionDir();
|
||||
const sessionFile = session.sessionManager.getSessionFile();
|
||||
|
||||
expect(sessionDir).toBe(expectedSessionDir);
|
||||
expect(sessionFile?.startsWith(`${expectedSessionDir}/`)).toBe(true);
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("keeps an explicit sessionManager override", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
expect(model).toBeTruthy();
|
||||
|
||||
const sessionManager = SessionManager.inMemory(cwd);
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model: model!,
|
||||
sessionManager,
|
||||
});
|
||||
|
||||
expect(session.sessionManager).toBe(sessionManager);
|
||||
expect(session.sessionManager.isPersisted()).toBe(false);
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
|
||||
import type { ResourceLoader } from "../src/core/resource-loader.js";
|
||||
import { createAgentSession } from "../src/core/sdk.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
|
||||
|
||||
describe("createAgentSession skills option", () => {
|
||||
let tempDir: string;
|
||||
let skillsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-sdk-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
skillsDir = join(tempDir, "skills", "test-skill");
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
// Create a test skill in the pi skills directory
|
||||
writeFileSync(
|
||||
join(skillsDir, "SKILL.md"),
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill for SDK tests.
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
This is a test skill.
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should discover skills by default and expose them on session.skills", async () => {
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
// Skills should be discovered and exposed on the session
|
||||
expect(session.resourceLoader.getSkills().skills.length).toBeGreaterThan(0);
|
||||
expect(session.resourceLoader.getSkills().skills.some((s) => s.name === "test-skill")).toBe(true);
|
||||
});
|
||||
|
||||
it("should have empty skills when resource loader returns none (--no-skills)", async () => {
|
||||
const resourceLoader: ResourceLoader = {
|
||||
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||
getSystemPrompt: () => undefined,
|
||||
getAppendSystemPrompt: () => [],
|
||||
extendResources: () => {},
|
||||
reload: async () => {},
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
expect(session.resourceLoader.getSkills().skills).toEqual([]);
|
||||
expect(session.resourceLoader.getSkills().diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("should use provided skills when resource loader supplies them", async () => {
|
||||
const customSkill = {
|
||||
name: "custom-skill",
|
||||
description: "A custom skill",
|
||||
filePath: "/fake/path/SKILL.md",
|
||||
baseDir: "/fake/path",
|
||||
sourceInfo: createSyntheticSourceInfo("/fake/path/SKILL.md", { source: "sdk" }),
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
|
||||
const resourceLoader: ResourceLoader = {
|
||||
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||
getSkills: () => ({ skills: [customSkill], diagnostics: [] }),
|
||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||
getSystemPrompt: () => undefined,
|
||||
getAppendSystemPrompt: () => [],
|
||||
extendResources: () => {},
|
||||
reload: async () => {},
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
expect(session.resourceLoader.getSkills().skills).toEqual([customSkill]);
|
||||
expect(session.resourceLoader.getSkills().diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "../src/core/agent-session-runtime.js";
|
||||
import { getMissingSessionCwdIssue, MissingSessionCwdError } from "../src/core/session-cwd.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
|
||||
function createTempDir(name: string): string {
|
||||
const dir = join(tmpdir(), `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeSessionFile(path: string, cwd: string): void {
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-id",
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("session cwd handling", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of cleanupPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects missing session cwd from persisted sessions", () => {
|
||||
const fallbackCwd = createTempDir("pi-session-cwd-fallback");
|
||||
const missingCwd = join(fallbackCwd, "does-not-exist");
|
||||
const sessionDir = createTempDir("pi-session-cwd-session-dir");
|
||||
const sessionFile = join(sessionDir, "session.jsonl");
|
||||
cleanupPaths.push(fallbackCwd, sessionDir);
|
||||
writeSessionFile(sessionFile, missingCwd);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const issue = getMissingSessionCwdIssue(sessionManager, fallbackCwd);
|
||||
expect(issue).toEqual({
|
||||
sessionFile: sessionManager.getSessionFile(),
|
||||
sessionCwd: missingCwd,
|
||||
fallbackCwd,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports overriding the effective cwd when opening a session", () => {
|
||||
const fallbackCwd = createTempDir("pi-session-cwd-override");
|
||||
const missingCwd = join(fallbackCwd, "does-not-exist");
|
||||
const sessionDir = createTempDir("pi-session-cwd-override-session-dir");
|
||||
const sessionFile = join(sessionDir, "session.jsonl");
|
||||
cleanupPaths.push(fallbackCwd, sessionDir);
|
||||
writeSessionFile(sessionFile, missingCwd);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, undefined, fallbackCwd);
|
||||
expect(sessionManager.getCwd()).toBe(fallbackCwd);
|
||||
expect(getMissingSessionCwdIssue(sessionManager, fallbackCwd)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws a controlled error before runtime creation when the stored cwd is missing", async () => {
|
||||
const fallbackCwd = createTempDir("pi-session-cwd-runtime");
|
||||
const missingCwd = join(fallbackCwd, "does-not-exist");
|
||||
const sessionDir = createTempDir("pi-session-cwd-runtime-session-dir");
|
||||
const sessionFile = join(sessionDir, "session.jsonl");
|
||||
cleanupPaths.push(fallbackCwd, sessionDir);
|
||||
writeSessionFile(sessionFile, missingCwd);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
let createRuntimeCalled = false;
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async () => {
|
||||
createRuntimeCalled = true;
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
await expect(
|
||||
createAgentSessionRuntime(createRuntime, {
|
||||
cwd: fallbackCwd,
|
||||
agentDir: fallbackCwd,
|
||||
sessionManager,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MissingSessionCwdError);
|
||||
expect(createRuntimeCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionHeader } from "../src/core/session-manager.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
function createSessionFile(path: string): void {
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
id: "test-session",
|
||||
version: 3,
|
||||
timestamp: new Date(0).toISOString(),
|
||||
cwd: "/tmp",
|
||||
};
|
||||
writeFileSync(path, `${JSON.stringify(header)}\n`, "utf8");
|
||||
|
||||
// SessionManager only persists once it has seen at least one assistant message.
|
||||
// Add a minimal assistant entry so subsequent appends are persisted.
|
||||
const mgr = SessionManager.open(path);
|
||||
mgr.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("SessionInfo.modified", () => {
|
||||
beforeAll(() => initTheme("dark"));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses last user/assistant message timestamp instead of file mtime", async () => {
|
||||
const filePath = join(tmpdir(), `pi-session-${Date.now()}-modified.jsonl`);
|
||||
createSessionFile(filePath);
|
||||
|
||||
const before = await stat(filePath);
|
||||
// Ensure the file mtime can differ from our message timestamp even on coarse filesystems.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const mgr = SessionManager.open(filePath);
|
||||
const msgTime = Date.now();
|
||||
mgr.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "later" }],
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: msgTime,
|
||||
});
|
||||
|
||||
const sessions = await SessionManager.list("/tmp", dirname(filePath));
|
||||
const s = sessions.find((x) => x.path === filePath);
|
||||
expect(s).toBeDefined();
|
||||
expect(s!.modified.getTime()).toBe(msgTime);
|
||||
expect(s!.modified.getTime()).not.toBe(before.mtime.getTime());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BranchSummaryEntry,
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
type ModelChangeEntry,
|
||||
type SessionEntry,
|
||||
type SessionMessageEntry,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "../../src/core/session-manager.js";
|
||||
|
||||
function msg(id: string, parentId: string | null, role: "user" | "assistant", text: string): SessionMessageEntry {
|
||||
const base = { type: "message" as const, id, parentId, timestamp: "2025-01-01T00:00:00Z" };
|
||||
if (role === "user") {
|
||||
return { ...base, message: { role, content: text, timestamp: 1 } };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
message: {
|
||||
role,
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compaction(id: string, parentId: string | null, summary: string, firstKeptEntryId: string): CompactionEntry {
|
||||
return {
|
||||
type: "compaction",
|
||||
id,
|
||||
parentId,
|
||||
timestamp: "2025-01-01T00:00:00Z",
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function branchSummary(id: string, parentId: string | null, summary: string, fromId: string): BranchSummaryEntry {
|
||||
return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary, fromId };
|
||||
}
|
||||
|
||||
function thinkingLevel(id: string, parentId: string | null, level: string): ThinkingLevelChangeEntry {
|
||||
return { type: "thinking_level_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", thinkingLevel: level };
|
||||
}
|
||||
|
||||
function modelChange(id: string, parentId: string | null, provider: string, modelId: string): ModelChangeEntry {
|
||||
return { type: "model_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", provider, modelId };
|
||||
}
|
||||
|
||||
describe("buildSessionContext", () => {
|
||||
describe("trivial cases", () => {
|
||||
it("empty entries returns empty context", () => {
|
||||
const ctx = buildSessionContext([]);
|
||||
expect(ctx.messages).toEqual([]);
|
||||
expect(ctx.thinkingLevel).toBe("off");
|
||||
expect(ctx.model).toBeNull();
|
||||
});
|
||||
|
||||
it("single user message", () => {
|
||||
const entries: SessionEntry[] = [msg("1", null, "user", "hello")];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.messages).toHaveLength(1);
|
||||
expect(ctx.messages[0].role).toBe("user");
|
||||
});
|
||||
|
||||
it("simple conversation", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
msg("2", "1", "assistant", "hi there"),
|
||||
msg("3", "2", "user", "how are you"),
|
||||
msg("4", "3", "assistant", "great"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect(ctx.messages.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]);
|
||||
});
|
||||
|
||||
it("tracks thinking level changes", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
thinkingLevel("2", "1", "high"),
|
||||
msg("3", "2", "assistant", "thinking hard"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.thinkingLevel).toBe("high");
|
||||
expect(ctx.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("tracks model from assistant message", () => {
|
||||
const entries: SessionEntry[] = [msg("1", null, "user", "hello"), msg("2", "1", "assistant", "hi")];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.model).toEqual({ provider: "anthropic", modelId: "claude-test" });
|
||||
});
|
||||
|
||||
it("tracks model from model change entry", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
modelChange("2", "1", "openai", "gpt-4"),
|
||||
msg("3", "2", "assistant", "hi"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
// Assistant message overwrites model change
|
||||
expect(ctx.model).toEqual({ provider: "anthropic", modelId: "claude-test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("with compaction", () => {
|
||||
it("includes summary before kept messages", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
msg("2", "1", "assistant", "response1"),
|
||||
msg("3", "2", "user", "second"),
|
||||
msg("4", "3", "assistant", "response2"),
|
||||
compaction("5", "4", "Summary of first two turns", "3"),
|
||||
msg("6", "5", "user", "third"),
|
||||
msg("7", "6", "assistant", "response3"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
|
||||
// Should have: summary + kept (3,4) + after (6,7) = 5 messages
|
||||
expect(ctx.messages).toHaveLength(5);
|
||||
expect((ctx.messages[0] as any).summary).toContain("Summary of first two turns");
|
||||
expect((ctx.messages[1] as any).content).toBe("second");
|
||||
expect((ctx.messages[2] as any).content[0].text).toBe("response2");
|
||||
expect((ctx.messages[3] as any).content).toBe("third");
|
||||
expect((ctx.messages[4] as any).content[0].text).toBe("response3");
|
||||
});
|
||||
|
||||
it("handles compaction keeping from first message", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
msg("2", "1", "assistant", "response"),
|
||||
compaction("3", "2", "Empty summary", "1"),
|
||||
msg("4", "3", "user", "second"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
|
||||
// Summary + all messages (1,2,4)
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect((ctx.messages[0] as any).summary).toContain("Empty summary");
|
||||
});
|
||||
|
||||
it("multiple compactions uses latest", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "a"),
|
||||
msg("2", "1", "assistant", "b"),
|
||||
compaction("3", "2", "First summary", "1"),
|
||||
msg("4", "3", "user", "c"),
|
||||
msg("5", "4", "assistant", "d"),
|
||||
compaction("6", "5", "Second summary", "4"),
|
||||
msg("7", "6", "user", "e"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
|
||||
// Should use second summary, keep from 4
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect((ctx.messages[0] as any).summary).toContain("Second summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with branches", () => {
|
||||
it("follows path to specified leaf", () => {
|
||||
// Tree:
|
||||
// 1 -> 2 -> 3 (branch A)
|
||||
// \-> 4 (branch B)
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "start"),
|
||||
msg("2", "1", "assistant", "response"),
|
||||
msg("3", "2", "user", "branch A"),
|
||||
msg("4", "2", "user", "branch B"),
|
||||
];
|
||||
|
||||
const ctxA = buildSessionContext(entries, "3");
|
||||
expect(ctxA.messages).toHaveLength(3);
|
||||
expect((ctxA.messages[2] as any).content).toBe("branch A");
|
||||
|
||||
const ctxB = buildSessionContext(entries, "4");
|
||||
expect(ctxB.messages).toHaveLength(3);
|
||||
expect((ctxB.messages[2] as any).content).toBe("branch B");
|
||||
});
|
||||
|
||||
it("includes branch summary in path", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "start"),
|
||||
msg("2", "1", "assistant", "response"),
|
||||
msg("3", "2", "user", "abandoned path"),
|
||||
branchSummary("4", "2", "Summary of abandoned work", "3"),
|
||||
msg("5", "4", "user", "new direction"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries, "5");
|
||||
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect((ctx.messages[2] as any).summary).toContain("Summary of abandoned work");
|
||||
expect((ctx.messages[3] as any).content).toBe("new direction");
|
||||
});
|
||||
|
||||
it("complex tree with multiple branches and compaction", () => {
|
||||
// Tree:
|
||||
// 1 -> 2 -> 3 -> 4 -> compaction(5) -> 6 -> 7 (main path)
|
||||
// \-> 8 -> 9 (abandoned branch)
|
||||
// \-> branchSummary(10) -> 11 (resumed from 3)
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "start"),
|
||||
msg("2", "1", "assistant", "r1"),
|
||||
msg("3", "2", "user", "q2"),
|
||||
msg("4", "3", "assistant", "r2"),
|
||||
compaction("5", "4", "Compacted history", "3"),
|
||||
msg("6", "5", "user", "q3"),
|
||||
msg("7", "6", "assistant", "r3"),
|
||||
// Abandoned branch from 3
|
||||
msg("8", "3", "user", "wrong path"),
|
||||
msg("9", "8", "assistant", "wrong response"),
|
||||
// Branch summary resuming from 3
|
||||
branchSummary("10", "3", "Tried wrong approach", "9"),
|
||||
msg("11", "10", "user", "better approach"),
|
||||
];
|
||||
|
||||
// Main path to 7: summary + kept(3,4) + after(6,7)
|
||||
const ctxMain = buildSessionContext(entries, "7");
|
||||
expect(ctxMain.messages).toHaveLength(5);
|
||||
expect((ctxMain.messages[0] as any).summary).toContain("Compacted history");
|
||||
expect((ctxMain.messages[1] as any).content).toBe("q2");
|
||||
expect((ctxMain.messages[2] as any).content[0].text).toBe("r2");
|
||||
expect((ctxMain.messages[3] as any).content).toBe("q3");
|
||||
expect((ctxMain.messages[4] as any).content[0].text).toBe("r3");
|
||||
|
||||
// Branch path to 11: 1,2,3 + branch_summary + 11
|
||||
const ctxBranch = buildSessionContext(entries, "11");
|
||||
expect(ctxBranch.messages).toHaveLength(5);
|
||||
expect((ctxBranch.messages[0] as any).content).toBe("start");
|
||||
expect((ctxBranch.messages[1] as any).content[0].text).toBe("r1");
|
||||
expect((ctxBranch.messages[2] as any).content).toBe("q2");
|
||||
expect((ctxBranch.messages[3] as any).summary).toContain("Tried wrong approach");
|
||||
expect((ctxBranch.messages[4] as any).content).toBe("better approach");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("uses last entry when leafId not found", () => {
|
||||
const entries: SessionEntry[] = [msg("1", null, "user", "hello"), msg("2", "1", "assistant", "hi")];
|
||||
const ctx = buildSessionContext(entries, "nonexistent");
|
||||
expect(ctx.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles orphaned entries gracefully", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
msg("2", "missing", "assistant", "orphan"), // parent doesn't exist
|
||||
];
|
||||
const ctx = buildSessionContext(entries, "2");
|
||||
// Should only get the orphan since parent chain is broken
|
||||
expect(ctx.messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SessionManager } from "../../src/core/session-manager.js";
|
||||
|
||||
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("SessionManager.newSession with custom id", () => {
|
||||
it("uses the provided id instead of generating one", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession({ id: "my-custom-id" });
|
||||
expect(session.getSessionId()).toBe("my-custom-id");
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when no id is provided", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession();
|
||||
const id = session.getSessionId();
|
||||
expect(id).toBeDefined();
|
||||
expect(id).not.toBe("");
|
||||
expect(id).toMatch(UUID_V7_RE);
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when options is provided without id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession({ parentSession: "parent.jsonl" });
|
||||
const id = session.getSessionId();
|
||||
expect(id).toBeDefined();
|
||||
expect(id).not.toBe("");
|
||||
expect(id).toMatch(UUID_V7_RE);
|
||||
});
|
||||
|
||||
it("includes the custom id in the session header", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession({ id: "header-test-id" });
|
||||
|
||||
const header = session.getHeader();
|
||||
expect(header).not.toBeNull();
|
||||
expect(header!.id).toBe("header-test-id");
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when constructed without an explicit id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getSessionId()).toMatch(UUID_V7_RE);
|
||||
expect(session.getHeader()!.id).toBe(session.getSessionId());
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when creating a branched session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
const firstId = session.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
session.createBranchedSession(firstId);
|
||||
|
||||
expect(session.getSessionId()).toMatch(UUID_V7_RE);
|
||||
expect(session.getHeader()!.id).toBe(session.getSessionId());
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when forking from another session file", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-"));
|
||||
const sourcePath = join(tempDir, "source.jsonl");
|
||||
writeFileSync(
|
||||
sourcePath,
|
||||
`${[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "legacy-session-id",
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: tempDir,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
].join("\n")}
|
||||
`,
|
||||
);
|
||||
|
||||
const forked = SessionManager.forkFrom(sourcePath, tempDir, tempDir);
|
||||
const header = forked.getHeader();
|
||||
expect(header).not.toBeNull();
|
||||
expect(header!.id).toMatch(UUID_V7_RE);
|
||||
expect(header!.parentSession).toBe(sourcePath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.js";
|
||||
|
||||
describe("loadEntriesFromFile", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns empty array for non-existent file", () => {
|
||||
const entries = loadEntriesFromFile(join(tempDir, "nonexistent.jsonl"));
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty file", () => {
|
||||
const file = join(tempDir, "empty.jsonl");
|
||||
writeFileSync(file, "");
|
||||
expect(loadEntriesFromFile(file)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for file without valid session header", () => {
|
||||
const file = join(tempDir, "no-header.jsonl");
|
||||
writeFileSync(file, '{"type":"message","id":"1"}\n');
|
||||
expect(loadEntriesFromFile(file)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for malformed JSON", () => {
|
||||
const file = join(tempDir, "malformed.jsonl");
|
||||
writeFileSync(file, "not json\n");
|
||||
expect(loadEntriesFromFile(file)).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads valid session file", () => {
|
||||
const file = join(tempDir, "valid.jsonl");
|
||||
writeFileSync(
|
||||
file,
|
||||
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
|
||||
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
|
||||
);
|
||||
const entries = loadEntriesFromFile(file);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].type).toBe("session");
|
||||
expect(entries[1].type).toBe("message");
|
||||
});
|
||||
|
||||
it("skips malformed lines but keeps valid ones", () => {
|
||||
const file = join(tempDir, "mixed.jsonl");
|
||||
writeFileSync(
|
||||
file,
|
||||
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
|
||||
"not valid json\n" +
|
||||
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
|
||||
);
|
||||
const entries = loadEntriesFromFile(file);
|
||||
expect(entries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMostRecentSession", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns null for empty directory", () => {
|
||||
expect(findMostRecentSession(tempDir)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-existent directory", () => {
|
||||
expect(findMostRecentSession(join(tempDir, "nonexistent"))).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores non-jsonl files", () => {
|
||||
writeFileSync(join(tempDir, "file.txt"), "hello");
|
||||
writeFileSync(join(tempDir, "file.json"), "{}");
|
||||
expect(findMostRecentSession(tempDir)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores jsonl files without valid session header", () => {
|
||||
writeFileSync(join(tempDir, "invalid.jsonl"), '{"type":"message"}\n');
|
||||
expect(findMostRecentSession(tempDir)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns single valid session file", () => {
|
||||
const file = join(tempDir, "session.jsonl");
|
||||
writeFileSync(file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
||||
expect(findMostRecentSession(tempDir)).toBe(file);
|
||||
});
|
||||
|
||||
it("returns most recently modified session", async () => {
|
||||
const file1 = join(tempDir, "older.jsonl");
|
||||
const file2 = join(tempDir, "newer.jsonl");
|
||||
|
||||
writeFileSync(file1, '{"type":"session","id":"old","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
||||
// Small delay to ensure different mtime
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
writeFileSync(file2, '{"type":"session","id":"new","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
||||
|
||||
expect(findMostRecentSession(tempDir)).toBe(file2);
|
||||
});
|
||||
|
||||
it("skips invalid files and returns valid one", async () => {
|
||||
const invalid = join(tempDir, "invalid.jsonl");
|
||||
const valid = join(tempDir, "valid.jsonl");
|
||||
|
||||
writeFileSync(invalid, '{"type":"not-session"}\n');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
||||
|
||||
expect(findMostRecentSession(tempDir)).toBe(valid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionManager.setSessionFile with corrupted files", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("truncates and rewrites empty file with valid header", () => {
|
||||
const emptyFile = join(tempDir, "empty.jsonl");
|
||||
writeFileSync(emptyFile, "");
|
||||
|
||||
const sm = SessionManager.open(emptyFile, tempDir);
|
||||
|
||||
// Should have created a new session with valid header
|
||||
expect(sm.getSessionId()).toBeTruthy();
|
||||
expect(sm.getHeader()).toBeTruthy();
|
||||
expect(sm.getHeader()?.type).toBe("session");
|
||||
|
||||
// File should now contain a valid header
|
||||
const content = readFileSync(emptyFile, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
expect(lines.length).toBe(1);
|
||||
const header = JSON.parse(lines[0]);
|
||||
expect(header.type).toBe("session");
|
||||
expect(header.id).toBe(sm.getSessionId());
|
||||
});
|
||||
|
||||
it("truncates and rewrites file without valid header", () => {
|
||||
const noHeaderFile = join(tempDir, "no-header.jsonl");
|
||||
// File with messages but no session header (corrupted state)
|
||||
writeFileSync(
|
||||
noHeaderFile,
|
||||
'{"type":"message","id":"abc","parentId":"orphaned","timestamp":"2025-01-01T00:00:00Z","message":{"role":"assistant","content":"test"}}\n',
|
||||
);
|
||||
|
||||
const sm = SessionManager.open(noHeaderFile, tempDir);
|
||||
|
||||
// Should have created a new session with valid header
|
||||
expect(sm.getSessionId()).toBeTruthy();
|
||||
expect(sm.getHeader()).toBeTruthy();
|
||||
expect(sm.getHeader()?.type).toBe("session");
|
||||
|
||||
// File should now contain only a valid header (old content truncated)
|
||||
const content = readFileSync(noHeaderFile, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
expect(lines.length).toBe(1);
|
||||
const header = JSON.parse(lines[0]);
|
||||
expect(header.type).toBe("session");
|
||||
expect(header.id).toBe(sm.getSessionId());
|
||||
});
|
||||
|
||||
it("preserves explicit session file path when recovering from corrupted file", () => {
|
||||
const explicitPath = join(tempDir, "my-session.jsonl");
|
||||
writeFileSync(explicitPath, "");
|
||||
|
||||
const sm = SessionManager.open(explicitPath, tempDir);
|
||||
|
||||
// The session file path should be preserved
|
||||
expect(sm.getSessionFile()).toBe(explicitPath);
|
||||
});
|
||||
|
||||
it("subsequent loads of recovered file work correctly", () => {
|
||||
const corruptedFile = join(tempDir, "corrupted.jsonl");
|
||||
writeFileSync(corruptedFile, "garbage content\n");
|
||||
|
||||
// First open recovers the file
|
||||
const sm1 = SessionManager.open(corruptedFile, tempDir);
|
||||
const sessionId = sm1.getSessionId();
|
||||
|
||||
// Second open should load the recovered file successfully
|
||||
const sm2 = SessionManager.open(corruptedFile, tempDir);
|
||||
expect(sm2.getSessionId()).toBe(sessionId);
|
||||
expect(sm2.getHeader()?.type).toBe("session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type LabelEntry, SessionManager } from "../../src/core/session-manager.js";
|
||||
|
||||
describe("SessionManager labels", () => {
|
||||
it("sets and gets labels", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
// No label initially
|
||||
expect(session.getLabel(msgId)).toBeUndefined();
|
||||
|
||||
// Set a label
|
||||
const labelId = session.appendLabelChange(msgId, "checkpoint");
|
||||
expect(session.getLabel(msgId)).toBe("checkpoint");
|
||||
|
||||
// Label entry should be in entries
|
||||
const entries = session.getEntries();
|
||||
const labelEntry = entries.find((e) => e.type === "label") as LabelEntry;
|
||||
expect(labelEntry).toBeDefined();
|
||||
expect(labelEntry.id).toBe(labelId);
|
||||
expect(labelEntry.targetId).toBe(msgId);
|
||||
expect(labelEntry.label).toBe("checkpoint");
|
||||
});
|
||||
|
||||
it("clears labels with undefined", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
session.appendLabelChange(msgId, "checkpoint");
|
||||
expect(session.getLabel(msgId)).toBe("checkpoint");
|
||||
|
||||
// Clear the label
|
||||
session.appendLabelChange(msgId, undefined);
|
||||
expect(session.getLabel(msgId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("last label wins", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
session.appendLabelChange(msgId, "first");
|
||||
session.appendLabelChange(msgId, "second");
|
||||
const lastLabelId = session.appendLabelChange(msgId, "third");
|
||||
|
||||
expect(session.getLabel(msgId)).toBe("third");
|
||||
|
||||
const entries = session.getEntries();
|
||||
const lastLabelEntry = entries.find((e) => e.id === lastLabelId) as LabelEntry;
|
||||
const tree = session.getTree();
|
||||
const msgNode = tree.find((n) => n.entry.id === msgId);
|
||||
expect(msgNode?.labelTimestamp).toBe(lastLabelEntry.timestamp);
|
||||
});
|
||||
|
||||
it("labels are included in tree nodes", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
});
|
||||
|
||||
const msg1LabelId = session.appendLabelChange(msg1Id, "start");
|
||||
const msg2LabelId = session.appendLabelChange(msg2Id, "response");
|
||||
|
||||
const entries = session.getEntries();
|
||||
const msg1LabelEntry = entries.find((e) => e.id === msg1LabelId) as LabelEntry;
|
||||
const msg2LabelEntry = entries.find((e) => e.id === msg2LabelId) as LabelEntry;
|
||||
const tree = session.getTree();
|
||||
|
||||
// Find the message nodes (skip label entries)
|
||||
const msg1Node = tree.find((n) => n.entry.id === msg1Id);
|
||||
expect(msg1Node?.label).toBe("start");
|
||||
expect(msg1Node?.labelTimestamp).toBe(msg1LabelEntry.timestamp);
|
||||
|
||||
// msg2 is a child of msg1
|
||||
const msg2Node = msg1Node?.children.find((n) => n.entry.id === msg2Id);
|
||||
expect(msg2Node?.label).toBe("response");
|
||||
expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp);
|
||||
});
|
||||
|
||||
it("labels are preserved in createBranchedSession", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
});
|
||||
|
||||
const msg1LabelId = session.appendLabelChange(msg1Id, "important");
|
||||
const msg2LabelId = session.appendLabelChange(msg2Id, "also-important");
|
||||
const originalEntries = session.getEntries();
|
||||
const msg1LabelEntry = originalEntries.find((e) => e.id === msg1LabelId) as LabelEntry;
|
||||
const msg2LabelEntry = originalEntries.find((e) => e.id === msg2LabelId) as LabelEntry;
|
||||
|
||||
// Branch from msg2 (in-memory mode returns null, but updates internal state)
|
||||
session.createBranchedSession(msg2Id);
|
||||
|
||||
// Labels should be preserved
|
||||
expect(session.getLabel(msg1Id)).toBe("important");
|
||||
expect(session.getLabel(msg2Id)).toBe("also-important");
|
||||
|
||||
// New label entries should exist
|
||||
const entries = session.getEntries();
|
||||
const labelEntries = entries.filter((e) => e.type === "label") as LabelEntry[];
|
||||
expect(labelEntries).toHaveLength(2);
|
||||
|
||||
const tree = session.getTree();
|
||||
const msg1Node = tree.find((n) => n.entry.id === msg1Id);
|
||||
const msg2Node = msg1Node?.children.find((n) => n.entry.id === msg2Id);
|
||||
expect(msg1Node?.labelTimestamp).toBe(msg1LabelEntry.timestamp);
|
||||
expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp);
|
||||
});
|
||||
|
||||
it("labels not on path are not preserved in createBranchedSession", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
});
|
||||
const msg3Id = session.appendMessage({ role: "user", content: "followup", timestamp: 3 });
|
||||
|
||||
// Label all messages
|
||||
session.appendLabelChange(msg1Id, "first");
|
||||
session.appendLabelChange(msg2Id, "second");
|
||||
session.appendLabelChange(msg3Id, "third");
|
||||
|
||||
// Branch from msg2 (excludes msg3)
|
||||
session.createBranchedSession(msg2Id);
|
||||
|
||||
// Only labels for msg1 and msg2 should be preserved
|
||||
expect(session.getLabel(msg1Id)).toBe("first");
|
||||
expect(session.getLabel(msg2Id)).toBe("second");
|
||||
expect(session.getLabel(msg3Id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("labels are not included in buildSessionContext", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
session.appendLabelChange(msgId, "checkpoint");
|
||||
|
||||
const ctx = session.buildSessionContext();
|
||||
expect(ctx.messages).toHaveLength(1);
|
||||
expect(ctx.messages[0].role).toBe("user");
|
||||
});
|
||||
|
||||
it("throws when labeling non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
expect(() => session.appendLabelChange("non-existent", "label")).toThrow("Entry non-existent not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type FileEntry, migrateSessionEntries } from "../../src/core/session-manager.js";
|
||||
|
||||
describe("migrateSessionEntries", () => {
|
||||
it("should add id/parentId to v1 entries", () => {
|
||||
const entries: FileEntry[] = [
|
||||
{ type: "session", id: "sess-1", timestamp: "2025-01-01T00:00:00Z", cwd: "/tmp" },
|
||||
{ type: "message", timestamp: "2025-01-01T00:00:01Z", message: { role: "user", content: "hi", timestamp: 1 } },
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2025-01-01T00:00:02Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
api: "test",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
] as FileEntry[];
|
||||
|
||||
migrateSessionEntries(entries);
|
||||
|
||||
// Header should have version set (v3 is current after hookMessage->custom migration)
|
||||
expect((entries[0] as any).version).toBe(3);
|
||||
|
||||
// Entries should have id/parentId
|
||||
const msg1 = entries[1] as any;
|
||||
const msg2 = entries[2] as any;
|
||||
|
||||
expect(msg1.id).toBeDefined();
|
||||
expect(msg1.id.length).toBe(8);
|
||||
expect(msg1.parentId).toBeNull();
|
||||
|
||||
expect(msg2.id).toBeDefined();
|
||||
expect(msg2.id.length).toBe(8);
|
||||
expect(msg2.parentId).toBe(msg1.id);
|
||||
});
|
||||
|
||||
it("should be idempotent (skip already migrated)", () => {
|
||||
const entries: FileEntry[] = [
|
||||
{ type: "session", id: "sess-1", version: 2, timestamp: "2025-01-01T00:00:00Z", cwd: "/tmp" },
|
||||
{
|
||||
type: "message",
|
||||
id: "abc12345",
|
||||
parentId: null,
|
||||
timestamp: "2025-01-01T00:00:01Z",
|
||||
message: { role: "user", content: "hi", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "def67890",
|
||||
parentId: "abc12345",
|
||||
timestamp: "2025-01-01T00:00:02Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
api: "test",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
] as FileEntry[];
|
||||
|
||||
migrateSessionEntries(entries);
|
||||
|
||||
// IDs should be unchanged
|
||||
expect((entries[1] as any).id).toBe("abc12345");
|
||||
expect((entries[2] as any).id).toBe("def67890");
|
||||
expect((entries[2] as any).parentId).toBe("abc12345");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type CustomEntry, SessionManager } from "../../src/core/session-manager.js";
|
||||
|
||||
describe("SessionManager.saveCustomEntry", () => {
|
||||
it("saves custom entries and includes them in tree traversal", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Save a message
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
// Save a custom entry
|
||||
const customId = session.appendCustomEntry("my_data", { foo: "bar" });
|
||||
|
||||
// Save another message
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
});
|
||||
|
||||
// Custom entry should be in entries
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
|
||||
const customEntry = entries.find((e) => e.type === "custom") as CustomEntry;
|
||||
expect(customEntry).toBeDefined();
|
||||
expect(customEntry.customType).toBe("my_data");
|
||||
expect(customEntry.data).toEqual({ foo: "bar" });
|
||||
expect(customEntry.id).toBe(customId);
|
||||
expect(customEntry.parentId).toBe(msgId);
|
||||
|
||||
// Tree structure should be correct
|
||||
const path = session.getBranch();
|
||||
expect(path).toHaveLength(3);
|
||||
expect(path[0].id).toBe(msgId);
|
||||
expect(path[1].id).toBe(customId);
|
||||
expect(path[2].id).toBe(msg2Id);
|
||||
|
||||
// buildSessionContext should work (custom entries skipped in messages)
|
||||
const ctx = session.buildSessionContext();
|
||||
expect(ctx.messages).toHaveLength(2); // only message entries
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type CustomEntry, SessionManager } from "../../src/core/session-manager.js";
|
||||
import { assistantMsg, userMsg } from "../utilities.js";
|
||||
|
||||
describe("SessionManager append and tree traversal", () => {
|
||||
describe("append operations", () => {
|
||||
it("appendMessage creates entry with correct parentId chain", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("first"));
|
||||
const id2 = session.appendMessage(assistantMsg("second"));
|
||||
const id3 = session.appendMessage(userMsg("third"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
|
||||
expect(entries[0].id).toBe(id1);
|
||||
expect(entries[0].parentId).toBeNull();
|
||||
expect(entries[0].type).toBe("message");
|
||||
|
||||
expect(entries[1].id).toBe(id2);
|
||||
expect(entries[1].parentId).toBe(id1);
|
||||
|
||||
expect(entries[2].id).toBe(id3);
|
||||
expect(entries[2].parentId).toBe(id2);
|
||||
});
|
||||
|
||||
it("appendThinkingLevelChange integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage(userMsg("hello"));
|
||||
const thinkingId = session.appendThinkingLevelChange("high");
|
||||
const _msg2Id = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
|
||||
const thinkingEntry = entries.find((e) => e.type === "thinking_level_change");
|
||||
expect(thinkingEntry).toBeDefined();
|
||||
expect(thinkingEntry!.id).toBe(thinkingId);
|
||||
expect(thinkingEntry!.parentId).toBe(msgId);
|
||||
|
||||
expect(entries[2].parentId).toBe(thinkingId);
|
||||
});
|
||||
|
||||
it("appendModelChange integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage(userMsg("hello"));
|
||||
const modelId = session.appendModelChange("openai", "gpt-4");
|
||||
const _msg2Id = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const modelEntry = entries.find((e) => e.type === "model_change");
|
||||
expect(modelEntry).toBeDefined();
|
||||
expect(modelEntry?.id).toBe(modelId);
|
||||
expect(modelEntry?.parentId).toBe(msgId);
|
||||
if (modelEntry?.type === "model_change") {
|
||||
expect(modelEntry.provider).toBe("openai");
|
||||
expect(modelEntry.modelId).toBe("gpt-4");
|
||||
}
|
||||
|
||||
expect(entries[2].parentId).toBe(modelId);
|
||||
});
|
||||
|
||||
it("appendCompaction integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const compactionId = session.appendCompaction("summary", id1, 1000);
|
||||
const _id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const compactionEntry = entries.find((e) => e.type === "compaction");
|
||||
expect(compactionEntry).toBeDefined();
|
||||
expect(compactionEntry?.id).toBe(compactionId);
|
||||
expect(compactionEntry?.parentId).toBe(id2);
|
||||
if (compactionEntry?.type === "compaction") {
|
||||
expect(compactionEntry.summary).toBe("summary");
|
||||
expect(compactionEntry.firstKeptEntryId).toBe(id1);
|
||||
expect(compactionEntry.tokensBefore).toBe(1000);
|
||||
}
|
||||
|
||||
expect(entries[3].parentId).toBe(compactionId);
|
||||
});
|
||||
|
||||
it("appendCustomEntry integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage(userMsg("hello"));
|
||||
const customId = session.appendCustomEntry("my_data", { key: "value" });
|
||||
const _msg2Id = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const customEntry = entries.find((e) => e.type === "custom") as CustomEntry;
|
||||
expect(customEntry).toBeDefined();
|
||||
expect(customEntry.id).toBe(customId);
|
||||
expect(customEntry.parentId).toBe(msgId);
|
||||
expect(customEntry.customType).toBe("my_data");
|
||||
expect(customEntry.data).toEqual({ key: "value" });
|
||||
|
||||
expect(entries[2].parentId).toBe(customId);
|
||||
});
|
||||
|
||||
it("leaf pointer advances after each append", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
expect(session.getLeafId()).toBeNull();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
expect(session.getLeafId()).toBe(id1);
|
||||
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
expect(session.getLeafId()).toBe(id2);
|
||||
|
||||
const id3 = session.appendThinkingLevelChange("high");
|
||||
expect(session.getLeafId()).toBe(id3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPath", () => {
|
||||
it("returns empty array for empty session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getBranch()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single entry path", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
const id = session.appendMessage(userMsg("hello"));
|
||||
|
||||
const path = session.getBranch();
|
||||
expect(path).toHaveLength(1);
|
||||
expect(path[0].id).toBe(id);
|
||||
});
|
||||
|
||||
it("returns full path from root to leaf", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendThinkingLevelChange("high");
|
||||
const id4 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const path = session.getBranch();
|
||||
expect(path).toHaveLength(4);
|
||||
expect(path.map((e) => e.id)).toEqual([id1, id2, id3, id4]);
|
||||
});
|
||||
|
||||
it("returns path from specified entry to root", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const _id3 = session.appendMessage(userMsg("3"));
|
||||
const _id4 = session.appendMessage(assistantMsg("4"));
|
||||
|
||||
const path = session.getBranch(id2);
|
||||
expect(path).toHaveLength(2);
|
||||
expect(path.map((e) => e.id)).toEqual([id1, id2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTree", () => {
|
||||
it("returns empty array for empty session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getTree()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single root for linear session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const tree = session.getTree();
|
||||
expect(tree).toHaveLength(1);
|
||||
|
||||
const root = tree[0];
|
||||
expect(root.entry.id).toBe(id1);
|
||||
expect(root.children).toHaveLength(1);
|
||||
expect(root.children[0].entry.id).toBe(id2);
|
||||
expect(root.children[0].children).toHaveLength(1);
|
||||
expect(root.children[0].children[0].entry.id).toBe(id3);
|
||||
expect(root.children[0].children[0].children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns tree with branches after branch", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Build: 1 -> 2 -> 3
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
// Branch from id2, add new path: 2 -> 4
|
||||
session.branch(id2);
|
||||
const id4 = session.appendMessage(userMsg("4-branch"));
|
||||
|
||||
const tree = session.getTree();
|
||||
expect(tree).toHaveLength(1);
|
||||
|
||||
const root = tree[0];
|
||||
expect(root.entry.id).toBe(id1);
|
||||
expect(root.children).toHaveLength(1);
|
||||
|
||||
const node2 = root.children[0];
|
||||
expect(node2.entry.id).toBe(id2);
|
||||
expect(node2.children).toHaveLength(2); // id3 and id4 are siblings
|
||||
|
||||
const childIds = node2.children.map((c) => c.entry.id).sort();
|
||||
expect(childIds).toEqual([id3, id4].sort());
|
||||
});
|
||||
|
||||
it("handles multiple branches at same point", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const _id1 = session.appendMessage(userMsg("root"));
|
||||
const id2 = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
// Branch A
|
||||
session.branch(id2);
|
||||
const idA = session.appendMessage(userMsg("branch-A"));
|
||||
|
||||
// Branch B
|
||||
session.branch(id2);
|
||||
const idB = session.appendMessage(userMsg("branch-B"));
|
||||
|
||||
// Branch C
|
||||
session.branch(id2);
|
||||
const idC = session.appendMessage(userMsg("branch-C"));
|
||||
|
||||
const tree = session.getTree();
|
||||
const node2 = tree[0].children[0];
|
||||
expect(node2.entry.id).toBe(id2);
|
||||
expect(node2.children).toHaveLength(3);
|
||||
|
||||
const branchIds = node2.children.map((c) => c.entry.id).sort();
|
||||
expect(branchIds).toEqual([idA, idB, idC].sort());
|
||||
});
|
||||
|
||||
it("handles deep branching", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Main path: 1 -> 2 -> 3 -> 4
|
||||
const _id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
const _id4 = session.appendMessage(assistantMsg("4"));
|
||||
|
||||
// Branch from 2: 2 -> 5 -> 6
|
||||
session.branch(id2);
|
||||
const id5 = session.appendMessage(userMsg("5"));
|
||||
const _id6 = session.appendMessage(assistantMsg("6"));
|
||||
|
||||
// Branch from 5: 5 -> 7
|
||||
session.branch(id5);
|
||||
const _id7 = session.appendMessage(userMsg("7"));
|
||||
|
||||
const tree = session.getTree();
|
||||
|
||||
// Verify structure
|
||||
const node2 = tree[0].children[0];
|
||||
expect(node2.children).toHaveLength(2); // id3 and id5
|
||||
|
||||
const node5 = node2.children.find((c) => c.entry.id === id5)!;
|
||||
expect(node5.children).toHaveLength(2); // id6 and id7
|
||||
|
||||
const node3 = node2.children.find((c) => c.entry.id === id3)!;
|
||||
expect(node3.children).toHaveLength(1); // id4
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch", () => {
|
||||
it("moves leaf pointer to specified entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const _id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
expect(session.getLeafId()).toBe(id3);
|
||||
|
||||
session.branch(id1);
|
||||
expect(session.getLeafId()).toBe(id1);
|
||||
});
|
||||
|
||||
it("throws for non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.appendMessage(userMsg("hello"));
|
||||
|
||||
expect(() => session.branch("nonexistent")).toThrow("Entry nonexistent not found");
|
||||
});
|
||||
|
||||
it("new appends become children of branch point", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const _id2 = session.appendMessage(assistantMsg("2"));
|
||||
|
||||
session.branch(id1);
|
||||
const id3 = session.appendMessage(userMsg("branched"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const branchedEntry = entries.find((e) => e.id === id3)!;
|
||||
expect(branchedEntry.parentId).toBe(id1); // sibling of id2
|
||||
});
|
||||
});
|
||||
|
||||
describe("branchWithSummary", () => {
|
||||
it("inserts branch summary and advances leaf", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const _id2 = session.appendMessage(assistantMsg("2"));
|
||||
const _id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const summaryId = session.branchWithSummary(id1, "Summary of abandoned work");
|
||||
|
||||
expect(session.getLeafId()).toBe(summaryId);
|
||||
|
||||
const entries = session.getEntries();
|
||||
const summaryEntry = entries.find((e) => e.type === "branch_summary");
|
||||
expect(summaryEntry).toBeDefined();
|
||||
expect(summaryEntry?.parentId).toBe(id1);
|
||||
if (summaryEntry?.type === "branch_summary") {
|
||||
expect(summaryEntry.summary).toBe("Summary of abandoned work");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws for non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.appendMessage(userMsg("hello"));
|
||||
|
||||
expect(() => session.branchWithSummary("nonexistent", "summary")).toThrow("Entry nonexistent not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLeafEntry", () => {
|
||||
it("returns undefined for empty session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getLeafEntry()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns current leaf entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
|
||||
const leaf = session.getLeafEntry();
|
||||
expect(leaf).toBeDefined();
|
||||
expect(leaf!.id).toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntry", () => {
|
||||
it("returns undefined for non-existent id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getEntry("nonexistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns entry by id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("first"));
|
||||
const id2 = session.appendMessage(assistantMsg("second"));
|
||||
|
||||
const entry1 = session.getEntry(id1);
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry1?.type).toBe("message");
|
||||
if (entry1?.type === "message" && entry1.message.role === "user") {
|
||||
expect(entry1.message.content).toBe("first");
|
||||
}
|
||||
|
||||
const entry2 = session.getEntry(id2);
|
||||
expect(entry2).toBeDefined();
|
||||
if (entry2?.type === "message" && entry2.message.role === "assistant") {
|
||||
expect((entry2.message.content as any)[0].text).toBe("second");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionContext with branches", () => {
|
||||
it("returns messages from current branch only", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Main: 1 -> 2 -> 3
|
||||
session.appendMessage(userMsg("msg1"));
|
||||
const id2 = session.appendMessage(assistantMsg("msg2"));
|
||||
session.appendMessage(userMsg("msg3"));
|
||||
|
||||
// Branch from 2: 2 -> 4
|
||||
session.branch(id2);
|
||||
session.appendMessage(assistantMsg("msg4-branch"));
|
||||
|
||||
const ctx = session.buildSessionContext();
|
||||
expect(ctx.messages).toHaveLength(3); // msg1, msg2, msg4-branch (not msg3)
|
||||
|
||||
expect((ctx.messages[0] as any).content).toBe("msg1");
|
||||
expect((ctx.messages[1] as any).content[0].text).toBe("msg2");
|
||||
expect((ctx.messages[2] as any).content[0].text).toBe("msg4-branch");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBranchedSession", () => {
|
||||
it("throws for non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.appendMessage(userMsg("hello"));
|
||||
|
||||
expect(() => session.createBranchedSession("nonexistent")).toThrow("Entry nonexistent not found");
|
||||
});
|
||||
|
||||
it("creates new session with path to specified leaf (in-memory)", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Build: 1 -> 2 -> 3 -> 4
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
session.appendMessage(assistantMsg("4"));
|
||||
|
||||
// Branch from 3: 3 -> 5
|
||||
session.branch(id3);
|
||||
const _id5 = session.appendMessage(userMsg("5"));
|
||||
|
||||
// Create branched session from id2 (should only have 1 -> 2)
|
||||
const result = session.createBranchedSession(id2);
|
||||
expect(result).toBeUndefined(); // in-memory returns null
|
||||
|
||||
// Session should now only have entries 1 and 2
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].id).toBe(id1);
|
||||
expect(entries[1].id).toBe(id2);
|
||||
});
|
||||
|
||||
it("extracts correct path from branched tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Build: 1 -> 2 -> 3
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
session.appendMessage(userMsg("3"));
|
||||
|
||||
// Branch from 2: 2 -> 4 -> 5
|
||||
session.branch(id2);
|
||||
const id4 = session.appendMessage(userMsg("4"));
|
||||
const id5 = session.appendMessage(assistantMsg("5"));
|
||||
|
||||
// Create branched session from id5 (should have 1 -> 2 -> 4 -> 5)
|
||||
session.createBranchedSession(id5);
|
||||
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(4);
|
||||
expect(entries.map((e) => e.id)).toEqual([id1, id2, id4, id5]);
|
||||
});
|
||||
|
||||
it("does not duplicate entries when forking from first user message", () => {
|
||||
const tempDir = join(tmpdir(), `session-fork-dedup-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Create a persisted session with a couple of turns
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
const id1 = session.appendMessage(userMsg("first question"));
|
||||
session.appendMessage(assistantMsg("first answer"));
|
||||
session.appendMessage(userMsg("second question"));
|
||||
session.appendMessage(assistantMsg("second answer"));
|
||||
|
||||
// Fork from the very first user message (no assistant in the branched path)
|
||||
const newFile = session.createBranchedSession(id1);
|
||||
expect(newFile).toBeDefined();
|
||||
|
||||
// The branched path has no assistant, so the file should not exist yet
|
||||
// (deferred to _persist on first assistant, matching newSession() contract)
|
||||
expect(existsSync(newFile!)).toBe(false);
|
||||
|
||||
// Simulate extension adding entry before assistant (like preset on turn_start)
|
||||
session.appendCustomEntry("preset-state", { name: "plan" });
|
||||
|
||||
// Now the assistant responds
|
||||
session.appendMessage(assistantMsg("new answer"));
|
||||
|
||||
// File should now exist with exactly one header and no duplicate IDs
|
||||
expect(existsSync(newFile!)).toBe(true);
|
||||
const content = readFileSync(newFile!, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const records = lines.map((line) => JSON.parse(line));
|
||||
|
||||
expect(records.filter((r) => r.type === "session")).toHaveLength(1);
|
||||
|
||||
const entryIds = records
|
||||
.filter((r) => r.type !== "session")
|
||||
.map((r) => r.id)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
expect(new Set(entryIds).size).toBe(entryIds.length);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes file immediately when forking from a point with assistant messages", () => {
|
||||
const tempDir = join(tmpdir(), `session-fork-with-assistant-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
session.appendMessage(userMsg("first question"));
|
||||
const id2 = session.appendMessage(assistantMsg("first answer"));
|
||||
session.appendMessage(userMsg("second question"));
|
||||
session.appendMessage(assistantMsg("second answer"));
|
||||
|
||||
// Fork including the assistant message
|
||||
const newFile = session.createBranchedSession(id2);
|
||||
expect(newFile).toBeDefined();
|
||||
|
||||
// Path includes an assistant, so file should be written immediately
|
||||
expect(existsSync(newFile!)).toBe(true);
|
||||
const content = readFileSync(newFile!, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const records = lines.map((line) => JSON.parse(line));
|
||||
expect(records.filter((r) => r.type === "session")).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { setKeybindings } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.js";
|
||||
import type { SessionInfo } from "../src/core/session-manager.js";
|
||||
import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
let reject: (err: unknown) => void = () => {};
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<SessionInfo> & { id: string }): SessionInfo {
|
||||
return {
|
||||
path: overrides.path ?? `/tmp/${overrides.id}.jsonl`,
|
||||
id: overrides.id,
|
||||
cwd: overrides.cwd ?? "",
|
||||
name: overrides.name,
|
||||
created: overrides.created ?? new Date(0),
|
||||
modified: overrides.modified ?? new Date(0),
|
||||
messageCount: overrides.messageCount ?? 1,
|
||||
firstMessage: overrides.firstMessage ?? "hello",
|
||||
allMessagesText: overrides.allMessagesText ?? "hello",
|
||||
};
|
||||
}
|
||||
|
||||
const CTRL_D = "\x04";
|
||||
const CTRL_BACKSPACE = "\x1b[127;5u";
|
||||
|
||||
describe("session selector path/delete interactions", () => {
|
||||
const keybindings = new KeybindingsManager();
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure test isolation: keybindings are a global singleton
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
// session selector uses the global theme instance
|
||||
initTheme("dark");
|
||||
});
|
||||
it("does not treat Ctrl+Backspace as delete when search query is non-empty", async () => {
|
||||
const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })];
|
||||
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const list = selector.getSessionList();
|
||||
const confirmationChanges: Array<string | null> = [];
|
||||
list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path);
|
||||
|
||||
list.handleInput("a");
|
||||
list.handleInput(CTRL_BACKSPACE);
|
||||
|
||||
expect(confirmationChanges).toEqual([]);
|
||||
});
|
||||
|
||||
it("enters confirmation mode on Ctrl+D even with a non-empty search query", async () => {
|
||||
const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })];
|
||||
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const list = selector.getSessionList();
|
||||
const confirmationChanges: Array<string | null> = [];
|
||||
list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path);
|
||||
|
||||
list.handleInput("a");
|
||||
list.handleInput(CTRL_D);
|
||||
|
||||
expect(confirmationChanges).toEqual([sessions[0]!.path]);
|
||||
});
|
||||
|
||||
it("enters confirmation mode on Ctrl+Backspace when search query is empty", async () => {
|
||||
const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })];
|
||||
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const list = selector.getSessionList();
|
||||
const confirmationChanges: Array<string | null> = [];
|
||||
list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path);
|
||||
|
||||
let deletedPath: string | null = null;
|
||||
list.onDeleteSession = async (sessionPath) => {
|
||||
deletedPath = sessionPath;
|
||||
};
|
||||
|
||||
list.handleInput(CTRL_BACKSPACE);
|
||||
expect(confirmationChanges).toEqual([sessions[0]!.path]);
|
||||
|
||||
list.handleInput("\r");
|
||||
expect(confirmationChanges).toEqual([sessions[0]!.path, null]);
|
||||
expect(deletedPath).toBe(sessions[0]!.path);
|
||||
});
|
||||
|
||||
it("does not switch scope back to All when All load resolves after toggling back to Current", async () => {
|
||||
const currentSessions = [makeSession({ id: "current" })];
|
||||
const allDeferred = createDeferred<SessionInfo[]>();
|
||||
let allLoadCalls = 0;
|
||||
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => currentSessions,
|
||||
async () => {
|
||||
allLoadCalls++;
|
||||
return allDeferred.promise;
|
||||
},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const list = selector.getSessionList();
|
||||
list.handleInput("\t"); // current -> all (starts async load)
|
||||
list.handleInput("\t"); // all -> current
|
||||
|
||||
allDeferred.resolve([makeSession({ id: "all" })]);
|
||||
await flushPromises();
|
||||
|
||||
expect(allLoadCalls).toBe(1);
|
||||
const output = selector.render(120).join("\n");
|
||||
expect(output).toContain("Resume Session (Current Folder)");
|
||||
expect(output).not.toContain("Resume Session (All)");
|
||||
});
|
||||
|
||||
it("does not start redundant All loads when toggling scopes while All is already loading", async () => {
|
||||
const currentSessions = [makeSession({ id: "current" })];
|
||||
const allDeferred = createDeferred<SessionInfo[]>();
|
||||
let allLoadCalls = 0;
|
||||
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => currentSessions,
|
||||
async () => {
|
||||
allLoadCalls++;
|
||||
return allDeferred.promise;
|
||||
},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const list = selector.getSessionList();
|
||||
list.handleInput("\t"); // current -> all (starts async load)
|
||||
list.handleInput("\t"); // all -> current
|
||||
list.handleInput("\t"); // current -> all again while load pending
|
||||
|
||||
expect(allLoadCalls).toBe(1);
|
||||
|
||||
allDeferred.resolve([makeSession({ id: "all" })]);
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { setKeybindings } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.js";
|
||||
import type { SessionInfo } from "../src/core/session-manager.js";
|
||||
import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<SessionInfo> & { id: string }): SessionInfo {
|
||||
return {
|
||||
path: overrides.path ?? `/tmp/${overrides.id}.jsonl`,
|
||||
id: overrides.id,
|
||||
cwd: overrides.cwd ?? "",
|
||||
name: overrides.name,
|
||||
created: overrides.created ?? new Date(0),
|
||||
modified: overrides.modified ?? new Date(0),
|
||||
messageCount: overrides.messageCount ?? 1,
|
||||
firstMessage: overrides.firstMessage ?? "hello",
|
||||
allMessagesText: overrides.allMessagesText ?? "hello",
|
||||
};
|
||||
}
|
||||
|
||||
// Kitty keyboard protocol encoding for Ctrl+R
|
||||
const CTRL_R = "\x1b[114;5u";
|
||||
|
||||
describe("session selector rename", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure test isolation: keybindings are a global singleton
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
it("shows rename hint in interactive /resume picker configuration", async () => {
|
||||
const sessions = [makeSession({ id: "a" })];
|
||||
const keybindings = new KeybindingsManager();
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ showRenameHint: true, keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const output = selector.render(120).join("\n");
|
||||
expect(output).toContain("ctrl+r");
|
||||
expect(output).toContain("rename");
|
||||
});
|
||||
|
||||
it("does not show rename hint in --resume picker configuration", async () => {
|
||||
const sessions = [makeSession({ id: "a" })];
|
||||
const keybindings = new KeybindingsManager();
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ showRenameHint: false, keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const output = selector.render(120).join("\n");
|
||||
expect(output).not.toContain("ctrl+r");
|
||||
expect(output).not.toContain("rename");
|
||||
});
|
||||
|
||||
it("enters rename mode on Ctrl+R and submits with Enter", async () => {
|
||||
const sessions = [makeSession({ id: "a", name: "Old" })];
|
||||
const renameSession = vi.fn(async () => {});
|
||||
|
||||
const keybindings = new KeybindingsManager();
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ renameSession, showRenameHint: true, keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
selector.getSessionList().handleInput(CTRL_R);
|
||||
await flushPromises();
|
||||
|
||||
// Rename mode layout
|
||||
const output = selector.render(120).join("\n");
|
||||
expect(output).toContain("Rename Session");
|
||||
expect(output).not.toContain("Resume Session");
|
||||
|
||||
// Type and submit
|
||||
selector.handleInput("X");
|
||||
selector.handleInput("\r");
|
||||
await flushPromises();
|
||||
|
||||
expect(renameSession).toHaveBeenCalledTimes(1);
|
||||
expect(renameSession).toHaveBeenCalledWith(sessions[0]!.path, "XOld");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionInfo } from "../src/core/session-manager.js";
|
||||
import { filterAndSortSessions } from "../src/modes/interactive/components/session-selector-search.js";
|
||||
|
||||
function makeSession(
|
||||
overrides: Partial<SessionInfo> & { id: string; modified: Date; allMessagesText: string },
|
||||
): SessionInfo {
|
||||
return {
|
||||
path: `/tmp/${overrides.id}.jsonl`,
|
||||
id: overrides.id,
|
||||
cwd: overrides.cwd ?? "",
|
||||
name: overrides.name,
|
||||
created: overrides.created ?? new Date(0),
|
||||
modified: overrides.modified,
|
||||
messageCount: overrides.messageCount ?? 1,
|
||||
firstMessage: overrides.firstMessage ?? "(no messages)",
|
||||
allMessagesText: overrides.allMessagesText,
|
||||
};
|
||||
}
|
||||
|
||||
describe("session selector search", () => {
|
||||
it("filters by quoted phrase with whitespace normalization", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "a",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "node\n\n cve was discussed",
|
||||
}),
|
||||
makeSession({
|
||||
id: "b",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: "node something else",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterAndSortSessions(sessions, '"node cve"', "recent");
|
||||
expect(result.map((s) => s.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("filters by regex (re:) and is case-insensitive", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "a",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: "Brave is great",
|
||||
}),
|
||||
makeSession({
|
||||
id: "b",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "bravery is not the same",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterAndSortSessions(sessions, "re:\\bbrave\\b", "recent");
|
||||
expect(result.map((s) => s.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("recent sort preserves input order", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "newer",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "older",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "nomatch",
|
||||
modified: new Date("2026-01-04T00:00:00.000Z"),
|
||||
allMessagesText: "something else",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterAndSortSessions(sessions, '"brave"', "recent");
|
||||
expect(result.map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("relevance sort orders by score and tie-breaks by modified desc", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "late",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "xxxx brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "early",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "brave xxxx",
|
||||
}),
|
||||
];
|
||||
|
||||
const result1 = filterAndSortSessions(sessions, '"brave"', "relevance");
|
||||
expect(result1.map((s) => s.id)).toEqual(["early", "late"]);
|
||||
|
||||
const tieSessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "newer",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "older",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
];
|
||||
|
||||
const result2 = filterAndSortSessions(tieSessions, '"brave"', "relevance");
|
||||
expect(result2.map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("returns empty list for invalid regex", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "a",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterAndSortSessions(sessions, "re:(", "recent");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
describe("name filter", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "named1",
|
||||
name: "My Project",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "blueberry",
|
||||
}),
|
||||
makeSession({
|
||||
id: "named2",
|
||||
name: "Another Named",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: "blueberry",
|
||||
}),
|
||||
makeSession({
|
||||
id: "other1",
|
||||
modified: new Date("2026-01-04T00:00:00.000Z"),
|
||||
allMessagesText: "blueberry",
|
||||
}),
|
||||
makeSession({
|
||||
id: "other2",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "blueberry",
|
||||
}),
|
||||
];
|
||||
|
||||
it("returns all sessions when nameFilter is 'all'", () => {
|
||||
const result = filterAndSortSessions(sessions, "", "recent", "all");
|
||||
expect(result.map((session) => session.id)).toEqual(["named1", "named2", "other1", "other2"]);
|
||||
});
|
||||
|
||||
it("returns only named sessions when nameFilter is 'named'", () => {
|
||||
const result = filterAndSortSessions(sessions, "", "recent", "named");
|
||||
expect(result.map((session) => session.id)).toEqual(["named1", "named2"]);
|
||||
});
|
||||
|
||||
it("applies name filter before search query", () => {
|
||||
const result = filterAndSortSessions(sessions, "blueberry", "recent", "named");
|
||||
expect(result.map((session) => session.id)).toEqual(["named1", "named2"]);
|
||||
});
|
||||
|
||||
it("excludes whitespace-only names from named filter", () => {
|
||||
const sessionsWithWhitespace: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "whitespace",
|
||||
name: " ",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "test",
|
||||
}),
|
||||
makeSession({
|
||||
id: "empty",
|
||||
name: "",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: "test",
|
||||
}),
|
||||
makeSession({
|
||||
id: "named",
|
||||
name: "Real Name",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "test",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterAndSortSessions(sessionsWithWhitespace, "", "recent", "named");
|
||||
expect(result.map((session) => session.id)).toEqual(["named"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
/**
|
||||
* Tests for the fix to a bug where external file changes to arrays were overwritten.
|
||||
*
|
||||
* The bug scenario was:
|
||||
* 1. Pi starts with settings.json containing packages: ["npm:some-pkg"]
|
||||
* 2. User externally edits file to packages: []
|
||||
* 3. User changes an unrelated setting (e.g., theme) via UI
|
||||
* 4. save() would overwrite packages back to ["npm:some-pkg"] from stale in-memory state
|
||||
*
|
||||
* The fix tracks which fields were explicitly modified during the session, and only
|
||||
* those fields override file values during save().
|
||||
*/
|
||||
describe("SettingsManager - External Edit Preservation", () => {
|
||||
const testDir = join(process.cwd(), "test-settings-bug-tmp");
|
||||
const agentDir = join(testDir, "agent");
|
||||
const projectDir = join(testDir, "project");
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve file changes to packages array when changing unrelated setting", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
|
||||
// Initial state: packages has one item
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: "dark",
|
||||
packages: ["npm:pi-mcp-adapter"],
|
||||
}),
|
||||
);
|
||||
|
||||
// Pi starts up, loads settings into memory
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
// At this point, globalSettings.packages = ["npm:pi-mcp-adapter"]
|
||||
expect(manager.getPackages()).toEqual(["npm:pi-mcp-adapter"]);
|
||||
|
||||
// User externally edits settings.json to remove the package
|
||||
const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
currentSettings.packages = []; // User wants to remove this!
|
||||
writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));
|
||||
|
||||
// Verify file was changed
|
||||
expect(JSON.parse(readFileSync(settingsPath, "utf-8")).packages).toEqual([]);
|
||||
|
||||
// User changes an UNRELATED setting via UI (this triggers save)
|
||||
manager.setTheme("light");
|
||||
await manager.flush();
|
||||
|
||||
// With the fix, packages should be preserved as [] (not reverted to startup value)
|
||||
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
|
||||
expect(savedSettings.packages).toEqual([]);
|
||||
expect(savedSettings.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("should preserve file changes to extensions array when changing unrelated setting", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: "dark",
|
||||
extensions: ["/old/extension.ts"],
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
// User externally updates extensions
|
||||
const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
currentSettings.extensions = ["/new/extension.ts"];
|
||||
writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));
|
||||
|
||||
// Change unrelated setting
|
||||
manager.setDefaultThinkingLevel("high");
|
||||
await manager.flush();
|
||||
|
||||
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
|
||||
// With the fix, extensions should be preserved (not reverted to startup value)
|
||||
expect(savedSettings.extensions).toEqual(["/new/extension.ts"]);
|
||||
});
|
||||
|
||||
it("should preserve external project settings changes when updating unrelated project field", async () => {
|
||||
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
|
||||
writeFileSync(
|
||||
projectSettingsPath,
|
||||
JSON.stringify({
|
||||
extensions: ["./old-extension.ts"],
|
||||
prompts: ["./old-prompt.md"],
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
const currentProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
|
||||
currentProjectSettings.prompts = ["./new-prompt.md"];
|
||||
writeFileSync(projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2));
|
||||
|
||||
manager.setProjectExtensionPaths(["./updated-extension.ts"]);
|
||||
await manager.flush();
|
||||
|
||||
const savedProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
|
||||
expect(savedProjectSettings.prompts).toEqual(["./new-prompt.md"]);
|
||||
expect(savedProjectSettings.extensions).toEqual(["./updated-extension.ts"]);
|
||||
});
|
||||
|
||||
it("should let in-memory project changes override external changes for the same project field", async () => {
|
||||
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
|
||||
writeFileSync(
|
||||
projectSettingsPath,
|
||||
JSON.stringify({
|
||||
extensions: ["./initial-extension.ts"],
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
const currentProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
|
||||
currentProjectSettings.extensions = ["./external-extension.ts"];
|
||||
writeFileSync(projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2));
|
||||
|
||||
manager.setProjectExtensionPaths(["./in-memory-extension.ts"]);
|
||||
await manager.flush();
|
||||
|
||||
const savedProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
|
||||
expect(savedProjectSettings.extensions).toEqual(["./in-memory-extension.ts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
describe("SettingsManager", () => {
|
||||
const testDir = join(process.cwd(), "test-settings-tmp");
|
||||
const agentDir = join(testDir, "agent");
|
||||
const projectDir = join(testDir, "project");
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up and create fresh directories
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("preserves externally added settings", () => {
|
||||
it("should preserve enabledModels when changing thinking level", async () => {
|
||||
// Create initial settings file
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: "dark",
|
||||
defaultModel: "claude-sonnet",
|
||||
}),
|
||||
);
|
||||
|
||||
// Create SettingsManager (simulates pi starting up)
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
// Simulate user editing settings.json externally to add enabledModels
|
||||
const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
currentSettings.enabledModels = ["claude-opus-4-5", "gpt-5.2-codex"];
|
||||
writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));
|
||||
|
||||
// User changes thinking level via Shift+Tab
|
||||
manager.setDefaultThinkingLevel("high");
|
||||
await manager.flush();
|
||||
|
||||
// Verify enabledModels is preserved
|
||||
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
expect(savedSettings.enabledModels).toEqual(["claude-opus-4-5", "gpt-5.2-codex"]);
|
||||
expect(savedSettings.defaultThinkingLevel).toBe("high");
|
||||
expect(savedSettings.theme).toBe("dark");
|
||||
expect(savedSettings.defaultModel).toBe("claude-sonnet");
|
||||
});
|
||||
|
||||
it("should preserve custom settings when changing theme", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
defaultModel: "claude-sonnet",
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
// User adds custom settings externally
|
||||
const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
currentSettings.shellPath = "/bin/zsh";
|
||||
currentSettings.extensions = ["/path/to/extension.ts"];
|
||||
writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));
|
||||
|
||||
// User changes theme
|
||||
manager.setTheme("light");
|
||||
await manager.flush();
|
||||
|
||||
// Verify all settings preserved
|
||||
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
expect(savedSettings.shellPath).toBe("/bin/zsh");
|
||||
expect(savedSettings.extensions).toEqual(["/path/to/extension.ts"]);
|
||||
expect(savedSettings.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("should let in-memory changes override file changes for same key", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: "dark",
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
// User externally sets thinking level to "low"
|
||||
const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
currentSettings.defaultThinkingLevel = "low";
|
||||
writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));
|
||||
|
||||
// But then changes it via UI to "high"
|
||||
manager.setDefaultThinkingLevel("high");
|
||||
await manager.flush();
|
||||
|
||||
// In-memory change should win
|
||||
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
expect(savedSettings.defaultThinkingLevel).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("packages migration", () => {
|
||||
it("should keep local-only extensions in extensions array", () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
extensions: ["/local/ext.ts", "./relative/ext.ts"],
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getPackages()).toEqual([]);
|
||||
expect(manager.getExtensionPaths()).toEqual(["/local/ext.ts", "./relative/ext.ts"]);
|
||||
});
|
||||
|
||||
it("should handle packages with filtering objects", () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
packages: [
|
||||
"npm:simple-pkg",
|
||||
{
|
||||
source: "npm:shitty-extensions",
|
||||
extensions: ["extensions/oracle.ts"],
|
||||
skills: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
const packages = manager.getPackages();
|
||||
expect(packages).toHaveLength(2);
|
||||
expect(packages[0]).toBe("npm:simple-pkg");
|
||||
expect(packages[1]).toEqual({
|
||||
source: "npm:shitty-extensions",
|
||||
extensions: ["extensions/oracle.ts"],
|
||||
skills: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reload", () => {
|
||||
it("should reload global settings from disk", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: "dark",
|
||||
extensions: ["/before.ts"],
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: "light",
|
||||
extensions: ["/after.ts"],
|
||||
defaultModel: "claude-sonnet",
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.reload();
|
||||
|
||||
expect(manager.getTheme()).toBe("light");
|
||||
expect(manager.getExtensionPaths()).toEqual(["/after.ts"]);
|
||||
expect(manager.getDefaultModel()).toBe("claude-sonnet");
|
||||
});
|
||||
|
||||
it("should keep previous settings when file is invalid", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
writeFileSync(settingsPath, "{ invalid json");
|
||||
await manager.reload();
|
||||
|
||||
expect(manager.getTheme()).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error tracking", () => {
|
||||
it("should collect and clear load errors via drainErrors", () => {
|
||||
const globalSettingsPath = join(agentDir, "settings.json");
|
||||
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
|
||||
writeFileSync(globalSettingsPath, "{ invalid global json");
|
||||
writeFileSync(projectSettingsPath, "{ invalid project json");
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
const errors = manager.drainErrors();
|
||||
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors.map((e) => e.scope).sort()).toEqual(["global", "project"]);
|
||||
expect(manager.drainErrors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project settings directory creation", () => {
|
||||
it("should not create .pi folder when only reading project settings", () => {
|
||||
// Create agent dir with global settings, but NO .pi folder in project
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
|
||||
|
||||
// Delete the .pi folder that beforeEach created
|
||||
rmSync(join(projectDir, ".pi"), { recursive: true });
|
||||
|
||||
// Create SettingsManager (reads both global and project settings)
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
// .pi folder should NOT have been created just from reading
|
||||
expect(existsSync(join(projectDir, ".pi"))).toBe(false);
|
||||
|
||||
// Settings should still be loaded from global
|
||||
expect(manager.getTheme()).toBe("dark");
|
||||
});
|
||||
|
||||
it("should create .pi folder when writing project settings", async () => {
|
||||
// Create agent dir with global settings, but NO .pi folder in project
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
|
||||
|
||||
// Delete the .pi folder that beforeEach created
|
||||
rmSync(join(projectDir, ".pi"), { recursive: true });
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
// .pi folder should NOT exist yet
|
||||
expect(existsSync(join(projectDir, ".pi"))).toBe(false);
|
||||
|
||||
// Write a project-specific setting
|
||||
manager.setProjectPackages([{ source: "npm:test-pkg" }]);
|
||||
await manager.flush();
|
||||
|
||||
// Now .pi folder should exist
|
||||
expect(existsSync(join(projectDir, ".pi"))).toBe(true);
|
||||
|
||||
// And settings file should be created
|
||||
expect(existsSync(join(projectDir, ".pi", "settings.json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellCommandPrefix", () => {
|
||||
it("should load shellCommandPrefix from settings", () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ shellCommandPrefix: "shopt -s expand_aliases" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getShellCommandPrefix()).toBe("shopt -s expand_aliases");
|
||||
});
|
||||
|
||||
it("should return undefined when shellCommandPrefix is not set", () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getShellCommandPrefix()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should preserve shellCommandPrefix when saving unrelated settings", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ shellCommandPrefix: "shopt -s expand_aliases" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
manager.setTheme("light");
|
||||
await manager.flush();
|
||||
|
||||
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
||||
expect(savedSettings.shellCommandPrefix).toBe("shopt -s expand_aliases");
|
||||
expect(savedSettings.theme).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionDir", () => {
|
||||
it("should return undefined when not set", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark" }));
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
expect(manager.getSessionDir()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return global sessionDir", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ sessionDir: "/tmp/sessions" }));
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
expect(manager.getSessionDir()).toBe("/tmp/sessions");
|
||||
});
|
||||
|
||||
it("should return project sessionDir, overriding global", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ sessionDir: "/global/sessions" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./sessions" }));
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
expect(manager.getSessionDir()).toBe("./sessions");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import { homedir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ResourceDiagnostic } from "../src/core/diagnostics.js";
|
||||
import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills.js";
|
||||
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
|
||||
|
||||
const fixturesDir = resolve(__dirname, "fixtures/skills");
|
||||
const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision");
|
||||
|
||||
function createTestSkill(options: {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
disableModelInvocation?: boolean;
|
||||
source?: string;
|
||||
}): Skill {
|
||||
return {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
filePath: options.filePath,
|
||||
baseDir: options.baseDir,
|
||||
sourceInfo: createSyntheticSourceInfo(options.filePath, { source: options.source ?? "test" }),
|
||||
disableModelInvocation: options.disableModelInvocation ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("skills", () => {
|
||||
describe("loadSkillsFromDir", () => {
|
||||
it("should load a valid skill", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "valid-skill"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("valid-skill");
|
||||
expect(skills[0].description).toBe("A valid skill for testing purposes.");
|
||||
expect(skills[0].sourceInfo.source).toBe("test");
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should warn when name doesn't match parent directory", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "name-mismatch"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("different-name");
|
||||
expect(
|
||||
diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not match parent directory")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn when name contains invalid characters", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "invalid-name-chars"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("invalid characters"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn when name exceeds 64 characters", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "long-name"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("exceeds 64 characters"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn and skip skill when description is missing", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "missing-description"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("description is required"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore unknown frontmatter fields", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "unknown-field"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should load nested skills recursively", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "nested"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("child-skill");
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should prefer a directory's root SKILL.md over nested SKILL.md files", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "root-skill-preferred"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("root-skill-preferred");
|
||||
expect(skills[0].description).toBe("Root skill should win.");
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should skip files without frontmatter", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "no-frontmatter"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
// no-frontmatter has no description, so it should be skipped
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("description is required"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn and skip skill when YAML frontmatter is invalid", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "invalid-yaml"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("at line"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should preserve multiline descriptions from YAML", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "multiline-description"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].description).toContain("\n");
|
||||
expect(skills[0].description).toContain("This is a multiline description.");
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should warn when name contains consecutive hyphens", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "consecutive-hyphens"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("consecutive hyphens"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should load all skills from fixture directory", () => {
|
||||
const { skills } = loadSkillsFromDir({
|
||||
dir: fixturesDir,
|
||||
source: "test",
|
||||
});
|
||||
|
||||
// Should load all skills that have descriptions (even with warnings)
|
||||
// valid-skill, name-mismatch, invalid-name-chars, long-name, unknown-field, nested/child-skill, consecutive-hyphens
|
||||
// NOT: missing-description, no-frontmatter (both missing descriptions)
|
||||
expect(skills.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("should return empty for non-existent directory", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: "/non/existent/path",
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should use parent directory name when name not in frontmatter", () => {
|
||||
// The no-frontmatter fixture has no name in frontmatter, so it should use "no-frontmatter"
|
||||
// But it also has no description, so it won't load
|
||||
// Let's test with a valid skill that relies on directory name
|
||||
const { skills } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "valid-skill"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("valid-skill");
|
||||
});
|
||||
|
||||
it("should parse disable-model-invocation frontmatter field", () => {
|
||||
const { skills, diagnostics } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "disable-model-invocation"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("disable-model-invocation");
|
||||
expect(skills[0].disableModelInvocation).toBe(true);
|
||||
// Should not warn about unknown field
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("unknown frontmatter field"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should default disableModelInvocation to false when not specified", () => {
|
||||
const { skills } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "valid-skill"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].disableModelInvocation).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillsForPrompt", () => {
|
||||
it("should return empty string for no skills", () => {
|
||||
const result = formatSkillsForPrompt([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should format skills as XML", () => {
|
||||
const skills: Skill[] = [
|
||||
createTestSkill({
|
||||
name: "test-skill",
|
||||
description: "A test skill.",
|
||||
filePath: "/path/to/skill/SKILL.md",
|
||||
baseDir: "/path/to/skill",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
|
||||
expect(result).toContain("<available_skills>");
|
||||
expect(result).toContain("</available_skills>");
|
||||
expect(result).toContain("<skill>");
|
||||
expect(result).toContain("<name>test-skill</name>");
|
||||
expect(result).toContain("<description>A test skill.</description>");
|
||||
expect(result).toContain("<location>/path/to/skill/SKILL.md</location>");
|
||||
});
|
||||
|
||||
it("should include intro text before XML", () => {
|
||||
const skills: Skill[] = [
|
||||
createTestSkill({
|
||||
name: "test-skill",
|
||||
description: "A test skill.",
|
||||
filePath: "/path/to/skill/SKILL.md",
|
||||
baseDir: "/path/to/skill",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
const xmlStart = result.indexOf("<available_skills>");
|
||||
const introText = result.substring(0, xmlStart);
|
||||
|
||||
expect(introText).toContain("The following skills provide specialized instructions");
|
||||
expect(introText).toContain("Use the read tool to load a skill's file");
|
||||
});
|
||||
|
||||
it("should escape XML special characters", () => {
|
||||
const skills: Skill[] = [
|
||||
createTestSkill({
|
||||
name: "test-skill",
|
||||
description: 'A skill with <special> & "characters".',
|
||||
filePath: "/path/to/skill/SKILL.md",
|
||||
baseDir: "/path/to/skill",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
|
||||
expect(result).toContain("<special>");
|
||||
expect(result).toContain("&");
|
||||
expect(result).toContain(""characters"");
|
||||
});
|
||||
|
||||
it("should format multiple skills", () => {
|
||||
const skills: Skill[] = [
|
||||
createTestSkill({
|
||||
name: "skill-one",
|
||||
description: "First skill.",
|
||||
filePath: "/path/one/SKILL.md",
|
||||
baseDir: "/path/one",
|
||||
}),
|
||||
createTestSkill({
|
||||
name: "skill-two",
|
||||
description: "Second skill.",
|
||||
filePath: "/path/two/SKILL.md",
|
||||
baseDir: "/path/two",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
|
||||
expect(result).toContain("<name>skill-one</name>");
|
||||
expect(result).toContain("<name>skill-two</name>");
|
||||
expect((result.match(/<skill>/g) || []).length).toBe(2);
|
||||
});
|
||||
|
||||
it("should exclude skills with disableModelInvocation from prompt", () => {
|
||||
const skills: Skill[] = [
|
||||
createTestSkill({
|
||||
name: "visible-skill",
|
||||
description: "A visible skill.",
|
||||
filePath: "/path/visible/SKILL.md",
|
||||
baseDir: "/path/visible",
|
||||
}),
|
||||
createTestSkill({
|
||||
name: "hidden-skill",
|
||||
description: "A hidden skill.",
|
||||
filePath: "/path/hidden/SKILL.md",
|
||||
baseDir: "/path/hidden",
|
||||
disableModelInvocation: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
|
||||
expect(result).toContain("<name>visible-skill</name>");
|
||||
expect(result).not.toContain("<name>hidden-skill</name>");
|
||||
expect((result.match(/<skill>/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("should return empty string when all skills have disableModelInvocation", () => {
|
||||
const skills: Skill[] = [
|
||||
createTestSkill({
|
||||
name: "hidden-skill",
|
||||
description: "A hidden skill.",
|
||||
filePath: "/path/hidden/SKILL.md",
|
||||
baseDir: "/path/hidden",
|
||||
disableModelInvocation: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSkills with options", () => {
|
||||
const emptyAgentDir = resolve(__dirname, "fixtures/empty-agent");
|
||||
const emptyCwd = resolve(__dirname, "fixtures/empty-cwd");
|
||||
|
||||
it("should load from explicit skillPaths", () => {
|
||||
const { skills, diagnostics } = loadSkills({
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: [join(fixturesDir, "valid-skill")],
|
||||
});
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].sourceInfo.scope).toBe("temporary");
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should warn when skill path does not exist", () => {
|
||||
const { skills, diagnostics } = loadSkills({
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: ["/non/existent/path"],
|
||||
});
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not exist"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should expand ~ in skillPaths", () => {
|
||||
const homeSkillsDir = join(homedir(), ".pi/agent/skills");
|
||||
const { skills: withTilde } = loadSkills({
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: ["~/.pi/agent/skills"],
|
||||
});
|
||||
const { skills: withoutTilde } = loadSkills({
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: [homeSkillsDir],
|
||||
});
|
||||
expect(withTilde.length).toBe(withoutTilde.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collision handling", () => {
|
||||
it("should detect name collisions and keep first skill", () => {
|
||||
// Load from first directory
|
||||
const first = loadSkillsFromDir({
|
||||
dir: join(collisionFixturesDir, "first"),
|
||||
source: "first",
|
||||
});
|
||||
|
||||
const second = loadSkillsFromDir({
|
||||
dir: join(collisionFixturesDir, "second"),
|
||||
source: "second",
|
||||
});
|
||||
|
||||
// Simulate the collision behavior from loadSkills()
|
||||
const skillMap = new Map<string, Skill>();
|
||||
const collisionWarnings: Array<{ skillPath: string; message: string }> = [];
|
||||
|
||||
for (const skill of first.skills) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
|
||||
for (const skill of second.skills) {
|
||||
const existing = skillMap.get(skill.name);
|
||||
if (existing) {
|
||||
collisionWarnings.push({
|
||||
skillPath: skill.filePath,
|
||||
message: `name collision: "${skill.name}" already loaded from ${existing.filePath}`,
|
||||
});
|
||||
} else {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
}
|
||||
|
||||
expect(skillMap.size).toBe(1);
|
||||
expect(skillMap.get("calendar")?.sourceInfo.source).toBe("first");
|
||||
expect(collisionWarnings).toHaveLength(1);
|
||||
expect(collisionWarnings[0].message).toContain("name collision");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../src/config.js";
|
||||
|
||||
const cliPath = resolve(__dirname, "../src/cli.ts");
|
||||
const tsxPath = resolve(__dirname, "../../../node_modules/tsx/dist/cli.mjs");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-stdout-clean-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
const tempRoot = createTempDir();
|
||||
const agentDir = join(tempRoot, "agent");
|
||||
const projectDir = join(tempRoot, "project");
|
||||
const projectConfigDir = join(projectDir, ".pi");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(projectConfigDir, { recursive: true });
|
||||
|
||||
const fakeNpmPath = join(tempRoot, "fake-npm.mjs");
|
||||
writeFileSync(
|
||||
fakeNpmPath,
|
||||
[
|
||||
'console.log("changed 1 package in 471ms");',
|
||||
'console.log("found 0 vulnerabilities");',
|
||||
"process.exit(0);",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(projectConfigDir, "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
packages: ["npm:fake-package"],
|
||||
npmCommand: [process.execPath, fakeNpmPath],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return await new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(process.execPath, [tsxPath, cliPath, ...args], {
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
[ENV_AGENT_DIR]: agentDir,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
resolvePromise({ stdout, stderr, code });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("stdout cleanliness in non-interactive modes", () => {
|
||||
it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => {
|
||||
const result = await runCli(["--mode", "json", "--help"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toContain("changed 1 package in 471ms");
|
||||
expect(result.stderr).toContain("found 0 vulnerabilities");
|
||||
expect(result.stderr).toContain("Usage:");
|
||||
});
|
||||
|
||||
it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => {
|
||||
const result = await runCli(["-p", "--help"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toContain("changed 1 package in 471ms");
|
||||
expect(result.stderr).toContain("found 0 vulnerabilities");
|
||||
expect(result.stderr).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Debug script to reproduce streaming rendering issues.
|
||||
* Uses real fixture data that caused the bug.
|
||||
* Run with: npx tsx test/streaming-render-debug.ts
|
||||
*/
|
||||
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.js";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Initialize dark theme with full color support
|
||||
process.env.COLORTERM = "truecolor";
|
||||
initTheme("dark");
|
||||
|
||||
// Load the real fixture that caused the bug
|
||||
const fixtureMessage: AssistantMessage = JSON.parse(
|
||||
readFileSync(join(__dirname, "fixtures/assistant-message-with-thinking-code.json"), "utf-8"),
|
||||
);
|
||||
|
||||
// Extract thinking and text content
|
||||
const thinkingContent = fixtureMessage.content.find((c) => c.type === "thinking");
|
||||
const textContent = fixtureMessage.content.find((c) => c.type === "text");
|
||||
|
||||
if (!thinkingContent || thinkingContent.type !== "thinking") {
|
||||
console.error("No thinking content in fixture");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fullThinkingText = thinkingContent.thinking;
|
||||
const fullTextContent = textContent && textContent.type === "text" ? textContent.text : "";
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const terminal = new ProcessTerminal();
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
// Start with empty message
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
const component = new AssistantMessageComponent(message, false);
|
||||
tui.addChild(component);
|
||||
tui.start();
|
||||
|
||||
// Simulate streaming thinking content
|
||||
let thinkingBuffer = "";
|
||||
const chunkSize = 10; // characters per "token"
|
||||
|
||||
for (let i = 0; i < fullThinkingText.length; i += chunkSize) {
|
||||
thinkingBuffer += fullThinkingText.slice(i, i + chunkSize);
|
||||
|
||||
// Update message content
|
||||
const updatedMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: thinkingBuffer }],
|
||||
} as AssistantMessage;
|
||||
|
||||
component.updateContent(updatedMessage);
|
||||
tui.requestRender();
|
||||
|
||||
await sleep(15); // Simulate token delay
|
||||
}
|
||||
|
||||
// Now add the text content
|
||||
await sleep(500);
|
||||
|
||||
const finalMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: fullThinkingText },
|
||||
{ type: "text", text: fullTextContent },
|
||||
],
|
||||
} as AssistantMessage;
|
||||
|
||||
component.updateContent(finalMessage);
|
||||
tui.requestRender();
|
||||
|
||||
// Keep alive for a moment to see the result
|
||||
await sleep(3000);
|
||||
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { BashOperations } from "../../src/core/tools/bash.js";
|
||||
import { createHarness, type Harness } from "./harness.js";
|
||||
|
||||
function getEntryTypes(harness: Harness): string[] {
|
||||
return harness.sessionManager.getEntries().map((entry) => entry.type);
|
||||
}
|
||||
|
||||
describe("AgentSession bash and persistence characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("records bash results immediately while idle", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.session.recordBashResult("echo hi", {
|
||||
output: "hi",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(false);
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
|
||||
expect(getEntryTypes(harness)).toContain("message");
|
||||
});
|
||||
|
||||
it("defers bash results while streaming and flushes them before the next prompt", async () => {
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for release",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [waitTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
fauxAssistantMessage("after flush"),
|
||||
]);
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const firstPrompt = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
harness.session.recordBashResult("echo hi", {
|
||||
output: "hi",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(true);
|
||||
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(false);
|
||||
|
||||
releaseToolExecution?.();
|
||||
await firstPrompt;
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(true);
|
||||
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(false);
|
||||
|
||||
await harness.session.prompt("next turn");
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(false);
|
||||
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(true);
|
||||
expect(getEntryTypes(harness).filter((type) => type === "message").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("executes bash commands and records the result", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
const result = await harness.session.executeBash("printf 'hello'");
|
||||
|
||||
expect(result.output).toContain("hello");
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
|
||||
});
|
||||
|
||||
it("cancels running bash commands with abortBash", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
return await new Promise<{ exitCode: number | null }>((_resolve, reject) => {
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(new Error("aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const bashPromise = harness.session.executeBash("sleep", undefined, { operations });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(harness.session.isBashRunning).toBe(true);
|
||||
harness.session.abortBash();
|
||||
|
||||
const result = await bashPromise;
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(harness.session.isBashRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("persists user, assistant, toolResult, and custom messages in order", async () => {
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [echoTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.sendCustomMessage({
|
||||
customType: "note",
|
||||
content: "hello",
|
||||
display: true,
|
||||
details: { a: 1 },
|
||||
});
|
||||
await harness.session.prompt("start");
|
||||
|
||||
const entries = harness.sessionManager.getEntries();
|
||||
expect(entries.map((entry) => entry.type)).toEqual([
|
||||
"custom_message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
]);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual([
|
||||
"custom",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit message_end for bash execution messages", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const messageEndRoles: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "message_end") {
|
||||
messageEndRoles.push(event.message.role);
|
||||
}
|
||||
});
|
||||
|
||||
harness.session.recordBashResult("echo hi", {
|
||||
output: "hi",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
expect(messageEndRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists aborted assistant messages", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("x".repeat(20_000))]);
|
||||
|
||||
const sawMessageUpdate = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "message_update") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("hi");
|
||||
await sawMessageUpdate;
|
||||
await harness.session.abort();
|
||||
await promptPromise;
|
||||
|
||||
const lastEntry = harness.sessionManager.getEntries()[harness.sessionManager.getEntries().length - 1];
|
||||
expect(lastEntry?.type).toBe("message");
|
||||
if (lastEntry?.type === "message") {
|
||||
expect(lastEntry.message.role).toBe("assistant");
|
||||
if (lastEntry.message.role === "assistant") {
|
||||
expect(lastEntry.message.stopReason).toBe("aborted");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("records bash output through custom operations", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
options.onData(Buffer.from("hello from custom ops"));
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await harness.session.executeBash("custom", undefined, { operations });
|
||||
|
||||
expect(result.output).toContain("hello from custom ops");
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { type AssistantMessage, fauxAssistantMessage, type Model } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHarness, type Harness } from "./harness.js";
|
||||
|
||||
type SessionWithCompactionInternals = {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
function createUsage(totalTokens: number) {
|
||||
return {
|
||||
input: totalTokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistant(
|
||||
harness: Harness,
|
||||
options: {
|
||||
stopReason?: AssistantMessage["stopReason"];
|
||||
errorMessage?: string;
|
||||
totalTokens?: number;
|
||||
timestamp?: number;
|
||||
},
|
||||
): AssistantMessage {
|
||||
const model = harness.getModel();
|
||||
return {
|
||||
...fauxAssistantMessage("", {
|
||||
stopReason: options.stopReason,
|
||||
errorMessage: options.errorMessage,
|
||||
timestamp: options.timestamp,
|
||||
}),
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createUsage(options.totalTokens ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession compaction characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("manually compacts using an extension-provided summary", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => ({
|
||||
compaction: {
|
||||
summary: "summary from extension",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: { source: "extension" },
|
||||
},
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.prompt("one");
|
||||
await harness.session.prompt("two");
|
||||
|
||||
const result = await harness.session.compact();
|
||||
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
|
||||
|
||||
expect(result.summary).toBe("summary from extension");
|
||||
expect(compactionEntries).toHaveLength(1);
|
||||
expect(harness.session.messages[0]?.role).toBe("compactionSummary");
|
||||
});
|
||||
|
||||
it("throws when compacting without a model", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.session.agent.state.model = undefined as unknown as Model<any>;
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow("No model selected");
|
||||
});
|
||||
|
||||
it("throws when compacting without configured auth", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow(`No API key found for ${harness.getModel().provider}.`);
|
||||
});
|
||||
|
||||
it("cancels in-progress manual compaction when abortCompaction is called", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => {
|
||||
return await new Promise<{ cancel: true }>((resolve) => {
|
||||
event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true });
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.prompt("one");
|
||||
await harness.session.prompt("two");
|
||||
|
||||
const compactPromise = harness.session.compact();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
harness.session.abortCompaction();
|
||||
|
||||
await expect(compactPromise).rejects.toThrow("Compaction cancelled");
|
||||
});
|
||||
|
||||
it("resumes after threshold compaction when only agent-level queued messages exist", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = await createHarness({
|
||||
settings: { compaction: { keepRecentTokens: 1 } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => ({
|
||||
compaction: {
|
||||
summary: "auto compacted",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: {},
|
||||
},
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
|
||||
await harness.session.prompt("first");
|
||||
await harness.session.prompt("second");
|
||||
|
||||
harness.session.agent.followUp({
|
||||
role: "custom",
|
||||
customType: "test",
|
||||
content: [{ type: "text", text: "queued custom" }],
|
||||
display: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue();
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
|
||||
await sessionInternals._runAutoCompaction("threshold", false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(continueSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry overflow recovery more than once", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const overflowMessage = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "prompt is too long",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
const compactionErrors: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "compaction_end" && event.errorMessage) {
|
||||
compactionErrors.push(event.errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
await sessionInternals._checkCompaction(overflowMessage);
|
||||
await sessionInternals._checkCompaction({ ...overflowMessage, timestamp: Date.now() + 1 });
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(compactionErrors).toContain(
|
||||
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores stale pre-compaction assistant usage on pre-prompt checks", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const staleTimestamp = Date.now() - 10_000;
|
||||
const staleAssistant = createAssistant(harness, {
|
||||
stopReason: "stop",
|
||||
totalTokens: 610_000,
|
||||
timestamp: staleTimestamp,
|
||||
});
|
||||
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: staleTimestamp - 1000,
|
||||
});
|
||||
harness.sessionManager.appendMessage(staleAssistant);
|
||||
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
|
||||
harness.sessionManager.appendCompaction(
|
||||
"summary",
|
||||
firstKeptEntryId,
|
||||
staleAssistant.usage.totalTokens,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "after compaction" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(staleAssistant, false);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers threshold compaction for error messages using the last successful usage", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const successfulAssistant = createAssistant(harness, {
|
||||
stopReason: "stop",
|
||||
totalTokens: 190_000,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const errorAssistant = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now() + 1000,
|
||||
});
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
successfulAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "retry" }], timestamp: Date.now() + 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
|
||||
});
|
||||
|
||||
it("does not trigger threshold compaction for error messages when no prior usage exists", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const errorAssistant = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger threshold compaction when only kept pre-compaction usage exists", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const preCompactionTimestamp = Date.now() - 10_000;
|
||||
const keptAssistant = createAssistant(harness, {
|
||||
stopReason: "stop",
|
||||
totalTokens: 190_000,
|
||||
timestamp: preCompactionTimestamp,
|
||||
});
|
||||
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: preCompactionTimestamp - 1000,
|
||||
});
|
||||
harness.sessionManager.appendMessage(keptAssistant);
|
||||
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
|
||||
harness.sessionManager.appendCompaction(
|
||||
"summary",
|
||||
firstKeptEntryId,
|
||||
keptAssistant.usage.totalTokens,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
const errorAssistant = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "kept user" }], timestamp: preCompactionTimestamp - 1000 },
|
||||
keptAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger threshold compaction below the threshold or when disabled", async () => {
|
||||
const belowThresholdHarness = await createHarness({
|
||||
settings: { compaction: { enabled: true, reserveTokens: 1000 } },
|
||||
models: [{ id: "faux-1", contextWindow: 200_000 }],
|
||||
});
|
||||
harnesses.push(belowThresholdHarness);
|
||||
const disabledHarness = await createHarness({ settings: { compaction: { enabled: false } } });
|
||||
harnesses.push(disabledHarness);
|
||||
|
||||
const belowThresholdInternals = belowThresholdHarness.session as unknown as SessionWithCompactionInternals;
|
||||
const disabledInternals = disabledHarness.session as unknown as SessionWithCompactionInternals;
|
||||
const belowThresholdSpy = vi.spyOn(belowThresholdInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
const disabledSpy = vi.spyOn(disabledInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await belowThresholdInternals._checkCompaction(
|
||||
createAssistant(belowThresholdHarness, { stopReason: "stop", totalTokens: 1_000, timestamp: Date.now() }),
|
||||
);
|
||||
await disabledInternals._checkCompaction(
|
||||
createAssistant(disabledHarness, { stopReason: "stop", totalTokens: 1_000_000, timestamp: Date.now() }),
|
||||
);
|
||||
|
||||
expect(belowThresholdSpy).not.toHaveBeenCalled();
|
||||
expect(disabledSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import type { AgentTool, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall, type Model } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionAPI } from "../../src/index.js";
|
||||
import { createHarness, getAssistantTexts, type Harness } from "./harness.js";
|
||||
|
||||
describe("AgentSession model and extension characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("setModel saves the model and emits model_select", async () => {
|
||||
const modelEvents: string[] = [];
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: true },
|
||||
],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("model_select", async (event) => {
|
||||
modelEvents.push(`${event.previousModel?.id ?? "none"}->${event.model.id}:${event.source}`);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const nextModel = harness.getModel("faux-2")!;
|
||||
|
||||
await harness.session.setModel(nextModel);
|
||||
|
||||
expect(harness.session.model?.id).toBe("faux-2");
|
||||
expect(modelEvents).toEqual(["faux-1->faux-2:set"]);
|
||||
expect(
|
||||
harness.sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "model_change")
|
||||
.map((entry) => `${entry.provider}/${entry.modelId}`),
|
||||
).toEqual([`${nextModel.provider}/${nextModel.id}`]);
|
||||
});
|
||||
|
||||
it("cycles through scoped models and preserves the scoped thinking preference", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: false },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const modelOne = harness.getModel("faux-1")!;
|
||||
const modelTwo = harness.getModel("faux-2")!;
|
||||
harness.session.setScopedModels([{ model: modelOne, thinkingLevel: "high" }, { model: modelTwo }] as Array<{
|
||||
model: Model<string>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}>);
|
||||
harness.session.setThinkingLevel("high");
|
||||
|
||||
await harness.session.cycleModel();
|
||||
expect(harness.session.model?.id).toBe("faux-2");
|
||||
expect(harness.session.thinkingLevel).toBe("off");
|
||||
|
||||
await harness.session.cycleModel();
|
||||
expect(harness.session.model?.id).toBe("faux-1");
|
||||
expect(harness.session.thinkingLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("clamps thinking levels to model capabilities and cycles available levels", async () => {
|
||||
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: false }] });
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.session.setThinkingLevel("high");
|
||||
expect(harness.session.thinkingLevel).toBe("off");
|
||||
expect(harness.session.cycleThinkingLevel()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when setModel is called without configured auth", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: true },
|
||||
],
|
||||
withConfiguredAuth: false,
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.setModel(harness.getModel("faux-2")!)).rejects.toThrow(
|
||||
`No API key for ${harness.getModel().provider}/faux-2`,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows extension tool_call handlers to block tool execution", async () => {
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async () => {
|
||||
throw new Error("tool should have been blocked");
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [echoTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("tool_call", async () => ({ block: true, reason: "Blocked by test" }));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
const toolResult = context.messages.find((message) => message.role === "toolResult");
|
||||
const errorText =
|
||||
toolResult?.role === "toolResult"
|
||||
? toolResult.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage(errorText);
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(getAssistantTexts(harness)).toContain("Blocked by test");
|
||||
expect(
|
||||
harness.session.messages.find((message) => message.role === "toolResult" && message.isError),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows extension tool_result handlers to modify tool results", async () => {
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
return { content: [{ type: "text", text }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [echoTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("tool_result", async () => ({
|
||||
content: [{ type: "text", text: "patched result" }],
|
||||
details: { patched: true },
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
const toolResult = context.messages.find((message) => message.role === "toolResult");
|
||||
const text =
|
||||
toolResult?.role === "toolResult"
|
||||
? toolResult.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage(text);
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(getAssistantTexts(harness)).toContain("patched result");
|
||||
expect(
|
||||
harness.session.messages.find((message) => message.role === "toolResult" && message.details?.patched === true),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows extension context handlers to modify messages before the LLM call", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("context", async (event) => ({
|
||||
messages: event.messages.map((message) =>
|
||||
message.role === "user"
|
||||
? { ...message, content: [{ type: "text", text: "rewritten" }], timestamp: message.timestamp }
|
||||
: message,
|
||||
),
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
let providerUserText = "";
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
providerUserText =
|
||||
user && typeof user.content !== "string"
|
||||
? user.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("original");
|
||||
|
||||
expect(providerUserText).toBe("rewritten");
|
||||
const storedUserMessage = harness.session.messages.find((message) => message.role === "user");
|
||||
expect(storedUserMessage?.role).toBe("user");
|
||||
if (storedUserMessage?.role === "user") {
|
||||
expect(storedUserMessage.content).toEqual([{ type: "text", text: "original" }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows extension input handlers to transform or handle input", async () => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
const transformedHarness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
pi.on("input", async (event) => {
|
||||
if (event.text === "ping") {
|
||||
return { action: "handled" };
|
||||
}
|
||||
return { action: "transform", text: `transformed:${event.text}` };
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(transformedHarness);
|
||||
let providerUserText = "";
|
||||
transformedHarness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
providerUserText =
|
||||
user && typeof user.content !== "string"
|
||||
? user.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await transformedHarness.session.prompt("hello");
|
||||
await transformedHarness.session.prompt("ping");
|
||||
|
||||
expect(providerUserText).toBe("transformed:hello");
|
||||
expect(transformedHarness.session.messages.filter((message) => message.role === "user")).toHaveLength(1);
|
||||
expect(extensionApi).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows before_agent_start handlers to inject custom messages and modify the system prompt", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", async (event) => ({
|
||||
message: {
|
||||
customType: "before-start",
|
||||
content: "injected",
|
||||
display: true,
|
||||
details: { injected: true },
|
||||
},
|
||||
systemPrompt: `${event.systemPrompt}\n\nextra instructions`,
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
let providerSystemPrompt = "";
|
||||
let sawInjectedUserMessage = false;
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
providerSystemPrompt = context.systemPrompt ?? "";
|
||||
sawInjectedUserMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "injected"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
|
||||
expect(providerSystemPrompt).toContain("extra instructions");
|
||||
expect(sawInjectedUserMessage).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.some((message) => message.role === "custom" && message.customType === "before-start"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("bindExtensions emits session_start and reload emits session_shutdown then session_start", async () => {
|
||||
const lifecycleEvents: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", async (event) => {
|
||||
lifecycleEvents.push(`start:${event.reason}`);
|
||||
});
|
||||
pi.on("session_shutdown", async () => {
|
||||
lifecycleEvents.push("shutdown");
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.bindExtensions({ shutdownHandler: () => {} });
|
||||
await harness.session.reload();
|
||||
|
||||
expect(lifecycleEvents).toEqual(["start:startup", "shutdown", "start:reload"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall, type Model } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PromptTemplate } from "../../src/core/prompt-templates.js";
|
||||
import { createSyntheticSourceInfo } from "../../src/core/source-info.js";
|
||||
import { createTestResourceLoader } from "../utilities.js";
|
||||
import { createHarness, getMessageText, type Harness } from "./harness.js";
|
||||
|
||||
describe("AgentSession prompt characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
while (tempDirs.length > 0) {
|
||||
const tempDir = tempDirs.pop();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("prompts while idle and records a single text response", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([fauxAssistantMessage("hello")]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[0]!)).toBe("hi");
|
||||
expect(harness.getPendingResponseCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("handles a tool call turn and waits for the follow-up LLM response", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
toolRuns.push(text);
|
||||
return {
|
||||
content: [{ type: "text", text: `echo:${text}` }],
|
||||
details: { text },
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [echoTool] });
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("echo", { text: "hello" }), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("start");
|
||||
|
||||
expect(toolRuns).toEqual(["hello"]);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(harness.session.messages[2]?.role).toBe("toolResult");
|
||||
expect(harness.session.messages[3]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("executes multiple tool calls from one response and continues with a single follow-up response", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const makeTool = (name: string, delayMs: number): AgentTool => ({
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} tool`,
|
||||
parameters: Type.Object({ value: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const value =
|
||||
typeof params === "object" && params !== null && "value" in params ? String(params.value) : "";
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
toolRuns.push(`${name}:${value}`);
|
||||
return {
|
||||
content: [{ type: "text", text: `${name}:${value}` }],
|
||||
details: { value },
|
||||
};
|
||||
},
|
||||
});
|
||||
const harness = await createHarness({ tools: [makeTool("slow", 25), makeTool("fast", 0)] });
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("slow", { value: "a" }), fauxToolCall("fast", { value: "b" })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
(context) => {
|
||||
const toolResults = context.messages.filter((message) => message.role === "toolResult");
|
||||
return fauxAssistantMessage(`tool results: ${toolResults.length}`);
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("run tools");
|
||||
|
||||
expect(toolRuns.sort()).toEqual(["fast:b", "slow:a"]);
|
||||
expect(harness.session.messages.filter((message) => message.role === "toolResult")).toHaveLength(2);
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("preserves image attachments in the provider context", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
let sawImage = false;
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
sawImage =
|
||||
user?.role === "user" &&
|
||||
typeof user.content !== "string" &&
|
||||
user.content.some((part) => part.type === "image");
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("describe", {
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
data: "ZmFrZQ==",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sawImage).toBe(true);
|
||||
});
|
||||
|
||||
it("expands skill commands before sending the prompt", async () => {
|
||||
const tempDir = join(tmpdir(), `pi-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
tempDirs.push(tempDir);
|
||||
const skillPath = join(tempDir, "test-skill.md");
|
||||
writeFileSync(skillPath, "# Test Skill\n\nUse the skill body.");
|
||||
|
||||
const resourceLoader = {
|
||||
...createTestResourceLoader(),
|
||||
getSkills: () => ({
|
||||
skills: [
|
||||
{
|
||||
name: "test",
|
||||
description: "Test skill",
|
||||
filePath: skillPath,
|
||||
disableModelInvocation: false,
|
||||
baseDir: tempDir,
|
||||
sourceInfo: createSyntheticSourceInfo(skillPath, {
|
||||
source: "local",
|
||||
scope: "project",
|
||||
origin: "top-level",
|
||||
baseDir: tempDir,
|
||||
}),
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
}),
|
||||
};
|
||||
const harness = await createHarness({ resourceLoader });
|
||||
harnesses.push(harness);
|
||||
let expandedPrompt = "";
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
expandedPrompt = user ? getMessageText(user) : "";
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("/skill:test explain this");
|
||||
|
||||
expect(expandedPrompt).toContain('<skill name="test" location="');
|
||||
expect(expandedPrompt).toContain("Use the skill body.");
|
||||
expect(expandedPrompt).toContain("explain this");
|
||||
});
|
||||
|
||||
it("expands prompt templates before sending the prompt", async () => {
|
||||
const template: PromptTemplate = {
|
||||
name: "review",
|
||||
description: "Review template",
|
||||
content: "Review this code: $1",
|
||||
filePath: "/virtual/review.md",
|
||||
sourceInfo: createSyntheticSourceInfo("/virtual/review.md", {
|
||||
source: "local",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
}),
|
||||
};
|
||||
const resourceLoader = {
|
||||
...createTestResourceLoader(),
|
||||
getPrompts: () => ({ prompts: [template], diagnostics: [] }),
|
||||
};
|
||||
const harness = await createHarness({ resourceLoader });
|
||||
harnesses.push(harness);
|
||||
let expandedPrompt = "";
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
expandedPrompt = user ? getMessageText(user) : "";
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("/review src/index.ts");
|
||||
|
||||
expect(expandedPrompt).toBe("Review this code: src/index.ts");
|
||||
});
|
||||
|
||||
it("dispatches extension commands without consuming a provider response", async () => {
|
||||
const commandRuns: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async (args) => {
|
||||
commandRuns.push(args);
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("should stay queued")]);
|
||||
|
||||
await harness.session.prompt("/testcmd hello world");
|
||||
|
||||
expect(commandRuns).toEqual(["hello world"]);
|
||||
expect(harness.session.messages).toEqual([]);
|
||||
expect(harness.getPendingResponseCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("sendUserMessage while idle triggers a turn", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([fauxAssistantMessage("response")]);
|
||||
|
||||
await harness.session.sendUserMessage("from extension");
|
||||
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[0]!)).toBe("from extension");
|
||||
});
|
||||
|
||||
it("throws when prompted during streaming without a streamingBehavior", async () => {
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for release",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [waitTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
|
||||
await expect(harness.session.prompt("second")).rejects.toThrow(
|
||||
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
|
||||
);
|
||||
|
||||
releaseToolExecution?.();
|
||||
await promptPromise;
|
||||
});
|
||||
|
||||
it("throws when prompting without a model", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.session.agent.state.model = undefined as unknown as Model<any>;
|
||||
|
||||
await expect(harness.session.prompt("hi")).rejects.toThrow("No model selected.");
|
||||
});
|
||||
|
||||
it("throws when prompting without configured auth", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.prompt("hi")).rejects.toThrow(
|
||||
`No API key found for ${harness.getModel().provider}.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.js";
|
||||
|
||||
async function createWaitingHarness(
|
||||
options: {
|
||||
tools?: AgentTool[];
|
||||
extensionFactories?: Harness["session"]["extensionRunner"] extends never
|
||||
? never
|
||||
: Array<(pi: ExtensionAPI) => void>;
|
||||
} = {},
|
||||
): Promise<{
|
||||
harness: Harness;
|
||||
releaseToolExecution: () => void;
|
||||
promptPromise: Promise<void>;
|
||||
waitForToolStart: Promise<void>;
|
||||
}> {
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for release",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [waitTool, ...(options.tools ?? [])],
|
||||
extensionFactories: options.extensionFactories,
|
||||
});
|
||||
|
||||
const waitForToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start" && event.toolName === "wait") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
harness,
|
||||
releaseToolExecution: () => releaseToolExecution?.(),
|
||||
promptPromise: harness.session.prompt("start"),
|
||||
waitForToolStart,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession queue characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches extension commands immediately when prompted while idle", async () => {
|
||||
const commandRuns: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async (args) => {
|
||||
commandRuns.push(args);
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.prompt("/testcmd hello world");
|
||||
|
||||
expect(commandRuns).toEqual(["hello world"]);
|
||||
expect(harness.getPendingResponseCount()).toBe(0);
|
||||
expect(harness.session.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("delivers extension-origin steering messages before the next LLM call", async () => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
const waiting = await createWaitingHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
},
|
||||
],
|
||||
});
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
const sawSteer = context.messages.some(
|
||||
(message) => message.role === "user" && getMessageText(message) === "steer now",
|
||||
);
|
||||
return fauxAssistantMessage(sawSteer ? "saw steer" : "missing steer");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
extensionApi?.sendUserMessage("steer now", { deliverAs: "steer" });
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "steer now"]);
|
||||
expect(getAssistantTexts(harness)).toContain("saw steer");
|
||||
});
|
||||
|
||||
it("delivers follow-up messages only after the current run finishes", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
const assistantSeenBeforeFollowUp: string[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
assistantSeenBeforeFollowUp.push(
|
||||
...context.messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) =>
|
||||
message.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n"),
|
||||
),
|
||||
);
|
||||
return fauxAssistantMessage("follow-up response");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.followUp("after current run");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "after current run"]);
|
||||
expect(assistantSeenBeforeFollowUp).toContain("");
|
||||
expect(getAssistantTexts(harness)).toContain("follow-up response");
|
||||
});
|
||||
|
||||
it("delivers multiple steering messages in order in one-at-a-time mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("handled steer 1"),
|
||||
fauxAssistantMessage("handled steer 2"),
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.steer("steer 1");
|
||||
await harness.session.steer("steer 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "steer 1", "steer 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual(["", "handled steer 1", "handled steer 2"]);
|
||||
});
|
||||
|
||||
it("delivers multiple follow-up messages in order in one-at-a-time mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("original turn complete"),
|
||||
fauxAssistantMessage("handled follow-up 1"),
|
||||
fauxAssistantMessage("handled follow-up 2"),
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.followUp("follow-up 1");
|
||||
await harness.session.followUp("follow-up 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "follow-up 1", "follow-up 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual([
|
||||
"",
|
||||
"original turn complete",
|
||||
"handled follow-up 1",
|
||||
"handled follow-up 2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("delivers all steering messages in one batch in all mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
harness.session.setSteeringMode("all");
|
||||
let batchedUserMessages: string[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
batchedUserMessages = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
return fauxAssistantMessage("batched steer response");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.steer("steer 1");
|
||||
await harness.session.steer("steer 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(batchedUserMessages).toEqual(["start", "steer 1", "steer 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual(["", "batched steer response"]);
|
||||
});
|
||||
|
||||
it("delivers all follow-up messages in one batch in all mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
harness.session.setFollowUpMode("all");
|
||||
let batchedUserMessages: string[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("original turn complete"),
|
||||
(context) => {
|
||||
batchedUserMessages = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
return fauxAssistantMessage("batched follow-up response");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.followUp("follow-up 1");
|
||||
await harness.session.followUp("follow-up 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(batchedUserMessages).toEqual(["start", "follow-up 1", "follow-up 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual(["", "original turn complete", "batched follow-up response"]);
|
||||
});
|
||||
|
||||
it("queues custom messages with deliverAs steer while streaming", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
let sawCustomMessage = false;
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
sawCustomMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "steer custom"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "queue-test", content: "steer custom", display: true, details: { value: 1 } },
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(sawCustomMessage).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.some((message) => message.role === "custom" && message.customType === "queue-test"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("queues custom messages with deliverAs followUp while streaming", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
let sawCustomMessage = false;
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("original turn complete"),
|
||||
(context) => {
|
||||
sawCustomMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "follow-up custom"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "queue-test", content: "follow-up custom", display: true, details: { value: 1 } },
|
||||
{ deliverAs: "followUp" },
|
||||
);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(sawCustomMessage).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.some((message) => message.role === "custom" && message.customType === "queue-test"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("injects nextTurn custom messages into the next prompt", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
let sawCustomMessage = false;
|
||||
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "next-turn", content: "carry this", display: true, details: {} },
|
||||
{ deliverAs: "nextTurn" },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
sawCustomMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "carry this"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("normal prompt");
|
||||
|
||||
expect(sawCustomMessage).toBe(true);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "custom", "assistant"]);
|
||||
});
|
||||
|
||||
it("updates pendingMessageCount and removes queued text before message_start is emitted", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
const countsAtQueuedMessageStart: number[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
harness.session.subscribe((event) => {
|
||||
if (
|
||||
event.type === "message_start" &&
|
||||
event.message.role === "user" &&
|
||||
getMessageText(event.message) === "queued"
|
||||
) {
|
||||
countsAtQueuedMessageStart.push(harness.session.pendingMessageCount);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.steer("queued");
|
||||
expect(harness.session.pendingMessageCount).toBe(1);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(countsAtQueuedMessageStart).toEqual([0]);
|
||||
expect(harness.session.pendingMessageCount).toBe(0);
|
||||
});
|
||||
|
||||
it("throws when queueing an extension command with steer", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async () => {},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.steer("/testcmd queued")).rejects.toThrow(
|
||||
'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when queueing an extension command with followUp", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async () => {},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.followUp("/testcmd queued")).rejects.toThrow(
|
||||
'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.',
|
||||
);
|
||||
});
|
||||
});
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxThinking, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "./harness.js";
|
||||
|
||||
function normalizeEventOrder(events: Harness["events"]): string[] {
|
||||
const normalized: string[] = [];
|
||||
for (const event of events) {
|
||||
const label =
|
||||
event.type === "message_start" || event.type === "message_end"
|
||||
? `${event.type}:${event.message.role}`
|
||||
: event.type === "tool_execution_start" || event.type === "tool_execution_end"
|
||||
? `${event.type}:${event.toolName}`
|
||||
: event.type;
|
||||
if (label === "message_update" && normalized[normalized.length - 1] === "message_update") {
|
||||
continue;
|
||||
}
|
||||
normalized.push(label);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
describe("AgentSession retry and event characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries after a transient error and succeeds", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("recovered"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "end:true"]);
|
||||
expect(harness.faux.state.callCount).toBe(2);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("retries multiple transient failures and succeeds on the final attempt", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("success"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "start:2", "end:true"]);
|
||||
expect(harness.faux.state.callCount).toBe(3);
|
||||
});
|
||||
|
||||
it("exhausts max retries and emits a failure event", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 2, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "start:2", "end:false"]);
|
||||
expect(harness.faux.state.callCount).toBe(3);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("prompt waits for retry completion even when assistant message_end handling is delayed", async () => {
|
||||
const harness = await createHarness({
|
||||
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("message_end", async (event) => {
|
||||
if (event.message.role === "assistant") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("recovered"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(2);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry when retry is disabled", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: false } } });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(1);
|
||||
expect(harness.eventsOfType("auto_retry_start")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not retry non-retryable errors", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "invalid_api_key" })]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(1);
|
||||
expect(harness.eventsOfType("auto_retry_start")).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancels retry sleep when abortRetry is called", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 100 } } });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })]);
|
||||
|
||||
const sawRetryStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("test");
|
||||
await sawRetryStart;
|
||||
harness.session.abortRetry();
|
||||
await promptPromise;
|
||||
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
expect(harness.eventsOfType("auto_retry_end").map((event) => event.finalError)).toContain("Retry cancelled");
|
||||
expect(harness.faux.state.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("waits for the full loop when retry recovery produces tool calls", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
toolRuns.push(text);
|
||||
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [echoTool],
|
||||
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("final answer"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(3);
|
||||
expect(toolRuns).toEqual(["hello"]);
|
||||
expect(harness.session.isStreaming).toBe(false);
|
||||
await harness.session.prompt("follow-up");
|
||||
expect(harness.faux.state.callCount).toBe(4);
|
||||
});
|
||||
|
||||
it("emits extension events before public event subscribers", async () => {
|
||||
const order: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("message_start", async (event) => {
|
||||
order.push(`extension:${event.type}:${event.message.role}`);
|
||||
});
|
||||
pi.on("message_end", async (event) => {
|
||||
order.push(`extension:${event.type}:${event.message.role}`);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "message_start" || event.type === "message_end") {
|
||||
order.push(`public:${event.type}:${event.message.role}`);
|
||||
}
|
||||
});
|
||||
harness.setResponses([fauxAssistantMessage("done")]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(order).toEqual([
|
||||
"extension:message_start:user",
|
||||
"public:message_start:user",
|
||||
"extension:message_end:user",
|
||||
"public:message_end:user",
|
||||
"extension:message_start:assistant",
|
||||
"public:message_start:assistant",
|
||||
"extension:message_end:assistant",
|
||||
"public:message_end:assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits the expected event order for a single prompt", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("hello")]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(normalizeEventOrder(harness.events)).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
"message_start:user",
|
||||
"message_end:user",
|
||||
"message_start:assistant",
|
||||
"message_update",
|
||||
"message_end:assistant",
|
||||
"turn_end",
|
||||
"agent_end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits the expected event order for a tool call turn", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
toolRuns.push(text);
|
||||
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [echoTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(toolRuns).toEqual(["hello"]);
|
||||
expect(normalizeEventOrder(harness.events)).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
"message_start:user",
|
||||
"message_end:user",
|
||||
"message_start:assistant",
|
||||
"message_update",
|
||||
"message_end:assistant",
|
||||
"tool_execution_start:echo",
|
||||
"tool_execution_end:echo",
|
||||
"message_start:toolResult",
|
||||
"message_end:toolResult",
|
||||
"turn_end",
|
||||
"turn_start",
|
||||
"message_start:assistant",
|
||||
"message_update",
|
||||
"message_end:assistant",
|
||||
"turn_end",
|
||||
"agent_end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits streaming deltas for text, thinking, and tool calls in message_update events", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(
|
||||
[fauxThinking("plan"), { type: "text", text: "answer" }, fauxToolCall("echo", { text: "hello" })],
|
||||
{
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi").catch(() => {});
|
||||
|
||||
const updateTypes = harness.eventsOfType("message_update").map((event) => event.assistantMessageEvent.type);
|
||||
expect(updateTypes).toContain("thinking_delta");
|
||||
expect(updateTypes).toContain("text_delta");
|
||||
expect(updateTypes).toContain("toolcall_delta");
|
||||
});
|
||||
|
||||
it("emits agent_end for error responses", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "broken" })]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(harness.events[harness.events.length - 1]?.type).toBe("agent_end");
|
||||
});
|
||||
|
||||
it("emits agent_end for aborted runs and persists the aborted assistant message", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("x".repeat(20_000))]);
|
||||
|
||||
const sawMessageUpdate = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "message_update") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("hi");
|
||||
await sawMessageUpdate;
|
||||
await harness.session.abort();
|
||||
await promptPromise;
|
||||
|
||||
expect(harness.events[harness.events.length - 1]?.type).toBe("agent_end");
|
||||
const lastMessage = harness.session.messages[harness.session.messages.length - 1];
|
||||
expect(lastMessage?.role).toBe("assistant");
|
||||
if (lastMessage?.role === "assistant") {
|
||||
expect(lastMessage.stopReason).toBe("aborted");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
} from "../../src/core/agent-session-runtime.js";
|
||||
import { AuthStorage } from "../../src/core/auth-storage.js";
|
||||
import { SessionManager } from "../../src/core/session-manager.js";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionFactory,
|
||||
SessionBeforeForkEvent,
|
||||
SessionBeforeSwitchEvent,
|
||||
SessionStartEvent,
|
||||
} from "../../src/index.js";
|
||||
|
||||
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
|
||||
|
||||
describe("AgentSessionRuntime characterization", () => {
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
process.chdir(tmpdir());
|
||||
});
|
||||
|
||||
async function createRuntimeForTest(
|
||||
extensionFactory: ExtensionFactory,
|
||||
options?: { cwd?: string; bootstrapModel?: boolean; bootstrapThinkingLevel?: boolean },
|
||||
) {
|
||||
const tempDir =
|
||||
options?.cwd ?? join(tmpdir(), `pi-runtime-suite-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const faux = registerFauxProvider({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: false },
|
||||
],
|
||||
});
|
||||
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
|
||||
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
|
||||
const runtimeOptions = {
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
model: options?.bootstrapModel === false ? undefined : faux.getModel(),
|
||||
thinkingLevel: options?.bootstrapThinkingLevel === false ? undefined : undefined,
|
||||
resourceLoaderOptions: {
|
||||
extensionFactories: [
|
||||
(pi: ExtensionAPI) => {
|
||||
pi.registerProvider(faux.getModel().provider, {
|
||||
baseUrl: faux.getModel().baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: faux.api,
|
||||
models: faux.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
})),
|
||||
});
|
||||
extensionFactory(pi);
|
||||
},
|
||||
],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({
|
||||
...runtimeOptions,
|
||||
cwd,
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model: runtimeOptions.model,
|
||||
thinkingLevel: runtimeOptions.thinkingLevel,
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
const runtime = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.create(tempDir),
|
||||
});
|
||||
await runtime.session.bindExtensions({});
|
||||
|
||||
cleanups.push(async () => {
|
||||
await runtime.dispose();
|
||||
faux.unregister();
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
return { runtime, faux, tempDir };
|
||||
}
|
||||
|
||||
it("emits session_before_switch and session_start for new and resume flows", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
const { runtime } = await createRuntimeForTest((pi: ExtensionAPI) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtime.session.prompt("hello");
|
||||
const originalSessionFile = runtime.session.sessionFile;
|
||||
const originalSession = runtime.session;
|
||||
|
||||
const newSessionResult = await runtime.newSession();
|
||||
expect(newSessionResult.cancelled).toBe(false);
|
||||
await runtime.session.bindExtensions({});
|
||||
expect(runtime.session).not.toBe(originalSession);
|
||||
expect(runtime.session.messages).toEqual([]);
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "new", targetSessionFile: undefined },
|
||||
{ type: "session_start", reason: "new", previousSessionFile: originalSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
const secondSessionFile = runtime.session.sessionFile;
|
||||
|
||||
const switchResult = await runtime.switchSession(originalSessionFile!);
|
||||
expect(switchResult.cancelled).toBe(false);
|
||||
await runtime.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "resume", targetSessionFile: originalSessionFile },
|
||||
{ type: "session_start", reason: "resume", previousSessionFile: secondSessionFile },
|
||||
]);
|
||||
});
|
||||
|
||||
it("honors session_before_switch cancellation for new and resume", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
let cancelReason: "new" | "resume" | undefined;
|
||||
const { runtime } = await createRuntimeForTest((pi: ExtensionAPI) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
if (event.reason === cancelReason) {
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
await runtime.session.prompt("hello");
|
||||
const originalSessionFile = runtime.session.sessionFile;
|
||||
|
||||
cancelReason = "new";
|
||||
const newResult = await runtime.newSession();
|
||||
expect(newResult.cancelled).toBe(true);
|
||||
expect(runtime.session.sessionFile).toBe(originalSessionFile);
|
||||
|
||||
events.length = 0;
|
||||
const otherDir = join(tmpdir(), `pi-runtime-other-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(otherDir, { recursive: true });
|
||||
const otherSession = SessionManager.create(otherDir);
|
||||
otherSession.appendMessage({ role: "user", content: [{ type: "text", text: "other" }], timestamp: Date.now() });
|
||||
const otherSessionFile = otherSession.getSessionFile();
|
||||
cancelReason = "resume";
|
||||
const resumeResult = await runtime.switchSession(otherSessionFile!);
|
||||
expect(resumeResult.cancelled).toBe(true);
|
||||
expect(runtime.session.sessionFile).toBe(originalSessionFile);
|
||||
});
|
||||
|
||||
it("emits session_before_fork and session_start and honors cancellation", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
let cancelNextFork = false;
|
||||
const { runtime } = await createRuntimeForTest((pi: ExtensionAPI) => {
|
||||
pi.on("session_before_fork", (event) => {
|
||||
events.push(event);
|
||||
if (cancelNextFork) {
|
||||
cancelNextFork = false;
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
events.length = 0;
|
||||
await runtime.session.prompt("hello");
|
||||
const userMessage = runtime.session.getUserMessagesForForking()[0]!;
|
||||
const previousSessionFile = runtime.session.sessionFile;
|
||||
|
||||
const successResult = await runtime.fork(userMessage.entryId);
|
||||
expect(successResult.cancelled).toBe(false);
|
||||
expect(successResult.selectedText).toBe("hello");
|
||||
await runtime.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_fork", entryId: userMessage.entryId },
|
||||
{ type: "session_start", reason: "fork", previousSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
cancelNextFork = true;
|
||||
const cancelResult = await runtime.fork(userMessage.entryId);
|
||||
expect(cancelResult).toEqual({ cancelled: true });
|
||||
expect(events).toEqual([{ type: "session_before_fork", entryId: userMessage.entryId }]);
|
||||
});
|
||||
|
||||
it("throws when forking with an invalid entry id", async () => {
|
||||
const { runtime } = await createRuntimeForTest(() => {});
|
||||
await expect(runtime.fork("missing-entry")).rejects.toThrow("Invalid entry ID for forking");
|
||||
});
|
||||
|
||||
it("updates process.cwd() on cross-cwd session replacement", async () => {
|
||||
const firstDir = join(tmpdir(), `pi-runtime-cwd-a-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const secondDir = join(tmpdir(), `pi-runtime-cwd-b-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(firstDir, { recursive: true });
|
||||
mkdirSync(secondDir, { recursive: true });
|
||||
const { runtime, faux, tempDir } = await createRuntimeForTest(() => {}, { cwd: firstDir });
|
||||
const otherAuthStorage = AuthStorage.inMemory();
|
||||
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
const otherRuntimeOptions = {
|
||||
agentDir: tempDir,
|
||||
authStorage: otherAuthStorage,
|
||||
resourceLoaderOptions: {
|
||||
extensionFactories: [
|
||||
(pi: ExtensionAPI) => {
|
||||
pi.registerProvider(faux.getModel().provider, {
|
||||
baseUrl: faux.getModel().baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: faux.api,
|
||||
models: faux.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
})),
|
||||
});
|
||||
},
|
||||
],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
|
||||
cwd,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
}) => {
|
||||
const services = await createAgentSessionServices({
|
||||
...otherRuntimeOptions,
|
||||
cwd,
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
|
||||
cwd: secondDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.create(secondDir),
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await otherRuntime.dispose();
|
||||
});
|
||||
await otherRuntime.session.prompt("other");
|
||||
const otherSessionFile = otherRuntime.session.sessionFile!;
|
||||
|
||||
await runtime.switchSession(otherSessionFile);
|
||||
|
||||
expect(realpathSync(process.cwd())).toBe(realpathSync(secondDir));
|
||||
expect(realpathSync(runtime.session.sessionManager.getCwd())).toBe(realpathSync(secondDir));
|
||||
});
|
||||
|
||||
it("restores model and thinking state from the destination session", async () => {
|
||||
const { runtime, faux, tempDir } = await createRuntimeForTest(() => {}, {
|
||||
bootstrapModel: false,
|
||||
bootstrapThinkingLevel: false,
|
||||
});
|
||||
const otherDir = join(tempDir, "other");
|
||||
mkdirSync(otherDir, { recursive: true });
|
||||
const otherAuthStorage = AuthStorage.inMemory();
|
||||
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
const otherRuntimeOptions = {
|
||||
agentDir: tempDir,
|
||||
authStorage: otherAuthStorage,
|
||||
resourceLoaderOptions: {
|
||||
extensionFactories: [
|
||||
(pi: ExtensionAPI) => {
|
||||
pi.registerProvider(faux.getModel().provider, {
|
||||
baseUrl: faux.getModel().baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: faux.api,
|
||||
models: faux.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
})),
|
||||
});
|
||||
},
|
||||
],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
|
||||
cwd,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
}) => {
|
||||
const services = await createAgentSessionServices({
|
||||
...otherRuntimeOptions,
|
||||
cwd,
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
|
||||
cwd: otherDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.create(otherDir),
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await otherRuntime.dispose();
|
||||
});
|
||||
await otherRuntime.session.setModel(faux.getModel("faux-2")!);
|
||||
otherRuntime.session.setThinkingLevel("off");
|
||||
await otherRuntime.session.prompt("hello");
|
||||
const targetSessionFile = otherRuntime.session.sessionFile!;
|
||||
|
||||
await runtime.switchSession(targetSessionFile);
|
||||
|
||||
expect(runtime.session.model?.id).toBe("faux-2");
|
||||
expect(runtime.session.thinkingLevel).toBe("off");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Local test harness for the new coding-agent test suite.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import type { FauxModelDefinition, FauxProviderRegistration, FauxResponseStep, Model } from "@mariozechner/pi-ai";
|
||||
import { registerFauxProvider } from "@mariozechner/pi-ai";
|
||||
import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../../src/core/auth-storage.js";
|
||||
import type { ExtensionRunner } from "../../src/core/extensions/index.js";
|
||||
import { convertToLlm } from "../../src/core/messages.js";
|
||||
import { ModelRegistry } from "../../src/core/model-registry.js";
|
||||
import { SessionManager } from "../../src/core/session-manager.js";
|
||||
import type { Settings } from "../../src/core/settings-manager.js";
|
||||
import { SettingsManager } from "../../src/core/settings-manager.js";
|
||||
import type { ExtensionFactory, ResourceLoader } from "../../src/index.js";
|
||||
import {
|
||||
type CreateTestExtensionsResultInput,
|
||||
createTestExtensionsResult,
|
||||
createTestResourceLoader,
|
||||
} from "../utilities.js";
|
||||
|
||||
type MessageTextPart = { type: "text"; text: string };
|
||||
|
||||
export function getMessageText(message: unknown): string {
|
||||
if (!message || typeof message !== "object" || !("content" in message)) {
|
||||
return "";
|
||||
}
|
||||
const content = (message as { content?: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (content === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.filter((part): part is MessageTextPart => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getUserTexts(harness: Harness): string[] {
|
||||
return harness.session.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
}
|
||||
|
||||
export function getAssistantTexts(harness: Harness): string[] {
|
||||
return harness.session.messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) => getMessageText(message));
|
||||
}
|
||||
|
||||
export interface HarnessOptions {
|
||||
models?: FauxModelDefinition[];
|
||||
settings?: Partial<Settings>;
|
||||
systemPrompt?: string;
|
||||
tools?: AgentTool[];
|
||||
resourceLoader?: ResourceLoader;
|
||||
extensionFactories?: Array<ExtensionFactory | CreateTestExtensionsResultInput>;
|
||||
withConfiguredAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
session: AgentSession;
|
||||
sessionManager: SessionManager;
|
||||
settingsManager: SettingsManager;
|
||||
authStorage: AuthStorage;
|
||||
faux: FauxProviderRegistration;
|
||||
models: [Model<string>, ...Model<string>[]];
|
||||
getModel(): Model<string>;
|
||||
getModel(modelId: string): Model<string> | undefined;
|
||||
setResponses: (responses: FauxResponseStep[]) => void;
|
||||
appendResponses: (responses: FauxResponseStep[]) => void;
|
||||
getPendingResponseCount: () => number;
|
||||
events: AgentSessionEvent[];
|
||||
eventsOfType<T extends AgentSessionEvent["type"]>(type: T): Extract<AgentSessionEvent, { type: T }>[];
|
||||
tempDir: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
const tempDir = join(tmpdir(), `pi-suite-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
export async function createHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
const tempDir = createTempDir();
|
||||
const fauxProvider: FauxProviderRegistration = registerFauxProvider({
|
||||
models: options.models,
|
||||
});
|
||||
fauxProvider.setResponses([]);
|
||||
const model = fauxProvider.getModel();
|
||||
const toolMap = options.tools ? Object.fromEntries(options.tools.map((tool) => [tool.name, tool])) : undefined;
|
||||
const withConfiguredAuth = options.withConfiguredAuth ?? true;
|
||||
const extensionRunnerRef: { current?: ExtensionRunner } = {};
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.inMemory(options.settings);
|
||||
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
if (withConfiguredAuth) {
|
||||
authStorage.setRuntimeApiKey(model.provider, "faux-key");
|
||||
}
|
||||
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
||||
if (withConfiguredAuth) {
|
||||
modelRegistry.registerProvider(model.provider, {
|
||||
baseUrl: model.baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: fauxProvider.api,
|
||||
models: fauxProvider.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
baseUrl: registeredModel.baseUrl,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm,
|
||||
onPayload: async (payload) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner?.hasHandlers("before_provider_request")) {
|
||||
return payload;
|
||||
}
|
||||
return runner.emitBeforeProviderRequest(payload);
|
||||
},
|
||||
onResponse: async (response) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner?.hasHandlers("after_provider_response")) {
|
||||
return;
|
||||
}
|
||||
await runner.emit({
|
||||
type: "after_provider_response",
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
},
|
||||
transformContext: async (messages: AgentMessage[]) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner) return messages;
|
||||
return runner.emitContext(messages);
|
||||
},
|
||||
});
|
||||
const extensionsResult = options.extensionFactories
|
||||
? await createTestExtensionsResult(options.extensionFactories, tempDir)
|
||||
: undefined;
|
||||
const resourceLoader =
|
||||
options.resourceLoader ?? createTestResourceLoader(extensionsResult ? { extensionsResult } : undefined);
|
||||
|
||||
const session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader,
|
||||
baseToolsOverride: toolMap,
|
||||
extensionRunnerRef,
|
||||
});
|
||||
|
||||
const events: AgentSessionEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
authStorage,
|
||||
faux: fauxProvider,
|
||||
models: fauxProvider.models,
|
||||
getModel: fauxProvider.getModel,
|
||||
setResponses: fauxProvider.setResponses,
|
||||
appendResponses: fauxProvider.appendResponses,
|
||||
getPendingResponseCount: fauxProvider.getPendingResponseCount,
|
||||
events,
|
||||
eventsOfType<T extends AgentSessionEvent["type"]>(type: T) {
|
||||
return events.filter((event): event is Extract<AgentSessionEvent, { type: T }> => event.type === type);
|
||||
},
|
||||
tempDir,
|
||||
cleanup() {
|
||||
session.dispose();
|
||||
fauxProvider.unregister();
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.js";
|
||||
|
||||
describe("issue #2023 queued slash-command follow-up", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats extension-origin queued slash-command follow-ups as raw user text instead of dispatching the command", async () => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
const commandRuns: string[] = [];
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for the test to release execution",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [waitTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async (args) => {
|
||||
commandRuns.push(args);
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("first turn complete"),
|
||||
fauxAssistantMessage("queued follow-up handled by model"),
|
||||
]);
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start" && event.toolName === "wait") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
extensionApi?.sendUserMessage("/testcmd queued", { deliverAs: "followUp" });
|
||||
releaseToolExecution?.();
|
||||
await promptPromise;
|
||||
|
||||
expect(commandRuns).toEqual([]);
|
||||
expect(getUserTexts(harness)).toEqual(["start", "/testcmd queued"]);
|
||||
expect(getAssistantTexts(harness)).toContain("queued follow-up handled by model");
|
||||
});
|
||||
});
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerFauxProvider } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
} from "../../../src/core/agent-session-runtime.js";
|
||||
import { AuthStorage } from "../../../src/core/auth-storage.js";
|
||||
import { SessionManager } from "../../../src/core/session-manager.js";
|
||||
|
||||
describe("issue #2753 reload stale resource settings", () => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length > 0) {
|
||||
cleanups.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies updated top-level prompt settings on reload after startup", async () => {
|
||||
const tempDir = join(tmpdir(), `pi-2753-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const agentDir = join(tempDir, "agent");
|
||||
const promptsDir = join(agentDir, "prompts");
|
||||
mkdirSync(promptsDir, { recursive: true });
|
||||
writeFileSync(join(promptsDir, "test.md"), "Echo test prompt\n");
|
||||
|
||||
const faux = registerFauxProvider({
|
||||
models: [{ id: "faux-1", reasoning: false }],
|
||||
});
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
agentDir,
|
||||
authStorage,
|
||||
resourceLoaderOptions: {
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerProvider(faux.getModel().provider, {
|
||||
baseUrl: faux.getModel().baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: faux.api,
|
||||
models: faux.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
})),
|
||||
});
|
||||
},
|
||||
],
|
||||
noSkills: true,
|
||||
noThemes: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model: faux.getModel(),
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
const runtime = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
sessionManager: SessionManager.create(tempDir),
|
||||
});
|
||||
|
||||
cleanups.push(() => {
|
||||
runtime.session.dispose();
|
||||
faux.unregister();
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).toContain("test");
|
||||
|
||||
writeFileSync(join(agentDir, "settings.json"), `${JSON.stringify({ prompts: ["-prompts/test.md"] }, null, 2)}\n`);
|
||||
|
||||
await runtime.session.reload();
|
||||
|
||||
expect(runtime.services.settingsManager.getGlobalSettings().prompts).toEqual(["-prompts/test.md"]);
|
||||
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).not.toContain("test");
|
||||
});
|
||||
});
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DefaultResourceLoader } from "../../../src/core/resource-loader.js";
|
||||
|
||||
describe("issue #2781 skill collision precedence: user skills should override package skills", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-2781-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
cwd = join(tempDir, "project");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createPackageWithSkill(name: string, description: string): string {
|
||||
const pkgDir = join(tempDir, `fake-package-${name}`);
|
||||
const skillDir = join(pkgDir, "skills", name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(pkgDir, "package.json"),
|
||||
JSON.stringify({ name: `fake-pkg-${name}`, version: "1.0.0", pi: { skills: [`skills/${name}`] } }, null, 2),
|
||||
);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\nPackage skill content`,
|
||||
);
|
||||
return pkgDir;
|
||||
}
|
||||
|
||||
function createUserSkill(name: string, description: string): string {
|
||||
const skillDir = join(agentDir, "skills", name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nUser skill content`);
|
||||
return skillPath;
|
||||
}
|
||||
|
||||
function createProjectSkill(name: string, description: string): string {
|
||||
const skillDir = join(cwd, ".pi", "skills", name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nProject skill content`);
|
||||
return skillPath;
|
||||
}
|
||||
|
||||
function createSettingsWithPackage(pkgDir: string, scope: "user" | "project"): void {
|
||||
const settingsDir = scope === "user" ? agentDir : join(cwd, ".pi");
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
writeFileSync(join(settingsDir, "settings.json"), JSON.stringify({ packages: [pkgDir] }, null, 2));
|
||||
}
|
||||
|
||||
it("user auto-discovered skill should override package skill with same name", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
const userSkillPath = createUserSkill("web-fetch", "User web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
const webFetch = skills.find((s) => s.name === "web-fetch");
|
||||
expect(webFetch).toBeDefined();
|
||||
expect(webFetch!.filePath).toBe(userSkillPath);
|
||||
expect(webFetch!.description).toBe("User web-fetch override");
|
||||
});
|
||||
|
||||
it("project auto-discovered skill should override package skill with same name", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
const webFetch = skills.find((s) => s.name === "web-fetch");
|
||||
expect(webFetch).toBeDefined();
|
||||
expect(webFetch!.filePath).toBe(projectSkillPath);
|
||||
expect(webFetch!.description).toBe("Project web-fetch override");
|
||||
});
|
||||
|
||||
it("project skill should override user skill which should override package skill", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
createUserSkill("web-fetch", "User web-fetch override");
|
||||
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
const webFetch = skills.find((s) => s.name === "web-fetch");
|
||||
expect(webFetch).toBeDefined();
|
||||
expect(webFetch!.filePath).toBe(projectSkillPath);
|
||||
expect(webFetch!.description).toBe("Project web-fetch override");
|
||||
});
|
||||
|
||||
it("collision diagnostics should report package skill as loser when user skill wins", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
createUserSkill("web-fetch", "User web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { diagnostics } = loader.getSkills();
|
||||
const collision = diagnostics.find((d) => d.type === "collision" && d.collision?.name === "web-fetch");
|
||||
expect(collision).toBeDefined();
|
||||
expect(collision!.collision!.loserPath).toContain("fake-package");
|
||||
});
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/2791
|
||||
*
|
||||
* fs.watch() returns an FSWatcher (EventEmitter). If the watcher emits an
|
||||
* 'error' event after creation and no error handler is attached, Node.js
|
||||
* treats it as an uncaught exception and terminates the process.
|
||||
*
|
||||
* We test this by spawning a child process that:
|
||||
* 1. Sets up a custom theme with the watcher enabled
|
||||
* 2. Finds the FSWatcher via process._getActiveHandles()
|
||||
* 3. Emits a synthetic 'error' event on it
|
||||
* 4. If the watcher has no error handler -> crash (exit != 0) -> bug present
|
||||
* 5. If the watcher has an error handler -> clean exit (exit 0) -> bug fixed
|
||||
*/
|
||||
describe("issue #2791 fs.watch error event crashes process", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-2791-"));
|
||||
const agentDir = join(tempRoot, "agent");
|
||||
const themesDir = join(agentDir, "themes");
|
||||
mkdirSync(themesDir, { recursive: true });
|
||||
|
||||
// Copy dark.json as "custom-test" theme
|
||||
const darkThemePath = join(__dirname, "../../../src/modes/interactive/theme/dark.json");
|
||||
const darkTheme = JSON.parse(readFileSync(darkThemePath, "utf-8"));
|
||||
darkTheme.name = "custom-test";
|
||||
writeFileSync(join(themesDir, "custom-test.json"), JSON.stringify(darkTheme, null, 2));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("process should survive an error event on the theme FSWatcher", () => {
|
||||
const themeModulePath = join(__dirname, "../../../src/modes/interactive/theme/theme.js").replace(/\\/g, "/");
|
||||
const agentDir = join(tempRoot, "agent").replace(/\\/g, "/");
|
||||
|
||||
// Script that sets up the watcher and emits a synthetic error on it.
|
||||
// If no .on('error') handler is attached, EventEmitter.emit('error')
|
||||
// throws, which either crashes the process or gets caught by our try/catch.
|
||||
const scriptPath = join(tempRoot, "test-watcher-error.mts");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`
|
||||
import { setTheme, stopThemeWatcher } from "${themeModulePath}";
|
||||
|
||||
process.env.PI_CODING_AGENT_DIR = "${agentDir}";
|
||||
|
||||
setTheme("custom-test", true);
|
||||
|
||||
// Find the FSWatcher among active handles
|
||||
const handles = (process as any)._getActiveHandles();
|
||||
const fsWatcher = handles.find((h: any) => h.constructor?.name === "FSWatcher");
|
||||
|
||||
if (!fsWatcher) {
|
||||
process.stderr.write("no FSWatcher found among active handles\\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const errorListenerCount = fsWatcher.listenerCount("error");
|
||||
if (errorListenerCount === 0) {
|
||||
process.stderr.write("BUG: FSWatcher has no error handler (issue #2791)\\n");
|
||||
}
|
||||
|
||||
// Emitting 'error' on an EventEmitter with no error listener throws.
|
||||
// This simulates an async OS error (e.g. ReadDirectoryChangesW invalidation).
|
||||
try {
|
||||
fsWatcher.emit("error", new Error("simulated OS watcher failure"));
|
||||
} catch {
|
||||
process.stderr.write("error event was unhandled and threw\\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
stopThemeWatcher();
|
||||
process.exit(0);
|
||||
`,
|
||||
);
|
||||
|
||||
let _stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number;
|
||||
try {
|
||||
_stdout = execFileSync("npx", ["tsx", scriptPath], {
|
||||
timeout: 10000,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, PI_CODING_AGENT_DIR: agentDir },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
exitCode = 0;
|
||||
} catch (err: unknown) {
|
||||
const e = err as { status: number; stdout: string; stderr: string };
|
||||
_stdout = e.stdout ?? "";
|
||||
stderr = e.stderr ?? "";
|
||||
exitCode = e.status ?? 1;
|
||||
}
|
||||
|
||||
expect(exitCode, `Child crashed (exit ${exitCode}). stderr: ${stderr.trim()}`).toBe(0);
|
||||
});
|
||||
});
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import type { TUI } from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.js";
|
||||
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.js";
|
||||
import { initTheme } from "../../../src/modes/interactive/theme/theme.js";
|
||||
import { createHarness, type Harness } from "../harness.js";
|
||||
|
||||
function createFakeTui(): TUI {
|
||||
return {
|
||||
requestRender: () => {},
|
||||
} as unknown as TUI;
|
||||
}
|
||||
|
||||
async function waitForAsyncRender(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("issue #3217 scoped model ordering", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates reordered scoped models back to the session state", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: true },
|
||||
{ id: "faux-3", name: "Three", reasoning: true },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
const orderedIds = harness.models.map((model) => `${model.provider}/${model.id}`);
|
||||
const changes: Array<string[] | null> = [];
|
||||
const selector = new ScopedModelsSelectorComponent(
|
||||
{
|
||||
allModels: [...harness.models],
|
||||
enabledModelIds: orderedIds,
|
||||
},
|
||||
{
|
||||
onChange: (enabledModelIds) => {
|
||||
changes.push(enabledModelIds);
|
||||
},
|
||||
onPersist: () => {},
|
||||
onCancel: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
selector.handleInput("\x1b[1;3B");
|
||||
|
||||
expect(changes).toEqual([[orderedIds[1], orderedIds[0], orderedIds[2]]]);
|
||||
});
|
||||
|
||||
it("preserves scoped model order in the /model scoped tab", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: true },
|
||||
{ id: "faux-3", name: "Three", reasoning: true },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
const modelOne = harness.getModel("faux-1")!;
|
||||
const modelTwo = harness.getModel("faux-2")!;
|
||||
const modelThree = harness.getModel("faux-3")!;
|
||||
const selector = new ModelSelectorComponent(
|
||||
createFakeTui(),
|
||||
modelOne,
|
||||
harness.settingsManager,
|
||||
harness.session.modelRegistry,
|
||||
[{ model: modelTwo }, { model: modelOne }, { model: modelThree }],
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
await waitForAsyncRender();
|
||||
|
||||
const renderedLines = stripAnsi(selector.render(120).join("\n"))
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(`[${modelOne.provider}]`));
|
||||
const orderedIds = renderedLines.slice(0, 3).map((line) => {
|
||||
const [modelId] = line.trim().replace(/^→\s*/, "").split(" [");
|
||||
return modelId?.trim() ?? "";
|
||||
});
|
||||
|
||||
expect(orderedIds).toEqual([modelTwo.id, modelOne.id, modelThree.id]);
|
||||
});
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/3302
|
||||
*
|
||||
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
|
||||
* default fd-backed implementation used `fd --glob <pattern>` without
|
||||
* `--full-path`, which makes fd match only against the basename. Any pattern
|
||||
* containing a `/` therefore silently returned no matches.
|
||||
*
|
||||
* The fix switches fd into full-path mode when the pattern contains a `/`
|
||||
* and prepends `**\/` so the pattern can match against the absolute candidate
|
||||
* path that fd feeds to the matcher.
|
||||
*/
|
||||
describe("issue #3302 find returns no results for path-based glob patterns", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3302-"));
|
||||
mkdirSync(join(tempRoot, "some", "parent", "child"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "src", "foo", "bar"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "file.ext"), "");
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "test.spec.ts"), "");
|
||||
writeFileSync(join(tempRoot, "src", "foo", "bar", "example.spec.ts"), "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runFind(pattern: string): Promise<string[]> {
|
||||
const def = createFindToolDefinition(tempRoot);
|
||||
// The find tool implementation does not touch ctx; pass a minimal stub.
|
||||
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = result.content[0]?.text ?? "";
|
||||
if (text === "No files found matching pattern") return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("["));
|
||||
}
|
||||
|
||||
it("basename pattern still matches (regression-safe)", async () => {
|
||||
const files = await runFind("*.spec.ts");
|
||||
expect(files.sort()).toEqual(["some/parent/child/test.spec.ts", "src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
|
||||
it("directory-prefixed pattern with ** tail matches subtree", async () => {
|
||||
const files = await runFind("some/parent/child/**");
|
||||
// Matches files (and possibly directories) under the subtree. Assert the two files are present.
|
||||
expect(files).toContain("some/parent/child/file.ext");
|
||||
expect(files).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("leading ** wildcard with path segments matches", async () => {
|
||||
const files = await runFind("**/parent/child/*");
|
||||
expect(files.sort()).toContain("some/parent/child/file.ext");
|
||||
expect(files.sort()).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("src/**/*.spec.ts matches nested spec file", async () => {
|
||||
const files = await runFind("src/**/*.spec.ts");
|
||||
expect(files).toEqual(["src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
});
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/3303
|
||||
*
|
||||
* The `find` tool previously collected every `.gitignore` under the search
|
||||
* path and passed them to `fd` via `--ignore-file`. fd treats `--ignore-file`
|
||||
* entries as a single global ignore source, so rules from `a/.gitignore`
|
||||
* also filtered files under sibling `b/`. The fix switches to fd's
|
||||
* hierarchical `.gitignore` handling via `--no-require-git` and drops the
|
||||
* manual collection.
|
||||
*/
|
||||
describe("issue #3303 nested .gitignore rules leak into sibling directories", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
async function runFind(pattern: string): Promise<string[]> {
|
||||
const def = createFindToolDefinition(tempRoot);
|
||||
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = result.content[0]?.text ?? "";
|
||||
if (text === "No files found matching pattern") return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("["))
|
||||
.sort();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("flat sibling case", () => {
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-flat-"));
|
||||
mkdirSync(join(tempRoot, "a"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||
});
|
||||
|
||||
it("applies a/.gitignore only inside a/ and leaves b/ untouched", async () => {
|
||||
const files = await runFind("**/*.txt");
|
||||
expect(files).toEqual(["a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deeply nested case", () => {
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-deep-"));
|
||||
mkdirSync(join(tempRoot, "a", "deep"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "deep", ".gitignore"), "secret.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "secret.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||
});
|
||||
|
||||
it("scopes each .gitignore to its own subtree", async () => {
|
||||
const files = await runFind("**/*.txt");
|
||||
// a/.gitignore ignores 'ignored.txt' within a/ and a/deep/.
|
||||
// a/deep/.gitignore additionally ignores 'secret.txt' within a/deep/.
|
||||
// b/ is untouched by either.
|
||||
expect(files).toEqual(["a/deep/kept.txt", "a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { fauxAssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, getAssistantTexts, type Harness } from "../harness.js";
|
||||
|
||||
describe("issue #3317 network connection lost retry", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries transient "Network connection lost." failures', async () => {
|
||||
const harness = await createHarness({
|
||||
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "Network connection lost." }),
|
||||
fauxAssistantMessage("recovered after reconnect"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(2);
|
||||
expect(harness.eventsOfType("auto_retry_start").map((event) => event.errorMessage)).toEqual([
|
||||
"Network connection lost.",
|
||||
]);
|
||||
expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]);
|
||||
expect(getAssistantTexts(harness)).toContain("recovered after reconnect");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 680 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user