Merge main into model-registry
This commit is contained in:
@@ -2,7 +2,8 @@ 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/compat";
|
||||
import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
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 +12,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 +33,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 +58,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",
|
||||
|
||||
@@ -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;
|
||||
@@ -134,6 +135,34 @@ 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";
|
||||
@@ -293,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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("config value env var syntax migration", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("rewrites legacy uppercase auth.json API key values to explicit env references", () => {
|
||||
it("leaves uppercase auth.json API key values unchanged", () => {
|
||||
const agentDir = createAgentDir();
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, "auth.json"),
|
||||
@@ -61,19 +61,17 @@ describe("config value env var syntax migration", () => {
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(migrated.anthropic.key).toBe("$ANTHROPIC_API_KEY");
|
||||
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");
|
||||
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
|
||||
expect(logMessage).toContain("explicit $ENV_VAR syntax");
|
||||
expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY');
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["malformed", '{\n "providers": {\n'],
|
||||
["blank", ""],
|
||||
])("does not throw on %s models.json during config migration", (_name, content) => {
|
||||
])("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");
|
||||
@@ -87,71 +85,93 @@ describe("config value env var syntax migration", () => {
|
||||
expect(loadError).toContain(`File: ${modelsPath}`);
|
||||
});
|
||||
|
||||
it("rewrites legacy uppercase models.json API key and header values", () => {
|
||||
it("leaves uppercase models.json API key and header values unchanged", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
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" },
|
||||
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" } },
|
||||
},
|
||||
],
|
||||
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",
|
||||
},
|
||||
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> }>;
|
||||
});
|
||||
} finally {
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
>;
|
||||
};
|
||||
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");
|
||||
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
|
||||
expect(logMessage).toContain(
|
||||
'models.json.providers["custom-provider"].apiKey: CUSTOM_API_KEY -> $CUSTOM_API_KEY',
|
||||
);
|
||||
expect(logMessage).toContain(
|
||||
'models.json.providers["custom-provider"].headers["x-api-key"]: HEADER_API_KEY -> $HEADER_API_KEY',
|
||||
);
|
||||
expect(logMessage).toContain(
|
||||
'models.json.providers["custom-provider"].models["model-a"].headers["x-model-key"]: MODEL_API_KEY -> $MODEL_API_KEY',
|
||||
);
|
||||
expect(logMessage).toContain(
|
||||
'models.json.providers["custom-provider"].modelOverrides["model-b"].headers["x-override-key"]: OVERRIDE_API_KEY -> $OVERRIDE_API_KEY',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,6 +188,29 @@ describe("detectInstallMethod", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates exact npm versions without uninstalling the current package", () => {
|
||||
const { prefix } = createNpmPrefixInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent", undefined, {
|
||||
packageName: "@earendil-works/pi-coding-agent",
|
||||
installSpec: "@earendil-works/pi-coding-agent@1.2.3",
|
||||
});
|
||||
|
||||
expect(command).toEqual({
|
||||
command: "npm",
|
||||
args: [
|
||||
"--prefix",
|
||||
prefix,
|
||||
"install",
|
||||
"-g",
|
||||
"--ignore-scripts",
|
||||
"--min-release-age=0",
|
||||
"@earendil-works/pi-coding-agent@1.2.3",
|
||||
],
|
||||
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent@1.2.3`,
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed packages from the current install prefix", () => {
|
||||
const { prefix } = createNpmPrefixInstall();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("InteractiveMode /clone", () => {
|
||||
await interactiveModePrototype.handleCloneCommand.call(context);
|
||||
|
||||
expect(fork).toHaveBeenCalledWith("leaf-123", { position: "at" });
|
||||
expect(renderCurrentSessionState).toHaveBeenCalled();
|
||||
expect(renderCurrentSessionState).not.toHaveBeenCalled();
|
||||
expect(setText).toHaveBeenCalledWith("");
|
||||
expect(showStatus).toHaveBeenCalledWith("Cloned to new session");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
|
||||
@@ -151,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() },
|
||||
};
|
||||
|
||||
@@ -158,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);
|
||||
@@ -173,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() },
|
||||
};
|
||||
|
||||
@@ -180,6 +192,7 @@ 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();
|
||||
});
|
||||
@@ -356,6 +369,59 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
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";
|
||||
import { clearDeprecationWarningsForTests } from "../src/utils/deprecation.ts";
|
||||
|
||||
describe("ModelRegistry", () => {
|
||||
let tempDir: string;
|
||||
@@ -25,7 +24,6 @@ describe("ModelRegistry", () => {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
modelsJsonPath = join(tempDir, "models.json");
|
||||
authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
clearDeprecationWarningsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -33,7 +31,6 @@ describe("ModelRegistry", () => {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
clearApiKeyCache();
|
||||
clearDeprecationWarningsForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -443,6 +440,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: {
|
||||
@@ -902,26 +936,87 @@ describe("ModelRegistry", () => {
|
||||
expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider");
|
||||
});
|
||||
|
||||
test("registerProvider warns and temporarily treats uppercase apiKey as an env reference", async () => {
|
||||
const originalEnv = process.env.CUSTOM_NAME;
|
||||
process.env.CUSTOM_NAME = "legacy-env-key";
|
||||
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("legacy-provider", {
|
||||
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("legacy-provider")).toBe("legacy-env-key");
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Pass "$CUSTOM_NAME" instead'));
|
||||
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 {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CUSTOM_NAME;
|
||||
} else {
|
||||
process.env.CUSTOM_NAME = originalEnv;
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1617,6 +1712,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,
|
||||
@@ -403,6 +444,7 @@ describe("resolveCliModel", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +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;
|
||||
@@ -22,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");
|
||||
@@ -37,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) {
|
||||
@@ -202,6 +216,69 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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" }));
|
||||
@@ -223,6 +300,7 @@ describe("package commands", () => {
|
||||
|
||||
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 {
|
||||
@@ -296,7 +374,7 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses global npmCommand and current package name for forced self updates without checking the api", async () => {
|
||||
it("uses the update check version for forced self updates even when current", async () => {
|
||||
const globalPrefix = join(tempDir, "global-prefix");
|
||||
const projectPrefix = join(tempDir, "project-prefix");
|
||||
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
|
||||
@@ -324,22 +402,25 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
|
||||
value: join(selfPackageDir, "dist", "cli.js"),
|
||||
configurable: true,
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
const fetchMock = vi.fn(async () => Response.json({ version: VERSION }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
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();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[];
|
||||
expect(recordedArgs).toContain(globalPrefix);
|
||||
expect(recordedArgs).toContain(PACKAGE_NAME);
|
||||
expect(recordedArgs).toContain(`${PACKAGE_NAME}@${VERSION}`);
|
||||
expect(recordedArgs).not.toContain(PACKAGE_NAME);
|
||||
expect(recordedArgs).not.toContain(projectPrefix);
|
||||
expect(stdout).toContain(`Updated pi from ${VERSION} to ${VERSION}`);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
@@ -368,20 +449,24 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
|
||||
value: join(selfPackageDir, "dist", "cli.js"),
|
||||
configurable: true,
|
||||
});
|
||||
const fetchMock = vi.fn(async () => Response.json({ version: getNewerPatchVersion() }));
|
||||
const targetVersion = getNewerPatchVersion();
|
||||
const fetchMock = vi.fn(async () => Response.json({ version: targetVersion }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
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();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[];
|
||||
expect(recordedArgs).toContain(PACKAGE_NAME);
|
||||
expect(recordedArgs).toContain(`${PACKAGE_NAME}@${targetVersion}`);
|
||||
expect(recordedArgs).not.toContain(PACKAGE_NAME);
|
||||
expect(stdout).toContain(`Updated pi from ${VERSION} to ${targetVersion}`);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
@@ -424,14 +509,14 @@ 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();
|
||||
const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][];
|
||||
expect(recordedCalls).toEqual([
|
||||
expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]),
|
||||
expect.arrayContaining(["install", "-g", activePackageName]),
|
||||
expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]),
|
||||
]);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
@@ -477,7 +562,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");
|
||||
@@ -487,7 +572,7 @@ if(args.includes("install")) process.exit(23);
|
||||
const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][];
|
||||
expect(recordedCalls).toEqual([
|
||||
expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]),
|
||||
expect.arrayContaining(["install", "-g", activePackageName]),
|
||||
expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]),
|
||||
]);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
|
||||
@@ -1128,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");
|
||||
@@ -2052,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"), "--legacy-peer-deps"],
|
||||
["install", "example@^1.0.0", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -2078,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();
|
||||
@@ -2298,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");
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import planModeExtension from "../examples/extensions/plan-mode/index.ts";
|
||||
import type { ExtensionAPI, ExtensionContext } from "../src/core/extensions/index.ts";
|
||||
|
||||
type CommandHandler = (args: string, ctx: ExtensionContext) => Promise<void> | void;
|
||||
type AgentEndHandler = (
|
||||
event: { type: "agent_end"; messages: AgentMessage[] },
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void> | void;
|
||||
|
||||
function createAssistantMessage(text: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(options: { activeTools?: string[]; selectChoice?: string; editorText?: string } = {}) {
|
||||
let activeTools = options.activeTools ?? ["read", "bash", "edit", "write"];
|
||||
const commands = new Map<string, CommandHandler>();
|
||||
let agentEndHandler: AgentEndHandler | undefined;
|
||||
|
||||
const sendMessage = vi.fn<ExtensionAPI["sendMessage"]>();
|
||||
const sendUserMessage = vi.fn<ExtensionAPI["sendUserMessage"]>();
|
||||
const setActiveTools = vi.fn<ExtensionAPI["setActiveTools"]>((toolNames) => {
|
||||
activeTools = [...toolNames];
|
||||
});
|
||||
const appendEntry = vi.fn<ExtensionAPI["appendEntry"]>();
|
||||
|
||||
const api = {
|
||||
registerFlag: vi.fn(),
|
||||
registerCommand(name: string, command: { handler: CommandHandler }) {
|
||||
commands.set(name, command.handler);
|
||||
},
|
||||
registerShortcut: vi.fn(),
|
||||
on(event: string, handler: unknown) {
|
||||
if (event === "agent_end") agentEndHandler = handler as AgentEndHandler;
|
||||
},
|
||||
getFlag: vi.fn(() => false),
|
||||
getActiveTools: vi.fn(() => [...activeTools]),
|
||||
setActiveTools,
|
||||
sendMessage,
|
||||
sendUserMessage,
|
||||
appendEntry,
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
planModeExtension(api);
|
||||
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
ui: {
|
||||
notify: vi.fn(),
|
||||
select: vi.fn(async () => options.selectChoice),
|
||||
editor: vi.fn(async () => options.editorText),
|
||||
setStatus: vi.fn(),
|
||||
setWidget: vi.fn(),
|
||||
theme: {
|
||||
fg: (_name: string, text: string) => text,
|
||||
strikethrough: (text: string) => text,
|
||||
},
|
||||
},
|
||||
sessionManager: { getEntries: () => [] },
|
||||
isIdle: () => false,
|
||||
hasPendingMessages: () => false,
|
||||
} as unknown as ExtensionContext;
|
||||
|
||||
async function runCommand(name: string): Promise<void> {
|
||||
const command = commands.get(name);
|
||||
if (!command) throw new Error(`Missing command: ${name}`);
|
||||
await command("", ctx);
|
||||
}
|
||||
|
||||
async function triggerAgentEnd(text: string): Promise<void> {
|
||||
if (!agentEndHandler) throw new Error("Missing agent_end handler");
|
||||
await agentEndHandler({ type: "agent_end", messages: [createAssistantMessage(text)] }, ctx);
|
||||
}
|
||||
|
||||
return {
|
||||
activeTools: () => activeTools,
|
||||
appendEntry,
|
||||
ctx,
|
||||
runCommand,
|
||||
sendMessage,
|
||||
sendUserMessage,
|
||||
setActiveTools,
|
||||
triggerAgentEnd,
|
||||
};
|
||||
}
|
||||
|
||||
describe("plan-mode example extension", () => {
|
||||
it("preserves custom active tools while toggling plan mode", async () => {
|
||||
const { activeTools, runCommand, setActiveTools } = setup({
|
||||
activeTools: ["read", "bash", "edit", "write", "echo_tool"],
|
||||
});
|
||||
|
||||
await runCommand("plan");
|
||||
|
||||
expect(activeTools()).toEqual(["read", "bash", "echo_tool", "grep", "find", "ls", "questionnaire"]);
|
||||
expect(setActiveTools).toHaveBeenLastCalledWith([
|
||||
"read",
|
||||
"bash",
|
||||
"echo_tool",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"questionnaire",
|
||||
]);
|
||||
|
||||
await runCommand("plan");
|
||||
|
||||
expect(activeTools()).toEqual(["read", "bash", "edit", "write", "echo_tool"]);
|
||||
expect(setActiveTools).toHaveBeenLastCalledWith(["read", "bash", "edit", "write", "echo_tool"]);
|
||||
});
|
||||
|
||||
it("does not prompt when the assistant response contains no plan", async () => {
|
||||
const { ctx, runCommand, sendMessage, triggerAgentEnd } = setup();
|
||||
|
||||
await runCommand("plan");
|
||||
await triggerAgentEnd("This file defines the command-line argument parser.");
|
||||
|
||||
expect(ctx.ui.select).not.toHaveBeenCalled();
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues plan refinement as a follow-up user message", async () => {
|
||||
const { runCommand, sendUserMessage, triggerAgentEnd } = setup({
|
||||
selectChoice: "Refine the plan",
|
||||
editorText: "Add a regression test.",
|
||||
});
|
||||
|
||||
await runCommand("plan");
|
||||
await triggerAgentEnd("Plan:\n1. Inspect the current implementation\n2. Add a regression test");
|
||||
|
||||
expect(sendUserMessage).toHaveBeenCalledWith("Add a regression test.", { deliverAs: "followUp" });
|
||||
});
|
||||
|
||||
it("queues plan execution as a follow-up custom message", async () => {
|
||||
const { activeTools, runCommand, sendMessage, triggerAgentEnd } = setup({
|
||||
activeTools: ["read", "bash", "edit", "write", "echo_tool"],
|
||||
selectChoice: "Execute the plan (track progress)",
|
||||
});
|
||||
|
||||
await runCommand("plan");
|
||||
await triggerAgentEnd("Plan:\n1. Inspect the current implementation\n2. Add a regression test");
|
||||
|
||||
expect(activeTools()).toEqual(["read", "bash", "edit", "write", "echo_tool"]);
|
||||
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "plan-mode-execute" }), {
|
||||
triggerTurn: true,
|
||||
deliverAs: "followUp",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -376,7 +376,7 @@ Content`,
|
||||
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
|
||||
});
|
||||
|
||||
it("should skip trust-gated project resources when project is not trusted", async () => {
|
||||
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");
|
||||
|
||||
@@ -196,6 +196,13 @@ describe("createAgentSession provider attribution headers", () => {
|
||||
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"));
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
-1
@@ -21,6 +21,7 @@ type ShutdownThis = {
|
||||
unregisterSignalHandlers: () => void;
|
||||
runtimeHost: { dispose: () => Promise<void> };
|
||||
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
|
||||
themeController: { disableAutoSync: () => void };
|
||||
stop: () => void;
|
||||
sessionManager: SessionManager;
|
||||
};
|
||||
@@ -81,6 +82,7 @@ function createContext(order: string[], sessionManager = createSessionManager())
|
||||
}),
|
||||
},
|
||||
},
|
||||
themeController: { disableAutoSync: vi.fn() },
|
||||
stop: vi.fn(() => {
|
||||
order.push("stop");
|
||||
}),
|
||||
@@ -116,7 +118,6 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => {
|
||||
|
||||
expect(order).toEqual(["dispose", "drainInput", "stop"]);
|
||||
expect(context.isShuttingDown).toBe(true);
|
||||
expect(context.unregisterSignalHandlers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("interactive quit stops the TUI before emitting session_shutdown", async () => {
|
||||
|
||||
@@ -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,95 @@
|
||||
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionFactory } from "../../../src/index.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
type SessionWithCompactionInternals = {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<boolean>;
|
||||
};
|
||||
|
||||
interface RecordedCompactionEvent {
|
||||
type: "session_before_compact" | "session_compact";
|
||||
reason: "manual" | "threshold" | "overflow";
|
||||
willRetry: boolean;
|
||||
}
|
||||
|
||||
function recordingExtension(recorded: RecordedCompactionEvent[]): ExtensionFactory {
|
||||
return (pi) => {
|
||||
pi.on("session_before_compact", async (event) => {
|
||||
recorded.push({ type: event.type, reason: event.reason, willRetry: event.willRetry });
|
||||
return {
|
||||
compaction: {
|
||||
summary: "summary from extension",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
pi.on("session_compact", async (event) => {
|
||||
recorded.push({ type: event.type, reason: event.reason, willRetry: event.willRetry });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function createCompactionHarness(recorded: RecordedCompactionEvent[]): Promise<Harness> {
|
||||
const harness = await createHarness({
|
||||
settings: { compaction: { keepRecentTokens: 1 } },
|
||||
extensionFactories: [recordingExtension(recorded)],
|
||||
});
|
||||
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
|
||||
await harness.session.prompt("first");
|
||||
await harness.session.prompt("second");
|
||||
return harness;
|
||||
}
|
||||
|
||||
describe("issue #5217 compaction reason on extension events", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports manual reason for compact()", async () => {
|
||||
const recorded: RecordedCompactionEvent[] = [];
|
||||
const harness = await createCompactionHarness(recorded);
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.compact();
|
||||
|
||||
expect(recorded).toEqual([
|
||||
{ type: "session_before_compact", reason: "manual", willRetry: false },
|
||||
{ type: "session_compact", reason: "manual", willRetry: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports threshold reason for auto-compaction", async () => {
|
||||
const recorded: RecordedCompactionEvent[] = [];
|
||||
const harness = await createCompactionHarness(recorded);
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
|
||||
await sessionInternals._runAutoCompaction("threshold", false);
|
||||
|
||||
expect(recorded).toEqual([
|
||||
{ type: "session_before_compact", reason: "threshold", willRetry: false },
|
||||
{ type: "session_compact", reason: "threshold", willRetry: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports overflow reason and willRetry for overflow recovery", async () => {
|
||||
const recorded: RecordedCompactionEvent[] = [];
|
||||
const harness = await createCompactionHarness(recorded);
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
|
||||
await sessionInternals._runAutoCompaction("overflow", true);
|
||||
|
||||
expect(recorded).toEqual([
|
||||
{ type: "session_before_compact", reason: "overflow", willRetry: true },
|
||||
{ type: "session_compact", reason: "overflow", willRetry: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,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,430 @@
|
||||
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentSessionEvent } from "../../../src/core/agent-session.ts";
|
||||
import type { ExtensionUIContext } from "../../../src/core/extensions/index.ts";
|
||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
import { initTheme, type Theme, theme } from "../../../src/modes/interactive/theme/theme.ts";
|
||||
import { createHarness } from "../harness.ts";
|
||||
|
||||
function createUiContext(
|
||||
onNotify: (message: string, type: "info" | "warning" | "error" | undefined) => void,
|
||||
): ExtensionUIContext {
|
||||
return {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: onNotify,
|
||||
onTerminalInput: () => () => {},
|
||||
setStatus: () => {},
|
||||
setWorkingMessage: () => {},
|
||||
setWorkingVisible: () => {},
|
||||
setWorkingIndicator: () => {},
|
||||
setHiddenThinkingLabel: () => {},
|
||||
setWidget: () => {},
|
||||
setFooter: () => {},
|
||||
setHeader: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async <T>() => undefined as T,
|
||||
pasteToEditor: () => {},
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
addAutocompleteProvider: () => {},
|
||||
setEditorComponent: () => {},
|
||||
getEditorComponent: () => undefined,
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
getAllThemes: () => [],
|
||||
getTheme: () => undefined,
|
||||
setTheme: (_theme: string | Theme) => ({ success: false, error: "Theme switching not available in tests" }),
|
||||
getToolsExpanded: () => false,
|
||||
setToolsExpanded: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
type RebindContext = {
|
||||
unsubscribe?: () => void;
|
||||
applyRuntimeSettings: () => void;
|
||||
renderCurrentSessionState: () => void;
|
||||
bindCurrentSessionExtensions: () => Promise<void>;
|
||||
subscribeToAgent: () => void;
|
||||
updateAvailableProviderCount: () => Promise<void>;
|
||||
updateEditorBorderColor: () => void;
|
||||
updateTerminalTitle: () => void;
|
||||
};
|
||||
|
||||
type ReloadCommandContext = {
|
||||
hideThinkingBlock: boolean;
|
||||
session: {
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
reload: (options?: { beforeSessionStart?: () => void | Promise<void> }) => Promise<void>;
|
||||
resourceLoader: { getThemes: () => { themes: [] } };
|
||||
extensionRunner: unknown;
|
||||
modelRegistry: { getError: () => string | undefined };
|
||||
};
|
||||
settingsManager: {
|
||||
getHttpIdleTimeoutMs: () => number;
|
||||
getHideThinkingBlock: () => boolean;
|
||||
getEditorPaddingX: () => number;
|
||||
getAutocompleteMaxVisible: () => number;
|
||||
getShowHardwareCursor: () => boolean;
|
||||
getClearOnShrink: () => boolean;
|
||||
};
|
||||
keybindings: { reload: () => void };
|
||||
customHeader?: unknown;
|
||||
builtInHeader?: unknown;
|
||||
editorContainer: { clear: () => void; addChild: (component: unknown) => void };
|
||||
ui: {
|
||||
setFocus: (component: unknown) => void;
|
||||
requestRender: (force?: boolean) => void;
|
||||
setShowHardwareCursor: (enabled: boolean) => void;
|
||||
setClearOnShrink: (enabled: boolean) => void;
|
||||
};
|
||||
editor: unknown;
|
||||
defaultEditor: { setPaddingX: (padding: number) => void; setAutocompleteMaxVisible: (maxVisible: number) => void };
|
||||
themeController: { applyFromSettings: () => Promise<void> };
|
||||
resetExtensionUI: () => void;
|
||||
rebuildChatFromMessages: () => void;
|
||||
setupAutocompleteProvider: () => void;
|
||||
setupExtensionShortcuts: (runner: unknown) => void;
|
||||
showLoadedResources: (options: unknown) => void;
|
||||
maybeSaveImplicitProjectTrustAfterReload: () => boolean;
|
||||
showStatus: (message: string) => void;
|
||||
showWarning: (message: string) => void;
|
||||
showError: (message: string) => void;
|
||||
};
|
||||
|
||||
type InteractiveModePrototype = {
|
||||
rebindCurrentSession(this: RebindContext, options?: { renderBeforeBind?: boolean }): Promise<void>;
|
||||
handleReloadCommand(this: ReloadCommandContext): Promise<void>;
|
||||
};
|
||||
|
||||
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
|
||||
|
||||
type ReloadCommandContextOverrides = Omit<
|
||||
Partial<ReloadCommandContext>,
|
||||
"session" | "settingsManager" | "keybindings" | "editorContainer" | "ui" | "defaultEditor" | "themeController"
|
||||
> & {
|
||||
session?: Partial<ReloadCommandContext["session"]>;
|
||||
settingsManager?: Partial<ReloadCommandContext["settingsManager"]>;
|
||||
keybindings?: Partial<ReloadCommandContext["keybindings"]>;
|
||||
editorContainer?: Partial<ReloadCommandContext["editorContainer"]>;
|
||||
ui?: Partial<ReloadCommandContext["ui"]>;
|
||||
defaultEditor?: Partial<ReloadCommandContext["defaultEditor"]>;
|
||||
themeController?: Partial<ReloadCommandContext["themeController"]>;
|
||||
};
|
||||
|
||||
function createReloadCommandContext(overrides: ReloadCommandContextOverrides = {}): ReloadCommandContext {
|
||||
const editor = overrides.editor ?? {};
|
||||
return {
|
||||
hideThinkingBlock: overrides.hideThinkingBlock ?? false,
|
||||
session: {
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
reload: async (options) => {
|
||||
await options?.beforeSessionStart?.();
|
||||
},
|
||||
resourceLoader: { getThemes: () => ({ themes: [] }) },
|
||||
extensionRunner: {},
|
||||
modelRegistry: { getError: () => undefined },
|
||||
...overrides.session,
|
||||
},
|
||||
settingsManager: {
|
||||
getHttpIdleTimeoutMs: () => 0,
|
||||
getHideThinkingBlock: () => false,
|
||||
getEditorPaddingX: () => 1,
|
||||
getAutocompleteMaxVisible: () => 10,
|
||||
getShowHardwareCursor: () => false,
|
||||
getClearOnShrink: () => false,
|
||||
...overrides.settingsManager,
|
||||
},
|
||||
keybindings: { reload: () => {}, ...overrides.keybindings },
|
||||
editorContainer: { clear: () => {}, addChild: () => {}, ...overrides.editorContainer },
|
||||
ui: {
|
||||
setFocus: () => {},
|
||||
requestRender: () => {},
|
||||
setShowHardwareCursor: () => {},
|
||||
setClearOnShrink: () => {},
|
||||
...overrides.ui,
|
||||
},
|
||||
editor,
|
||||
defaultEditor: { setPaddingX: () => {}, setAutocompleteMaxVisible: () => {}, ...overrides.defaultEditor },
|
||||
themeController: { applyFromSettings: async () => {}, ...overrides.themeController },
|
||||
customHeader: overrides.customHeader,
|
||||
builtInHeader: overrides.builtInHeader,
|
||||
resetExtensionUI: overrides.resetExtensionUI ?? (() => {}),
|
||||
rebuildChatFromMessages: overrides.rebuildChatFromMessages ?? (() => {}),
|
||||
setupAutocompleteProvider: overrides.setupAutocompleteProvider ?? (() => {}),
|
||||
setupExtensionShortcuts: overrides.setupExtensionShortcuts ?? (() => {}),
|
||||
showLoadedResources: overrides.showLoadedResources ?? (() => {}),
|
||||
maybeSaveImplicitProjectTrustAfterReload: overrides.maybeSaveImplicitProjectTrustAfterReload ?? (() => false),
|
||||
showStatus: overrides.showStatus ?? (() => {}),
|
||||
showWarning: overrides.showWarning ?? (() => {}),
|
||||
showError: overrides.showError ?? (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
type MessageEvent = Extract<AgentSessionEvent, { type: "message_start" | "message_end" }>;
|
||||
|
||||
function getMessageText(event: MessageEvent): string {
|
||||
const message = event.message;
|
||||
if (!("content" in message)) {
|
||||
return "";
|
||||
}
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("regression #5943: session_start transient UI", () => {
|
||||
it("renders replacement session state before session_start handlers can notify", async () => {
|
||||
const events: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
ctx.ui.notify("Hello Error", "error");
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const context: RebindContext = {
|
||||
applyRuntimeSettings: () => events.push("apply"),
|
||||
renderCurrentSessionState: () => events.push("render"),
|
||||
bindCurrentSessionExtensions: async () => {
|
||||
events.push("bind");
|
||||
await harness.session.bindExtensions({
|
||||
uiContext: createUiContext((message) => events.push(`notify:${message}`)),
|
||||
mode: "tui",
|
||||
});
|
||||
},
|
||||
subscribeToAgent: () => events.push("subscribe"),
|
||||
updateAvailableProviderCount: async () => {},
|
||||
updateEditorBorderColor: () => {},
|
||||
updateTerminalTitle: () => {},
|
||||
};
|
||||
|
||||
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
|
||||
|
||||
expect(events).toEqual(["apply", "render", "subscribe", "bind", "notify:Hello Error"]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("subscribes before replacement session_start handlers send messages", async () => {
|
||||
const events: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.sendMessage({
|
||||
customType: "session-start",
|
||||
content: "custom from start",
|
||||
display: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const context: RebindContext = {
|
||||
applyRuntimeSettings: () => {},
|
||||
renderCurrentSessionState: () => events.push("render"),
|
||||
bindCurrentSessionExtensions: async () => {
|
||||
events.push("bind");
|
||||
await harness.session.bindExtensions({
|
||||
uiContext: createUiContext(() => {}),
|
||||
mode: "tui",
|
||||
});
|
||||
},
|
||||
subscribeToAgent: () => {
|
||||
events.push("subscribe");
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type !== "message_start" && event.type !== "message_end") {
|
||||
return;
|
||||
}
|
||||
events.push(`${event.type}:${event.message.role}:${getMessageText(event)}`);
|
||||
});
|
||||
},
|
||||
updateAvailableProviderCount: async () => {},
|
||||
updateEditorBorderColor: () => {},
|
||||
updateTerminalTitle: () => {},
|
||||
};
|
||||
|
||||
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
|
||||
|
||||
expect(events).toEqual([
|
||||
"render",
|
||||
"subscribe",
|
||||
"bind",
|
||||
"message_start:custom:custom from start",
|
||||
"message_end:custom:custom from start",
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("subscribes before replacement session_start handlers send user messages", async () => {
|
||||
const events: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.sendUserMessage("user from start");
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harness.setResponses([fauxAssistantMessage("assistant from start")]);
|
||||
|
||||
try {
|
||||
const context: RebindContext = {
|
||||
applyRuntimeSettings: () => {},
|
||||
renderCurrentSessionState: () => events.push("render"),
|
||||
bindCurrentSessionExtensions: async () => {
|
||||
events.push("bind");
|
||||
await harness.session.bindExtensions({
|
||||
uiContext: createUiContext(() => {}),
|
||||
mode: "tui",
|
||||
});
|
||||
},
|
||||
subscribeToAgent: () => {
|
||||
events.push("subscribe");
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type !== "message_start" && event.type !== "message_end") {
|
||||
return;
|
||||
}
|
||||
events.push(`${event.type}:${event.message.role}:${getMessageText(event)}`);
|
||||
});
|
||||
},
|
||||
updateAvailableProviderCount: async () => {},
|
||||
updateEditorBorderColor: () => {},
|
||||
updateTerminalTitle: () => {},
|
||||
};
|
||||
|
||||
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
|
||||
await harness.session.agent.waitForIdle();
|
||||
|
||||
expect(events.slice(0, 3)).toEqual(["render", "subscribe", "bind"]);
|
||||
expect(events).toContain("message_start:user:user from start");
|
||||
expect(events).toContain("message_end:user:user from start");
|
||||
expect(events).toContain("message_end:assistant:assistant from start");
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the reload render hook before reload session_start handlers can notify", async () => {
|
||||
const events: string[] = [];
|
||||
const beforeSessionStart = vi.fn(() => {
|
||||
events.push("render");
|
||||
});
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", (event, ctx) => {
|
||||
events.push(`start:${event.reason}`);
|
||||
ctx.ui.notify(`notify:${event.reason}`, "error");
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await harness.session.bindExtensions({
|
||||
uiContext: createUiContext((message) => events.push(message)),
|
||||
mode: "tui",
|
||||
});
|
||||
expect(events).toEqual(["start:startup", "notify:startup"]);
|
||||
|
||||
events.length = 0;
|
||||
await harness.session.reload({ beforeSessionStart });
|
||||
|
||||
expect(beforeSessionStart).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual(["render", "start:reload", "notify:reload"]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes hideThinkingBlock before rebuilding chat during reload", async () => {
|
||||
initTheme("dark", false);
|
||||
const events: string[] = [];
|
||||
let context: ReloadCommandContext;
|
||||
context = createReloadCommandContext({
|
||||
settingsManager: { getHideThinkingBlock: () => true },
|
||||
session: {
|
||||
reload: async (options) => {
|
||||
events.push("reload");
|
||||
await options?.beforeSessionStart?.();
|
||||
events.push(`start:${context.hideThinkingBlock}`);
|
||||
},
|
||||
},
|
||||
rebuildChatFromMessages: () => {
|
||||
events.push(`rebuild:${context.hideThinkingBlock}`);
|
||||
},
|
||||
});
|
||||
|
||||
await interactiveModePrototype.handleReloadCommand.call(context);
|
||||
|
||||
expect(context.hideThinkingBlock).toBe(true);
|
||||
expect(events).toEqual(["reload", "rebuild:true", "start:true"]);
|
||||
});
|
||||
|
||||
it("keeps the reload blocker focused until async reload completes", async () => {
|
||||
initTheme("dark", false);
|
||||
const editor = {};
|
||||
let focused: unknown;
|
||||
let chatRestored = false;
|
||||
let markReloadWaiting!: () => void;
|
||||
let finishReload!: () => void;
|
||||
const reloadWaiting = new Promise<void>((resolve) => {
|
||||
markReloadWaiting = resolve;
|
||||
});
|
||||
const reloadFinished = new Promise<void>((resolve) => {
|
||||
finishReload = resolve;
|
||||
});
|
||||
|
||||
const context = createReloadCommandContext({
|
||||
editor,
|
||||
session: {
|
||||
reload: async (options) => {
|
||||
await options?.beforeSessionStart?.();
|
||||
markReloadWaiting();
|
||||
await reloadFinished;
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
setFocus: (component) => {
|
||||
focused = component;
|
||||
},
|
||||
},
|
||||
rebuildChatFromMessages: () => {
|
||||
chatRestored = true;
|
||||
},
|
||||
});
|
||||
|
||||
const reloadPromise = interactiveModePrototype.handleReloadCommand.call(context);
|
||||
await reloadWaiting;
|
||||
|
||||
expect(chatRestored).toBe(true);
|
||||
expect(focused).not.toBe(editor);
|
||||
|
||||
finishReload();
|
||||
await reloadPromise;
|
||||
|
||||
expect(focused).toBe(editor);
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
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 {
|
||||
getProjectTrustPath,
|
||||
hasProjectConfigDir,
|
||||
hasProjectTrustInputs,
|
||||
ProjectTrustStore,
|
||||
} from "../src/core/trust-manager.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../src/core/trust-manager.ts";
|
||||
|
||||
describe("ProjectTrustStore", () => {
|
||||
let tempDir: string;
|
||||
@@ -26,86 +21,47 @@ describe("ProjectTrustStore", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores decisions per cwd", () => {
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
|
||||
expect(store.get(cwd)).toBeNull();
|
||||
expect(store.getEntry(cwd)).toBeNull();
|
||||
store.set(cwd, true);
|
||||
expect(store.get(cwd)).toBe(true);
|
||||
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true });
|
||||
store.set(cwd, false);
|
||||
expect(store.get(cwd)).toBe(false);
|
||||
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false });
|
||||
store.set(cwd, null);
|
||||
expect(store.get(cwd)).toBeNull();
|
||||
expect(store.getEntry(cwd)).toBeNull();
|
||||
});
|
||||
|
||||
it("inherits the closest saved decision from parent directories", () => {
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
const parentDir = join(tempDir, "trusted-parent");
|
||||
const childDir = join(parentDir, "project");
|
||||
const grandchildDir = join(childDir, "nested");
|
||||
mkdirSync(grandchildDir, { recursive: true });
|
||||
|
||||
store.set(parentDir, true);
|
||||
expect(store.get(childDir)).toBe(true);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
|
||||
expect(store.get(grandchildDir)).toBe(true);
|
||||
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
|
||||
|
||||
store.set(childDir, false);
|
||||
expect(store.get(grandchildDir)).toBe(false);
|
||||
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
|
||||
});
|
||||
|
||||
it("can clear a child override to inherit parent trust", () => {
|
||||
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);
|
||||
store.set(childDir, false);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
|
||||
|
||||
store.setMany([
|
||||
{ path: parentDir, decision: true },
|
||||
{ path: childDir, decision: null },
|
||||
]);
|
||||
expect(store.get(childDir)).toBe(true);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
|
||||
store.set(childDir, false);
|
||||
expect(store.get(childDir)).toBe(false);
|
||||
store.set(childDir, null);
|
||||
expect(store.get(childDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails loudly without overwriting malformed trust stores", () => {
|
||||
const trustPath = join(agentDir, "trust.json");
|
||||
writeFileSync(trustPath, "{not json", "utf-8");
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
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);
|
||||
|
||||
expect(() => store.get(cwd)).toThrow(/Failed to read trust store/);
|
||||
expect(() => store.set(cwd, true)).toThrow(/Failed to read trust store/);
|
||||
expect(readFileSync(trustPath, "utf-8")).toBe("{not json");
|
||||
});
|
||||
writeFileSync(join(tempDir, ".pi", "settings.json"), "{}");
|
||||
expect(hasTrustRequiringProjectResources(tempDir)).toBe(true);
|
||||
rmSync(join(tempDir, ".pi", "settings.json"), { force: true });
|
||||
|
||||
it("detects project trust inputs", () => {
|
||||
expect(hasProjectConfigDir(cwd)).toBe(false);
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||
writeFileSync(join(cwd, ".pi", "settings.json"), "{}");
|
||||
expect(hasTrustRequiringProjectResources(cwd)).toBe(true);
|
||||
|
||||
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||
expect(hasProjectConfigDir(cwd)).toBe(true);
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(true);
|
||||
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
|
||||
|
||||
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
rmSync(join(cwd, "AGENTS.md"), { force: true });
|
||||
|
||||
writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions");
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
rmSync(join(cwd, "CLAUDE.md"), { force: true });
|
||||
|
||||
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
|
||||
expect(hasProjectTrustInputs(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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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