feat(coding-agent): merge origin/main into model runtime facade
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+12
@@ -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();
|
||||
|
||||
|
||||
+56
@@ -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")];
|
||||
|
||||
Reference in New Issue
Block a user