feat(coding-agent): merge origin/main into model runtime facade

This commit is contained in:
Mario Zechner
2026-07-15 12:25:36 +02:00
119 changed files with 4275 additions and 631 deletions
@@ -4,6 +4,7 @@ import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboar
type ClipboardRequire = (id: string) => unknown;
const fakeClipboard: ClipboardModule = {
getText: async () => "",
setText: async () => {},
hasImage: () => true,
getImageBinary: async () => [1, 2, 3],
+19 -1
View File
@@ -1,11 +1,12 @@
import { execSync, spawn } from "child_process";
import { platform } from "os";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { copyToClipboard } from "../src/utils/clipboard.ts";
import { copyToClipboard, readClipboardText } from "../src/utils/clipboard.ts";
const mocks = vi.hoisted(() => {
return {
clipboard: {
getText: vi.fn<() => Promise<string>>(),
setText: vi.fn<(text: string) => Promise<void>>(),
},
execSync: vi.fn(),
@@ -59,6 +60,7 @@ beforeEach(() => {
vi.stubEnv("MOSH_CONNECTION", "");
stdoutWrites = [];
nativeResolved = false;
mocks.clipboard.getText.mockReset();
mocks.clipboard.setText.mockReset();
mocks.execSync.mockReset();
mocks.spawn.mockReset();
@@ -66,6 +68,7 @@ beforeEach(() => {
mocks.isWaylandSession.mockReset();
mockedPlatform.mockReturnValue("darwin");
mocks.isWaylandSession.mockReturnValue(false);
mocks.clipboard.getText.mockResolvedValue("");
mocks.clipboard.setText.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
nativeResolved = true;
@@ -86,6 +89,21 @@ afterEach(() => {
vi.unstubAllEnvs();
});
describe("readClipboardText", () => {
test("returns native clipboard text", async () => {
mocks.clipboard.getText.mockResolvedValue("clipboard text");
await expect(readClipboardText()).resolves.toBe("clipboard text");
});
test("returns null for empty or unavailable clipboard text", async () => {
await expect(readClipboardText()).resolves.toBeNull();
mocks.clipboard.getText.mockRejectedValue(new Error("clipboard unavailable"));
await expect(readClipboardText()).resolves.toBeNull();
});
});
describe("copyToClipboard", () => {
test("local native success skips OSC 52 and shell fallbacks", async () => {
await copyToClipboard("hello");
@@ -0,0 +1,59 @@
import { InMemoryModelsStore, type Model } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
function model(id: string): Model<"openai-completions"> {
return {
id,
name: id,
api: "openai-completions",
provider: "extension-oauth",
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
describe("legacy extension OAuth modifyModels", () => {
it("applies the synchronous projection after async credential initialization", async () => {
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory({
"extension-oauth": {
type: "oauth",
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
},
}),
modelsStore: new InMemoryModelsStore(),
modelsPath: null,
allowModelNetwork: false,
});
runtime.registerProvider("extension-oauth", {
baseUrl: "https://example.test/v1",
api: "openai-completions",
models: [model("base")],
oauth: {
name: "Extension OAuth",
login: async () => {
throw new Error("not used");
},
refreshToken: async (credential) => credential,
getApiKey: (credential) => credential.access,
modifyModels: (models, credential) =>
credential.access === "access" ? [...models, model("credential-model")] : models,
},
});
await runtime.refresh({ allowNetwork: false });
expect(runtime.getModel("extension-oauth", "base")).toBeDefined();
expect(runtime.getModel("extension-oauth", "credential-model")).toBeDefined();
await runtime.logout("extension-oauth");
expect(runtime.getModel("extension-oauth", "credential-model")).toBeUndefined();
});
});
@@ -0,0 +1,50 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Model } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { FileModelsStore } from "../src/core/models-store.ts";
const tempDirs: string[] = [];
afterEach(() => {
for (const path of tempDirs.splice(0)) {
if (existsSync(path)) rmSync(path, { recursive: true });
}
});
function model(provider: string, id: string): Model<"openai-completions"> {
return {
id,
name: id,
api: "openai-completions",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
describe("FileModelsStore", () => {
it("persists provider catalogs without replacing unrelated providers", async () => {
const dir = join(tmpdir(), `pi-models-store-${Date.now()}-${Math.random().toString(36).slice(2)}`);
tempDirs.push(dir);
mkdirSync(dir, { recursive: true });
const path = join(dir, "models-store.json");
const store = new FileModelsStore(path);
await store.write("one", [model("one", "m1")]);
await store.write("two", [model("two", "m2")]);
const reloaded = new FileModelsStore(path);
expect((await reloaded.read("one"))?.map((entry) => entry.id)).toEqual(["m1"]);
expect((await reloaded.read("two"))?.map((entry) => entry.id)).toEqual(["m2"]);
await reloaded.delete("one");
expect(await reloaded.read("one")).toBeUndefined();
expect((await reloaded.read("two"))?.map((entry) => entry.id)).toEqual(["m2"]);
});
});
@@ -722,6 +722,19 @@ Content`,
);
});
it("should pass legacy peer deps when uninstalling npm packages", async () => {
mkdirSync(join(agentDir, "npm"), { recursive: true });
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
await packageManager.remove("npm:@scope/pkg");
expect(runCommandSpy).toHaveBeenCalledWith(
"npm",
["uninstall", "@scope/pkg", "--prefix", join(agentDir, "npm"), "--legacy-peer-deps"],
undefined,
);
});
it("should use bun --cwd for npm package installs", async () => {
settingsManager = SettingsManager.inMemory({
npmCommand: ["mise", "exec", "bun@1", "--", "bun"],
+151
View File
@@ -0,0 +1,151 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
import { RADIUS_PROVIDER_ID } from "../src/core/radius.ts";
function radiusOAuthCredential(gatewayBaseUrl: string) {
return {
type: "oauth" as const,
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60 * 60 * 1000,
gatewayConfig: radiusConfig(gatewayBaseUrl),
};
}
function radiusConfig(baseUrl: string) {
return {
baseUrl,
models: [
{
id: "auto",
name: "Radius Auto",
reasoning: false,
input: ["text" as const],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
contextWindow: 128000,
maxTokens: 16384,
},
],
};
}
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-test-radius-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
vi.restoreAllMocks();
if (tempDir && existsSync(tempDir)) rmSync(tempDir, { recursive: true });
});
describe("Radius provider", () => {
it("restores the legacy credential catalog without network access", async () => {
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory({
[RADIUS_PROVIDER_ID]: radiusOAuthCredential("https://radius.example.com/v1"),
}),
modelsStore: new InMemoryModelsStore(),
modelsPath: null,
allowModelNetwork: false,
});
const model = runtime.getModel(RADIUS_PROVIDER_ID, "auto");
expect(model).toMatchObject({ api: "pi-messages", baseUrl: "https://radius.example.com/v1" });
expect(runtime.getProvider(RADIUS_PROVIDER_ID)?.name).toBe("Radius");
expect(runtime.hasConfiguredAuth(RADIUS_PROVIDER_ID)).toBe(true);
});
it("fetches and stores the catalog for configured Radius auth", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(radiusConfig("https://radius.example.com/v1")), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const modelsStore = new InMemoryModelsStore();
const credentials = AuthStorage.inMemory({
[RADIUS_PROVIDER_ID]: {
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60 * 60 * 1000,
},
});
const runtime = await ModelRuntime.create({
credentials,
modelsStore,
modelsPath: null,
allowModelNetwork: true,
});
expect(runtime.getModel(RADIUS_PROVIDER_ID, "auto")).toBeDefined();
expect(await modelsStore.read(RADIUS_PROVIDER_ID)).toHaveLength(1);
expect(vi.mocked(fetch).mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer access-token" });
});
it("does not fetch or expose Radius models without configured auth", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory(),
modelsStore: new InMemoryModelsStore(),
modelsPath: null,
allowModelNetwork: true,
});
expect(runtime.getModels(RADIUS_PROVIDER_ID)).toEqual([]);
expect(fetchSpy.mock.calls.some(([url]) => String(url).includes("radius.pi.dev/v1/config"))).toBe(false);
});
it("supports custom Radius gateways from models.json", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(radiusConfig("http://localhost:8788/v1")), { status: 200 }),
);
const modelsPath = join(tempDir, "models.json");
writeFileSync(
modelsPath,
JSON.stringify({
providers: { "radius-dev": { name: "Radius (dev)", baseUrl: "http://localhost:8788", oauth: "radius" } },
}),
);
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory({
"radius-dev": {
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60 * 60 * 1000,
},
}),
modelsStore: new InMemoryModelsStore(),
modelsPath,
allowModelNetwork: true,
});
expect(runtime.getModel("radius-dev", "auto")).toMatchObject({
api: "pi-messages",
baseUrl: "http://localhost:8788/v1",
});
expect(runtime.getProvider("radius-dev")?.name).toBe("Radius (dev)");
});
it("requires baseUrl for custom Radius gateways", async () => {
const modelsPath = join(tempDir, "models.json");
writeFileSync(modelsPath, JSON.stringify({ providers: { "radius-dev": { oauth: "radius" } } }));
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory(),
modelsStore: new InMemoryModelsStore(),
modelsPath,
allowModelNetwork: false,
});
expect(runtime.getError()).toContain('"baseUrl" is required when "oauth" is set');
});
});
@@ -84,6 +84,18 @@ describe("LoginDialogComponent OAuth prompts", () => {
expect(output).toContain("Press Enter to continue:");
});
test("preserves setup details when showing a prompt", () => {
const dialog = createDialog();
dialog.showDetails(["AWS credential setup:", "providers.md"]);
dialog.showPrompt("Enter API key:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("AWS credential setup:");
expect(output).toContain("providers.md");
expect(output).toContain("Enter API key:");
});
test("keeps previous manual input stable when a later prompt is active", async () => {
const dialog = createDialog();
@@ -66,6 +66,62 @@ describe("extension active tools next-turn refresh", () => {
}
});
it("records additive active tool changes on the current tool result", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.registerTool({
name: "load_more_tools",
label: "Load More Tools",
description: "Load more tools",
parameters: Type.Object({}),
execute: async () => {
pi.setActiveTools([...pi.getActiveTools(), "after_load"]);
return {
content: [{ type: "text", text: "loaded" }],
details: {},
};
},
});
pi.registerTool({
name: "after_load",
label: "After Load",
description: "Tool available after loading",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "after" }],
details: {},
}),
});
},
];
const harness = await createHarness({ extensionFactories });
try {
harness.session.setActiveToolsByName(["load_more_tools"]);
const addedToolNames: string[][] = [];
harness.setResponses([
() => fauxAssistantMessage(fauxToolCall("load_more_tools", {}), { stopReason: "toolUse" }),
(context) => {
addedToolNames.push(
context.messages
.filter((message) => message.role === "toolResult")
.flatMap((message) => message.addedToolNames ?? []),
);
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("start");
expect(harness.session.getActiveToolNames()).toEqual(["load_more_tools", "after_load"]);
expect(addedToolNames).toEqual([["after_load"]]);
} finally {
harness.cleanup();
}
});
it("preserves before_agent_start system prompt overrides when tools change mid-run", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
@@ -0,0 +1,61 @@
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { assistantMsg, userMsg } from "../../utilities.ts";
import { createHarness, type Harness } from "../harness.ts";
describe("issue #6324 branch summary ambient auth", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("summarizes tree branches when request auth has no API key", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
let streamCallCount = 0;
harness.session.agent.streamFn = (model, _context, options) => {
streamCallCount++;
expect(options?.apiKey).toBeUndefined();
const stream = createAssistantMessageEventStream();
stream.push({
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "branch summary text" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
});
return stream;
};
const targetId = harness.sessionManager.appendMessage(userMsg("first branch"));
harness.sessionManager.appendMessage(assistantMsg("first reply"));
harness.sessionManager.appendMessage(userMsg("abandoned branch work"));
harness.sessionManager.appendMessage(assistantMsg("abandoned reply"));
const result = await harness.session.navigateTree(targetId, { summarize: true });
expect(result.cancelled).toBe(false);
expect(streamCallCount).toBe(1);
expect(result.summaryEntry?.type).toBe("branch_summary");
expect(result.summaryEntry?.summary).toContain("branch summary text");
});
});
@@ -264,6 +264,7 @@ describe("TreeSelectorComponent", () => {
const plainLines = selector.render(30).map(stripVTControlCharacters);
const plain = plainLines.join("\n");
expect(plain).toContain("branch");
expect(plain).toContain("copy");
expect(plain).toContain("filters");
expect(plain).toContain("cycle");
expect(plain).toContain("label time");
@@ -272,6 +273,28 @@ describe("TreeSelectorComponent", () => {
});
});
describe("copy", () => {
test("copies the full selected message with ctrl+x", () => {
const message = `${"long message ".repeat(30)}\nsecond line`;
const tree = buildTree([userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", message)]);
const selector = new TreeSelectorComponent(
tree,
"asst-1",
24,
() => {},
() => {},
);
let copied: string | undefined;
selector.onCopy = (text) => {
copied = text;
};
selector.handleInput("\x18");
expect(copied).toBe(message);
});
});
describe("label timestamps", () => {
test("toggles label timestamps for labeled nodes", () => {
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];