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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
49
packages/coding-agent/test/changelog.test.ts
Normal file
49
packages/coding-agent/test/changelog.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
31
packages/coding-agent/test/clipboard-native.test.ts
Normal file
31
packages/coding-agent/test/clipboard-native.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
177
packages/coding-agent/test/config-value-migration.test.ts
Normal file
177
packages/coding-agent/test/config-value-migration.test.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
44
packages/coding-agent/test/experimental.test.ts
Normal file
44
packages/coding-agent/test/experimental.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
39
packages/coding-agent/test/first-time-setup-fork.test.ts
Normal file
39
packages/coding-agent/test/first-time-setup-fork.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
packages/coding-agent/test/first-time-setup.test.ts
Normal file
95
packages/coding-agent/test/first-time-setup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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%");
|
||||
});
|
||||
});
|
||||
|
||||
135
packages/coding-agent/test/format-resume-command.test.ts
Normal file
135
packages/coding-agent/test/format-resume-command.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
53
packages/coding-agent/test/http-dispatcher.test.ts
Normal file
53
packages/coding-agent/test/http-dispatcher.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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 });
|
||||
|
||||
38
packages/coding-agent/test/rpc-client-process-exit.test.ts
Normal file
38
packages/coding-agent/test/rpc-client-process-exit.test.ts
Normal 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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
153
packages/coding-agent/test/sdk-stream-options.test.ts
Normal file
153
packages/coding-agent/test/sdk-stream-options.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
131
packages/coding-agent/test/session-id-readonly.test.ts
Normal file
131
packages/coding-agent/test/session-id-readonly.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
135
packages/coding-agent/test/startup-session-name.test.ts
Normal file
135
packages/coding-agent/test/startup-session-name.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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,
|
||||
|
||||
67
packages/coding-agent/test/trust-manager.test.ts
Normal file
67
packages/coding-agent/test/trust-manager.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
87
packages/coding-agent/test/trust-selector.test.ts
Normal file
87
packages/coding-agent/test/trust-selector.test.ts
Normal 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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user