feat: sync upstream pi-mono (386 commits)

从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突:

保留 SproutClaw 独有功能:
- RPC: reload 命令、bash 流式输出、get_extensions 命令
- settings-manager: showChangelogOnStartup 设置
- agent-session: turnIndex getter
- 移除 pi 版本检查通知(interactive-mode)
- skills 系统、启动脚本、自定义工具脚本

直接采用上游版本:
- packages/ai 模型列表及测试文件
- packages/coding-agent 文档
- 其余未冲突的全部上游代码

升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:18:02 +08:00
409 changed files with 27402 additions and 6514 deletions

View File

@@ -2,7 +2,12 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core";
import { type AssistantMessage, getModel } from "@earendil-works/pi-ai";
import {
type AssistantMessage,
createAssistantMessageEventStream,
fauxAssistantMessage,
getModel,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
@@ -11,51 +16,10 @@ import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createTestResourceLoader } from "./utilities.ts";
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 settingsManager: SettingsManager;
let tempDir: string;
beforeEach(() => {
@@ -73,7 +37,7 @@ describe("AgentSession auto-compaction queue resume", () => {
});
sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
@@ -98,6 +62,57 @@ describe("AgentSession auto-compaction queue resume", () => {
});
it("should resume after threshold compaction when only agent-level queued messages exist", async () => {
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const model = session.model!;
const now = Date.now();
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "assistant response to compact" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 100,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: now - 500,
});
session.agent.state.messages = sessionManager.buildSessionContext().messages;
session.agent.streamFn = (summaryModel) => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: {
...fauxAssistantMessage("compacted"),
api: summaryModel.api,
provider: summaryModel.provider,
model: summaryModel.id,
usage: {
input: 10,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 10,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
},
});
});
return stream;
};
session.agent.followUp({
role: "custom",
customType: "test",

View File

@@ -444,6 +444,7 @@ describe("AgentSession concurrent prompt guard", () => {
text: string,
images: unknown,
source: "interactive" | "rpc" | "extension",
streamingBehavior?: "steer" | "followUp",
) => Promise<{ action: "continue" }>;
emitBeforeAgentStart: (
prompt: string,
@@ -588,6 +589,7 @@ describe("AgentSession concurrent prompt guard", () => {
text: string,
images: unknown,
source: "interactive" | "rpc" | "extension",
streamingBehavior?: "steer" | "followUp",
) => Promise<{ action: "continue" }>;
emitBeforeAgentStart: (
prompt: string,

View File

@@ -72,6 +72,9 @@ describe("AgentSession dynamic tool registration", () => {
const readTool = allTools.find((tool) => tool.name === "read");
expect(allTools.map((tool) => tool.name)).toContain("dynamic_tool");
expect(dynamicTool?.promptGuidelines).toEqual([
"Use dynamic_tool when the user asks for dynamic behavior tests.",
]);
expect(dynamicTool?.sourceInfo).toMatchObject({
path: "<inline:1>",
source: "inline",

View File

@@ -130,6 +130,11 @@ describe("parseArgs", () => {
expect(result.session).toBe("/path/to/session.jsonl");
});
test("parses --session-id", () => {
const result = parseArgs(["--session-id", "orchestrated-session"]);
expect(result.sessionId).toBe("orchestrated-session");
});
test("parses --fork", () => {
const result = parseArgs(["--fork", "1234abcd"]);
expect(result.fork).toBe("1234abcd");
@@ -152,6 +157,36 @@ describe("parseArgs", () => {
});
});
describe("--name flag", () => {
test("parses --name flag with value", () => {
const result = parseArgs(["--name", "my-session"]);
expect(result.name).toBe("my-session");
});
test("parses -n shorthand", () => {
const result = parseArgs(["-n", "quick-session"]);
expect(result.name).toBe("quick-session");
});
test("preserves empty values for main validation", () => {
const result = parseArgs(["--name", ""]);
expect(result.name).toBe("");
});
test("reports missing value", () => {
const result = parseArgs(["--name"]);
expect(result.diagnostics).toEqual([{ type: "error", message: "--name requires a value" }]);
});
test("works alongside other flags", () => {
const result = parseArgs(["--name", "named-run", "--print", "--model", "gpt-4o", "hello"]);
expect(result.name).toBe("named-run");
expect(result.print).toBe(true);
expect(result.model).toBe("gpt-4o");
expect(result.messages).toEqual(["hello"]);
});
});
describe("--no-session flag", () => {
test("parses --no-session flag", () => {
const result = parseArgs(["--no-session"]);
@@ -258,6 +293,28 @@ describe("parseArgs", () => {
});
});
describe("project approval flags", () => {
test("parses --approve", () => {
const result = parseArgs(["--approve"]);
expect(result.projectTrustOverride).toBe(true);
});
test("parses -a shorthand", () => {
const result = parseArgs(["-a"]);
expect(result.projectTrustOverride).toBe(true);
});
test("parses --no-approve", () => {
const result = parseArgs(["--no-approve"]);
expect(result.projectTrustOverride).toBe(false);
});
test("parses -na shorthand", () => {
const result = parseArgs(["-na"]);
expect(result.projectTrustOverride).toBe(false);
});
});
describe("--verbose flag", () => {
test("parses --verbose flag", () => {
const result = parseArgs(["--verbose"]);
@@ -303,6 +360,16 @@ describe("parseArgs", () => {
expect(result.tools).toEqual(["read", "bash"]);
});
test("parses --exclude-tools flag", () => {
const result = parseArgs(["--exclude-tools", "read,bash"]);
expect(result.excludeTools).toEqual(["read", "bash"]);
});
test("parses -xt shorthand", () => {
const result = parseArgs(["-xt", "read,bash"]);
expect(result.excludeTools).toEqual(["read", "bash"]);
});
test("parses --no-tools with explicit --tools flags", () => {
const result = parseArgs(["--no-tools", "--tools", "read,bash"]);
expect(result.noTools).toBe(true);

View File

@@ -5,7 +5,8 @@ import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
import lockfile from "proper-lockfile";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { clearConfigValueCache } from "../src/core/resolve-config-value.ts";
import { clearConfigValueCache, resolveConfigValueUncached } from "../src/core/resolve-config-value.ts";
import * as shellModule from "../src/utils/shell.ts";
describe("AuthStorage", () => {
let tempDir: string;
@@ -112,13 +113,13 @@ describe("AuthStorage", () => {
expect(apiKey).toBeUndefined();
});
test("apiKey as environment variable name resolves to env value", async () => {
test("apiKey with $ prefix 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" },
anthropic: { type: "api_key", key: "$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
@@ -134,6 +135,168 @@ describe("AuthStorage", () => {
}
});
test("apiKey env bag takes precedence over process.env", async () => {
const originalEnv = process.env.TEST_AUTH_SCOPED_API_KEY_12345;
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = "process-env-value";
try {
writeAuthJson({
anthropic: {
type: "api_key",
key: "$TEST_AUTH_SCOPED_API_KEY_12345",
env: { TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value" },
},
});
authStorage = AuthStorage.create(authJsonPath);
expect(await authStorage.getApiKey("anthropic")).toBe("credential-env-value");
expect(authStorage.getProviderEnv("anthropic")).toEqual({
TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value",
});
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_SCOPED_API_KEY_12345;
} else {
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey with braced env syntax resolves to env value", async () => {
const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345;
process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value";
const bracedKey = "$" + "{TEST_AUTH_BRACED_API_KEY_12345}";
try {
writeAuthJson({
anthropic: { type: "api_key", key: bracedKey },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("braced-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_BRACED_API_KEY_12345;
} else {
process.env.TEST_AUTH_BRACED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey interpolates braced env references inside literals", async () => {
const originalPartA = process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
const originalPartB = process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = "left";
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = "right";
const interpolatedKey = [
"$",
"{TEST_AUTH_INTERPOLATED_PART_A_12345}_$",
"{TEST_AUTH_INTERPOLATED_PART_B_12345}",
].join("");
try {
writeAuthJson({
anthropic: { type: "api_key", key: interpolatedKey },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("left_right");
} finally {
if (originalPartA === undefined) {
delete process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
} else {
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = originalPartA;
}
if (originalPartB === undefined) {
delete process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
} else {
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = originalPartB;
}
}
});
test("apiKey with $$ prefix escapes a leading dollar", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "$$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("$TEST_AUTH_API_KEY_12345");
});
test("apiKey with $! escapes a literal bang and still interpolates later env refs", 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: "$!literal-$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("!literal-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("plain API key is used directly even when it matches an env var", 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("TEST_AUTH_API_KEY_12345");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_API_KEY_12345;
} else {
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
}
}
});
test("literal public API key is not corrupted by the Windows PUBLIC env var", async () => {
const originalPublic = process.env.PUBLIC;
process.env.PUBLIC = "C:\\Users\\Public";
try {
writeAuthJson({
opencode: { type: "api_key", key: "public" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("opencode");
expect(apiKey).toBe("public");
} finally {
if (originalPublic === undefined) {
delete process.env.PUBLIC;
} else {
process.env.PUBLIC = originalPublic;
}
}
});
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;
@@ -159,6 +322,30 @@ describe("AuthStorage", () => {
expect(apiKey).toBe("hello-world");
});
test("command config uses stdin when configured shell requires it", () => {
if (process.platform === "win32") return;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({
shell: "/bin/bash",
args: ["-s"],
commandTransport: "stdin",
});
try {
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
const nameExpansion = "$" + "{name}";
expect(resolveConfigValueUncached(`!name='World'; echo "Hello, ${nameExpansion}!"`)).toBe("Hello, World!");
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
describe("caching", () => {
test("command is only executed once per process", async () => {
// Use a command that writes to a file to count invocations
@@ -274,7 +461,7 @@ describe("AuthStorage", () => {
process.env[envVarName] = "first-value";
writeAuthJson({
anthropic: { type: "api_key", key: envVarName },
anthropic: { type: "api_key", key: `$${envVarName}` },
});
authStorage = AuthStorage.create(authJsonPath);

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts";
const entry: ChangelogEntry = {
major: 0,
minor: 79,
patch: 0,
content: "",
};
describe("normalizeChangelogLinks", () => {
test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => {
const markdown = [
"[Project Trust](README.md#project-trust)",
"[Extensions](docs/extensions.md#project_trust)",
"[Examples](examples/extensions/)",
"[Root README](../../README.md#supply-chain-hardening)",
].join("\n");
expect(normalizeChangelogLinks(markdown, entry)).toBe(
[
"[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)",
"[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)",
"[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)",
"[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)",
].join("\n"),
);
});
test("canonicalizes old repository URLs without changing external links", () => {
const markdown = [
"[#5167](https://github.com/earendil-works/pi-mono/pull/5167)",
"[#4163](https://github.com/badlogic/pi-mono/issues/4163)",
"[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)",
"[External](https://example.com/docs)",
"[Local anchor](#settings)",
].join("\n");
expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe(
[
"[#5167](https://github.com/earendil-works/pi/pull/5167)",
"[#4163](https://github.com/earendil-works/pi/issues/4163)",
"[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)",
"[External](https://example.com/docs)",
"[Local anchor](#settings)",
].join("\n"),
);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, test, vi } from "vitest";
import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboard-native.ts";
type ClipboardRequire = (id: string) => unknown;
const fakeClipboard: ClipboardModule = {
setText: async () => {},
hasImage: () => true,
getImageBinary: async () => [1, 2, 3],
};
describe("loadClipboardNative", () => {
test("falls back to the next require root", () => {
const primary = vi.fn<ClipboardRequire>(() => {
throw new Error("missing from bundled root");
});
const fallback = vi.fn<ClipboardRequire>(() => fakeClipboard);
expect(loadClipboardNative([primary, fallback])).toBe(fakeClipboard);
expect(primary).toHaveBeenCalledWith("@mariozechner/clipboard");
expect(fallback).toHaveBeenCalledWith("@mariozechner/clipboard");
});
test("returns null when no require root can load clipboard", () => {
const missing = vi.fn<ClipboardRequire>(() => {
throw new Error("missing");
});
expect(loadClipboardNative([missing])).toBeNull();
});
});

View File

@@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const sessionManager = SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage);

View File

@@ -9,7 +9,6 @@ import {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
findCutPoint,
getLastAssistantUsage,
prepareCompaction,
@@ -396,7 +395,7 @@ describe("buildSessionContext", () => {
});
describe("prepareCompaction with previous compaction", () => {
it("should preserve kept messages across repeated compactions when they still fit", () => {
it("should skip repeated compactions when kept messages 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"));
@@ -408,29 +407,9 @@ describe("prepareCompaction with previous compaction", () => {
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");
expect(preparation).toBeUndefined();
});
it("should re-summarize previously kept messages when the recent window moves past them", () => {

View File

@@ -0,0 +1,177 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { runMigrations } from "../src/migrations.ts";
describe("config value env var syntax migration", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
function createAgentDir(): string {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-config-value-migration-test-"));
tempDirs.push(agentDir);
return agentDir;
}
function withAgentDir(agentDir: string, fn: () => void): void {
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
try {
fn();
} finally {
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
}
}
it("leaves uppercase auth.json API key values unchanged", () => {
const agentDir = createAgentDir();
fs.writeFileSync(
path.join(agentDir, "auth.json"),
`${JSON.stringify(
{
anthropic: { type: "api_key", key: "ANTHROPIC_API_KEY" },
openai: { type: "api_key", key: "$OPENAI_API_KEY" },
opencode: { type: "api_key", key: "public" },
github: { type: "oauth", access: "ACCESS_TOKEN", refresh: "REFRESH_TOKEN", expires: 1 },
},
null,
2,
)}\n`,
"utf-8",
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
withAgentDir(agentDir, () => runMigrations(agentDir));
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8")) as Record<
string,
Record<string, unknown>
>;
expect(migrated.anthropic.key).toBe("ANTHROPIC_API_KEY");
expect(migrated.openai.key).toBe("$OPENAI_API_KEY");
expect(migrated.opencode.key).toBe("public");
expect(migrated.github.access).toBe("ACCESS_TOKEN");
expect(logSpy).not.toHaveBeenCalled();
});
it.each([
["malformed", '{\n "providers": {\n'],
["blank", ""],
])("does not throw on %s models.json during migrations", (_name, content) => {
const agentDir = createAgentDir();
const modelsPath = path.join(agentDir, "models.json");
fs.writeFileSync(modelsPath, content, "utf-8");
withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow());
expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content);
const registry = ModelRegistry.create(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath);
const loadError = registry.getError();
expect(loadError).toContain("Failed to parse models.json");
expect(loadError).toContain(`File: ${modelsPath}`);
});
it("leaves uppercase models.json API key and header values unchanged", async () => {
const agentDir = createAgentDir();
const envKeys = ["CUSTOM_API_KEY", "HEADER_API_KEY", "MODEL_API_KEY", "OVERRIDE_API_KEY"];
const savedEnv: Record<string, string | undefined> = {};
for (const key of envKeys) {
savedEnv[key] = process.env[key];
process.env[key] = `env-${key}`;
}
try {
fs.writeFileSync(
path.join(agentDir, "models.json"),
`${JSON.stringify(
{
providers: {
"custom-provider": {
baseUrl: "https://example.com/v1",
apiKey: "CUSTOM_API_KEY",
api: "openai-completions",
headers: {
"x-api-key": "HEADER_API_KEY",
"x-literal": "literal",
},
models: [
{
id: "model-a",
headers: { "x-model-key": "MODEL_API_KEY" },
},
],
modelOverrides: {
"model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } },
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
withAgentDir(agentDir, () => runMigrations(agentDir));
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as {
providers: Record<
string,
{
apiKey?: string;
headers?: Record<string, string>;
models?: Array<{ headers?: Record<string, string> }>;
modelOverrides?: Record<string, { headers?: Record<string, string> }>;
}
>;
};
const provider = migrated.providers["custom-provider"]!;
expect(provider.apiKey).toBe("CUSTOM_API_KEY");
expect(provider.headers?.["x-api-key"]).toBe("HEADER_API_KEY");
expect(provider.headers?.["x-literal"]).toBe("literal");
expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("MODEL_API_KEY");
expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("OVERRIDE_API_KEY");
expect(logSpy).not.toHaveBeenCalled();
const registry = ModelRegistry.create(
AuthStorage.create(path.join(agentDir, "auth.json")),
path.join(agentDir, "models.json"),
);
const model = registry.find("custom-provider", "model-a");
expect(model).toBeDefined();
expect(await registry.getApiKeyForProvider("custom-provider")).toBe("CUSTOM_API_KEY");
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
ok: true,
apiKey: "CUSTOM_API_KEY",
headers: {
"x-api-key": "HEADER_API_KEY",
"x-literal": "literal",
"x-model-key": "MODEL_API_KEY",
},
});
} finally {
for (const key of envKeys) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
}
});
});

View File

@@ -153,7 +153,7 @@ describe("detectInstallMethod", () => {
expect(detectInstallMethod()).toBe("pnpm");
expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe(
"Run: pnpm install -g --ignore-scripts @earendil-works/pi-coding-agent",
"Run: pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @earendil-works/pi-coding-agent",
);
});
@@ -175,8 +175,16 @@ describe("detectInstallMethod", () => {
expect(detectInstallMethod()).toBe("npm");
expect(command).toEqual({
command: "npm",
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"],
display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`,
args: [
"--prefix",
prefix,
"install",
"-g",
"--ignore-scripts",
"--min-release-age=0",
"@earendil-works/pi-coding-agent",
],
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`,
});
});
@@ -187,8 +195,8 @@ describe("detectInstallMethod", () => {
expect(command).toEqual({
command: "npm",
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"],
display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`,
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"],
display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`,
steps: [
{
command: "npm",
@@ -197,8 +205,8 @@ describe("detectInstallMethod", () => {
},
{
command: "npm",
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"],
display: `npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`,
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"],
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`,
},
],
});
@@ -211,8 +219,16 @@ describe("detectInstallMethod", () => {
expect(command).toEqual({
command: "npm",
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"],
display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`,
args: [
"--prefix",
prefix,
"install",
"-g",
"--ignore-scripts",
"--min-release-age=0",
"@earendil-works/pi-coding-agent",
],
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`,
});
});
@@ -227,6 +243,7 @@ describe("detectInstallMethod", () => {
"install",
"-g",
"--ignore-scripts",
"--min-release-age=0",
"@earendil-works/pi-coding-agent",
]);
});
@@ -237,7 +254,7 @@ describe("detectInstallMethod", () => {
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent");
expect(command?.display).toBe(
`npm --prefix "${prefix}" install -g --ignore-scripts @earendil-works/pi-coding-agent`,
`npm --prefix "${prefix}" install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`,
);
});
@@ -248,7 +265,7 @@ describe("detectInstallMethod", () => {
expect(detectInstallMethod()).toBe("npm");
expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe(
"Run: npm install -g --ignore-scripts @earendil-works/pi-coding-agent",
"Run: npm install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent",
);
});
@@ -260,8 +277,8 @@ describe("detectInstallMethod", () => {
expect(detectInstallMethod()).toBe("bun");
expect(command).toEqual({
command: "bun",
args: ["install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"],
display: "bun install -g --ignore-scripts @earendil-works/pi-coding-agent",
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@earendil-works/pi-coding-agent"],
display: "bun install -g --ignore-scripts --minimum-release-age=0 @earendil-works/pi-coding-agent",
});
});
@@ -273,8 +290,9 @@ describe("detectInstallMethod", () => {
expect(detectInstallMethod()).toBe("pnpm");
expect(command).toEqual({
command: "pnpm",
args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"],
display: "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts @new-scope/pi",
args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"],
display:
"pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi",
steps: [
{
command: "pnpm",
@@ -283,8 +301,8 @@ describe("detectInstallMethod", () => {
},
{
command: "pnpm",
args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"],
display: "pnpm install -g --ignore-scripts @new-scope/pi",
args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"],
display: "pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi",
},
],
});
@@ -328,8 +346,8 @@ describe("detectInstallMethod", () => {
expect(detectInstallMethod()).toBe("pnpm");
expect(command).toEqual({
command: "pnpm",
args: ["install", "-g", "--ignore-scripts", packageName],
display: `pnpm install -g --ignore-scripts ${packageName}`,
args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", packageName],
display: `pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 ${packageName}`,
});
});
@@ -366,8 +384,9 @@ describe("detectInstallMethod", () => {
expect(detectInstallMethod()).toBe("bun");
expect(command).toEqual({
command: "bun",
args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"],
display: "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts @new-scope/pi",
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"],
display:
"bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi",
steps: [
{
command: "bun",
@@ -376,8 +395,8 @@ describe("detectInstallMethod", () => {
},
{
command: "bun",
args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"],
display: "bun install -g --ignore-scripts @new-scope/pi",
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"],
display: "bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi",
},
],
});

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "vitest";
import { areExperimentalFeaturesEnabled } from "../src/core/experimental.ts";
describe("areExperimentalFeaturesEnabled", () => {
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
afterEach(() => {
if (originalPiExperimental === undefined) {
delete process.env.PI_EXPERIMENTAL;
} else {
process.env.PI_EXPERIMENTAL = originalPiExperimental;
}
});
it("returns false when PI_EXPERIMENTAL is unset", () => {
delete process.env.PI_EXPERIMENTAL;
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
it("returns false when PI_EXPERIMENTAL is empty", () => {
process.env.PI_EXPERIMENTAL = "";
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
it("returns true when PI_EXPERIMENTAL is set to 1", () => {
process.env.PI_EXPERIMENTAL = "1";
expect(areExperimentalFeaturesEnabled()).toBe(true);
});
it("returns false when PI_EXPERIMENTAL is set to 0", () => {
process.env.PI_EXPERIMENTAL = "0";
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
it("returns false when PI_EXPERIMENTAL is set to a non-1 value", () => {
process.env.PI_EXPERIMENTAL = "true";
expect(areExperimentalFeaturesEnabled()).toBe(false);
});
});

View File

@@ -4,15 +4,20 @@ import { describe, expect, it } from "vitest";
describe("export HTML markdown link sanitization", () => {
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
it("overrides the marked link renderer to block javascript: protocol", () => {
// The custom link renderer must check for dangerous protocols
it("overrides the marked link renderer to use scheme allow-list sanitization", () => {
expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/);
expect(templateJs).toMatch(/javascript/i);
expect(templateJs).toMatch(/vbscript/i);
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
expect(templateJs).toMatch(/\^\(https\?\|mailto\|tel\|ftp\)/);
});
it("overrides the marked image renderer to block javascript: protocol", () => {
it("overrides the marked image renderer to use scheme allow-list sanitization", () => {
expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/);
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
});
it("strips C0 controls before checking and emitting markdown URLs", () => {
expect(templateJs).toContain("replace(/[\\x00-\\x1f\\x7f]/g, '')");
expect(templateJs).not.toMatch(/\^\\s\*\(javascript\|vbscript\|data\):/i);
});
it("escapes href attributes in the custom link renderer", () => {

View File

@@ -94,6 +94,18 @@ describe("Input Event", () => {
}
});
it("passes streamingBehavior correctly", async () => {
const r = await createRunner(
`export default p => p.on("input", async e => { globalThis.testVar = e.streamingBehavior; return { action: "continue" }; });`,
);
await r.emitInput("x", undefined, "interactive", "steer");
expect((globalThis as any).testVar).toBe("steer");
await r.emitInput("x", undefined, "interactive", "followUp");
expect((globalThis as any).testVar).toBe("followUp");
await r.emitInput("x", undefined, "interactive");
expect((globalThis as any).testVar).toBeUndefined();
});
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[] = [];

View File

@@ -7,9 +7,14 @@ 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.ts";
import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.ts";
import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts";
import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts";
import type {
ExtensionActions,
ExtensionContextActions,
ExtensionUIContext,
ProviderConfig,
} from "../src/core/extensions/types.ts";
import { KeybindingsManager, type KeyId } from "../src/core/keybindings.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
@@ -36,7 +41,7 @@ describe("ExtensionRunner", () => {
const providerModelConfig: ProviderConfig = {
baseUrl: "https://provider.test/v1",
apiKey: "PROVIDER_TEST_KEY",
apiKey: "provider-test-key",
api: "openai-completions",
models: [
{
@@ -71,6 +76,7 @@ describe("ExtensionRunner", () => {
const extensionContextActions: ExtensionContextActions = {
getModel: () => undefined,
isIdle: () => true,
isProjectTrusted: () => true,
getSignal: () => undefined,
abort: () => {},
hasPendingMessages: () => false,
@@ -80,6 +86,45 @@ describe("ExtensionRunner", () => {
getSystemPrompt: () => "",
};
describe("project_trust", () => {
it("continues past undecided handlers and returns the first yes/no decision", async () => {
const undecidedPath = path.join(extensionsDir, "undecided.ts");
const decidedPath = path.join(extensionsDir, "decided.ts");
fs.writeFileSync(
undecidedPath,
`export default function(pi) {
pi.on("project_trust", () => ({ trusted: "undecided", remember: true }));
}`,
);
fs.writeFileSync(
decidedPath,
`export default function(pi) {
pi.on("project_trust", () => ({ trusted: "no", remember: true }));
}`,
);
const extensionsResult = await loadExtensions([undecidedPath, decidedPath], tempDir);
const result = await emitProjectTrustEvent(
extensionsResult,
{ type: "project_trust", cwd: tempDir },
{
cwd: tempDir,
mode: "tui",
hasUI: false,
ui: {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
},
},
);
expect(result.result).toEqual({ trusted: "no", remember: true });
expect(result.errors).toEqual([]);
});
});
describe("shortcut conflicts", () => {
it("warns when extension shortcut conflicts with built-in", async () => {
const extCode = `
@@ -441,6 +486,50 @@ describe("ExtensionRunner", () => {
controller.abort();
expect(ctx.signal?.aborted).toBe(true);
});
it("exposes print mode and hasUI false by default", async () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
runner.bindCore(extensionActions, extensionContextActions);
const ctx = runner.createContext();
expect(ctx.mode).toBe("print");
expect(ctx.hasUI).toBe(false);
});
it("exposes project trust state on ExtensionContext", async () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
runner.bindCore(extensionActions, {
...extensionContextActions,
isProjectTrusted: () => false,
});
const ctx = runner.createContext();
expect(ctx.isProjectTrusted()).toBe(false);
});
it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
runner.bindCore(extensionActions, extensionContextActions);
runner.setUIContext({} as ExtensionUIContext, "rpc");
const ctx = runner.createContext();
expect(ctx.mode).toBe("rpc");
expect(ctx.hasUI).toBe(true);
});
it("exposes tui mode with hasUI true when a TUI UI context is provided", async () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
runner.bindCore(extensionActions, extensionContextActions);
runner.setUIContext({} as ExtensionUIContext, "tui");
const ctx = runner.createContext();
expect(ctx.mode).toBe("tui");
expect(ctx.hasUI).toBe(true);
});
});
describe("error handling", () => {

View File

@@ -10,6 +10,18 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
async function resolvesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
return Promise.race([promise.then(() => true), delay(ms).then(() => false)]);
}
const tempDirs: string[] = [];
async function createTempDir(): Promise<string> {
@@ -160,4 +172,103 @@ describe("built-in edit and write tools", () => {
const content = await readFile(filePath, "utf8");
expect(content).toBe("replacement\n");
});
it("keeps write queue locked while an aborted write is still in flight", async () => {
const dir = await createTempDir();
const filePath = join(dir, "abort-write.txt");
const firstWriteStarted = createDeferred();
const finishFirstWrite = createDeferred();
const secondWriteStarted = createDeferred();
let firstWriteSettled = false;
const writeTool = createWriteTool(dir, {
operations: {
mkdir: async () => {},
writeFile: async (path, content) => {
if (content === "first\n") {
firstWriteStarted.resolve();
await finishFirstWrite.promise;
await writeFile(path, content, "utf8");
firstWriteSettled = true;
return;
}
if (content === "second\n") {
expect(firstWriteSettled).toBe(true);
secondWriteStarted.resolve();
}
await writeFile(path, content, "utf8");
},
},
});
const controller = new AbortController();
const firstWrite = writeTool.execute("call-1", { path: filePath, content: "first\n" }, controller.signal);
await firstWriteStarted.promise;
controller.abort();
const secondWrite = writeTool.execute("call-2", { path: filePath, content: "second\n" });
expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
finishFirstWrite.resolve();
await expect(firstWrite).rejects.toThrow("Operation aborted");
await secondWrite;
const content = await readFile(filePath, "utf8");
expect(content).toBe("second\n");
});
it("keeps edit queue locked while an aborted edit write is still in flight", async () => {
const dir = await createTempDir();
const filePath = join(dir, "abort-edit.txt");
await writeFile(filePath, "alpha\nbeta\n", "utf8");
const firstWriteStarted = createDeferred();
const finishFirstWrite = createDeferred();
const secondWriteStarted = createDeferred();
let firstWriteSettled = false;
const editTool = createEditTool(dir, {
operations: {
access,
readFile,
writeFile: async (path, content) => {
if (content === "ALPHA\nbeta\n") {
firstWriteStarted.resolve();
await finishFirstWrite.promise;
await writeFile(path, content, "utf8");
firstWriteSettled = true;
return;
}
if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
expect(firstWriteSettled).toBe(true);
secondWriteStarted.resolve();
}
await writeFile(path, content, "utf8");
},
},
});
const controller = new AbortController();
const firstEdit = editTool.execute(
"call-1",
{ path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] },
controller.signal,
);
await firstWriteStarted.promise;
controller.abort();
const secondEdit = editTool.execute("call-2", {
path: filePath,
edits: [{ oldText: "beta", newText: "BETA" }],
});
expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
finishFirstWrite.resolve();
await expect(firstEdit).rejects.toThrow("Operation aborted");
await secondEdit;
const content = await readFile(filePath, "utf8");
expect(content).toBe("ALPHA\nBETA\n");
});
});

View File

@@ -0,0 +1,39 @@
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/config.ts", async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as Record<string, unknown>),
PACKAGE_NAME: "@example/pi-coding-agent",
};
});
import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts";
describe("shouldRunFirstTimeSetup in forked distributions", () => {
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
let tempDir: string;
let settingsPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-fork-"));
settingsPath = join(tempDir, "settings.json");
process.env.PI_EXPERIMENTAL = "1";
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
if (originalPiExperimental === undefined) {
delete process.env.PI_EXPERIMENTAL;
} else {
process.env.PI_EXPERIMENTAL = originalPiExperimental;
}
});
it("returns false for a forked package", () => {
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
});
});

View File

@@ -0,0 +1,95 @@
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts";
import { ENV_AGENT_DIR } from "../src/config.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
describe("shouldRunFirstTimeSetup", () => {
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
const originalAgentDir = process.env[ENV_AGENT_DIR];
let tempDir: string;
let settingsPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-"));
settingsPath = join(tempDir, "settings.json");
process.env.PI_EXPERIMENTAL = "1";
delete process.env[ENV_AGENT_DIR];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
if (originalPiExperimental === undefined) {
delete process.env.PI_EXPERIMENTAL;
} else {
process.env.PI_EXPERIMENTAL = originalPiExperimental;
}
if (originalAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = originalAgentDir;
}
});
it("returns true when experimental, default agent dir, and no settings.json", () => {
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(true);
});
it("returns false when experimental features are disabled", () => {
delete process.env.PI_EXPERIMENTAL;
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
});
it("returns false when a custom agent dir is set", () => {
process.env[ENV_AGENT_DIR] = tempDir;
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
});
it("returns false when settings.json already exists", () => {
writeFileSync(settingsPath, "{}", "utf-8");
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
});
});
describe("analytics settings", () => {
it("defaults to disabled with no tracking identifier", () => {
const manager = SettingsManager.inMemory();
expect(manager.getEnableAnalytics()).toBe(false);
expect(manager.getTrackingId()).toBeUndefined();
});
it("generates a tracking identifier on opt-in", () => {
const manager = SettingsManager.inMemory();
manager.setEnableAnalytics(true);
expect(manager.getEnableAnalytics()).toBe(true);
expect(manager.getTrackingId()).toMatch(/^[0-9a-f-]{36}$/);
});
it("does not generate a tracking identifier on opt-out", () => {
const manager = SettingsManager.inMemory();
manager.setEnableAnalytics(false);
expect(manager.getEnableAnalytics()).toBe(false);
expect(manager.getTrackingId()).toBeUndefined();
});
it("keeps the tracking identifier when toggling analytics", () => {
const manager = SettingsManager.inMemory();
manager.setEnableAnalytics(true);
const trackingId = manager.getTrackingId();
manager.setEnableAnalytics(false);
manager.setEnableAnalytics(true);
expect(manager.getTrackingId()).toBe(trackingId);
});
});

View File

@@ -2,8 +2,9 @@ import { visibleWidth } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, it } from "vitest";
import type { AgentSession } from "../src/core/agent-session.ts";
import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts";
import { FooterComponent } from "../src/modes/interactive/components/footer.ts";
import { FooterComponent, formatCwdForFooter } from "../src/modes/interactive/components/footer.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
type AssistantUsage = {
input: number;
@@ -73,6 +74,17 @@ function createFooterData(providerCount: number): ReadonlyFooterDataProvider {
return provider;
}
describe("formatCwdForFooter", () => {
it("does not abbreviate sibling paths that share the home prefix", () => {
expect(formatCwdForFooter("/home/user2", "/home/user")).toBe("/home/user2");
});
it("abbreviates the home directory and descendants", () => {
expect(formatCwdForFooter("/home/user", "/home/user")).toBe("~");
expect(formatCwdForFooter("/home/user/project", "/home/user")).toBe("~/project");
});
});
describe("FooterComponent width handling", () => {
beforeAll(() => {
initTheme(undefined, false);
@@ -112,4 +124,21 @@ describe("FooterComponent width handling", () => {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
});
it("shows the latest cache hit rate when cache usage is present", () => {
const session = createSession({
sessionName: "",
usage: {
input: 100,
output: 10,
cacheRead: 50,
cacheWrite: 50,
cost: { total: 0.001 },
},
});
const footer = new FooterComponent(session, createFooterData(1));
const statsLine = stripAnsi(footer.render(120)[1]);
expect(statsLine).toContain("CH25.0%");
});
});

View File

@@ -0,0 +1,135 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { APP_NAME } from "../src/config.ts";
import type { SessionManager } from "../src/core/session-manager.ts";
import { formatResumeCommand } from "../src/modes/interactive/interactive-mode.ts";
const tempDirs: string[] = [];
const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
afterEach(() => {
if (originalStdoutIsTTY) {
Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function setStdoutIsTTY(value: boolean): void {
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value });
}
function createTempFile(): string {
const dir = mkdtempSync(join(tmpdir(), "pi-format-resume-command-"));
tempDirs.push(dir);
const file = join(dir, "session.jsonl");
writeFileSync(file, "\n");
return file;
}
function createSessionManager(options: {
persisted?: boolean;
sessionFile?: string;
sessionId?: string;
sessionDir?: string;
usesDefaultSessionDir?: boolean;
}): SessionManager {
return {
isPersisted: () => options.persisted ?? true,
getSessionFile: () => options.sessionFile,
getSessionId: () => options.sessionId ?? "0197f6e4-4cf9-7f44-a2d8-f8f7f49ee9d3",
getSessionDir: () => options.sessionDir ?? "/tmp/pi-sessions",
usesDefaultSessionDir: () => options.usesDefaultSessionDir ?? true,
} as unknown as SessionManager;
}
describe("formatResumeCommand", () => {
it("returns a session resume command for default session dirs", () => {
setStdoutIsTTY(true);
const sessionFile = createTempFile();
const sessionManager = createSessionManager({ sessionFile, sessionId: "test-session" });
expect(formatResumeCommand(sessionManager)).toBe(`${APP_NAME} --session test-session`);
});
it("includes unquoted safe session dirs for non-default session dirs", () => {
setStdoutIsTTY(true);
const sessionFile = createTempFile();
const sessionManager = createSessionManager({
sessionFile,
sessionId: "test-session",
sessionDir: "/tmp/custom-pi-sessions",
usesDefaultSessionDir: false,
});
expect(formatResumeCommand(sessionManager)).toBe(
`${APP_NAME} --session-dir /tmp/custom-pi-sessions --session test-session`,
);
});
it("quotes session dirs containing spaces", () => {
setStdoutIsTTY(true);
const sessionFile = createTempFile();
const sessionManager = createSessionManager({
sessionFile,
sessionId: "test-session",
sessionDir: "/tmp/custom pi sessions",
usesDefaultSessionDir: false,
});
expect(formatResumeCommand(sessionManager)).toBe(
`${APP_NAME} --session-dir '/tmp/custom pi sessions' --session test-session`,
);
});
it("quotes session dirs containing single quotes", () => {
setStdoutIsTTY(true);
const sessionFile = createTempFile();
const sessionManager = createSessionManager({
sessionFile,
sessionId: "test-session",
sessionDir: "/tmp/custom pi's sessions",
usesDefaultSessionDir: false,
});
expect(formatResumeCommand(sessionManager)).toBe(
`${APP_NAME} --session-dir '/tmp/custom pi'\\''s sessions' --session test-session`,
);
});
it("returns undefined when stdout is not a TTY", () => {
setStdoutIsTTY(false);
const sessionFile = createTempFile();
const sessionManager = createSessionManager({ sessionFile });
expect(formatResumeCommand(sessionManager)).toBeUndefined();
});
it("returns undefined for in-memory sessions", () => {
setStdoutIsTTY(true);
const sessionFile = createTempFile();
const sessionManager = createSessionManager({ persisted: false, sessionFile });
expect(formatResumeCommand(sessionManager)).toBeUndefined();
});
it("returns undefined when the session file is missing", () => {
setStdoutIsTTY(true);
const sessionManager = createSessionManager({ sessionFile: "/tmp/pi-missing-session.jsonl" });
expect(formatResumeCommand(sessionManager)).toBeUndefined();
});
it("returns undefined when the session file is not set", () => {
setStdoutIsTTY(true);
const sessionManager = createSessionManager({ sessionFile: undefined });
expect(formatResumeCommand(sessionManager)).toBeUndefined();
});
});

View File

@@ -0,0 +1,206 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import mergeAndResolve from "../examples/extensions/git-merge-and-resolve.ts";
import type { ExecResult, ExtensionAPI, ExtensionContext } from "../src/core/extensions/index.ts";
type AgentEndHandler = (event: { type: "agent_end" }, ctx: ExtensionContext) => Promise<undefined>;
const ok: ExecResult = { stdout: "", stderr: "", code: 0, killed: false };
const fail: ExecResult = { stdout: "", stderr: "error", code: 1, killed: false };
/** Standard exec results for a clean repo tracking origin/main, not in a merge. */
function withUpstream(results: Map<string, ExecResult>): Map<string, ExecResult> {
results.set("git rev-parse --git-dir", ok);
results.set("git rev-parse MERGE_HEAD", fail);
results.set("git status --porcelain", ok);
results.set("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { ...ok, stdout: "origin/main\n" });
results.set("git fetch origin", ok);
return results;
}
function setup(cwd: string, execResults: Map<string, ExecResult>) {
let handler: AgentEndHandler | undefined;
const sendUserMessage = vi.fn();
const exec = vi.fn<ExtensionAPI["exec"]>().mockImplementation(async (cmd, args) => {
const key = [cmd, ...args].join(" ");
return execResults.get(key) ?? fail;
});
const api = {
on: (event: string, h: AgentEndHandler) => {
if (event === "agent_end") handler = h;
},
exec,
sendUserMessage,
} as unknown as ExtensionAPI;
mergeAndResolve(api);
const ctx = { cwd, ui: { notify: vi.fn() } } as unknown as ExtensionContext;
async function trigger() {
await handler!({ type: "agent_end" }, ctx);
}
return { trigger, exec, sendUserMessage };
}
describe("git-merge-and-resolve example", () => {
let tempDir: string;
afterEach(() => {
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
});
function createTempDir() {
tempDir = mkdtempSync(join(tmpdir(), "pi-merge-test-"));
return tempDir;
}
it("skips when not a git repository", async () => {
const cwd = createTempDir();
const results = new Map<string, ExecResult>();
results.set("git rev-parse --git-dir", fail);
const { trigger, exec, sendUserMessage } = setup(cwd, results);
await trigger();
expect(exec).toHaveBeenCalledTimes(1);
expect(sendUserMessage).not.toHaveBeenCalled();
});
it("skips when no upstream is configured", async () => {
const cwd = createTempDir();
const results = new Map<string, ExecResult>();
results.set("git rev-parse --git-dir", ok);
results.set("git rev-parse --abbrev-ref --symbolic-full-name @{u}", fail);
const { trigger, sendUserMessage } = setup(cwd, results);
await trigger();
expect(sendUserMessage).not.toHaveBeenCalled();
});
it("re-sends conflicts when in an unfinished merge", async () => {
const cwd = createTempDir();
const conflictContent = ["<<<<<<< HEAD", "ours", "=======", "theirs", ">>>>>>> origin/main"].join("\n");
writeFileSync(join(cwd, "file.ts"), conflictContent);
const results = new Map<string, ExecResult>();
results.set("git rev-parse --git-dir", ok);
results.set("git rev-parse MERGE_HEAD", ok);
results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "file.ts\n" });
const { trigger, exec, sendUserMessage } = setup(cwd, results);
await trigger();
// Should not attempt a new fetch/merge
expect(exec).not.toHaveBeenCalledWith("git", ["fetch", "origin"]);
expect(sendUserMessage).toHaveBeenCalledTimes(1);
const message = sendUserMessage.mock.calls[0][0] as string;
expect(message).toContain("file.ts:1-5");
});
it("skips when working tree is dirty and not in a merge", async () => {
const cwd = createTempDir();
const results = new Map<string, ExecResult>();
results.set("git rev-parse --git-dir", ok);
results.set("git rev-parse MERGE_HEAD", fail);
results.set("git status --porcelain", { ...ok, stdout: " M src/index.ts\n" });
const { trigger, exec, sendUserMessage } = setup(cwd, results);
await trigger();
expect(exec).not.toHaveBeenCalledWith("git", ["fetch", "origin"]);
expect(sendUserMessage).not.toHaveBeenCalled();
});
it("skips when fetch fails", async () => {
const cwd = createTempDir();
const results = withUpstream(new Map<string, ExecResult>());
results.set("git fetch origin", fail);
const { trigger, sendUserMessage } = setup(cwd, results);
await trigger();
expect(sendUserMessage).not.toHaveBeenCalled();
});
it("skips when merge is clean", async () => {
const cwd = createTempDir();
const results = withUpstream(new Map<string, ExecResult>());
results.set("git merge --no-ff origin/main", ok);
const { trigger, sendUserMessage } = setup(cwd, results);
await trigger();
expect(sendUserMessage).not.toHaveBeenCalled();
});
it("sends conflict report as a follow-up", async () => {
const cwd = createTempDir();
const conflictContent = [
"line 1",
"<<<<<<< HEAD",
"our change",
"=======",
"their change",
">>>>>>> origin/main",
"line 7",
"<<<<<<< HEAD",
"second conflict",
"=======",
"their second",
">>>>>>> origin/main",
].join("\n");
mkdirSync(join(cwd, "src"), { recursive: true });
writeFileSync(join(cwd, "src/index.ts"), conflictContent);
const results = withUpstream(new Map<string, ExecResult>());
results.set("git merge --no-ff origin/main", { ...fail, code: 1 });
results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "src/index.ts\n" });
const { trigger, sendUserMessage } = setup(cwd, results);
await trigger();
expect(sendUserMessage).toHaveBeenCalledTimes(1);
const [message, options] = sendUserMessage.mock.calls[0];
expect(message).toContain("src/index.ts:2-6 (ours 3, theirs 5)");
expect(message).toContain("src/index.ts:8-12 (ours 9, theirs 11)");
expect(options).toEqual({ deliverAs: "followUp" });
});
it("handles empty ours or theirs sections", async () => {
const cwd = createTempDir();
const conflictContent = ["<<<<<<< HEAD", "=======", "only theirs", ">>>>>>> origin/main"].join("\n");
writeFileSync(join(cwd, "empty-ours.ts"), conflictContent);
const results = withUpstream(new Map<string, ExecResult>());
results.set("git merge --no-ff origin/main", { ...fail, code: 1 });
results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "empty-ours.ts\n" });
const { trigger, sendUserMessage } = setup(cwd, results);
await trigger();
expect(sendUserMessage).toHaveBeenCalledTimes(1);
const message = sendUserMessage.mock.calls[0][0] as string;
expect(message).toContain("empty-ours.ts:1-4 (ours empty, theirs 3)");
});
it("skips message when merge fails but no conflict markers found", async () => {
const cwd = createTempDir();
const results = withUpstream(new Map<string, ExecResult>());
results.set("git merge --no-ff origin/main", { ...fail, code: 1 });
results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "" });
const { trigger, sendUserMessage } = setup(cwd, results);
await trigger();
expect(sendUserMessage).not.toHaveBeenCalled();
});
});

View File

@@ -62,6 +62,19 @@ describe("Git URL Parsing", () => {
});
});
it("should reject unsafe git install path inputs", () => {
for (const source of [
"git:git@evil.example:../../victim/repo",
"https://evil.example/..%2F..%2Fvictim/repo",
"https://evil.example/..%2F..%2Fvictim/repo%",
"git:git@evil.example:/absolute/repo",
"git:git@evil.example:user\\repo/name",
"git:git@evil.example:user/repo\0name",
]) {
expect(parseGitUrl(source)).toBeNull();
}
});
describe("unsupported without git: prefix", () => {
it("should reject git@host:path without git: prefix", () => {
expect(parseGitUrl("git@github.com:user/repo")).toBeNull();

View File

@@ -7,7 +7,6 @@
*/
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";
@@ -51,6 +50,20 @@ function getFileContent(repoDir: string, filename: string): string {
return readFileSync(join(repoDir, filename), "utf-8");
}
type GitSourceForTest = {
type: "git";
repo: string;
host: string;
path: string;
pinned: boolean;
ref?: string;
};
interface PackageManagerPathInternals {
parseSource(source: string): GitSourceForTest;
getGitInstallPath(source: GitSourceForTest, scope: "temporary"): string;
}
describe("DefaultPackageManager git update", () => {
let tempDir: string;
let remoteDir: string; // Simulates the "remote" repository
@@ -282,7 +295,7 @@ describe("DefaultPackageManager git update", () => {
});
describe("pinned sources", () => {
it("should not update pinned git sources (with @ref)", async () => {
it("should not move pinned git sources past their configured ref", async () => {
// Create remote repo first to get the initial commit
mkdirSync(remoteDir, { recursive: true });
initGitRepo(remoteDir);
@@ -301,21 +314,83 @@ describe("DefaultPackageManager git update", () => {
// 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");
});
it("should checkout the configured pinned git ref during full and targeted updates", async () => {
mkdirSync(remoteDir, { recursive: true });
initGitRepo(remoteDir);
const v1Commit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
git(["tag", "v1"], remoteDir);
const v2Commit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
git(["tag", "v2"], remoteDir);
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
git(["clone", remoteDir, installedDir], tempDir);
git(["checkout", "v1"], installedDir);
expect(getCurrentCommit(installedDir)).toBe(v1Commit);
const pinnedSource = `${gitSource}@v2`;
settingsManager.setPackages([pinnedSource]);
await packageManager.update();
expect(getCurrentCommit(installedDir)).toBe(v2Commit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
git(["checkout", "v1"], installedDir);
await packageManager.update(pinnedSource);
expect(getCurrentCommit(installedDir)).toBe(v2Commit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
});
it("should not reset an annotated tag checkout that already matches the configured ref", async () => {
mkdirSync(remoteDir, { recursive: true });
initGitRepo(remoteDir);
const taggedCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
git(["tag", "-a", "v1", "-m", "v1"], remoteDir);
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
git(["clone", remoteDir, installedDir], tempDir);
git(["checkout", "v1"], installedDir);
expect(getCurrentCommit(installedDir)).toBe(taggedCommit);
settingsManager.setPackages([`${gitSource}@v1`]);
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 origin v1");
expect(executedCommands.some((command) => command.startsWith("git reset --hard"))).toBe(false);
expect(executedCommands).not.toContain("git clean -fdx");
expect(getCurrentCommit(installedDir)).toBe(taggedCommit);
});
});
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 managerWithPaths = packageManager as unknown as PackageManagerPathInternals;
const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary");
const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts");
rmSync(cachedDir, { recursive: true, force: true });
@@ -359,10 +434,8 @@ describe("DefaultPackageManager git update", () => {
});
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 managerWithPaths = packageManager as unknown as PackageManagerPathInternals;
const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary");
const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts");
rmSync(cachedDir, { recursive: true, force: true });

View File

@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { applyHttpProxySettings } from "../src/core/http-dispatcher.ts";
const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY"] as const;
describe("http proxy settings", () => {
let savedEnv: Record<(typeof PROXY_ENV_KEYS)[number], string | undefined>;
beforeEach(() => {
savedEnv = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) as Record<
(typeof PROXY_ENV_KEYS)[number],
string | undefined
>;
for (const key of PROXY_ENV_KEYS) {
delete process.env[key];
}
});
afterEach(() => {
for (const key of PROXY_ENV_KEYS) {
const value = savedEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("applies httpProxy to HTTP_PROXY and HTTPS_PROXY", () => {
applyHttpProxySettings("http://127.0.0.1:7890");
expect(process.env.HTTP_PROXY).toBe("http://127.0.0.1:7890");
expect(process.env.HTTPS_PROXY).toBe("http://127.0.0.1:7890");
});
it("does not override existing proxy env vars", () => {
process.env.HTTP_PROXY = "http://env-http:8080";
process.env.HTTPS_PROXY = "http://env-https:8080";
applyHttpProxySettings("http://settings:7890");
expect(process.env.HTTP_PROXY).toBe("http://env-http:8080");
expect(process.env.HTTPS_PROXY).toBe("http://env-https:8080");
});
it("ignores empty values", () => {
applyHttpProxySettings(" ");
expect(process.env.HTTP_PROXY).toBeUndefined();
expect(process.env.HTTPS_PROXY).toBeUndefined();
});
});

View File

@@ -22,6 +22,10 @@ const MEDIUM_PNG_100x100 =
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=";
function imageBytes(base64Data: string): Uint8Array {
return Buffer.from(base64Data, "base64");
}
describe("convertToPng", () => {
it("should return original data for PNG input", async () => {
const result = await convertToPng(TINY_PNG, "image/png");
@@ -46,11 +50,28 @@ describe("convertToPng", () => {
});
describe("resizeImage", () => {
it("should keep caller input bytes intact", async () => {
const input = new Uint8Array(imageBytes(TINY_PNG));
const originalByteLength = input.byteLength;
const originalFirstByte = input[0];
const result = await resizeImage(input, "image/png", {
maxWidth: 100,
maxHeight: 100,
maxBytes: 1024 * 1024,
});
expect(result).not.toBeNull();
expect(input.byteLength).toBe(originalByteLength);
expect(input[0]).toBe(originalFirstByte);
});
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 },
);
const result = await resizeImage(imageBytes(TINY_PNG), "image/png", {
maxWidth: 100,
maxHeight: 100,
maxBytes: 1024 * 1024,
});
expect(result).not.toBeNull();
expect(result!.wasResized).toBe(false);
@@ -62,10 +83,11 @@ describe("resizeImage", () => {
});
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 },
);
const result = await resizeImage(imageBytes(MEDIUM_PNG_100x100), "image/png", {
maxWidth: 50,
maxHeight: 50,
maxBytes: 1024 * 1024,
});
expect(result).not.toBeNull();
expect(result!.wasResized).toBe(true);
@@ -80,10 +102,11 @@ describe("resizeImage", () => {
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) },
);
const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "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();
@@ -93,19 +116,21 @@ describe("resizeImage", () => {
});
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 },
);
const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "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 },
);
const result = await resizeImage(imageBytes(TINY_JPEG), "image/jpeg", {
maxWidth: 100,
maxHeight: 100,
maxBytes: 1024 * 1024,
});
expect(result).not.toBeNull();
expect(result!.wasResized).toBe(false);

View File

@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import inputTransformStreaming from "../examples/extensions/input-transform-streaming.ts";
import type {
ExecResult,
ExtensionAPI,
ExtensionContext,
InputEvent,
InputEventResult,
} from "../src/core/extensions/index.ts";
type InputHandler = (event: InputEvent, ctx: ExtensionContext) => Promise<InputEventResult | undefined>;
function setup(execResult: ExecResult) {
let handler: InputHandler | undefined;
const exec = vi.fn<ExtensionAPI["exec"]>().mockResolvedValue(execResult);
const api = {
on: (event: string, h: InputHandler) => {
if (event === "input") handler = h;
},
exec,
} as unknown as ExtensionAPI;
inputTransformStreaming(api);
const ctx = {} as ExtensionContext;
function emit(text: string, streamingBehavior?: "steer" | "followUp") {
return handler!({ type: "input", text, source: "interactive", streamingBehavior }, ctx);
}
return { emit, exec };
}
describe("input-transform-streaming example", () => {
const diffOutput = " src/index.ts | 5 ++---\n 1 file changed, 2 insertions(+), 3 deletions(-)";
const gitSuccess: ExecResult = { stdout: diffOutput, stderr: "", code: 0, killed: false };
const gitEmpty: ExecResult = { stdout: "", stderr: "", code: 0, killed: false };
const gitFail: ExecResult = { stdout: "", stderr: "not a git repo", code: 128, killed: false };
it("skips exec during steering", async () => {
const { emit, exec } = setup(gitSuccess);
const result = await emit("what changes did I make?", "steer");
expect(result).toEqual({ action: "continue" });
expect(exec).not.toHaveBeenCalled();
});
it("transforms when idle and text matches trigger", async () => {
const { emit, exec } = setup(gitSuccess);
const result = await emit("review my changes");
expect(exec).toHaveBeenCalledWith("git", ["diff", "--stat"]);
expect(result).toMatchObject({ action: "transform" });
const text = (result as { text: string }).text;
expect(text).toContain("review my changes");
expect(text).toContain("src/index.ts");
});
it("transforms when queued as follow-up", async () => {
const { emit, exec } = setup(gitSuccess);
const result = await emit("show me the diff", "followUp");
expect(exec).toHaveBeenCalled();
expect(result).toMatchObject({ action: "transform" });
});
it("continues when text does not match trigger", async () => {
const { emit, exec } = setup(gitSuccess);
const result = await emit("explain this function");
expect(result).toEqual({ action: "continue" });
expect(exec).not.toHaveBeenCalled();
});
it("continues when git diff is empty", async () => {
const { emit } = setup(gitEmpty);
const result = await emit("any changes?");
expect(result).toEqual({ action: "continue" });
});
it("continues when git fails", async () => {
const { emit } = setup(gitFail);
const result = await emit("show modified files");
expect(result).toEqual({ action: "continue" });
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
type SubmitContext = {
defaultEditor: { onSubmit?: (text: string) => void };
editor: {
addToHistory?: (text: string) => void;
setText: (text: string) => void;
};
session: {
isCompacting: boolean;
isStreaming: boolean;
isBashRunning: boolean;
prompt: (text: string, options?: unknown) => Promise<void>;
};
flushPendingBashComponents: () => void;
onInputCallback?: (text: string) => void;
pendingUserInputs: string[];
};
type InputContext = {
onInputCallback?: (text: string) => void;
pendingUserInputs: string[];
};
type InteractiveModePrivate = {
setupEditorSubmitHandler(this: SubmitContext): void;
getUserInput(this: InputContext): Promise<string>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrivate;
function createSubmitContext(): SubmitContext {
return {
defaultEditor: {},
editor: {
addToHistory: vi.fn(),
setText: vi.fn(),
},
session: {
isCompacting: false,
isStreaming: false,
isBashRunning: false,
prompt: vi.fn(async () => {}),
},
flushPendingBashComponents: vi.fn(),
pendingUserInputs: [],
};
}
describe("InteractiveMode startup input", () => {
it("queues a normal prompt submitted before the input callback is installed", async () => {
const context = createSubmitContext();
interactiveModePrototype.setupEditorSubmitHandler.call(context);
await context.defaultEditor.onSubmit?.(" early prompt ");
expect(context.pendingUserInputs).toEqual(["early prompt"]);
expect(context.flushPendingBashComponents).toHaveBeenCalledTimes(1);
expect(context.editor.addToHistory).toHaveBeenCalledWith("early prompt");
});
it("returns queued startup input before installing a new input callback", async () => {
const context: InputContext = {
pendingUserInputs: ["queued prompt"],
};
await expect(interactiveModePrototype.getUserInput.call(context)).resolves.toBe("queued prompt");
expect(context.onInputCallback).toBeUndefined();
expect(context.pendingUserInputs).toEqual([]);
});
});

View File

@@ -1,7 +1,9 @@
import { homedir } from "node:os";
import * as path from "node:path";
import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui";
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import { type Component, Container, type Focusable, TUI } from "../../tui/src/tui.ts";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts";
import type { SourceInfo } from "../src/core/source-info.ts";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
@@ -17,6 +19,41 @@ function renderAll(container: Container, width = 120): string {
return container.children.flatMap((child) => child.render(width)).join("\n");
}
class TestFocusableComponent implements Component, Focusable {
focused = false;
inputs: string[] = [];
private readonly label: string;
private text = "";
constructor(label: string) {
this.label = label;
}
handleInput(data: string): void {
this.inputs.push(data);
}
getText(): string {
return this.text;
}
setText(text: string): void {
this.text = text;
}
render(): string[] {
return [this.label];
}
invalidate(): void {}
}
async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await Promise.resolve();
await terminal.waitForRender();
}
function normalizeRenderedOutput(container: Container, width = 220): string {
return renderAll(container, width)
.replace(/\u001b\[[0-9;]*m/g, "")
@@ -114,6 +151,13 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const fakeThis: any = {
session: { settingsManager },
settingsManager,
themeController: {
setThemeInstance: vi.fn(() => ({ success: true })),
setThemeName: vi.fn(() => {
fakeThis.ui.requestRender();
return { success: true };
}),
},
ui: { requestRender: vi.fn() },
};
@@ -121,6 +165,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const result = uiContext.setTheme("light");
expect(result.success).toBe(true);
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("light");
expect(settingsManager.setTheme).toHaveBeenCalledWith("light");
expect(currentTheme).toBe("light");
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
@@ -136,6 +181,10 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const fakeThis: any = {
session: { settingsManager },
settingsManager,
themeController: {
setThemeInstance: vi.fn(() => ({ success: true })),
setThemeName: vi.fn(() => ({ success: false, error: "Theme not found" })),
},
ui: { requestRender: vi.fn() },
};
@@ -143,11 +192,84 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const result = uiContext.setTheme("__missing_theme__");
expect(result.success).toBe(false);
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("__missing_theme__");
expect(settingsManager.setTheme).not.toHaveBeenCalled();
expect(fakeThis.ui.requestRender).not.toHaveBeenCalled();
});
});
describe("InteractiveMode.showExtensionCustom", () => {
beforeAll(() => {
initTheme("dark");
});
test("overlay custom UI reclaims input after non-overlay custom UI closes", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
const editorContainer = new Container();
const editor = new TestFocusableComponent("EDITOR");
const palette = new TestFocusableComponent("PALETTE");
const overlay = new TestFocusableComponent("OVERLAY");
const replacement = new TestFocusableComponent("REPLACEMENT");
let closeOverlay: (value: string) => void = () => {
throw new Error("closeOverlay was not initialized");
};
let closeReplacement: (value: string) => void = () => {
throw new Error("closeReplacement was not initialized");
};
const fakeThis = {
editor,
editorContainer,
keybindings: {},
ui,
};
const showExtensionCustom = <T>(
factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component,
options?: { overlay?: boolean },
): Promise<T> =>
(InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise<T>;
editorContainer.addChild(editor);
ui.addChild(editorContainer);
ui.addChild(palette);
ui.setFocus(palette);
ui.start();
try {
const overlayPromise = showExtensionCustom<string>(
(_tui, _theme, _keybindings, done) => {
closeOverlay = done;
return overlay;
},
{ overlay: true },
);
await flushTui(ui, terminal);
expect(overlay.focused).toBe(true);
const replacementPromise = showExtensionCustom<string>((_tui, _theme, _keybindings, done) => {
closeReplacement = done;
return replacement;
});
await flushTui(ui, terminal);
expect(replacement.focused).toBe(true);
closeReplacement("done");
await replacementPromise;
await flushTui(ui, terminal);
terminal.sendInput("x");
await flushTui(ui, terminal);
expect(overlay.inputs).toEqual(["x"]);
expect(editor.inputs).toEqual([]);
expect(overlay.focused).toBe(true);
closeOverlay("closed");
await overlayPromise;
} finally {
ui.stop();
}
});
});
describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => {
test("stores wrapper factories and rebuilds autocomplete immediately", () => {
const wrapper: AutocompleteProviderFactory = (current) => current;
@@ -215,6 +337,89 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true);
expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]);
});
test("merges triggerCharacters from wrapper factories", () => {
const defaultEditor = { setAutocompleteProvider: vi.fn() };
const customEditor = { setAutocompleteProvider: vi.fn() };
const passThrough =
(triggerCharacters: string[]): AutocompleteProviderFactory =>
(current) => ({
triggerCharacters,
getSuggestions: (lines, cursorLine, cursorCol, options) =>
current.getSuggestions(lines, cursorLine, cursorCol, options),
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) =>
current.applyCompletion(lines, cursorLine, cursorCol, item, prefix),
});
const fakeThis = {
createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined),
defaultEditor,
editor: customEditor,
autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])],
};
(
InteractiveMode as unknown as {
prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void };
}
).prototype.setupAutocompleteProvider.call(fakeThis);
const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider;
expect(provider.triggerCharacters).toEqual(["$", "!"]);
});
});
describe("InteractiveMode.createBaseAutocompleteProvider", () => {
test("matches model command arguments across provider/model order", async () => {
type TestModel = { id: string; provider: string; name: string };
type FakeInteractiveMode = {
session: {
scopedModels: Array<{ model: TestModel }>;
modelRegistry: { getAvailable: () => TestModel[] };
promptTemplates: [];
extensionRunner: { getRegisteredCommands: () => [] };
resourceLoader: { getSkills: () => { skills: [] } };
};
settingsManager: { getEnableSkillCommands: () => boolean };
skillCommands: Map<string, string>;
sessionManager: { getCwd: () => string };
fdPath: null;
};
const createBaseAutocompleteProvider = (
InteractiveMode as unknown as {
prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider };
}
).prototype.createBaseAutocompleteProvider;
const models = [
{ id: "gpt-5.2-codex", provider: "github-copilot", name: "GPT-5.2 Codex" },
{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" },
];
const fakeThis: FakeInteractiveMode = {
session: {
scopedModels: [],
modelRegistry: { getAvailable: () => models },
promptTemplates: [],
extensionRunner: { getRegisteredCommands: () => [] },
resourceLoader: { getSkills: () => ({ skills: [] }) },
},
settingsManager: { getEnableSkillCommands: () => false },
skillCommands: new Map(),
sessionManager: { getCwd: () => "/tmp" },
fdPath: null,
};
const provider = createBaseAutocompleteProvider.call(fakeThis);
const line = "/model codexgpt";
const suggestions = await provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
expect(suggestions?.items.map((item) => item.value)).toEqual([
"openai-codex/gpt-5.5",
"github-copilot/gpt-5.2-codex",
]);
});
});
describe("InteractiveMode.showLoadedResources", () => {

View File

@@ -4,7 +4,7 @@ import { join } from "node:path";
import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
import { getApiProvider } from "@earendil-works/pi-ai";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts";
@@ -25,6 +25,7 @@ describe("ModelRegistry", () => {
rmSync(tempDir, { recursive: true });
}
clearApiKeyCache();
vi.restoreAllMocks();
});
/** Create minimal provider config */
@@ -35,7 +36,7 @@ describe("ModelRegistry", () => {
): ProviderConfigInput {
return {
baseUrl,
apiKey: "TEST_KEY",
apiKey: "test-key",
api: api as Api,
models: models.map((m) => ({
id: m.id,
@@ -433,6 +434,43 @@ describe("ModelRegistry", () => {
expect(compat?.cacheControlFormat).toBe("anthropic");
});
test("compat schema accepts chat template thinking configuration", () => {
writeRawModelsJson({
demo: {
baseUrl: "https://example.com/v1",
apiKey: "DEMO_KEY",
api: "openai-completions",
models: [
{
id: "demo-model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
compat: {
thinkingFormat: "chat-template",
chatTemplateKwargs: {
preserve_thinking: true,
thinking: { $var: "thinking.enabled" },
},
},
},
],
},
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined;
expect(registry.getError()).toBeUndefined();
expect(compat?.thinkingFormat).toBe("chat-template");
expect(compat?.chatTemplateKwargs).toEqual({
preserve_thinking: true,
thinking: { $var: "thinking.enabled" },
});
});
test("compat schema accepts Anthropic eager tool input streaming flag", () => {
writeRawModelsJson({
demo: {
@@ -852,7 +890,7 @@ describe("ModelRegistry", () => {
registry.registerProvider("named-provider", {
name: "Named Provider",
baseUrl: "https://provider.test/v1",
apiKey: "TEST_KEY",
apiKey: "test-key",
api: "openai-completions",
models: [
{
@@ -892,6 +930,91 @@ describe("ModelRegistry", () => {
expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider");
});
test("stored API key env propagates to request auth and resolves headers", async () => {
authStorage.set("cloudflare-ai-gateway", {
type: "api_key",
key: "$CLOUDFLARE_API_KEY",
env: {
CLOUDFLARE_API_KEY: "stored-cf-token",
CLOUDFLARE_ACCOUNT_ID: "stored-account",
},
});
writeRawModelsJson({
"cloudflare-ai-gateway": {
headers: { "x-account": "$CLOUDFLARE_ACCOUNT_ID" },
},
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const model = registry.getAll().find((m) => m.provider === "cloudflare-ai-gateway");
expect(model).toBeDefined();
const auth = await registry.getApiKeyAndHeaders(model!);
expect(auth).toEqual({
ok: true,
apiKey: "stored-cf-token",
headers: { "x-account": "stored-account" },
env: {
CLOUDFLARE_API_KEY: "stored-cf-token",
CLOUDFLARE_ACCOUNT_ID: "stored-account",
},
});
});
test("registerProvider treats uppercase apiKey and headers as literals", async () => {
const envKeys = ["CUSTOM_NAME", "BEARER", "MODEL_TOKEN"];
const savedEnv: Record<string, string | undefined> = {};
for (const key of envKeys) {
savedEnv[key] = process.env[key];
process.env[key] = `env-${key}`;
}
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
registry.registerProvider("literal-provider", {
...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"),
apiKey: "CUSTOM_NAME",
headers: { Authorization: "BEARER" },
models: [
{
id: "demo-model",
name: "demo-model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100000,
maxTokens: 8000,
headers: { "x-model-token": "MODEL_TOKEN" },
},
],
});
expect(await registry.getApiKeyForProvider("literal-provider")).toBe("CUSTOM_NAME");
const model = registry.find("literal-provider", "demo-model");
expect(model).toBeDefined();
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
ok: true,
apiKey: "CUSTOM_NAME",
headers: {
Authorization: "BEARER",
"x-model-token": "MODEL_TOKEN",
},
});
expect(warnSpy).not.toHaveBeenCalled();
} finally {
for (const key of envKeys) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
}
});
test("failed registerProvider does not persist invalid streamSimple config", () => {
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
@@ -911,7 +1034,7 @@ describe("ModelRegistry", () => {
registry.registerProvider("demo-provider", {
baseUrl: "https://provider.test/v1",
apiKey: "TEST_KEY",
apiKey: "test-key",
api: "openai-completions",
models: [
{
@@ -931,7 +1054,7 @@ describe("ModelRegistry", () => {
expect(() =>
registry.registerProvider("demo-provider", {
baseUrl: "https://provider.test/v2",
apiKey: "TEST_KEY",
apiKey: "test-key",
models: [
{
id: "broken-model",
@@ -1187,7 +1310,117 @@ describe("ModelRegistry", () => {
expect(apiKey).toBeUndefined();
});
test("apiKey as environment variable name resolves to env value", async () => {
test("apiKey with $ prefix resolves to env value", async () => {
const originalEnv = process.env.TEST_API_KEY_12345;
process.env.TEST_API_KEY_12345 = "env-api-key-value";
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey("$TEST_API_KEY_12345"),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_API_KEY_12345;
} else {
process.env.TEST_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey with braced env syntax resolves to env value", async () => {
const originalEnv = process.env.TEST_BRACED_API_KEY_12345;
process.env.TEST_BRACED_API_KEY_12345 = "braced-env-api-key-value";
const bracedKey = "$" + "{TEST_BRACED_API_KEY_12345}";
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(bracedKey),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("braced-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_BRACED_API_KEY_12345;
} else {
process.env.TEST_BRACED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey interpolates braced env references inside literals", async () => {
const originalPartA = process.env.TEST_INTERPOLATED_PART_A_12345;
const originalPartB = process.env.TEST_INTERPOLATED_PART_B_12345;
process.env.TEST_INTERPOLATED_PART_A_12345 = "left";
process.env.TEST_INTERPOLATED_PART_B_12345 = "right";
const interpolatedKey = ["$", "{TEST_INTERPOLATED_PART_A_12345}_$", "{TEST_INTERPOLATED_PART_B_12345}"].join(
"",
);
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(interpolatedKey),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("left_right");
} finally {
if (originalPartA === undefined) {
delete process.env.TEST_INTERPOLATED_PART_A_12345;
} else {
process.env.TEST_INTERPOLATED_PART_A_12345 = originalPartA;
}
if (originalPartB === undefined) {
delete process.env.TEST_INTERPOLATED_PART_B_12345;
} else {
process.env.TEST_INTERPOLATED_PART_B_12345 = originalPartB;
}
}
});
test("apiKey with $$ prefix escapes a leading dollar", async () => {
writeRawModelsJson({
"custom-provider": providerWithApiKey("$$TEST_API_KEY_12345"),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("$TEST_API_KEY_12345");
});
test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => {
const originalEnv = process.env.TEST_API_KEY_12345;
process.env.TEST_API_KEY_12345 = "env-api-key-value";
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey("$!literal-$TEST_API_KEY_12345"),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("!literal-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_API_KEY_12345;
} else {
process.env.TEST_API_KEY_12345 = originalEnv;
}
}
});
test("plain apiKey is used directly even when it matches an env var", async () => {
const originalEnv = process.env.TEST_API_KEY_12345;
process.env.TEST_API_KEY_12345 = "env-api-key-value";
@@ -1199,7 +1432,7 @@ describe("ModelRegistry", () => {
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const apiKey = await registry.getApiKeyForProvider("custom-provider");
expect(apiKey).toBe("env-api-key-value");
expect(apiKey).toBe("TEST_API_KEY_12345");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_API_KEY_12345;
@@ -1318,7 +1551,7 @@ describe("ModelRegistry", () => {
process.env[envVarName] = "status-test-key";
writeRawModelsJson({
"custom-provider": providerWithApiKey(envVarName),
"custom-provider": providerWithApiKey(`$${envVarName}`),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
@@ -1337,6 +1570,41 @@ describe("ModelRegistry", () => {
}
});
test("provider auth status reports interpolated apiKey environment variables", () => {
const envVarNameA = "TEST_API_KEY_STATUS_PART_A_98765";
const envVarNameB = "TEST_API_KEY_STATUS_PART_B_98765";
const originalEnvA = process.env[envVarNameA];
const originalEnvB = process.env[envVarNameB];
process.env[envVarNameA] = "left";
process.env[envVarNameB] = "right";
const interpolatedKey = ["$", "{", envVarNameA, "}_$", "{", envVarNameB, "}"].join("");
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(interpolatedKey),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
expect(registry.getProviderAuthStatus("custom-provider")).toEqual({
configured: true,
source: "environment",
label: `${envVarNameA}, ${envVarNameB}`,
});
} finally {
if (originalEnvA === undefined) {
delete process.env[envVarNameA];
} else {
process.env[envVarNameA] = originalEnvA;
}
if (originalEnvB === undefined) {
delete process.env[envVarNameB];
} else {
process.env[envVarNameB] = originalEnvB;
}
}
});
test("provider auth status reports non-env apiKey values from models.json as a config key", () => {
writeRawModelsJson({
"custom-provider": providerWithApiKey("literal_api_key_value"),
@@ -1350,6 +1618,29 @@ describe("ModelRegistry", () => {
});
});
test("missing explicit env apiKey keeps provider unavailable", () => {
const envVarName = "TEST_API_KEY_MISSING_TEST_98765";
const originalEnv = process.env[envVarName];
delete process.env[envVarName];
try {
writeRawModelsJson({
"custom-provider": providerWithApiKey(`$${envVarName}`),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
expect(registry.getProviderAuthStatus("custom-provider")).toEqual({ configured: false });
expect(registry.getAvailable().some((model) => model.provider === "custom-provider")).toBe(false);
} finally {
if (originalEnv === undefined) {
delete process.env[envVarName];
} else {
process.env[envVarName] = originalEnv;
}
}
});
test("provider auth status reports command apiKey values from models.json without executing them", () => {
const counterFile = join(tempDir, "status-counter");
writeFileSync(counterFile, "0");
@@ -1376,7 +1667,7 @@ describe("ModelRegistry", () => {
process.env[envVarName] = "first-value";
writeRawModelsJson({
"custom-provider": providerWithApiKey(envVarName),
"custom-provider": providerWithApiKey(`$${envVarName}`),
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
@@ -1415,6 +1706,25 @@ describe("ModelRegistry", () => {
expect(count).toBe(0);
});
test("getAvailable filters GitHub Copilot OAuth models to account picker availability", () => {
authStorage.set("github-copilot", {
type: "oauth",
refresh: "github-access-token",
access: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires: Date.now() + 60_000,
availableModelIds: ["gpt-4.1"],
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
expect(
registry
.getAvailable()
.filter((m) => m.provider === "github-copilot")
.map((m) => m.id),
).toEqual(["gpt-4.1"]);
});
test("getApiKeyAndHeaders resolves authHeader on every request", async () => {
const tokenFile = join(tempDir, "token");
writeFileSync(tokenFile, "token-1");

View File

@@ -344,6 +344,7 @@ describe("resolveCliModel", () => {
};
const registry = {
getAll: () => [...allModels, zaiModel, gatewayModel],
hasConfiguredAuth: () => true,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
@@ -356,6 +357,46 @@ describe("resolveCliModel", () => {
expect(result.model?.id).toBe("glm-5");
});
test("prefers an authenticated exact raw model id over an unauthenticated inferred provider", () => {
const commandcodeModel: Model<"anthropic-messages"> = {
id: "xiaomi/mimo-v2.5-pro",
name: "Xiaomi MiMo via Commandcode",
api: "anthropic-messages",
provider: "commandcode",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const xiaomiModel: Model<"anthropic-messages"> = {
id: "mimo-v2.5-pro",
name: "Xiaomi MiMo",
api: "anthropic-messages",
provider: "xiaomi",
baseUrl: "https://api.xiaomimimo.com",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getAll: () => [...allModels, commandcodeModel, xiaomiModel],
hasConfiguredAuth: (model: Model<"anthropic-messages">) => model.provider === "commandcode",
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "xiaomi/mimo-v2.5-pro",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("commandcode");
expect(result.model?.id).toBe("xiaomi/mimo-v2.5-pro");
});
test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => {
const registry = {
getAll: () => allModels,
@@ -370,6 +411,128 @@ describe("resolveCliModel", () => {
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
});
describe("custom model fallback with :thinking suffix (#5552)", () => {
// Models for a provider that has registered models but the specific model ID
// is not in the registry (triggers buildFallbackModel path).
const neuralwattModel: Model<"anthropic-messages"> = {
id: "some-base-model",
name: "Some Base Model",
api: "anthropic-messages",
provider: "neuralwatt",
baseUrl: "https://api.neuralwatt.com",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const modelsWithNeuralwatt = [...allModels, neuralwattModel];
test("strips :thinking suffix from custom model id in fallback path", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("neuralwatt");
// The :high suffix must NOT leak into the model id sent to the API
expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8");
expect(result.model?.reasoning).toBe(true);
expect(result.thinkingLevel).toBe("high");
});
test("custom model without thinking suffix works normally in fallback path", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("neuralwatt");
expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8");
expect(result.thinkingLevel).toBeUndefined();
});
test("all valid thinking levels work in fallback path", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) {
const result = resolveCliModel({
cliModel: `neuralwatt/zai-org/GLM-5.1-FP8:${level}`,
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8");
expect(result.thinkingLevel).toBe(level);
}
});
test("invalid thinking suffix on custom model is treated as part of model id", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:banana",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("neuralwatt");
// Invalid suffix stays in the id (it's not a thinking level)
expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:banana");
expect(result.thinkingLevel).toBeUndefined();
});
test("explicit --provider with custom model:thinking strips suffix correctly", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliProvider: "neuralwatt",
cliModel: "zai-org/GLM-5.1-FP8:high",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("neuralwatt");
expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8");
expect(result.thinkingLevel).toBe("high");
});
test("with explicit --thinking, :suffix is kept as part of model id", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high",
cliThinking: "medium",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("neuralwatt");
// :high is kept as part of the model id since --thinking was explicit
expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:high");
expect(result.thinkingLevel).toBeUndefined();
});
});
});
describe("default model selection", () => {
@@ -378,11 +541,12 @@ describe("default model selection", () => {
expect(defaultModelPerProvider["openai-codex"]).toBe("gpt-5.5");
});
test("zai, minimax, and cerebras defaults track current models", () => {
test("zai, minimax, cerebras, and ant-ling defaults track current models", () => {
expect(defaultModelPerProvider.zai).toBe("glm-5.1");
expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7");
expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7");
expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7");
expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T");
});
test("ai-gateway default tracks current model", () => {

View File

@@ -1,9 +1,11 @@
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, 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, PACKAGE_NAME, VERSION } from "../src/config.ts";
import { ProjectTrustStore } from "../src/core/trust-manager.ts";
import { main } from "../src/main.ts";
import { handlePackageCommand } from "../src/package-manager-cli.ts";
describe("package commands", () => {
let tempDir: string;
@@ -21,6 +23,10 @@ describe("package commands", () => {
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
}
async function runPackageCommandDirectly(args: string[]): Promise<void> {
expect(await handlePackageCommand(args)).toBe(true);
}
beforeEach(() => {
tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
@@ -36,12 +42,21 @@ describe("package commands", () => {
originalExitCode = process.exitCode;
originalExecPath = process.execPath;
process.exitCode = undefined;
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
if (code === undefined || code === null || Number(code) === 0) {
process.exitCode = undefined;
} else {
process.exitCode = code;
}
return undefined as never;
}) as typeof process.exit);
process.env[ENV_AGENT_DIR] = agentDir;
process.chdir(projectDir);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
process.chdir(originalCwd);
process.exitCode = originalExitCode;
if (originalAgentDir === undefined) {
@@ -85,6 +100,231 @@ describe("package commands", () => {
expect(removedSettings.packages ?? []).toHaveLength(0);
});
it("skips untrusted project package settings", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
} finally {
logSpy.mockRestore();
}
});
it("uses remembered project trust for list", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("overrides remembered trust for list with --no-approve", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list", "--no-approve"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("approves project trust for list with --approve", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list", "--approve"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("uses default project trust for list", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("uses project_trust extensions for package commands", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(
main(["list"], {
extensionFactories: [
(pi) => {
pi.on("project_trust", () => ({ trusted: "yes" }));
},
],
}),
).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("does not prompt or ask extensions for project trust during update", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
const fakeNpmPath = join(tempDir, "fake-project-npm.cjs");
const recordPath = join(tempDir, "project-update.json");
writeFileSync(
fakeNpmPath,
`const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`,
);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }),
);
let projectTrustCalled = false;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(
main(["update", "--extensions"], {
extensionFactories: [
(pi) => {
pi.on("project_trust", () => {
projectTrustCalled = true;
return { trusted: "yes" };
});
},
],
}),
).resolves.toBeUndefined();
expect(projectTrustCalled).toBe(false);
expect(existsSync(recordPath)).toBe(false);
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("uses saved project trust during update", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
const fakeNpmPath = join(tempDir, "fake-trusted-project-npm.cjs");
const recordPath = join(tempDir, "trusted-project-update.json");
writeFileSync(
fakeNpmPath,
`const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`,
);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }),
);
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["update", "--extensions"])).resolves.toBeUndefined();
expect(existsSync(recordPath)).toBe(true);
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("lets trust.json override default project trust", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, false);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("blocks local package changes when project is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), "{}");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined();
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain("Project is not trusted. Use --approve to modify local package config.");
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
}
});
it("allows local package install to initialize fresh project settings", async () => {
await main(["install", "-l", packageDir]);
const settingsPath = join(projectDir, ".pi", "settings.json");
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
expect(settings.packages?.length).toBe(1);
const stored = settings.packages?.[0] ?? "";
expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir));
expect(process.exitCode).toBeUndefined();
});
it("shows install subcommand help", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -111,7 +351,7 @@ describe("package commands", () => {
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(stderr).toContain('Use "pi --help" or "pi install <source> [-l] [--approve|--no-approve]".');
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
@@ -169,7 +409,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
@@ -213,7 +453,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
@@ -262,7 +502,7 @@ else {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
@@ -315,7 +555,7 @@ if(args.includes("install")) process.exit(23);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");

View File

@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { PassThrough } from "node:stream";
@@ -33,6 +33,20 @@ interface PackageManagerInternals {
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
): Promise<string>;
getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>;
parseSource(
source: string,
):
| { type: "npm"; spec: string; name: string; pinned: boolean }
| { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string }
| { type: "local"; path: string };
getNpmInstallPath(
source: { type: "npm"; spec: string; name: string; pinned: boolean },
scope: "user" | "project" | "temporary",
): string;
getGitInstallPath(
source: { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string },
scope: "user" | "project" | "temporary",
): string;
}
// Helper to check if a resource is enabled
@@ -693,7 +707,17 @@ Content`,
expect(runCommandSpy).toHaveBeenCalledWith(
"mise",
["exec", "node@20", "--", "npm", "install", "@scope/pkg", "--prefix", join(agentDir, "npm")],
[
"exec",
"node@20",
"--",
"npm",
"install",
"@scope/pkg",
"--prefix",
join(agentDir, "npm"),
"--legacy-peer-deps",
],
undefined,
);
});
@@ -714,7 +738,7 @@ Content`,
expect(runCommandSpy).toHaveBeenCalledWith(
"mise",
["exec", "bun@1", "--", "bun", "install", "@scope/pkg", "--cwd", join(agentDir, "npm")],
["exec", "bun@1", "--", "bun", "install", "@scope/pkg", "--cwd", join(agentDir, "npm"), "--omit=peer"],
undefined,
);
});
@@ -748,7 +772,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "HEAD") {
return "old-head";
}
if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") {
if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD^{commit}") {
return "new-head";
}
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
@@ -758,7 +782,9 @@ Content`,
await packageManager.install(source);
expect(runCommandSpy).toHaveBeenCalledWith("git", ["fetch", "origin", "v2"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD^{commit}"], {
cwd: targetDir,
});
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir });
});
@@ -779,7 +805,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "HEAD") {
return "old-head";
}
if (args[0] === "rev-parse" && args[1] === "origin/HEAD") {
if (args[0] === "rev-parse" && args[1] === "origin/HEAD^{commit}") {
return "new-head";
}
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
@@ -789,7 +815,9 @@ Content`,
await packageManager.install(source);
expect(runCommandSpy).toHaveBeenCalledWith("git", fetchArgs, { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD^{commit}"], {
cwd: targetDir,
});
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
});
@@ -832,7 +860,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") {
return "origin/main";
}
if (args[0] === "rev-parse" && args[1] === "@{upstream}") {
if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) {
return "remote-head";
}
if (args[0] === "rev-parse" && args[1] === "HEAD") {
@@ -868,7 +896,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") {
return "origin/main";
}
if (args[0] === "rev-parse" && args[1] === "@{upstream}") {
if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) {
return "remote-head";
}
if (args[0] === "rev-parse" && args[1] === "HEAD") {
@@ -949,6 +977,8 @@ Content`,
"pnpm-pkg",
"--prefix",
join(agentDir, "npm"),
"--config.auto-install-peers=false",
"--config.strict-peer-dependencies=false",
"--config.strict-dep-builds=false",
]);
mkdirSync(join(packagePath, "extensions"), { recursive: true });
@@ -1098,8 +1128,17 @@ Content`,
});
it("should parse package source types from docs examples", () => {
expect((packageManager as any).parseSource("npm:@scope/pkg@1.2.3").type).toBe("npm");
expect((packageManager as any).parseSource("npm:pkg").type).toBe("npm");
const parseNpm = (source: string) => {
const parsed = (packageManager as any).parseSource(source);
if (parsed.type !== "npm") {
throw new Error(`Expected npm source: ${source}`);
}
return parsed;
};
expect(parseNpm("npm:@scope/pkg@1.2.3").pinned).toBe(true);
expect(parseNpm("npm:@scope/pkg@^1.2.3").pinned).toBe(false);
expect(parseNpm("npm:pkg").pinned).toBe(false);
expect((packageManager as any).parseSource("git:github.com/user/repo@v1").type).toBe("git");
expect((packageManager as any).parseSource("https://github.com/user/repo@v1").type).toBe("git");
@@ -1122,6 +1161,45 @@ Content`,
});
});
describe("git install paths", () => {
it("should reject paths outside git install roots", () => {
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
const traversalSource = {
type: "git" as const,
repo: "git@evil.example:../../victim/repo",
host: "evil.example",
path: "../../victim/repo",
pinned: false,
};
for (const scope of ["user", "project", "temporary"] as const) {
expect(() => managerWithInternals.getGitInstallPath(traversalSource, scope)).toThrow(
"outside package install root",
);
}
});
});
describe("temporary install paths", () => {
it("should place temporary npm packages under the agent temp extension folder", () => {
const managerWithInternals = packageManager as unknown as PackageManagerInternals;
const source = managerWithInternals.parseSource("npm:left-pad");
if (source.type !== "npm") {
throw new Error("Expected npm source");
}
const installPath = managerWithInternals.getNpmInstallPath(source, "temporary");
const tempRoot = join(agentDir, "tmp", "extensions");
expect(pathEndsWith(installPath, "node_modules/left-pad")).toBe(true);
expect(relative(tempRoot, installPath).startsWith("..")).toBe(false);
expect(installPath.startsWith(join(tmpdir(), "pi-extensions"))).toBe(false);
if (process.platform !== "win32") {
expect(statSync(tempRoot).mode & 0o777).toBe(0o700);
}
});
});
describe("settings source normalization", () => {
it("should store global local packages relative to agent settings base", () => {
const pkgDir = join(tempDir, "packages", "local-global-pkg");
@@ -1983,25 +2061,27 @@ export default function(api) { api.registerTool({ name: "test", description: "te
});
describe("offline mode and network timeouts", () => {
it("should update project npm packages using @latest when newer version is available", async () => {
it("should update npm range packages using the configured spec", async () => {
const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example");
mkdirSync(installedPath, { recursive: true });
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" }));
settingsManager.setProjectPackages(["npm:example"]);
settingsManager.setProjectPackages(["npm:example@^1.0.0"]);
const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"');
const runCommandCaptureSpy = vi
.spyOn(packageManager as any, "runCommandCapture")
.mockResolvedValue('["1.0.0","1.2.0"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
await packageManager.update("npm:example");
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example", "version", "--json"],
["view", "example@^1.0.0", "version", "--json"],
expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }),
);
expect(runCommandSpy).toHaveBeenCalledWith(
"npm",
["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm")],
["install", "example@^1.0.0", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"],
undefined,
);
});
@@ -2009,17 +2089,19 @@ export default function(api) { api.registerTool({ name: "test", description: "te
it("should skip project npm update when installed version matches latest", async () => {
const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example");
mkdirSync(installedPath, { recursive: true });
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.3" }));
settingsManager.setProjectPackages(["npm:example"]);
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.3.1" }));
settingsManager.setProjectPackages(["npm:example@^1.0.0"]);
const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"');
const runCommandCaptureSpy = vi
.spyOn(packageManager as any, "runCommandCapture")
.mockResolvedValue('["1.0.0","1.3.1","1.0.2"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
await packageManager.update("npm:example");
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example", "version", "--json"],
["view", "example@^1.0.0", "version", "--json"],
expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }),
);
expect(runCommandSpy).not.toHaveBeenCalled();
@@ -2040,7 +2122,13 @@ export default function(api) { api.registerTool({ name: "test", description: "te
.mockImplementation(async (...callArgs: unknown[]) => {
const [command, args] = callArgs as [string, string[]];
expect(command).toBe("npm");
expect(args).toEqual(["install", "legacy-pkg@latest", "--prefix", join(agentDir, "npm")]);
expect(args).toEqual([
"install",
"legacy-pkg@latest",
"--prefix",
join(agentDir, "npm"),
"--legacy-peer-deps",
]);
mkdirSync(managedPath, { recursive: true });
writeFileSync(
join(managedPath, "package.json"),
@@ -2057,7 +2145,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te
expect(packageManager.getInstalledPath("npm:legacy-pkg", "user")).toBe(managedPath);
});
it("should batch npm updates per scope and run git updates in parallel while skipping pinned and current packages", async () => {
it("should batch npm updates per scope and run git updates in parallel while skipping pinned npm and current packages", async () => {
const userOldPath = join(agentDir, "npm", "node_modules", "user-old");
const userCurrentPath = join(agentDir, "npm", "node_modules", "user-current");
const userUnknownPath = join(agentDir, "npm", "node_modules", "user-unknown");
@@ -2150,16 +2238,30 @@ export default function(api) { api.registerTool({ name: "test", description: "te
expect(runCommandSpy).toHaveBeenNthCalledWith(
1,
"npm",
["install", "user-old@latest", "user-unknown@latest", "--prefix", join(agentDir, "npm")],
[
"install",
"user-old@latest",
"user-unknown@latest",
"--prefix",
join(agentDir, "npm"),
"--legacy-peer-deps",
],
undefined,
);
expect(runCommandSpy).toHaveBeenNthCalledWith(
2,
"npm",
["install", "project-old@latest", "project-missing@latest", "--prefix", join(tempDir, ".pi", "npm")],
[
"install",
"project-old@latest",
"project-missing@latest",
"--prefix",
join(tempDir, ".pi", "npm"),
"--legacy-peer-deps",
],
undefined,
);
expect(updateGitSpy).toHaveBeenCalledTimes(3);
expect(updateGitSpy).toHaveBeenCalledTimes(4);
expect(maxConcurrentNpmUpdates).toBeGreaterThan(1);
expect(maxConcurrentGitUpdates).toBeGreaterThan(1);
});
@@ -2209,11 +2311,12 @@ export default function(api) { api.registerTool({ name: "test", description: "te
});
it("should not run npm view during resolve for installed unpinned packages", async () => {
process.env.PI_OFFLINE = "1";
const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example");
mkdirSync(join(installedPath, "extensions"), { recursive: true });
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" }));
writeFileSync(join(installedPath, "extensions", "index.ts"), "export default function() {};");
settingsManager.setProjectPackages(["npm:example"]);
settingsManager.setProjectPackages(["npm:example@^1.0.0"]);
const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture");

View File

@@ -190,6 +190,52 @@ describe("substituteArgs", () => {
});
});
// ============================================================================
// substituteArgs - Positional Defaults
// ============================================================================
describe("substituteArgs - positional defaults", () => {
test("should use default when positional arg is missing", () => {
expect(substituteArgs(`List exactly \${1:-7} next steps`, [])).toBe("List exactly 7 next steps");
});
test("should use positional arg when present", () => {
expect(substituteArgs(`List exactly \${1:-7} next steps`, ["3"])).toBe("List exactly 3 next steps");
});
test("should use default when positional arg is empty", () => {
expect(substituteArgs(`Mode: \${1:-brief}`, [""])).toBe("Mode: brief");
});
test("should support multiple positional defaults", () => {
expect(substituteArgs(`\${1:-7} \${2:-brief}`, [])).toBe("7 brief");
expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3"])).toBe("3 brief");
expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3", "verbose"])).toBe("3 verbose");
});
test("should not recursively substitute patterns in arg values", () => {
expect(substituteArgs(`\${1:-7}`, ["$ARGUMENTS"])).toBe("$ARGUMENTS");
expect(substituteArgs(`\${1:-7}`, ["$1"])).toBe("$1");
});
test("should not recursively substitute patterns in default values", () => {
expect(substituteArgs(`\${1:-$ARGUMENTS}`, ["a", "b"])).toBe("a");
expect(substituteArgs(`\${3:-$ARGUMENTS}`, ["a", "b"])).toBe("$ARGUMENTS");
});
test("should support defaults with spaces", () => {
expect(substituteArgs(`\${1:-seven steps}`, [])).toBe("seven steps");
});
test("should support out-of-range positional defaults", () => {
expect(substituteArgs(`\${3:-fallback}`, ["a", "b"])).toBe("fallback");
});
test("should mix positional defaults with existing placeholders", () => {
expect(substituteArgs(`$1 \${2:-x} $ARGUMENTS`, ["a"])).toBe("a x a");
});
});
// ============================================================================
// substituteArgs - Array Slicing (Bash-Style)
// ============================================================================

View File

@@ -187,6 +187,53 @@ Project skill`,
expect(extensionsResult.extensions[0].path).toBe(join(cwd, ".pi", "extensions", "shared.ts"));
});
it("should load user extensions before trust and reuse them after trust resolves", async () => {
const userExtDir = join(agentDir, "extensions");
const projectExtDir = join(cwd, ".pi", "extensions");
mkdirSync(userExtDir, { recursive: true });
mkdirSync(projectExtDir, { recursive: true });
const loadCountKey = `__piTrustPreloadCount_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const globalState = globalThis as typeof globalThis & Record<string, number | undefined>;
writeFileSync(
join(userExtDir, "user.ts"),
`globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1;
export default function(pi) {
pi.on("project_trust", () => ({ trusted: "yes" }));
pi.registerCommand("user-trust", {
description: "user trust",
handler: async () => {},
});
}`,
);
writeFileSync(
join(projectExtDir, "project.ts"),
`export default function(pi) {
pi.registerCommand("project-trusted", {
description: "project trusted",
handler: async () => {},
});
}`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload({
resolveProjectTrust: async ({ extensionsResult }) => {
expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([
join(userExtDir, "user.ts"),
]);
return true;
},
});
const extensionsResult = loader.getExtensions();
expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([
join(cwd, ".pi", "extensions", "project.ts"),
join(userExtDir, "user.ts"),
]);
expect(globalState[loadCountKey]).toBe(1);
});
it("should keep both extensions loaded when command names collide", async () => {
const userExtDir = join(agentDir, "extensions");
const projectExtDir = join(cwd, ".pi", "extensions");
@@ -329,6 +376,52 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should skip project resources that require trust when project is not trusted", async () => {
const piDir = join(cwd, ".pi");
const extensionsDir = join(piDir, "extensions");
const skillDir = join(piDir, "skills", "project-skill");
const promptsDir = join(piDir, "prompts");
const themesDir = join(piDir, "themes");
mkdirSync(extensionsDir, { recursive: true });
mkdirSync(skillDir, { recursive: true });
mkdirSync(promptsDir, { recursive: true });
mkdirSync(themesDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt.");
writeFileSync(join(agentDir, "AGENTS.md"), "Global instructions");
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
writeFileSync(join(extensionsDir, "project.ts"), `throw new Error("should not load");`);
writeFileSync(
join(skillDir, "SKILL.md"),
`---
name: project-skill
description: Project skill
---
Project skill content`,
);
writeFileSync(join(promptsDir, "project.md"), "Project prompt");
const themeData = JSON.parse(
readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"),
) as { name: string };
themeData.name = "project-theme";
writeFileSync(join(themesDir, "project.json"), JSON.stringify(themeData, null, 2));
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await loader.reload();
expect(loader.getSystemPrompt()).toBe("Global system prompt.");
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe(
true,
);
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(true);
expect(loader.getExtensions().extensions).toHaveLength(0);
expect(loader.getExtensions().errors).toEqual([]);
expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false);
expect(loader.getPrompts().prompts.some((prompt) => prompt.name === "project")).toBe(false);
expect(loader.getThemes().themes.some((theme) => theme.name === "project-theme")).toBe(false);
});
it("should discover APPEND_SYSTEM.md", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });

View File

@@ -0,0 +1,38 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { RpcClient } from "../src/modes/rpc/rpc-client.ts";
const tempDirs: string[] = [];
function writeChildScript(contents: string): string {
const dir = mkdtempSync(join(tmpdir(), "pi-rpc-client-exit-"));
tempDirs.push(dir);
const path = join(dir, "child.mjs");
writeFileSync(path, contents);
return path;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("RpcClient child process failures", () => {
test("rejects an in-flight request when the child process exits", async () => {
const client = new RpcClient({
cliPath: writeChildScript(`
process.stdin.once("data", () => {
process.exit(43);
});
process.stdin.resume();
`),
});
await client.start();
await expect(client.getCommands()).rejects.toThrow(/Agent process exited \(code=43 signal=null\)/);
});
});

View File

@@ -25,7 +25,9 @@ const rpcIo = vi.hoisted(() => ({
}));
vi.mock("../src/core/output-guard.js", () => ({
flushRawStdout: vi.fn(async () => {}),
takeOverStdout: vi.fn(),
waitForRawStdoutBackpressure: vi.fn(async () => {}),
writeRawStdout: (line: string) => {
rpcIo.outputLines.push(line);
},

View File

@@ -15,14 +15,14 @@ import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
describe("createAgentSession OpenRouter attribution headers", () => {
describe("createAgentSession provider attribution headers", () => {
let tempDir: string;
let cwd: string;
let agentDir: string;
let originalTelemetryEnv: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-sdk-openrouter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
cwd = join(tempDir, "project");
agentDir = join(tempDir, "agent");
mkdirSync(cwd, { recursive: true });
@@ -42,9 +42,9 @@ describe("createAgentSession OpenRouter attribution headers", () => {
}
});
function createModel(provider: string, baseUrl: string): Model<Api> {
function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model<Api> {
return {
id: `${provider}-test-model`,
id,
name: `${provider} Test Model`,
api: "openai-completions",
provider,
@@ -86,6 +86,7 @@ describe("createAgentSession OpenRouter attribution headers", () => {
telemetryEnabled?: boolean;
providerHeaders?: Record<string, string>;
requestHeaders?: Record<string, string>;
sessionId?: string;
} = {},
): Promise<Record<string, string> | undefined> {
const settingsManager = SettingsManager.create(cwd, agentDir);
@@ -112,6 +113,11 @@ describe("createAgentSession OpenRouter attribution headers", () => {
registeredProviders.push(model.provider);
}
const sessionManager = SessionManager.inMemory(cwd);
if (options.sessionId) {
sessionManager.newSession({ id: options.sessionId });
}
const { session } = await createAgentSession({
cwd,
agentDir,
@@ -119,14 +125,17 @@ describe("createAgentSession OpenRouter attribution headers", () => {
authStorage,
modelRegistry,
settingsManager,
sessionManager: SessionManager.inMemory(cwd),
sessionManager,
});
try {
await session.agent.streamFn(
model,
{ messages: [] },
options.requestHeaders ? { headers: options.requestHeaders } : undefined,
{
sessionId: session.sessionId,
...(options.requestHeaders ? { headers: options.requestHeaders } : {}),
},
);
return capturedOptions?.headers;
} finally {
@@ -163,6 +172,14 @@ describe("createAgentSession OpenRouter attribution headers", () => {
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("preserves legacy OpenRouter base URL substring attribution matching", async () => {
const headers = await captureHeaders(createModel("custom-openrouter", "not-a-url-openrouter.ai"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("lets provider and request headers override the defaults", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
providerHeaders: {
@@ -178,4 +195,83 @@ describe("createAgentSession OpenRouter attribution headers", () => {
expect(headers?.["X-OpenRouter-Title"]).toBe("request-title");
expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category");
});
it("adds default attribution headers for Vercel AI Gateway models", async () => {
const headers = await captureHeaders(createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1"));
expect(headers?.["http-referer"]).toBe("https://pi.dev");
expect(headers?.["x-title"]).toBe("pi");
});
it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => {
const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("adds default attribution headers for the NVIDIA provider", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://example.test/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
telemetryEnabled: false,
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("lets provider and request headers override NVIDIA NIM defaults", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
providerHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Provider",
},
requestHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Request",
},
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Request");
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through OpenRouter", async () => {
const headers = await captureHeaders(
createModel("openrouter", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through Vercel AI Gateway", async () => {
const headers = await captureHeaders(
createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("adds OpenCode session headers", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
});
expect(headers?.["x-opencode-session"]).toBe("opencode-session");
expect(headers?.["x-opencode-client"]).toBe("pi");
});
it("lets configured OpenCode headers override the defaults", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
providerHeaders: {
"x-opencode-session": "configured-session",
"x-opencode-client": "configured-client",
},
});
expect(headers?.["x-opencode-session"]).toBe("configured-session");
expect(headers?.["x-opencode-client"]).toBe("configured-client");
});
});

View File

@@ -0,0 +1,153 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type Api,
type AssistantMessage,
createAssistantMessageEventStream,
type Model,
type SimpleStreamOptions,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
describe("createAgentSession stream options", () => {
let tempDir: string;
let cwd: string;
let agentDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-sdk-stream-options-"));
cwd = join(tempDir, "project");
agentDir = join(tempDir, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
});
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function createModel(api: Api): Model<Api> {
return {
id: "capture-model",
name: "Capture Model",
api,
provider: "capture-provider",
baseUrl: "https://capture.invalid/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
function createDoneStream(api: Api) {
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "ok" }],
api,
provider: "capture-provider",
model: "capture-model",
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(),
};
stream.end(message);
return stream;
}
async function captureStreamOptions(
api: Api,
settings: { httpIdleTimeoutMs?: number; websocketConnectTimeoutMs?: number },
requestOptions: SimpleStreamOptions = {},
): Promise<SimpleStreamOptions | undefined> {
const model = createModel(api);
const settingsManager = SettingsManager.inMemory(settings);
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
authStorage.setRuntimeApiKey(model.provider, "test-api-key");
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
let capturedOptions: SimpleStreamOptions | undefined;
modelRegistry.registerProvider(model.provider, {
api,
streamSimple: (_model, _context, providerOptions) => {
capturedOptions = providerOptions;
return createDoneStream(api);
},
});
const sessionManager = SessionManager.inMemory(cwd);
const { session } = await createAgentSession({
cwd,
agentDir,
model,
authStorage,
modelRegistry,
settingsManager,
sessionManager,
});
try {
await session.agent.streamFn(model, { messages: [] }, requestOptions);
return capturedOptions;
} finally {
session.dispose();
modelRegistry.unregisterProvider(model.provider);
}
}
it("forwards httpIdleTimeoutMs as timeoutMs for OpenAI Codex", async () => {
const options = await captureStreamOptions("openai-codex-responses", { httpIdleTimeoutMs: 1234 });
expect(options?.timeoutMs).toBe(1234);
});
it("defaults timeoutMs from httpIdleTimeoutMs for all providers", async () => {
const options = await captureStreamOptions("openai-completions", { httpIdleTimeoutMs: 1234 });
expect(options?.timeoutMs).toBe(1234);
});
it("lets request timeoutMs override httpIdleTimeoutMs for OpenAI Codex", async () => {
const options = await captureStreamOptions(
"openai-codex-responses",
{ httpIdleTimeoutMs: 1234 },
{ timeoutMs: 0 },
);
expect(options?.timeoutMs).toBe(0);
});
it("forwards websocketConnectTimeoutMs from settings", async () => {
const options = await captureStreamOptions("openai-codex-responses", { websocketConnectTimeoutMs: 1234 });
expect(options?.websocketConnectTimeoutMs).toBe(1234);
});
it("lets request websocketConnectTimeoutMs override settings", async () => {
const options = await captureStreamOptions(
"openai-codex-responses",
{ websocketConnectTimeoutMs: 1234 },
{ websocketConnectTimeoutMs: 0 },
);
expect(options?.websocketConnectTimeoutMs).toBe(0);
});
});

View File

@@ -0,0 +1,131 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, 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.ts";
const cliPath = resolve(__dirname, "../src/cli.ts");
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-session-id-readonly-"));
tempDirs.push(dir);
return dir;
}
function hasSessionWithId(root: string, sessionId: string): boolean {
if (!existsSync(root)) return false;
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory() && hasSessionWithId(path, sessionId)) return true;
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
try {
const firstLine = readFileSync(path, "utf8").split("\n", 1)[0];
const header = JSON.parse(firstLine) as { type?: string; id?: string };
if (header.type === "session" && header.id === sessionId) return true;
} catch {
// Ignore malformed session files.
}
}
return false;
}
interface CliDirs {
agentDir: string;
projectDir: string;
sessionDir: string;
}
async function runCli(
args: string[] | ((dirs: CliDirs) => string[]),
setup?: (dirs: CliDirs) => void,
): Promise<{ code: number | null; agentDir: string; stderr: string }> {
const tempRoot = createTempDir();
const dirs: CliDirs = {
agentDir: join(tempRoot, "agent"),
projectDir: join(tempRoot, "project"),
sessionDir: join(tempRoot, "sessions"),
};
mkdirSync(dirs.agentDir, { recursive: true });
mkdirSync(dirs.projectDir, { recursive: true });
setup?.(dirs);
const resolvedArgs = typeof args === "function" ? args(dirs) : args;
let stderr = "";
const code = await new Promise<number | null>((resolvePromise, reject) => {
const child = spawn(process.execPath, [cliPath, ...resolvedArgs], {
cwd: dirs.projectDir,
env: {
...process.env,
[ENV_AGENT_DIR]: dirs.agentDir,
PI_OFFLINE: "1",
TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"),
},
stdio: ["ignore", "ignore", "pipe"],
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", resolvePromise);
});
return { code, agentDir: dirs.agentDir, stderr };
}
function writeSession(sessionDir: string, cwd: string, id: string): void {
writeFileSync(
join(sessionDir, `${id}.jsonl`),
`${JSON.stringify({ type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd })}\n`,
);
}
describe("--session-id read-only commands", () => {
it("does not reserve a session for --help", async () => {
const result = await runCli(["--session-id", "read-only-help", "--help"]);
expect(result.code).toBe(0);
expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false);
});
it("does not reserve a session for --list-models", async () => {
const result = await runCli(["--session-id", "read-only-models", "--list-models"]);
expect(result.code).toBe(0);
expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-models")).toBe(false);
});
it("rejects an existing fork target session id", async () => {
const result = await runCli(
(dirs) => ["--session-dir", dirs.sessionDir, "--fork", "source-id", "--session-id", "existing-id", "-p", "hi"],
(dirs) => {
mkdirSync(dirs.sessionDir, { recursive: true });
writeSession(dirs.sessionDir, dirs.projectDir, "source-id");
writeSession(dirs.sessionDir, dirs.projectDir, "existing-id");
},
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("Session already exists with id 'existing-id'");
});
});
describe("--session-id validation", () => {
it("rejects ids invalid under SessionManager rules without stack traces", async () => {
for (const id of ["-bad", "bad id"]) {
const result = await runCli(["--session-id", id, "-p", "hi"]);
expect(result.code).toBe(1);
expect(result.stderr).toContain("Session id must be non-empty");
expect(result.stderr).not.toContain("SessionManager.create");
}
});
});

View File

@@ -1,6 +1,6 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import { describe, expect, it } from "vitest";
import { SessionManager } from "../../src/core/session-manager.ts";
@@ -13,6 +13,23 @@ describe("SessionManager.newSession with custom id", () => {
expect(session.getSessionId()).toBe("my-custom-id");
});
it("allows alphanumeric session ids with interior punctuation", () => {
const session = SessionManager.inMemory();
session.newSession({ id: "abc-123_def.456" });
expect(session.getSessionId()).toBe("abc-123_def.456");
});
it("rejects invalid custom session ids", () => {
const invalidIds = ["", "-abc", "abc-", "_abc", "abc_", ".abc", "abc.", "abc/def", "abc\\def", "abc def"];
for (const id of invalidIds) {
const session = SessionManager.inMemory();
expect(() => session.newSession({ id })).toThrow(
"Session id must be non-empty, contain only alphanumeric characters",
);
}
});
it("generates a UUIDv7 id when no id is provided", () => {
const session = SessionManager.inMemory();
session.newSession();
@@ -46,6 +63,18 @@ describe("SessionManager.newSession with custom id", () => {
expect(session.getHeader()!.id).toBe(session.getSessionId());
});
it("uses the provided id when creating a persisted session", () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-"));
const session = SessionManager.create(tempDir, tempDir, { id: "created-session-id" });
expect(session.getSessionId()).toBe("created-session-id");
expect(session.getHeader()!.id).toBe("created-session-id");
const sessionFile = session.getSessionFile()!;
expect(sessionFile).toContain("created-session-id");
expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_created-session-id\.jsonl$/);
expect(existsSync(sessionFile)).toBe(false);
});
it("generates a UUIDv7 id when creating a branched session", () => {
const session = SessionManager.inMemory();
const firstId = session.appendMessage({
@@ -106,4 +135,28 @@ describe("SessionManager.newSession with custom id", () => {
expect(header!.id).toMatch(UUID_V7_RE);
expect(header!.parentSession).toBe(sourcePath);
});
it("uses the provided 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: "source-session-id",
timestamp: new Date().toISOString(),
cwd: tempDir,
})}\n`,
);
const forked = SessionManager.forkFrom(sourcePath, tempDir, tempDir, { id: "forked-session-id" });
const header = forked.getHeader();
expect(header).not.toBeNull();
expect(header!.id).toBe("forked-session-id");
expect(header!.parentSession).toBe(sourcePath);
const sessionFile = forked.getSessionFile()!;
expect(sessionFile).toContain("forked-session-id");
expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_forked-session-id\.jsonl$/);
});
});

View File

@@ -1,4 +1,5 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { constants as bufferConstants } from "buffer";
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -63,6 +64,35 @@ describe("loadEntriesFromFile", () => {
const entries = loadEntriesFromFile(file);
expect(entries).toHaveLength(2);
});
it("opens session files larger than Node's max string length", () => {
const file = join(tempDir, "large.jsonl");
writeFileSync(
file,
'{"type":"session","version":3,"id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n',
);
const fd = openSync(file, "r+");
try {
const newline = Buffer.from("\n");
const stride = 16 * 1024 * 1024;
for (let offset = stride; offset <= bufferConstants.MAX_STRING_LENGTH + stride; offset += stride) {
writeSync(fd, newline, 0, newline.length, offset);
}
} finally {
closeSync(fd);
}
appendFileSync(
file,
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
);
const sessionManager = SessionManager.open(file, tempDir);
expect(sessionManager.getSessionId()).toBe("abc");
expect(sessionManager.getEntries()).toHaveLength(1);
expect(sessionManager.buildSessionContext().messages).toEqual([{ role: "user", content: "hi", timestamp: 1 }]);
});
});
describe("findMostRecentSession", () => {
@@ -124,6 +154,86 @@ describe("findMostRecentSession", () => {
expect(findMostRecentSession(tempDir)).toBe(valid);
});
it("filters most recent session by cwd", async () => {
const projectA = join(tempDir, "project-a");
const projectB = join(tempDir, "project-b");
const fileA = join(tempDir, "a.jsonl");
const fileB = join(tempDir, "b.jsonl");
writeFileSync(
fileA,
`${JSON.stringify({ type: "session", id: "a", timestamp: "2025-01-01T00:00:00Z", cwd: projectA })}\n`,
);
await new Promise((r) => setTimeout(r, 10));
writeFileSync(
fileB,
`${JSON.stringify({ type: "session", id: "b", timestamp: "2025-01-01T00:00:00Z", cwd: projectB })}\n`,
);
expect(findMostRecentSession(tempDir, projectA)).toBe(fileA);
expect(findMostRecentSession(tempDir, projectB)).toBe(fileB);
});
});
describe("SessionManager custom flat session directory", () => {
let tempDir: string;
let projectA: string;
let projectB: string;
beforeEach(() => {
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
projectA = join(tempDir, "project-a");
projectB = join(tempDir, "project-b");
mkdirSync(projectA, { recursive: true });
mkdirSync(projectB, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function createPersistedSession(cwd: string, label: string): string {
const session = SessionManager.create(cwd, tempDir);
session.appendMessage({ role: "user", content: label, timestamp: Date.now() });
session.appendMessage({
role: "assistant",
content: [{ type: "text", text: `reply to ${label}` }],
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: Date.now(),
});
const sessionFile = session.getSessionFile();
if (!sessionFile) {
throw new Error("Expected persisted session file");
}
return sessionFile;
}
it("scopes current-folder APIs by cwd while listing all flat sessions", async () => {
const sessionA = createPersistedSession(projectA, "from A");
await new Promise((r) => setTimeout(r, 10));
const sessionB = createPersistedSession(projectB, "from B");
const currentA = await SessionManager.list(projectA, tempDir);
expect(currentA.map((session) => session.path)).toEqual([sessionA]);
const all = await SessionManager.listAll(tempDir);
expect(new Set(all.map((session) => session.path))).toEqual(new Set([sessionA, sessionB]));
const continuedA = SessionManager.continueRecent(projectA, tempDir);
expect(continuedA.getSessionFile()).toBe(sessionA);
});
});
describe("SessionManager.setSessionFile with corrupted files", () => {

View File

@@ -142,6 +142,19 @@ describe("SessionManager labels", () => {
expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp);
});
it("rewires children of removed labels when forking", () => {
const session = SessionManager.inMemory();
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
session.appendLabelChange(msg1Id, "checkpoint");
const modelChangeId = session.appendModelChange("anthropic", "claude-test");
const msg2Id = session.appendMessage({ role: "user", content: "followup", timestamp: 2 });
session.createBranchedSession(msg2Id);
expect(session.getEntry(modelChangeId)?.parentId).toBe(msg1Id);
});
it("labels not on path are not preserved in createBranchedSession", () => {
const session = SessionManager.inMemory();

View File

@@ -198,6 +198,24 @@ describe("SettingsManager", () => {
});
});
describe("theme setting", () => {
it("stores slash-separated automatic theme settings separately from fixed theme names", async () => {
const settingsPath = join(agentDir, "settings.json");
writeFileSync(settingsPath, JSON.stringify({ theme: "light/dark" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getTheme()).toBeUndefined();
expect(manager.getThemeSetting()).toBe("light/dark");
manager.setTheme("solarized-light/tokyo-night");
await manager.flush();
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
expect(savedSettings.theme).toBe("solarized-light/tokyo-night");
});
});
describe("error tracking", () => {
it("should collect and clear load errors via drainErrors", () => {
const globalSettingsPath = join(agentDir, "settings.json");
@@ -214,6 +232,61 @@ describe("SettingsManager", () => {
});
});
describe("project trust", () => {
it("should skip project settings when project is not trusted", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
expect(manager.isProjectTrusted()).toBe(false);
expect(manager.getTheme()).toBe("global");
expect(manager.getProjectSettings()).toEqual({});
});
it("should reload project settings after trust changes to true", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
manager.setProjectTrusted(true);
expect(manager.isProjectTrusted()).toBe(true);
expect(manager.getTheme()).toBe("project");
});
it("should fail project settings writes when project is not trusted", async () => {
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
expect(() => manager.setProjectPackages(["npm:new"])).toThrow(
"Project is not trusted; refusing to write project settings",
);
await manager.flush();
expect(manager.getProjectSettings()).toEqual({});
expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] });
});
it("should read default project trust from global settings only", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultProjectTrust: "never" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getDefaultProjectTrust()).toBe("always");
});
it("should default invalid project trust settings to ask", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "sometimes" }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getDefaultProjectTrust()).toBe("ask");
});
});
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

View File

@@ -0,0 +1,135 @@
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, 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.ts";
const cliPath = resolve(__dirname, "../src/cli.ts");
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-startup-session-name-"));
tempDirs.push(dir);
return dir;
}
interface CliDirs {
agentDir: string;
projectDir: string;
sessionFile: string;
}
interface CliResult {
code: number | null;
signal: NodeJS.Signals | null;
stderr: string;
}
function createSessionFile(projectDir: string, sessionFile: string): void {
const timestamp = new Date().toISOString();
writeFileSync(
sessionFile,
`${JSON.stringify({ type: "session", version: 3, id: "existing-session", timestamp, cwd: projectDir })}\n${JSON.stringify(
{
type: "message",
id: "assistant-1",
parentId: null,
timestamp,
message: {
role: "assistant",
content: [{ type: "text", text: "hello" }],
provider: "anthropic",
model: "claude-sonnet-4-5",
timestamp: Date.now(),
},
},
)}\n`,
);
}
function readSessionInfoNames(sessionFile: string): string[] {
return readFileSync(sessionFile, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { type?: string; name?: string })
.filter((entry) => entry.type === "session_info")
.map((entry) => entry.name ?? "");
}
async function runCli(args: string[], dirs: CliDirs): Promise<CliResult> {
let stderr = "";
const child = spawn(process.execPath, [cliPath, ...args], {
cwd: dirs.projectDir,
env: {
...process.env,
[ENV_AGENT_DIR]: dirs.agentDir,
PI_OFFLINE: "1",
TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"),
},
stdio: ["ignore", "ignore", "pipe"],
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
return new Promise((resolvePromise, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
}, 10_000);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
resolvePromise({ code, signal, stderr });
});
});
}
function setup(): CliDirs {
const tempRoot = createTempDir();
const dirs = {
agentDir: join(tempRoot, "agent"),
projectDir: join(tempRoot, "project"),
sessionFile: join(tempRoot, "session.jsonl"),
};
mkdirSync(dirs.agentDir, { recursive: true });
mkdirSync(dirs.projectDir, { recursive: true });
createSessionFile(dirs.projectDir, dirs.sessionFile);
return dirs;
}
describe("startup session name", () => {
it("sets --name on the selected session before runtime model validation", async () => {
const dirs = setup();
const result = await runCli(
["--session", dirs.sessionFile, "--name", " CLI Named Session ", "--model", "missing-model", "-p", "hi"],
dirs,
);
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(readSessionInfoNames(dirs.sessionFile)).toEqual(["CLI Named Session"]);
});
it("rejects empty --name values without appending session metadata", async () => {
const dirs = setup();
const result = await runCli(
["--session", dirs.sessionFile, "--name", " ", "--model", "missing-model", "-p", "hi"],
dirs,
);
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.stderr).toContain("--name requires a non-empty value");
expect(readSessionInfoNames(dirs.sessionFile)).toEqual([]);
});
});

View File

@@ -80,8 +80,24 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
}
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"]);
it("prints --version to stdout when stdout is redirected", async () => {
const result = await runCli(["--version"]);
expect(result.code).toBe(0);
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
expect(result.stderr).toBe("");
});
it("prints plain --help to stdout when stdout is redirected", async () => {
const result = await runCli(["--help"]);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Usage:");
expect(result.stderr).not.toContain("Usage:");
});
it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help", "--approve"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("");
@@ -90,13 +106,23 @@ describe("stdout cleanliness in non-interactive modes", () => {
expect(result.stderr).toContain("Usage:");
});
it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => {
it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["-p", "--help", "--approve"]);
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("ignores untrusted project package installs for help", 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).not.toContain("changed 1 package in 471ms");
expect(result.stderr).not.toContain("found 0 vulnerabilities");
expect(result.stderr).toContain("Usage:");
});
});

View File

@@ -5,6 +5,7 @@ import {
type Model,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { estimateTokens } from "../../src/core/compaction/index.ts";
import { createHarness, type Harness } from "./harness.ts";
type SessionWithCompactionInternals = {
@@ -67,19 +68,20 @@ function useSummaryStreamFn(harness: Harness, summary: string): () => number {
}
function seedCompactableSession(harness: Harness): void {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const now = Date.now();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
harness.sessionManager.appendMessage(
createAssistant(harness, {
stopReason: "stop",
totalTokens: 100,
timestamp: now - 500,
}),
);
const assistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 100,
timestamp: now - 500,
});
assistant.content = [{ type: "text", text: "assistant response to compact" }];
harness.sessionManager.appendMessage(assistant);
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
}
@@ -96,6 +98,7 @@ describe("AgentSession compaction characterization", () => {
it("manually compacts using an extension-provided summary", async () => {
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
@@ -116,8 +119,10 @@ describe("AgentSession compaction characterization", () => {
const result = await harness.session.compact();
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
const estimatedTokensAfter = harness.session.messages.reduce((sum, message) => sum + estimateTokens(message), 0);
expect(result.summary).toBe("summary from extension");
expect(result.estimatedTokensAfter).toBe(estimatedTokensAfter);
expect(compactionEntries).toHaveLength(1);
expect(harness.session.messages[0]?.role).toBe("compactionSummary");
});
@@ -145,7 +150,7 @@ describe("AgentSession compaction characterization", () => {
const result = await harness.session.compact();
expect(result.summary).toBe("summary from custom stream");
expect(result.summary).toContain("summary from custom stream");
expect(getStreamCallCount()).toBe(1);
});
@@ -159,12 +164,15 @@ describe("AgentSession compaction characterization", () => {
await sessionInternals._runAutoCompaction("threshold", false);
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
expect(compactionEntries).toHaveLength(1);
expect(compactionEnd?.result?.estimatedTokensAfter).toBeGreaterThan(0);
expect(getStreamCallCount()).toBe(1);
});
it("cancels in-progress manual compaction when abortCompaction is called", async () => {
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => {
@@ -248,6 +256,37 @@ describe("AgentSession compaction characterization", () => {
);
});
it("compacts successful overflow responses without retrying", async () => {
const harness = await createHarness({
settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0 } },
models: [{ id: "faux-1", contextWindow: 1, maxTokens: 100 }],
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "successful overflow compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("completed answer")]);
await expect(harness.session.prompt("hello")).resolves.toBeUndefined();
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
expect(compactionEnd).toMatchObject({
reason: "overflow",
aborted: false,
willRetry: false,
});
expect(harness.faux.state.callCount).toBe(1);
});
it("ignores stale pre-compaction assistant usage on pre-prompt checks", async () => {
const harness = await createHarness();
harnesses.push(harness);

View File

@@ -2,7 +2,7 @@ import type { AgentTool, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionAPI } from "../../src/index.ts";
import type { BuildSystemPromptOptions, ExtensionAPI } from "../../src/index.ts";
import { createHarness, getAssistantTexts, type Harness } from "./harness.ts";
describe("AgentSession model and extension characterization", () => {
@@ -260,6 +260,34 @@ describe("AgentSession model and extension characterization", () => {
expect(extensionApi).toBeDefined();
});
it("allows extension commands to inspect live system prompt options", async () => {
const seenOptions: BuildSystemPromptOptions[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.registerCommand("inspect-options", {
description: "Inspect system prompt options",
handler: async (_args, ctx) => {
const options = ctx.getSystemPromptOptions();
seenOptions.push(options);
options.selectedTools?.push("mutated_tool");
},
});
},
],
});
harnesses.push(harness);
await harness.session.prompt("/inspect-options");
await harness.session.prompt("/inspect-options");
expect(seenOptions).toHaveLength(2);
expect(seenOptions[0]).toBe(seenOptions[1]);
expect(seenOptions[0]?.cwd).toBe(harness.tempDir);
expect(seenOptions[0]?.selectedTools).toContain("read");
expect(seenOptions[1]?.selectedTools).toContain("mutated_tool");
});
it("allows before_agent_start handlers to inject custom messages and modify the system prompt", async () => {
const harness = await createHarness({
extensionFactories: [

View File

@@ -5,6 +5,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { InputEvent } from "../../src/core/extensions/index.ts";
import type { PromptTemplate } from "../../src/core/prompt-templates.ts";
import { createSyntheticSourceInfo } from "../../src/core/source-info.ts";
import { createTestResourceLoader } from "../utilities.ts";
@@ -259,6 +260,80 @@ describe("AgentSession prompt characterization", () => {
expect(getMessageText(harness.session.messages[0]!)).toBe("from extension");
});
it("does not report streamingBehavior to input handlers while idle", async () => {
const inputEvents: InputEvent[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("input", (event) => {
inputEvents.push(event);
});
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("ok")]);
await harness.session.prompt("idle", { streamingBehavior: "followUp" });
expect(inputEvents).toHaveLength(1);
expect(inputEvents[0]?.streamingBehavior).toBeUndefined();
});
it("reports streamingBehavior to input handlers while streaming", async () => {
let releaseToolExecution: (() => void) | undefined;
const toolRelease = new Promise<void>((resolve) => {
releaseToolExecution = resolve;
});
const inputEvents: InputEvent[] = [];
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],
extensionFactories: [
(pi) => {
pi.on("input", (event) => {
inputEvents.push(event);
});
},
],
});
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 harness.session.prompt("queued", { streamingBehavior: "followUp" });
expect(inputEvents.map((event) => event.streamingBehavior)).toEqual([undefined, "followUp"]);
releaseToolExecution?.();
await promptPromise;
});
it("throws when prompted during streaming without a streamingBehavior", async () => {
let releaseToolExecution: (() => void) | undefined;
const toolRelease = new Promise<void>((resolve) => {

View File

@@ -419,4 +419,27 @@ describe("AgentSession queue characterization", () => {
'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.',
);
});
it("delivers follow-ups queued during agent_end", async () => {
let sent = false;
const harness = await createHarness({
extensionFactories: [
(pi: ExtensionAPI) => {
pi.on("agent_end", async () => {
if (sent) return;
sent = true;
pi.sendUserMessage("conflict report", { deliverAs: "followUp" });
});
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("reply"), fauxAssistantMessage("follow-up reply")]);
await harness.session.prompt("hello");
await harness.session.agent.waitForIdle();
expect(getUserTexts(harness)).toEqual(["hello", "conflict report"]);
});
});

View File

@@ -60,6 +60,9 @@ export interface HarnessOptions {
settings?: Partial<Settings>;
systemPrompt?: string;
tools?: AgentTool[];
initialActiveToolNames?: string[];
allowedToolNames?: string[];
excludedToolNames?: string[];
resourceLoader?: ResourceLoader;
extensionFactories?: Array<ExtensionFactory | CreateTestExtensionsResultInput>;
withConfiguredAuth?: boolean;
@@ -173,6 +176,9 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
modelRegistry,
resourceLoader,
baseToolsOverride: toolMap,
initialActiveToolNames: options.initialActiveToolNames,
allowedToolNames: options.allowedToolNames,
excludedToolNames: options.excludedToolNames,
extensionRunnerRef,
});

View File

@@ -0,0 +1,185 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import chalk from "chalk";
import { afterEach, describe, expect, test, vi } from "vitest";
import { APP_NAME } from "../../../src/config.ts";
import type { SessionManager } from "../../../src/core/session-manager.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
// Regression for https://github.com/earendil-works/pi/issues/5080
//
// On SIGTERM/SIGHUP the graceful shutdown must emit `session_shutdown`
// (runtimeHost.dispose) BEFORE touching the terminal. Extension teardown such
// as removing a socket does not write to the tty, so it must not be skipped if
// a later terminal-restore write fails on a dead or stalled terminal. The
// interactive quit path (Ctrl+D, /quit) keeps the opposite order to preserve
// the final TUI frame.
type ShutdownThis = {
isShuttingDown: boolean;
unregisterSignalHandlers: () => void;
runtimeHost: { dispose: () => Promise<void> };
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
themeController: { disableAutoSync: () => void };
stop: () => void;
sessionManager: SessionManager;
};
type InteractiveModePrototypeWithShutdown = {
shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown;
const tempDirs: string[] = [];
const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
class ProcessExitError extends Error {}
function createSessionManager(options: { sessionFile?: string } = {}): SessionManager {
return {
isPersisted: () => options.sessionFile !== undefined,
getSessionFile: () => options.sessionFile,
getSessionId: () => "test-session",
getSessionDir: () => "/tmp/pi-sessions",
usesDefaultSessionDir: () => true,
} as unknown as SessionManager;
}
function createTempFile(): string {
const dir = mkdtempSync(join(tmpdir(), "pi-shutdown-resume-hint-"));
tempDirs.push(dir);
const file = join(dir, "session.jsonl");
writeFileSync(file, "\n");
return file;
}
function setStdoutIsTTY(value: boolean): void {
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value });
}
function restoreStdoutIsTTY(): void {
if (originalStdoutIsTTY) {
Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
function createContext(order: string[], sessionManager = createSessionManager()): ShutdownThis {
return {
isShuttingDown: false,
unregisterSignalHandlers: vi.fn(),
runtimeHost: {
dispose: vi.fn(async () => {
order.push("dispose");
}),
},
ui: {
terminal: {
drainInput: vi.fn(async () => {
order.push("drainInput");
}),
},
},
themeController: { disableAutoSync: vi.fn() },
stop: vi.fn(() => {
order.push("stop");
}),
sessionManager,
};
}
async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void> {
try {
await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options);
} catch (error) {
if (!(error instanceof ProcessExitError)) throw error;
}
}
describe("InteractiveMode.shutdown ordering (#5080)", () => {
afterEach(() => {
vi.restoreAllMocks();
restoreStdoutIsTTY();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
test("signal-triggered shutdown emits session_shutdown before terminal writes", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
await callShutdown(context, { fromSignal: true });
expect(order).toEqual(["dispose", "drainInput", "stop"]);
expect(context.isShuttingDown).toBe(true);
});
test("interactive quit stops the TUI before emitting session_shutdown", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
await callShutdown(context);
expect(order).toEqual(["drainInput", "stop", "dispose"]);
});
test("interactive quit prints a resume hint for persisted sessions", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation((() => true) as typeof process.stdout.write);
setStdoutIsTTY(true);
const order: string[] = [];
const context = createContext(order, createSessionManager({ sessionFile: createTempFile() }));
await callShutdown(context);
expect(order).toEqual(["drainInput", "stop", "dispose"]);
expect(stdoutWrite).toHaveBeenCalledWith(
`${chalk.dim("To resume this session:")} ${APP_NAME} --session test-session\n`,
);
});
test("signal-triggered shutdown does not print a resume hint", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation((() => true) as typeof process.stdout.write);
setStdoutIsTTY(true);
const order: string[] = [];
const context = createContext(order, createSessionManager({ sessionFile: createTempFile() }));
await callShutdown(context, { fromSignal: true });
for (const call of stdoutWrite.mock.calls) {
expect(call[0]).not.toContain("To resume this session:");
}
});
test("re-entrant shutdown is a no-op", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
context.isShuttingDown = true;
await callShutdown(context, { fromSignal: true });
expect(order).toEqual([]);
expect(context.runtimeHost.dispose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import { createHarness } from "../harness.ts";
function toolNames(tools: Array<{ name: string }>): string[] {
return tools.map((tool) => tool.name).sort();
}
describe("regression #5109: exclude tools", () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.on("session_start", () => {
pi.registerTool({
name: "ask_question",
label: "Ask Question",
description: "Ask a question",
promptSnippet: "Ask a question",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
});
pi.registerTool({
name: "dynamic_tool",
label: "Dynamic Tool",
description: "Dynamic test tool",
promptSnippet: "Run dynamic test behavior",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
});
});
},
];
it("filters built-in and extension tools from available and active tools", async () => {
const harness = await createHarness({
excludedToolNames: ["read", "ask_question"],
extensionFactories,
});
try {
await harness.session.bindExtensions({});
const allToolNames = toolNames(harness.session.getAllTools());
expect(allToolNames).not.toContain("read");
expect(allToolNames).not.toContain("ask_question");
expect(allToolNames).toContain("bash");
expect(allToolNames).toContain("dynamic_tool");
expect(harness.session.getActiveToolNames().sort()).toEqual(["bash", "dynamic_tool", "edit", "write"]);
expect(harness.session.systemPrompt).not.toContain("- read:");
expect(harness.session.systemPrompt).not.toContain("ask_question");
expect(harness.session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior");
} finally {
harness.cleanup();
}
});
it("lets excluded tools override the allowlist", async () => {
const harness = await createHarness({
allowedToolNames: ["read", "bash", "ask_question"],
excludedToolNames: ["read", "ask_question"],
initialActiveToolNames: ["read", "bash", "ask_question"],
extensionFactories,
});
try {
await harness.session.bindExtensions({});
expect(toolNames(harness.session.getAllTools())).toEqual(["bash"]);
expect(harness.session.getActiveToolNames()).toEqual(["bash"]);
expect(harness.session.systemPrompt).toContain("- bash:");
expect(harness.session.systemPrompt).not.toContain("- read:");
expect(harness.session.systemPrompt).not.toContain("ask_question");
} finally {
harness.cleanup();
}
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { type BashOperations, createBashTool } from "../../../src/core/tools/bash.ts";
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("regression #5208: late bash output callbacks", () => {
it("ignores output callbacks after bash operations resolve", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(Buffer.from("before\n", "utf-8"));
setTimeout(() => onData(Buffer.from("late\n", "utf-8")), 0);
return { exitCode: 0 };
},
};
const bash = createBashTool(process.cwd(), { operations });
const result = await bash.execute("test-call-late-output", { command: "late-output" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(getTextOutput(result).trim()).toBe("before");
});
});

View File

@@ -0,0 +1,79 @@
import type { ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { spawnProcess, waitForChildProcess } from "../../../src/utils/child-process.ts";
/**
* Regression test for https://github.com/earendil-works/pi/issues/5303
*
* waitForChildProcess armed a fixed 100ms timer on `exit` and destroyed the
* stdio streams when it fired. When a short-lived detached descendant kept the
* stdout pipe open, `close` never fired, so that timer was the only thing that
* resolved the wait, and any output written more than 100ms after exit was
* binned. In practice every git commit whose pre-commit hook runs lint-staged
* came back truncated mid-listr2 output, read by the model as a hang.
*
* The fix re-arms the grace on each chunk, so an actively writing pipe keeps us
* reading while a genuinely idle held-open handle still releases after the
* grace elapses. Both behaviours are covered below.
*/
describe.skipIf(process.platform === "win32")("issue #5303 bash output truncation past exit", () => {
let child: ChildProcessByStdio<null, Readable, Readable> | undefined;
afterEach(() => {
if (child?.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
// Already gone.
}
}
child = undefined;
});
it("captures output emitted after exit while a detached child holds stdout open", async () => {
// The shell exits immediately, but a backgrounded subshell keeps the stdout
// pipe open and emits ticks every 50ms, the last well past the 100ms grace.
const command = 'printf "HEAD\\n"; ( for i in 1 2 3 4 5 6; do sleep 0.05; printf "TICK$i\\n"; done ) &';
child = spawnProcess("/bin/sh", ["-c", command], {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
}) as ChildProcessByStdio<null, Readable, Readable>;
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const exitCode = await waitForChildProcess(child);
expect(exitCode).toBe(0);
expect(output).toContain("HEAD");
expect(output).toContain("TICK6");
});
it("resolves promptly when a detached child holds stdout open but stays quiet", async () => {
// The shell exits, but a backgrounded sleeper inherits the stdout pipe and
// keeps it open for a long time without writing. `close` never fires, so we
// must still release via the idle grace rather than hang on the open handle.
const command = 'printf "DONE\\n"; ( sleep 30 ) &';
child = spawnProcess("/bin/sh", ["-c", command], {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
}) as ChildProcessByStdio<null, Readable, Readable>;
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const start = Date.now();
const exitCode = await waitForChildProcess(child);
const elapsed = Date.now() - start;
expect(exitCode).toBe(0);
expect(output).toContain("DONE");
// Must not wait for the 30s sleeper; the idle grace releases us in well under a second.
expect(elapsed).toBeLessThan(2000);
});
});

View File

@@ -0,0 +1,93 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
vi.mock("../../../src/utils/open-browser.ts", () => ({
openBrowser: vi.fn(),
}));
function createDialog(): LoginDialogComponent {
return new LoginDialogComponent(
{ requestRender: vi.fn() } as unknown as TUI,
"prompt-repro",
() => {},
"Prompt Repro",
);
}
function renderDialog(dialog: LoginDialogComponent): string[] {
return stripAnsi(dialog.render(120).join("\n"))
.split("\n")
.map((line) => line.trimEnd());
}
function countRenderedValue(lines: string[], value: string): number {
return lines.filter((line) => line.trim() === `> ${value}`).length;
}
describe("LoginDialogComponent OAuth prompts", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
test("keeps previous prompt input stable when a later prompt is active", async () => {
const dialog = createDialog();
const firstPrompt = dialog.showPrompt("First prompt:", "first-value");
dialog.handleInput("first-value");
dialog.handleInput("\n");
await expect(firstPrompt).resolves.toBe("first-value");
const secondPrompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("First prompt:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "first-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(secondPrompt).resolves.toBe("second-secret-demo");
});
test("preserves auth instructions when showing a prompt", () => {
const dialog = createDialog();
dialog.showAuth("https://example.invalid/login", "Authorize the extension");
dialog.showPrompt("First prompt:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("https://example.invalid/login");
expect(output).toContain("Authorize the extension");
expect(output).toContain("First prompt:");
});
test("keeps previous manual input stable when a later prompt is active", async () => {
const dialog = createDialog();
const manualInput = dialog.showManualInput("Paste callback URL:");
dialog.handleInput("callback-value");
dialog.handleInput("\n");
await expect(manualInput).resolves.toBe("callback-value");
const prompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("Paste callback URL:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "callback-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(prompt).resolves.toBe("second-secret-demo");
});
});

View File

@@ -0,0 +1,89 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { AgentSession } from "../../../src/core/agent-session.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { convertToLlm } from "../../../src/core/messages.ts";
import { ModelRegistry } from "../../../src/core/model-registry.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { createTestResourceLoader } from "../../utilities.ts";
describe("regression #5596: missing configured theme export", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
initTheme("dark");
});
it("exports with the active fallback theme when the configured theme is missing", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-5596-"));
const faux = registerFauxProvider({
models: [{ id: "faux-1", reasoning: false }],
});
faux.setResponses([fauxAssistantMessage("hello")]);
const model = faux.getModel();
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(model.provider, "faux-key");
const modelRegistry = ModelRegistry.inMemory(authStorage);
modelRegistry.registerProvider(model.provider, {
baseUrl: model.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,
baseUrl: registeredModel.baseUrl,
})),
});
const settingsManager = SettingsManager.inMemory({ theme: "missing-theme" });
const sessionManager = SessionManager.create(tempDir, join(tempDir, "sessions"));
const agent = new Agent({
getApiKey: () => "faux-key",
initialState: {
model,
systemPrompt: "You are a test assistant.",
tools: [],
},
convertToLlm,
});
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
});
cleanups.push(() => {
session.dispose();
faux.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
await session.prompt("hi");
initTheme(settingsManager.getTheme());
const outputPath = join(tempDir, "export.html");
await expect(session.exportToHtml(outputPath)).resolves.toBe(outputPath);
expect(existsSync(outputPath)).toBe(true);
expect(settingsManager.getTheme()).toBe("missing-theme");
});
});

View File

@@ -0,0 +1,91 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ENV_AGENT_DIR } from "../../../src/config.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { ModelRegistry } from "../../../src/core/model-registry.ts";
import { runMigrations } from "../../../src/migrations.ts";
import { createHarness } from "../harness.ts";
describe("regression #5661: uppercase models.json header values", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
});
function withAgentDir(agentDir: string, fn: () => void): void {
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
try {
fn();
} finally {
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
}
}
it("keeps uppercase header strings as literals during startup migrations", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
cleanups.push(harness.cleanup);
const envKeys = ["CUSTOM_API_KEY", "BEARER"];
const savedEnv: Record<string, string | undefined> = {};
for (const key of envKeys) {
savedEnv[key] = process.env[key];
process.env[key] = `env-${key}`;
}
cleanups.push(() => {
for (const key of envKeys) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
});
const modelsPath = join(harness.tempDir, "models.json");
writeFileSync(
modelsPath,
`${JSON.stringify(
{
providers: {
"my-provider": {
baseUrl: "https://example.com/v1",
apiKey: "CUSTOM_API_KEY",
api: "openai-completions",
headers: { Authorization: "BEARER" },
models: [{ id: "my-model" }],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
withAgentDir(harness.tempDir, () => runMigrations(harness.tempDir));
const migrated = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
providers: Record<string, { apiKey?: string; headers?: Record<string, string> }>;
};
expect(migrated.providers["my-provider"]?.apiKey).toBe("CUSTOM_API_KEY");
expect(migrated.providers["my-provider"]?.headers?.Authorization).toBe("BEARER");
const registry = ModelRegistry.create(AuthStorage.create(join(harness.tempDir, "auth.json")), modelsPath);
const model = registry.find("my-provider", "my-model");
expect(model).toBeDefined();
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
ok: true,
apiKey: "CUSTOM_API_KEY",
headers: { Authorization: "BEARER" },
});
});
});

View File

@@ -0,0 +1,94 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
// Regression for https://github.com/earendil-works/pi/issues/5724
//
// `proper-lockfile` installs `signal-exit`, whose signal listener re-sends
// SIGTERM/SIGHUP when it observes no other process listeners during the same
// signal dispatch. InteractiveMode must therefore keep its signal handlers
// registered until async terminal cleanup has completed.
type ShutdownThis = {
isShuttingDown: boolean;
unregisterSignalHandlers: () => void;
runtimeHost: { dispose: () => Promise<void> };
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
themeController: { disableAutoSync: () => void };
stop: () => void;
};
type InteractiveModePrototypeWithShutdown = {
shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown;
class ProcessExitError extends Error {}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve: (() => void) | undefined;
const promise = new Promise<void>((res) => {
resolve = res;
});
return {
promise,
resolve: () => resolve?.(),
};
}
async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void> {
try {
await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options);
} catch (error) {
if (!(error instanceof ProcessExitError)) throw error;
}
}
describe("InteractiveMode SIGTERM shutdown with signal-exit (#5724)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("keeps signal handlers registered while signal-triggered cleanup is pending", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const dispose = deferred();
const context: ShutdownThis = {
isShuttingDown: false,
unregisterSignalHandlers: vi.fn(() => {
order.push("unregister");
}),
runtimeHost: {
dispose: vi.fn(() => {
order.push("dispose");
return dispose.promise;
}),
},
ui: {
terminal: {
drainInput: vi.fn(async () => {
order.push("drainInput");
}),
},
},
themeController: { disableAutoSync: vi.fn() },
stop: vi.fn(() => {
order.push("stop");
}),
};
const shutdownPromise = callShutdown(context, { fromSignal: true });
await Promise.resolve();
expect(order).toEqual(["dispose"]);
expect(context.unregisterSignalHandlers).not.toHaveBeenCalled();
dispose.resolve();
await shutdownPromise;
expect(order).toEqual(["dispose", "drainInput", "stop"]);
});
});

View File

@@ -0,0 +1,113 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSessionRuntime } from "../../../src/core/agent-session-runtime.ts";
import { runRpcMode } from "../../../src/modes/rpc/rpc-mode.ts";
import { createHarness, type Harness } from "../harness.ts";
// Regression for https://github.com/earendil-works/pi/issues/5868
const rpcIo = vi.hoisted(() => ({
outputLines: [] as string[],
lineHandler: undefined as ((line: string) => void) | undefined,
}));
vi.mock("../../../src/core/output-guard.js", () => ({
flushRawStdout: vi.fn(async () => {}),
takeOverStdout: vi.fn(),
waitForRawStdoutBackpressure: vi.fn(async () => {}),
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 () => {
rpcIo.lineHandler = undefined;
};
}),
serializeJsonLine: (value: unknown) => `${JSON.stringify(value)}\n`,
}));
type NodeListener = Parameters<typeof process.on>[1];
type ListenerSnapshot = {
stdinEnd: NodeListener[];
signals: Map<NodeJS.Signals, NodeListener[]>;
};
function takeListenerSnapshot(): ListenerSnapshot {
const signals: NodeJS.Signals[] = process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"];
return {
stdinEnd: process.stdin.listeners("end") as NodeListener[],
signals: new Map(signals.map((signal) => [signal, process.listeners(signal) as NodeListener[]])),
};
}
function restoreListeners(snapshot: ListenerSnapshot): void {
for (const listener of process.stdin.listeners("end") as NodeListener[]) {
if (!snapshot.stdinEnd.includes(listener)) {
process.stdin.off("end", listener);
}
}
for (const [signal, previousListeners] of snapshot.signals) {
for (const listener of process.listeners(signal) as NodeListener[]) {
if (!previousListeners.includes(listener)) {
process.off(signal, listener);
}
}
}
}
function parseOutputLines(): Array<Record<string, unknown>> {
return rpcIo.outputLines
.flatMap((line) => line.split("\n"))
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
function createRuntimeHost(harness: Harness): AgentSessionRuntime {
return {
session: harness.session,
newSession: vi.fn(async () => ({ cancelled: true })),
switchSession: vi.fn(async () => ({ cancelled: true })),
fork: vi.fn(async () => ({ cancelled: true, selectedText: "" })),
dispose: vi.fn(async () => {}),
setRebindSession: vi.fn(),
} as unknown as AgentSessionRuntime;
}
describe("RPC unknown command responses (#5868)", () => {
afterEach(() => {
rpcIo.outputLines = [];
rpcIo.lineHandler = undefined;
});
test("preserves the request id on unknown command errors", async () => {
const listenerSnapshot = takeListenerSnapshot();
const harness = await createHarness();
try {
void runRpcMode(createRuntimeHost(harness));
await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined());
rpcIo.lineHandler?.(JSON.stringify({ id: "test", type: "foobar" }));
await vi.waitFor(() => {
expect(parseOutputLines()).toContainEqual({
id: "test",
type: "response",
command: "foobar",
success: false,
error: "Unknown command: foobar",
});
});
} finally {
harness.cleanup();
restoreListeners(listenerSnapshot);
}
});
});

View File

@@ -0,0 +1,131 @@
import { existsSync, 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 { clearExtensionCache, loadExtensions, loadExtensionsCached } from "../../../src/core/extensions/loader.ts";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts";
interface TestState {
moduleLoads?: number;
factoryRuns?: number;
}
function state(): TestState {
const global = globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState };
if (!global.__extensionFactoryCacheTest) {
global.__extensionFactoryCacheTest = {};
}
return global.__extensionFactoryCacheTest;
}
function resetState(): void {
delete (globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }).__extensionFactoryCacheTest;
}
function writeCountingExtension(filePath: string): void {
writeFileSync(
filePath,
`
const state = (globalThis.__extensionFactoryCacheTest ??= {});
state.moduleLoads = (state.moduleLoads ?? 0) + 1;
export default function () {
state.factoryRuns = (state.factoryRuns ?? 0) + 1;
}
`,
"utf-8",
);
}
describe("extension factory cache", () => {
const roots: string[] = [];
function fixture(name: string) {
const root = join(tmpdir(), `pi-extension-cache-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const cwd = join(root, "project");
const agentDir = join(root, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
roots.push(root);
return { root, cwd, agentDir };
}
beforeEach(() => {
resetState();
clearExtensionCache();
});
afterEach(() => {
while (roots.length > 0) {
const root = roots.pop();
if (root && existsSync(root)) {
rmSync(root, { recursive: true, force: true });
}
}
resetState();
clearExtensionCache();
});
it("caches extension modules for cached same-cwd loads but reruns factories", async () => {
const { root, cwd } = fixture("same-cwd");
const extensionPath = join(root, "counting.ts");
writeCountingExtension(extensionPath);
const first = await loadExtensionsCached([extensionPath], cwd);
const second = await loadExtensionsCached([extensionPath], cwd);
expect(state().moduleLoads).toBe(1);
expect(state().factoryRuns).toBe(2);
expect(first.extensions[0]).not.toBe(second.extensions[0]);
expect(first.runtime).not.toBe(second.runtime);
});
it("does not cache direct loadExtensions calls", async () => {
const { root, cwd } = fixture("direct");
const extensionPath = join(root, "counting.ts");
writeCountingExtension(extensionPath);
await loadExtensions([extensionPath], cwd);
await loadExtensions([extensionPath], cwd);
expect(state().moduleLoads).toBe(2);
expect(state().factoryRuns).toBe(2);
});
it("clears the cache on resource loader reload", async () => {
const { cwd, agentDir } = fixture("reload");
const extensionDir = join(agentDir, "extensions");
mkdirSync(extensionDir, { recursive: true });
writeCountingExtension(join(extensionDir, "counting.ts"));
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
});
await loader.reload();
await loader.reload();
expect(state().moduleLoads).toBe(2);
expect(state().factoryRuns).toBe(2);
});
it("keeps the cache scoped to one cwd", async () => {
const { root } = fixture("cross-cwd");
const firstCwd = join(root, "first");
const secondCwd = join(root, "second");
mkdirSync(firstCwd, { recursive: true });
mkdirSync(secondCwd, { recursive: true });
const extensionPath = join(root, "counting.ts");
writeCountingExtension(extensionPath);
await loadExtensionsCached([extensionPath], firstCwd);
await loadExtensionsCached([extensionPath], secondCwd);
await loadExtensionsCached([extensionPath], secondCwd);
expect(state().moduleLoads).toBe(2);
expect(state().factoryRuns).toBe(3);
});
});

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts";
import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts";
describe("syntax highlight renderer", () => {
@@ -46,3 +48,29 @@ describe("syntax highlight renderer", () => {
expect(rendered).toContain("[number:1]");
});
});
describe("theme syntax highlighting", () => {
beforeEach(() => {
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
initTheme("dark");
});
afterEach(() => {
resetCapabilitiesCache();
});
it("colors diff additions and deletions in fenced diff blocks", () => {
const lines = highlightCode("-old\n+new\n", "diff");
expect(lines[0]).toBe("\x1b[38;2;204;102;102m-old\x1b[39m");
expect(lines[1]).toBe("\x1b[38;2;181;189;104m+new\x1b[39m");
});
it("keeps cli-highlight default styled scopes mapped to theme styles", () => {
expect(highlightCode("const re = /foo+/gi;", "javascript")[0]).toContain(
"\x1b[38;2;206;145;120m/foo+/gi\x1b[39m",
);
expect(highlightCode("@decorator", "python")[0]).toBe("\x1b[38;2;128;128;128m@decorator\x1b[39m");
expect(highlightCode("<div></div>", "html")[0]).toContain("\x1b[38;2;86;156;214mdiv\x1b[39m");
});
});

View File

@@ -1,24 +1,26 @@
import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
import { type RgbColor, resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
import { afterEach, describe, expect, it } from "vitest";
import {
detectTerminalBackground,
detectTerminalBackgroundFromEnv,
detectTerminalBackgroundTheme,
getThemeByName,
getThemeForRgbColor,
parseOsc11BackgroundColor,
parseAutoThemeSetting,
resolveThemeSetting,
} from "../src/modes/interactive/theme/theme.ts";
afterEach(() => {
resetCapabilitiesCache();
});
describe("detectTerminalBackground", () => {
describe("detectTerminalBackgroundFromEnv", () => {
it("uses the COLORFGBG background color index", () => {
expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;15" } })).toMatchObject({
theme: "light",
source: "COLORFGBG",
confidence: "high",
});
expect(detectTerminalBackground({ env: { COLORFGBG: "15;0" } })).toMatchObject({
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "15;0" } })).toMatchObject({
theme: "dark",
source: "COLORFGBG",
confidence: "high",
@@ -26,11 +28,11 @@ describe("detectTerminalBackground", () => {
});
it("uses the last COLORFGBG field as the background", () => {
expect(detectTerminalBackground({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light");
expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light");
});
it("defaults to dark without terminal background hints", () => {
expect(detectTerminalBackground({ env: {} })).toMatchObject({
expect(detectTerminalBackgroundFromEnv({ env: {} })).toMatchObject({
theme: "dark",
source: "fallback",
confidence: "low",
@@ -38,6 +40,65 @@ describe("detectTerminalBackground", () => {
});
});
describe("detectTerminalBackgroundTheme", () => {
it("uses the queried terminal background before environment hints", async () => {
let queriedTimeoutMs: number | undefined;
const detection = await detectTerminalBackgroundTheme({
env: { COLORFGBG: "15;0" },
timeoutMs: 250,
ui: {
async queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined> {
queriedTimeoutMs = timeoutMs;
return { r: 250, g: 250, b: 250 };
},
},
});
expect(queriedTimeoutMs).toBe(250);
expect(detection).toMatchObject({
theme: "light",
source: "terminal background",
confidence: "high",
});
});
it("falls back to environment hints when the terminal query returns no color", async () => {
const detection = await detectTerminalBackgroundTheme({
env: { COLORFGBG: "15;0" },
timeoutMs: 250,
ui: {
async queryTerminalBackgroundColor(): Promise<RgbColor | undefined> {
return undefined;
},
},
});
expect(detection).toMatchObject({
theme: "dark",
source: "COLORFGBG",
confidence: "high",
});
});
it("falls back to environment hints when the terminal query fails", async () => {
const detection = await detectTerminalBackgroundTheme({
env: { COLORFGBG: "0;15" },
timeoutMs: 250,
ui: {
async queryTerminalBackgroundColor(): Promise<RgbColor | undefined> {
throw new Error("terminal write failed");
},
},
});
expect(detection).toMatchObject({
theme: "light",
source: "COLORFGBG",
confidence: "high",
});
});
});
describe("theme color mode", () => {
it("uses terminal capabilities", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
@@ -54,18 +115,19 @@ describe("theme color mode", () => {
});
});
describe("parseOsc11BackgroundColor", () => {
it("parses 16-bit OSC 11 rgb responses", () => {
expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 });
});
it("parses OSC 11 hex responses", () => {
expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\")).toEqual({ r: 255, g: 255, b: 255 });
expect(parseOsc11BackgroundColor("\x1b]11;#000000\x07")).toEqual({ r: 0, g: 0, b: 0 });
});
describe("theme detection from RGB", () => {
it("classifies RGB colors by luminance", () => {
expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark");
expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light");
});
});
describe("theme setting helpers", () => {
it("parses and resolves automatic theme settings", () => {
expect(parseAutoThemeSetting("light/dark")).toEqual({ lightTheme: "light", darkTheme: "dark" });
expect(resolveThemeSetting("dark", "light")).toBe("dark");
expect(resolveThemeSetting("light/dark", "light")).toBe("light");
expect(resolveThemeSetting("light/dark", "dark")).toBe("dark");
expect(resolveThemeSetting("light/dark/extra", "dark")).toBeUndefined();
});
});

View File

@@ -67,6 +67,37 @@ describe("ToolExecutionComponent parity", () => {
expect(rendered).toContain("custom result");
});
test("self-rendered empty tool rows take no layout space", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderShell: "self",
renderCall: () => new Text("", 0, 0),
renderResult: () => new Text("", 0, 0),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-empty-self-render",
{},
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
expect(component.render(120)).toEqual([]);
component.updateResult(
{
content: [],
details: {},
isError: false,
},
false,
);
expect(component.render(120)).toEqual([]);
});
test("uses built-in rendering for built-in overrides without custom renderers", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("edit"),
@@ -191,6 +222,7 @@ describe("ToolExecutionComponent parity", () => {
process.cwd(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
component.setExpanded(true);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("override call");
expect(rendered).toContain("hello");
@@ -362,12 +394,38 @@ describe("ToolExecutionComponent parity", () => {
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },
false,
);
component.setExpanded(true);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("one");
expect(rendered).toContain("two");
expect(rendered).not.toContain("two\n\n");
});
test("collapses ordinary read results until expanded", () => {
const component = new ToolExecutionComponent(
"read",
"tool-ordinary-read-collapsed",
{ path: "notes.txt" },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
component.updateResult(
{ content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false },
false,
);
const collapsed = stripAnsi(component.render(120).join("\n"));
expect(collapsed).toContain("read");
expect(collapsed).toContain("notes.txt");
expect(collapsed).not.toContain("hidden content");
component.setExpanded(true);
const expanded = stripAnsi(component.render(120).join("\n"));
expect(expanded).toContain("hidden content");
});
for (const scenario of [
{
title: "SKILL.md",

View File

@@ -44,6 +44,7 @@ describe("Coding Agent Tools", () => {
});
afterEach(() => {
vi.restoreAllMocks();
// Clean up test directory
rmSync(testDir, { recursive: true, force: true });
});
@@ -535,6 +536,56 @@ describe("Coding Agent Tools", () => {
expect(getShellConfigSpy).toHaveBeenCalledWith("/custom/bash");
});
it("should send commands over stdin when shell resolution requires it", async () => {
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({
shell: process.execPath,
args: [
"-e",
'let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { process.stdout.write(input); });',
],
commandTransport: "stdin",
});
const chunks: Buffer[] = [];
const ops = createLocalBashOperations({ shellPath: "C:\\Windows\\System32\\bash.exe" });
const nameExpansion = "$" + "{name}";
const countExpansion = "$" + "{count}";
const iExpansion = "$" + "{i}";
const command = `name='World'; echo "Hello, ${nameExpansion}!"; count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`;
const result = await ops.exec(command, testDir, {
onData: (data) => chunks.push(data),
});
expect(result.exitCode).toBe(0);
expect(Buffer.concat(chunks).toString("utf-8")).toBe(command);
});
it("should resolve legacy WSL bash.exe to stdin command transport", () => {
if (process.platform === "win32") return;
const originalCwd = process.cwd();
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const shellPath = "C:\\Windows\\System32\\bash.exe";
writeFileSync(join(testDir, shellPath), "");
try {
process.chdir(testDir);
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
expect(shellModule.getShellConfig(shellPath)).toEqual({
shell: shellPath,
args: ["-s"],
commandTransport: "stdin",
});
} finally {
process.chdir(originalCwd);
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
it("should prepend command prefix when configured", async () => {
const bashWithPrefix = createBashTool(testDir, {
commandPrefix: "export TEST_VAR=hello",
@@ -980,6 +1031,57 @@ describe("edit tool fuzzy matching", () => {
expect(readFileSync(testFile, "utf-8")).toBe("console.log('world');\nhello universe\n");
});
it("should preserve the correct occurrence when fuzzy replacement equals a nearby line", async () => {
const testFile = join(testDir, "fuzzy-preserve-duplicate-line.txt");
const originalContent = ["replace me\u0020\u0020\u0020", "after\u0020\u0020\u0020", ""].join("\n");
writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-fuzzy-preserve-duplicate-line", {
path: testFile,
edits: [{ oldText: "replace me\n", newText: "after\n" }],
});
const expectedContent = ["after", "after\u0020\u0020\u0020", ""].join("\n");
expect(readFileSync(testFile, "utf-8")).toBe(expectedContent);
expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent);
});
it("should preserve untouched lines and produce an applicable patch for fuzzy multi-edits", async () => {
const testFile = join(testDir, "fuzzy-preserve-multi.txt");
const originalContent = [
"keep before\u0020\u0020",
"first target\u0020\u0020",
"first after",
"keep middle\u0020\u0020\u0020",
"second target\u0020\u0020",
"second after",
"keep after\u0020\u0020",
"",
].join("\n");
writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-fuzzy-preserve-multi", {
path: testFile,
edits: [
{ oldText: "first target\nfirst after", newText: "FIRST\nFIRST2" },
{ oldText: "second target\nsecond after", newText: "SECOND\nSECOND2" },
],
});
const expectedContent = [
"keep before\u0020\u0020",
"FIRST",
"FIRST2",
"keep middle\u0020\u0020\u0020",
"SECOND",
"SECOND2",
"keep after\u0020\u0020",
"",
].join("\n");
expect(readFileSync(testFile, "utf-8")).toBe(expectedContent);
expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent);
});
});
describe("edit tool CRLF handling", () => {

View File

@@ -1,4 +1,5 @@
import { setKeybindings } from "@earendil-works/pi-tui";
import { stripVTControlCharacters } from "node:util";
import { setKeybindings, visibleWidth } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import type {
@@ -248,6 +249,29 @@ describe("TreeSelectorComponent", () => {
});
});
describe("help", () => {
test("renders semantic help rows without truncating narrow terminal controls", () => {
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
const tree = buildTree(entries);
const selector = new TreeSelectorComponent(
tree,
"asst-1",
24,
() => {},
() => {},
);
const plainLines = selector.render(30).map(stripVTControlCharacters);
const plain = plainLines.join("\n");
expect(plain).toContain("branch");
expect(plain).toContain("filters");
expect(plain).toContain("cycle");
expect(plain).toContain("label time");
expect(plain).not.toContain("...");
expect(plainLines.every((line) => visibleWidth(line) <= 30)).toBe(true);
});
});
describe("label timestamps", () => {
test("toggles label timestamps for labeled nodes", () => {
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];

View File

@@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "..
function createContext(tokens: number | null, compact = vi.fn()): ExtensionContext {
return {
mode: "print",
hasUI: false,
ui: {} as ExtensionContext["ui"],
cwd: process.cwd(),
@@ -11,6 +12,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte
modelRegistry: {} as ExtensionContext["modelRegistry"],
model: undefined,
isIdle: () => true,
isProjectTrusted: () => true,
signal: undefined,
abort: vi.fn(),
hasPendingMessages: () => false,

View File

@@ -0,0 +1,67 @@
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 { hasTrustRequiringProjectResources, ProjectTrustStore } from "../src/core/trust-manager.ts";
describe("ProjectTrustStore", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
beforeEach(() => {
tempDir = join(tmpdir(), `trust-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 });
});
it("stores decisions and inherits from parent directories", () => {
const store = new ProjectTrustStore(agentDir);
const parentDir = join(tempDir, "trusted-parent");
const childDir = join(parentDir, "project");
mkdirSync(childDir, { recursive: true });
expect(store.get(childDir)).toBeNull();
store.set(parentDir, true);
expect(store.get(childDir)).toBe(true);
store.set(childDir, false);
expect(store.get(childDir)).toBe(false);
store.set(childDir, null);
expect(store.get(childDir)).toBe(true);
});
it("detects trust-requiring project resources", () => {
const originalHome = process.env.HOME;
process.env.HOME = tempDir;
try {
mkdirSync(join(tempDir, ".pi", "agent"), { recursive: true });
mkdirSync(join(tempDir, ".agents", "skills"), { recursive: true });
expect(hasTrustRequiringProjectResources(tempDir)).toBe(false);
expect(hasTrustRequiringProjectResources(cwd)).toBe(false);
writeFileSync(join(tempDir, ".pi", "settings.json"), "{}");
expect(hasTrustRequiringProjectResources(tempDir)).toBe(true);
rmSync(join(tempDir, ".pi", "settings.json"), { force: true });
mkdirSync(join(cwd, ".pi"), { recursive: true });
writeFileSync(join(cwd, ".pi", "settings.json"), "{}");
expect(hasTrustRequiringProjectResources(cwd)).toBe(true);
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
expect(hasTrustRequiringProjectResources(cwd)).toBe(true);
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
}
});
});

View File

@@ -0,0 +1,87 @@
import { setKeybindings } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import { TrustSelectorComponent } from "../src/modes/interactive/components/trust-selector.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("TrustSelectorComponent", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
it("marks the saved trusted decision", () => {
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: { path: "/project", decision: true },
projectTrusted: true,
onSelect: () => {},
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted (/project)");
expect(output).toContain("Current session: trusted");
expect(output).toContain("Trust ✓");
expect(output).not.toContain("Do not trust ✓");
});
it("selects a trust decision", () => {
const onSelect = vi.fn();
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: null,
projectTrusted: false,
onSelect,
onCancel: () => {},
});
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ path: "/project", decision: true }] });
});
it("labels saved ancestor decisions as inherited", () => {
const selector = new TrustSelectorComponent({
cwd: "/parent/project/nested",
savedDecision: { path: "/parent", decision: true },
projectTrusted: true,
onSelect: () => {},
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted (inherited from /parent)");
});
it("adds a trust parent option", () => {
const onSelect = vi.fn();
const selector = new TrustSelectorComponent({
cwd: "/parent/project",
savedDecision: { path: "/parent", decision: true },
projectTrusted: true,
onSelect,
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted (inherited from /parent)");
expect(output).toContain("Trust parent folder (/parent) ✓");
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith({
trusted: true,
updates: [
{ path: "/parent", decision: true },
{ path: "/parent/project", decision: null },
],
});
});
});

View File

@@ -29,6 +29,7 @@ describe("version checks", () => {
expect(comparePackageVersions("0.70.6", "0.70.5")).toBeGreaterThan(0);
expect(comparePackageVersions("0.70.5", "0.70.5")).toBe(0);
expect(comparePackageVersions("0.70.4", "0.70.5")).toBeLessThan(0);
expect(comparePackageVersions("5.0.0-beta.20", "5.0.0-beta.9")).toBeGreaterThan(0);
expect(isNewerPackageVersion("0.70.5", "0.70.5")).toBe(false);
expect(isNewerPackageVersion("0.70.6", "0.70.5")).toBe(true);
});