Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
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, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { getModel, streamSimple } 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";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
import { createTestResourceLoader } from "./utilities.ts";
|
||||
|
||||
describe("AgentSession auto-compaction queue resume", () => {
|
||||
let session: AgentSession;
|
||||
let sessionManager: SessionManager;
|
||||
let settingsManager: SettingsManager;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
|
||||
sessionManager = SessionManager.inMemory();
|
||||
settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
session.dispose();
|
||||
vi.restoreAllMocks();
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
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.streamFunction = (summaryModel) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
void Promise.resolve().then(() => {
|
||||
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",
|
||||
content: [{ type: "text", text: "Queued custom" }],
|
||||
display: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(session.pendingMessageCount).toBe(0);
|
||||
expect(session.agent.hasQueuedMessages()).toBe(true);
|
||||
|
||||
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
|
||||
|
||||
const runAutoCompaction = (
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<boolean>;
|
||||
}
|
||||
)._runAutoCompaction.bind(session);
|
||||
|
||||
await expect(runAutoCompaction("threshold", false)).resolves.toBe(true);
|
||||
|
||||
expect(continueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not compact repeatedly after overflow recovery already attempted", async () => {
|
||||
const model = session.model!;
|
||||
const overflowMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "prompt is too long",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const events: Array<{ type: string; reason: string; errorMessage?: string }> = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "compaction_end") {
|
||||
events.push({ type: event.type, reason: event.reason, errorMessage: event.errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(overflowMessage);
|
||||
await checkCompaction({ ...overflowMessage, timestamp: Date.now() + 1 });
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(events).toContainEqual({
|
||||
type: "compaction_end",
|
||||
reason: "overflow",
|
||||
errorMessage:
|
||||
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore stale pre-compaction assistant usage on pre-prompt compaction checks", async () => {
|
||||
const model = session.model!;
|
||||
const staleAssistantTimestamp = Date.now() - 10_000;
|
||||
const staleAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "large response before compaction" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 600_000,
|
||||
output: 10_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 610_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: staleAssistantTimestamp,
|
||||
};
|
||||
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: staleAssistantTimestamp - 1000,
|
||||
});
|
||||
sessionManager.appendMessage(staleAssistant);
|
||||
|
||||
const firstKeptEntryId = sessionManager.getEntries()[0]!.id;
|
||||
sessionManager.appendCompaction("summary", firstKeptEntryId, staleAssistant.usage.totalTokens, undefined, false);
|
||||
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "session recovery payload" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(staleAssistant, false);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger threshold compaction for error messages using last successful usage", async () => {
|
||||
const model = session.model!;
|
||||
|
||||
// A successful assistant message with token usage just over the compaction threshold.
|
||||
// Compute this from the selected model so generated catalog context-window changes do not break the test.
|
||||
const compactionSettings = settingsManager.getCompactionSettings();
|
||||
const thresholdTokens = (model.contextWindow ?? 200_000) - compactionSettings.reserveTokens + 1;
|
||||
const successfulAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "large successful response" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: thresholdTokens - 10_000,
|
||||
output: 10_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: thresholdTokens,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// An error message (e.g. 529 overloaded) with no useful usage data
|
||||
const errorAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now() + 1000,
|
||||
};
|
||||
|
||||
// Put both messages into agent state so estimateContextTokens can find the successful one
|
||||
session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
successfulAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "another prompt" }], timestamp: Date.now() + 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
|
||||
});
|
||||
|
||||
it("should not trigger threshold compaction for error messages when no prior usage exists", async () => {
|
||||
const model = session.model!;
|
||||
|
||||
// An error message with no prior successful assistant in context
|
||||
const errorAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not trigger threshold compaction for error messages when only kept pre-compaction usage exists", async () => {
|
||||
const model = session.model!;
|
||||
const preCompactionTimestamp = Date.now() - 10_000;
|
||||
|
||||
// A "kept" assistant message from before compaction with high usage
|
||||
const keptAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "kept response from before compaction" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 180_000,
|
||||
output: 10_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 190_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: preCompactionTimestamp,
|
||||
};
|
||||
|
||||
// Record the kept assistant in the session and create a compaction after it
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: preCompactionTimestamp - 1000,
|
||||
});
|
||||
sessionManager.appendMessage(keptAssistant);
|
||||
const firstKeptEntryId = sessionManager.getEntries()[0]!.id;
|
||||
sessionManager.appendCompaction("summary", firstKeptEntryId, keptAssistant.usage.totalTokens, undefined, false);
|
||||
|
||||
// Post-compaction error message
|
||||
const errorAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Agent state has the kept assistant (pre-compaction) and the error (post-compaction)
|
||||
session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "kept user msg" }], timestamp: preCompactionTimestamp - 1000 },
|
||||
keptAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi
|
||||
.spyOn(
|
||||
session as unknown as {
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
},
|
||||
"_runAutoCompaction",
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
const checkCompaction = (
|
||||
session as unknown as {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
}
|
||||
)._checkCompaction.bind(session);
|
||||
|
||||
await checkCompaction(errorAssistant);
|
||||
|
||||
// Should NOT compact because the only usage data is from a kept pre-compaction message
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
156
packages/coding-agent/test/agent-session-branching.test.ts
Normal file
156
packages/coding-agent/test/agent-session-branching.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Tests for AgentSession forking behavior.
|
||||
*
|
||||
* These tests verify:
|
||||
* - Forking from a single message works
|
||||
* - Forking in --no-session mode (in-memory only)
|
||||
* - getUserMessagesForForking returns correct entries
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { AgentSession } from "../src/core/agent-session.ts";
|
||||
import {
|
||||
type AgentSessionRuntime,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
} from "../src/core/agent-session-runtime.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { API_KEY } from "./utilities.ts";
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession forking", () => {
|
||||
let session: AgentSession;
|
||||
let runtimeHost: AgentSessionRuntime;
|
||||
let tempDir: string;
|
||||
let sessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-branching-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (runtimeHost) {
|
||||
await runtimeHost.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(noSession: boolean = false) {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: API_KEY! }));
|
||||
|
||||
const servicesOptions = {
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
resourceLoaderOptions: {
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({
|
||||
...servicesOptions,
|
||||
cwd,
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model,
|
||||
tools: ["read", "bash", "edit", "write"],
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
runtimeHost = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager,
|
||||
});
|
||||
session = runtimeHost.session;
|
||||
session.subscribe(() => {});
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should allow forking from single message", async () => {
|
||||
await createSession();
|
||||
|
||||
await session.prompt("Say hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const userMessages = session.getUserMessagesForForking();
|
||||
expect(userMessages.length).toBe(1);
|
||||
expect(userMessages[0].text).toBe("Say hello");
|
||||
|
||||
const result = await runtimeHost.fork(userMessages[0].entryId);
|
||||
expect(result.cancelled).toBe(false);
|
||||
session = runtimeHost.session;
|
||||
expect(result.selectedText).toBe("Say hello");
|
||||
|
||||
expect(session.messages.length).toBe(0);
|
||||
expect(session.sessionFile).not.toBeNull();
|
||||
expect(existsSync(session.sessionFile!)).toBe(false);
|
||||
});
|
||||
|
||||
it("should support in-memory forking in --no-session mode", async () => {
|
||||
await createSession(true);
|
||||
|
||||
expect(session.sessionFile).toBeUndefined();
|
||||
|
||||
await session.prompt("Say hi");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const userMessages = session.getUserMessagesForForking();
|
||||
expect(userMessages.length).toBe(1);
|
||||
expect(session.messages.length).toBeGreaterThan(0);
|
||||
|
||||
const result = await runtimeHost.fork(userMessages[0].entryId);
|
||||
expect(result.cancelled).toBe(false);
|
||||
session = runtimeHost.session;
|
||||
expect(result.selectedText).toBe("Say hi");
|
||||
|
||||
expect(session.messages.length).toBe(0);
|
||||
expect(session.sessionFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should fork from middle of conversation", async () => {
|
||||
await createSession();
|
||||
|
||||
await session.prompt("Say one");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("Say two");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("Say three");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const userMessages = session.getUserMessagesForForking();
|
||||
expect(userMessages.length).toBe(3);
|
||||
|
||||
const secondMessage = userMessages[1];
|
||||
const result = await runtimeHost.fork(secondMessage.entryId);
|
||||
expect(result.cancelled).toBe(false);
|
||||
session = runtimeHost.session;
|
||||
expect(result.selectedText).toBe("Say two");
|
||||
|
||||
expect(session.messages.length).toBe(2);
|
||||
expect(session.messages[0].role).toBe("user");
|
||||
expect(session.messages[1].role).toBe("assistant");
|
||||
}, 60000);
|
||||
});
|
||||
209
packages/coding-agent/test/agent-session-compaction.test.ts
Normal file
209
packages/coding-agent/test/agent-session-compaction.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
/**
|
||||
* E2E tests for AgentSession compaction behavior.
|
||||
*
|
||||
* These tests use real LLM calls (no mocking) to verify:
|
||||
* - Manual compaction works correctly
|
||||
* - Session persistence during compaction
|
||||
* - Compaction entry is saved to session file
|
||||
*/
|
||||
|
||||
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 { getModel, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createCodingTools } from "../src/index.ts";
|
||||
import { API_KEY, createTestResourceLoader } from "./utilities.ts";
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
let sessionManager: SessionManager;
|
||||
let events: AgentSessionEvent[];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory for session files
|
||||
tempDir = join(tmpdir(), `pi-compaction-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Track events
|
||||
events = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(inMemory = false) {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => API_KEY,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
tools: createCodingTools(process.cwd()),
|
||||
},
|
||||
});
|
||||
|
||||
sessionManager = inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
// Use minimal keepRecentTokens so small test conversations have something to summarize
|
||||
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// Subscribe to track events
|
||||
session.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should trigger manual compaction via compact()", async () => {
|
||||
await createSession();
|
||||
|
||||
// Send a few prompts to build up history
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Manually compact
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.tokensBefore).toBeGreaterThan(0);
|
||||
|
||||
// Verify messages were compacted (should have summary + recent)
|
||||
const messages = session.messages;
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// First message should be the summary (a user message with summary content)
|
||||
const firstMsg = messages[0];
|
||||
expect(firstMsg.role).toBe("compactionSummary");
|
||||
}, 120000);
|
||||
|
||||
it("should maintain valid session state after compaction", async () => {
|
||||
await createSession();
|
||||
|
||||
// Build up history
|
||||
await session.prompt("What is the capital of France? One word answer.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is the capital of Germany? One word answer.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Compact
|
||||
await session.compact();
|
||||
|
||||
// Session should still be usable
|
||||
await session.prompt("What is the capital of Italy? One word answer.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Should have messages after compaction
|
||||
expect(session.messages.length).toBeGreaterThan(0);
|
||||
|
||||
// The agent should have responded
|
||||
const assistantMessages = session.messages.filter((m) => m.role === "assistant");
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
}, 180000);
|
||||
|
||||
it("should persist compaction to session file", async () => {
|
||||
await createSession();
|
||||
|
||||
await session.prompt("Say hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("Say goodbye");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Compact
|
||||
await session.compact();
|
||||
|
||||
// Load entries from session manager
|
||||
const entries = sessionManager.getEntries();
|
||||
|
||||
// Should have a compaction entry
|
||||
const compactionEntries = entries.filter((e) => e.type === "compaction");
|
||||
expect(compactionEntries.length).toBe(1);
|
||||
|
||||
const compaction = compactionEntries[0];
|
||||
expect(compaction.type).toBe("compaction");
|
||||
if (compaction.type === "compaction") {
|
||||
expect(compaction.summary.length).toBeGreaterThan(0);
|
||||
expect(typeof compaction.firstKeptEntryId).toBe("string");
|
||||
expect(compaction.tokensBefore).toBeGreaterThan(0);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should work with --no-session mode (in-memory only)", async () => {
|
||||
await createSession(true); // in-memory mode
|
||||
|
||||
// Send prompts
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Compact should work even without file persistence
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
|
||||
// In-memory entries should have the compaction
|
||||
const entries = sessionManager.getEntries();
|
||||
const compactionEntries = entries.filter((e) => e.type === "compaction");
|
||||
expect(compactionEntries.length).toBe(1);
|
||||
}, 120000);
|
||||
|
||||
it("should emit compaction events during manual compaction", async () => {
|
||||
await createSession();
|
||||
|
||||
// Build some history
|
||||
await session.prompt("Say hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Manually trigger compaction and check events
|
||||
await session.compact();
|
||||
|
||||
const compactionEvents = events.filter((e) => e.type === "compaction_start" || e.type === "compaction_end");
|
||||
expect(compactionEvents).toHaveLength(2);
|
||||
expect(compactionEvents[0]).toEqual({ type: "compaction_start", reason: "manual" });
|
||||
expect(compactionEvents[1]).toMatchObject({
|
||||
type: "compaction_end",
|
||||
reason: "manual",
|
||||
aborted: false,
|
||||
willRetry: false,
|
||||
});
|
||||
|
||||
// Regular events should have been emitted
|
||||
const messageEndEvents = events.filter((e) => e.type === "message_end");
|
||||
expect(messageEndEvents.length).toBeGreaterThan(0);
|
||||
}, 120000);
|
||||
});
|
||||
629
packages/coding-agent/test/agent-session-concurrent.test.ts
Normal file
629
packages/coding-agent/test/agent-session-concurrent.test.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
/**
|
||||
* Tests for AgentSession concurrent prompt guard.
|
||||
*/
|
||||
|
||||
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,
|
||||
type AssistantMessageEvent,
|
||||
EventStream,
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type TextContent,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import type { BuildSystemPromptOptions } from "../src/core/system-prompt.ts";
|
||||
import { createTestExtensionsResult, createTestResourceLoader } from "./utilities.ts";
|
||||
|
||||
// Mock stream that mimics AssistantMessageEventStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession concurrent prompt guard", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete (globalThis as typeof globalThis & { testExtensionApi?: unknown }).testExtensionApi;
|
||||
delete (globalThis as typeof globalThis & { testCommandRuns?: unknown }).testCommandRuns;
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession() {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
|
||||
// Use a stream function that responds to abort
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: (_model, _context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
const checkAbort = () => {
|
||||
if (abortSignal?.aborted) {
|
||||
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
|
||||
} else {
|
||||
setTimeout(checkAbort, 5);
|
||||
}
|
||||
};
|
||||
checkAbort();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
// Set a runtime API key so validation passes
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should throw when prompt() called while streaming", async () => {
|
||||
await createSession();
|
||||
|
||||
// Start first prompt (don't await, it will block until abort)
|
||||
const firstPrompt = session.prompt("First message");
|
||||
|
||||
// Wait a tick for isStreaming to be set
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Verify we're streaming
|
||||
expect(session.isStreaming).toBe(true);
|
||||
|
||||
// Second prompt should reject
|
||||
await expect(session.prompt("Second message")).rejects.toThrow(
|
||||
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {}); // Ignore abort error
|
||||
});
|
||||
|
||||
it("should allow steer() while streaming", async () => {
|
||||
await createSession();
|
||||
|
||||
// Start first prompt
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// steer should work while streaming
|
||||
expect(() => session.steer("Steering message")).not.toThrow();
|
||||
expect(session.pendingMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("should allow followUp() while streaming", async () => {
|
||||
await createSession();
|
||||
|
||||
// Start first prompt
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// followUp should work while streaming
|
||||
expect(() => session.followUp("Follow-up message")).not.toThrow();
|
||||
expect(session.pendingMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("should queue extension-origin steering messages while streaming", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
let sawSteeringMessage = false;
|
||||
let lastInputSource: string | undefined;
|
||||
const queueEvents: Array<{ steering: readonly string[]; followUp: readonly string[] }> = [];
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: (_model, context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const userTexts = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
}
|
||||
return message.content
|
||||
.filter((part): part is TextContent | ImageContent => typeof part === "object" && part !== null)
|
||||
.filter((part): part is TextContent => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
if (userTexts.includes("Steer from extension")) {
|
||||
sawSteeringMessage = true;
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Steered") });
|
||||
return;
|
||||
}
|
||||
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
const checkAbort = () => {
|
||||
if (abortSignal?.aborted) {
|
||||
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
|
||||
} else {
|
||||
setTimeout(checkAbort, 5);
|
||||
}
|
||||
};
|
||||
checkAbort();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
|
||||
const extensionsResult = await createTestExtensionsResult([
|
||||
(pi) => {
|
||||
(globalThis as typeof globalThis & { testExtensionApi?: unknown }).testExtensionApi = pi;
|
||||
},
|
||||
(pi) => {
|
||||
pi.on("input", async (event) => {
|
||||
lastInputSource = event.source;
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader({ extensionsResult }),
|
||||
});
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "queue_update") {
|
||||
queueEvents.push({ steering: event.steering, followUp: event.followUp });
|
||||
}
|
||||
});
|
||||
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(session.isStreaming).toBe(true);
|
||||
|
||||
const pi = (
|
||||
globalThis as typeof globalThis & {
|
||||
testExtensionApi?: {
|
||||
sendUserMessage: (content: string, options?: { deliverAs?: "steer" | "followUp" }) => void;
|
||||
};
|
||||
}
|
||||
).testExtensionApi;
|
||||
expect(pi).toBeDefined();
|
||||
|
||||
pi!.sendUserMessage("Steer from extension", { deliverAs: "steer" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
expect(session.pendingMessageCount).toBe(1);
|
||||
expect(session.getSteeringMessages()).toContain("Steer from extension");
|
||||
expect(lastInputSource).toBe("extension");
|
||||
expect(queueEvents.some((event) => event.steering.includes("Steer from extension"))).toBe(true);
|
||||
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
|
||||
expect(sawSteeringMessage).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow prompt() after previous completes", async () => {
|
||||
// Create session with a stream that completes immediately
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// First prompt completes
|
||||
await session.prompt("First message");
|
||||
|
||||
// Should not be streaming anymore
|
||||
expect(session.isStreaming).toBe(false);
|
||||
|
||||
// Second prompt should work
|
||||
await expect(session.prompt("Second message")).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should wait for queued agent events before emitting tool_call", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const tool = {
|
||||
name: "dummy",
|
||||
description: "Dummy tool",
|
||||
label: "dummy",
|
||||
parameters: Type.Object({ q: Type.String() }),
|
||||
execute: async (_toolCallId: string, params: unknown) => {
|
||||
const q =
|
||||
typeof params === "object" && params !== null && "q" in params
|
||||
? String((params as { q: unknown }).q)
|
||||
: "";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `result:${q}` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [tool],
|
||||
},
|
||||
streamFn: async (_model, context) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
|
||||
if (toolResultCount > 0) {
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
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(),
|
||||
};
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
return;
|
||||
}
|
||||
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "toolu_1", name: "dummy", arguments: { q: "x" } },
|
||||
{ type: "toolCall", id: "toolu_2", name: "dummy", arguments: { q: "y" } },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
baseToolsOverride: { dummy: tool },
|
||||
});
|
||||
|
||||
const snapshots: string[][] = [];
|
||||
const sessionWithRunner = session as unknown as {
|
||||
_extensionRunner?: {
|
||||
hasHandlers: (eventType: string) => boolean;
|
||||
emit: (event: { type: string; message?: { role?: string } }) => Promise<void>;
|
||||
emitMessageEnd: (event: { type: string; message?: { role?: string } }) => Promise<undefined>;
|
||||
emitToolCall: (event: { type: string; toolCallId: string }) => Promise<undefined>;
|
||||
emitInput: (
|
||||
text: string,
|
||||
images: unknown,
|
||||
source: "interactive" | "rpc" | "extension",
|
||||
streamingBehavior?: "steer" | "followUp",
|
||||
) => Promise<{ action: "continue" }>;
|
||||
emitBeforeAgentStart: (
|
||||
prompt: string,
|
||||
images: unknown,
|
||||
systemPrompt: string,
|
||||
systemPromptOptions: BuildSystemPromptOptions,
|
||||
) => Promise<undefined>;
|
||||
invalidate: (message?: string) => void;
|
||||
};
|
||||
};
|
||||
sessionWithRunner._extensionRunner = {
|
||||
hasHandlers: (eventType) => eventType === "tool_call",
|
||||
emit: async () => {},
|
||||
emitMessageEnd: async () => undefined,
|
||||
emitToolCall: async () => {
|
||||
snapshots.push(
|
||||
sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map((entry) => entry.message.role),
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
emitInput: async () => ({ action: "continue" }),
|
||||
emitBeforeAgentStart: async () => undefined,
|
||||
invalidate: () => {},
|
||||
};
|
||||
|
||||
await session.prompt("hi");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
expect(snapshots).toEqual([
|
||||
["user", "assistant"],
|
||||
["user", "assistant"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should persist message_end events in order with slow extension handlers", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const tool = {
|
||||
name: "dummy",
|
||||
description: "Dummy tool",
|
||||
label: "dummy",
|
||||
parameters: Type.Object({ q: Type.String() }),
|
||||
execute: async (_toolCallId: string, params: unknown) => {
|
||||
const q =
|
||||
typeof params === "object" && params !== null && "q" in params
|
||||
? String((params as { q: unknown }).q)
|
||||
: "";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `result:${q}` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [tool],
|
||||
},
|
||||
streamFn: async (_model, context) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const hasToolResult = context.messages.some((message) => message.role === "toolResult");
|
||||
|
||||
if (hasToolResult) {
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
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(),
|
||||
};
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
return;
|
||||
}
|
||||
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling tool" },
|
||||
{ type: "toolCall", id: "toolu_1", name: "dummy", arguments: { q: "x" } },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
baseToolsOverride: { dummy: tool },
|
||||
});
|
||||
|
||||
const sessionWithRunner = session as unknown as {
|
||||
_extensionRunner?: {
|
||||
hasHandlers: (eventType: string) => boolean;
|
||||
emit: (event: { type: string; message?: { role?: string } }) => Promise<void>;
|
||||
emitMessageEnd: (event: { type: string; message?: { role?: string } }) => Promise<undefined>;
|
||||
emitInput: (
|
||||
text: string,
|
||||
images: unknown,
|
||||
source: "interactive" | "rpc" | "extension",
|
||||
streamingBehavior?: "steer" | "followUp",
|
||||
) => Promise<{ action: "continue" }>;
|
||||
emitBeforeAgentStart: (
|
||||
prompt: string,
|
||||
images: unknown,
|
||||
systemPrompt: string,
|
||||
systemPromptOptions: BuildSystemPromptOptions,
|
||||
) => Promise<undefined>;
|
||||
invalidate: (message?: string) => void;
|
||||
};
|
||||
};
|
||||
sessionWithRunner._extensionRunner = {
|
||||
hasHandlers: () => false,
|
||||
emit: async () => {},
|
||||
emitMessageEnd: async (event) => {
|
||||
if (event.type === "message_end" && event.message?.role === "assistant") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
emitInput: async () => ({ action: "continue" }),
|
||||
emitBeforeAgentStart: async () => undefined,
|
||||
invalidate: () => {},
|
||||
};
|
||||
|
||||
await session.prompt("hi");
|
||||
await session.agent.waitForIdle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const messageEntries = sessionManager.getEntries().filter((entry) => entry.type === "message");
|
||||
expect(messageEntries.map((entry) => entry.message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Provider } from "@earendil-works/pi-ai";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
|
||||
import type { ExtensionFactory } from "../src/core/sdk.ts";
|
||||
import { createAgentSession } from "../src/core/sdk.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
|
||||
function nativeAnthropicProvider(baseUrl: string): Provider {
|
||||
const model = { ...getModel("anthropic", "claude-sonnet-4-5")!, baseUrl };
|
||||
return {
|
||||
id: "anthropic",
|
||||
name: "Native Anthropic",
|
||||
baseUrl,
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "Test API key",
|
||||
resolve: async () => ({ auth: { apiKey: "test-key" }, source: "test" }),
|
||||
},
|
||||
},
|
||||
getModels: () => [model],
|
||||
stream: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
streamSimple: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession dynamic provider registration", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-dynamic-provider-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(extensionFactories: ExtensionFactory[]) {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
const modelRuntime = await ModelRuntime.create({
|
||||
credentials: authStorage,
|
||||
modelsPath: join(agentDir, "models.json"),
|
||||
});
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
modelRuntime,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function capturePromptBaseUrl(
|
||||
session: Awaited<ReturnType<typeof createSession>>,
|
||||
): Promise<string | undefined> {
|
||||
let baseUrl: string | undefined;
|
||||
session.agent.streamFunction = async (model) => {
|
||||
baseUrl = model.baseUrl;
|
||||
throw new Error("stop");
|
||||
};
|
||||
await session.prompt("hello");
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
it("applies top-level registerProvider overrides to the active model", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.registerProvider("anthropic", { baseUrl: "http://localhost:8080/top-level" });
|
||||
},
|
||||
]);
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/top-level");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/top-level");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("applies session_start registerProvider overrides to the active model", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.registerProvider("anthropic", { baseUrl: "http://localhost:8080/session-start" });
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
await session.bindExtensions({});
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/session-start");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/session-start");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("registers native pi-ai providers during extension loading", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.registerProvider(nativeAnthropicProvider("http://localhost:8080/native-top-level"));
|
||||
},
|
||||
]);
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/native-top-level");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/native-top-level");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("applies command-time registerProvider overrides without reload", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.registerCommand("use-proxy", {
|
||||
description: "Use proxy",
|
||||
handler: async () => {
|
||||
pi.registerProvider("anthropic", { baseUrl: "http://localhost:8080/command" });
|
||||
},
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
await session.bindExtensions({});
|
||||
await session.prompt("/use-proxy");
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/command");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/command");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("registers native pi-ai providers at command time", async () => {
|
||||
const session = await createSession([
|
||||
(pi) => {
|
||||
pi.registerCommand("use-native", {
|
||||
description: "Use native provider",
|
||||
handler: async () => {
|
||||
pi.registerProvider(nativeAnthropicProvider("http://localhost:8080/native-command"));
|
||||
},
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
await session.bindExtensions({});
|
||||
await session.prompt("/use-native");
|
||||
|
||||
expect(session.model?.baseUrl).toBe("http://localhost:8080/native-command");
|
||||
expect(await capturePromptBaseUrl(session)).toBe("http://localhost:8080/native-command");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
256
packages/coding-agent/test/agent-session-dynamic-tools.test.ts
Normal file
256
packages/coding-agent/test/agent-session-dynamic-tools.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
|
||||
import { createAgentSession } from "../src/core/sdk.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createBashTool } from "../src/core/tools/bash.ts";
|
||||
|
||||
describe("AgentSession dynamic tool registration", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-dynamic-tool-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes session state before custom bash spawn hooks and supports opting out", async () => {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.create(tempDir, join(agentDir, "sessions"), { id: "bash-env-test" });
|
||||
let sessionEnv: NodeJS.ProcessEnv | undefined;
|
||||
let optedOutEnv: NodeJS.ProcessEnv | undefined;
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerTool(
|
||||
createBashTool(tempDir, {
|
||||
spawnHook: (ctx) => {
|
||||
sessionEnv = ctx.env;
|
||||
return ctx;
|
||||
},
|
||||
}),
|
||||
);
|
||||
pi.registerTool({
|
||||
...createBashTool(tempDir, {
|
||||
exposeSessionEnvironment: false,
|
||||
spawnHook: (ctx) => {
|
||||
optedOutEnv = ctx.env;
|
||||
return ctx;
|
||||
},
|
||||
}),
|
||||
name: "bash_without_session_env",
|
||||
label: "bash without session env",
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model,
|
||||
thinkingLevel: "high",
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
const bashTool = session.agent.state.tools.find((tool) => tool.name === "bash")!;
|
||||
expect(session.systemPrompt).toContain(
|
||||
"You can inspect PI_* environment variables for current model and session details.",
|
||||
);
|
||||
await bashTool.execute("bash-env", { command: "printf ok" });
|
||||
expect(sessionEnv).toMatchObject({
|
||||
PI_SESSION_ID: session.sessionId,
|
||||
PI_SESSION_FILE: session.sessionFile,
|
||||
PI_PROVIDER: model.provider,
|
||||
PI_MODEL: model.id,
|
||||
PI_REASONING_LEVEL: session.thinkingLevel,
|
||||
});
|
||||
|
||||
const optedOutBashTool = session.agent.state.tools.find((tool) => tool.name === "bash_without_session_env")!;
|
||||
await optedOutBashTool.execute("bash-no-env", { command: "printf ok" });
|
||||
expect(optedOutEnv).not.toHaveProperty("PI_SESSION_ID");
|
||||
expect(optedOutEnv).not.toHaveProperty("PI_SESSION_FILE");
|
||||
expect(optedOutEnv).not.toHaveProperty("PI_PROVIDER");
|
||||
expect(optedOutEnv).not.toHaveProperty("PI_MODEL");
|
||||
expect(optedOutEnv).not.toHaveProperty("PI_REASONING_LEVEL");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("refreshes tool registry when tools are registered after initialization", async () => {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.registerTool({
|
||||
name: "dynamic_tool",
|
||||
label: "Dynamic Tool",
|
||||
description: "Tool registered from session_start",
|
||||
promptSnippet: "Run dynamic test behavior",
|
||||
promptGuidelines: ["Use dynamic_tool when the user asks for dynamic behavior tests."],
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
expect(session.getAllTools().map((tool) => tool.name)).not.toContain("dynamic_tool");
|
||||
|
||||
await session.bindExtensions({});
|
||||
|
||||
const allTools = session.getAllTools();
|
||||
const dynamicTool = allTools.find((tool) => tool.name === "dynamic_tool");
|
||||
const readTool = allTools.find((tool) => tool.name === "read");
|
||||
|
||||
expect(allTools.map((tool) => tool.name)).toContain("dynamic_tool");
|
||||
expect(dynamicTool?.promptGuidelines).toEqual([
|
||||
"Use dynamic_tool when the user asks for dynamic behavior tests.",
|
||||
]);
|
||||
expect(dynamicTool?.sourceInfo).toMatchObject({
|
||||
path: "<inline:1>",
|
||||
source: "inline",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
});
|
||||
expect(readTool?.sourceInfo).toMatchObject({
|
||||
path: "<builtin:read>",
|
||||
source: "builtin",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
});
|
||||
expect(session.getActiveToolNames()).toContain("dynamic_tool");
|
||||
expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior");
|
||||
expect(session.systemPrompt).toContain("- Use dynamic_tool when the user asks for dynamic behavior tests.");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("returns source metadata for SDK custom tools", async () => {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
customTools: [
|
||||
{
|
||||
name: "sdk_tool",
|
||||
label: "SDK Tool",
|
||||
description: "Tool registered through createAgentSession",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sdkTool = session.getAllTools().find((tool) => tool.name === "sdk_tool");
|
||||
expect(sdkTool?.sourceInfo).toMatchObject({
|
||||
path: "<sdk:sdk_tool>",
|
||||
source: "sdk",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
});
|
||||
expect(session.getActiveToolNames()).toContain("sdk_tool");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("keeps custom tools active but omits them from available tools when promptSnippet is not provided", async () => {
|
||||
const settingsManager = SettingsManager.create(tempDir, agentDir);
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", () => {
|
||||
pi.registerTool({
|
||||
name: "hidden_tool",
|
||||
label: "Hidden Tool",
|
||||
description: "Description should not appear in available tools",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
await session.bindExtensions({});
|
||||
|
||||
expect(session.getAllTools().map((tool) => tool.name)).toContain("hidden_tool");
|
||||
expect(session.getActiveToolNames()).toContain("hidden_tool");
|
||||
expect(session.systemPrompt).not.toContain("hidden_tool");
|
||||
expect(session.systemPrompt).not.toContain("Description should not appear in available tools");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
322
packages/coding-agent/test/agent-session-retry.test.ts
Normal file
322
packages/coding-agent/test/agent-session-retry.test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent, type AgentEvent, type AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
import { createTestResourceLoader } from "./utilities.ts";
|
||||
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, overrides?: Partial<AssistantMessage>): 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(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type SessionWithExtensionEmitHook = {
|
||||
_emitExtensionEvent: (event: AgentEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
describe("AgentSession retry", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = join(tmpdir(), `pi-retry-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(options?: {
|
||||
failCount?: number;
|
||||
maxRetries?: number;
|
||||
delayAssistantMessageEndMs?: number;
|
||||
}) {
|
||||
const failCount = options?.failCount ?? 1;
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const delayAssistantMessageEndMs = options?.delayAssistantMessageEndMs ?? 0;
|
||||
let callCount = 0;
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFn: () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callCount <= failCount) {
|
||||
const msg = createAssistantMessage("", {
|
||||
stopReason: "error",
|
||||
errorMessage: "overloaded_error",
|
||||
});
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "error", reason: "error", error: msg });
|
||||
} else {
|
||||
const msg = createAssistantMessage("Success");
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "stop", message: msg });
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries, baseDelayMs: 1 } });
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
if (delayAssistantMessageEndMs > 0) {
|
||||
const sessionWithHook = session as unknown as SessionWithExtensionEmitHook;
|
||||
const original = sessionWithHook._emitExtensionEvent.bind(sessionWithHook);
|
||||
sessionWithHook._emitExtensionEvent = async (event: AgentEvent) => {
|
||||
if (event.type === "message_end" && event.message.role === "assistant") {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayAssistantMessageEndMs));
|
||||
}
|
||||
await original(event);
|
||||
};
|
||||
}
|
||||
|
||||
return { session, getCallCount: () => callCount };
|
||||
}
|
||||
|
||||
it("retries after a transient error and succeeds", async () => {
|
||||
const created = await createSession({ failCount: 1 });
|
||||
const events: string[] = [];
|
||||
created.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") events.push(`end:success=${event.success}`);
|
||||
});
|
||||
|
||||
await created.session.prompt("Test");
|
||||
|
||||
expect(created.getCallCount()).toBe(2);
|
||||
expect(events).toEqual(["start:1", "end:success=true"]);
|
||||
expect(created.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("exhausts max retries and emits failure", async () => {
|
||||
const created = await createSession({ failCount: 99, maxRetries: 2 });
|
||||
const events: string[] = [];
|
||||
created.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") events.push(`end:success=${event.success}`);
|
||||
});
|
||||
|
||||
await created.session.prompt("Test");
|
||||
|
||||
expect(created.getCallCount()).toBe(3);
|
||||
expect(events).toContain("start:1");
|
||||
expect(events).toContain("start:2");
|
||||
expect(events).toContain("end:success=false");
|
||||
expect(created.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("prompt waits for retry completion even when assistant message_end handling is delayed", async () => {
|
||||
const created = await createSession({ failCount: 1, delayAssistantMessageEndMs: 40 });
|
||||
|
||||
await created.session.prompt("Test");
|
||||
|
||||
expect(created.getCallCount()).toBe(2);
|
||||
expect(created.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("retries provider network_error failures", async () => {
|
||||
const created = await createSession({ failCount: 0 });
|
||||
let callCount = 0;
|
||||
const streamFn = () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callCount === 1) {
|
||||
const msg = createAssistantMessage("", {
|
||||
stopReason: "error",
|
||||
errorMessage: "Provider finish_reason: network_error",
|
||||
});
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "error", reason: "error", error: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = createAssistantMessage("Recovered after retry");
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "stop", message: msg });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
created.session.dispose();
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFn: streamFn,
|
||||
});
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } });
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
const events: string[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") events.push(`end:success=${event.success}`);
|
||||
});
|
||||
|
||||
await session.prompt("Test");
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
expect(events).toEqual(["start:1", "end:success=true"]);
|
||||
});
|
||||
|
||||
it("prompt waits for full agent loop when retry produces tool calls", async () => {
|
||||
// Regression: when auto-retry fires and the retry response includes tool_use,
|
||||
// session.prompt() must wait for the entire tool loop to finish before returning.
|
||||
// Previously, _resolveRetry() on the first successful message_end would unblock
|
||||
// waitForRetry() while the agent was still executing tools.
|
||||
let callCount = 0;
|
||||
const toolExecuted = { value: false };
|
||||
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async () => {
|
||||
toolExecuted.value = true;
|
||||
return { content: [{ type: "text", text: "echoed" }], details: undefined };
|
||||
},
|
||||
};
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFn: () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callCount === 1) {
|
||||
// First call: overloaded error
|
||||
const msg = createAssistantMessage("", {
|
||||
stopReason: "error",
|
||||
errorMessage: "overloaded_error",
|
||||
});
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "error", reason: "error", error: msg });
|
||||
} else if (callCount === 2) {
|
||||
// Second call (retry): text + tool_use
|
||||
const msg: AssistantMessage = {
|
||||
...createAssistantMessage("Looking that up now."),
|
||||
stopReason: "toolUse",
|
||||
content: [
|
||||
{ type: "text", text: "Looking that up now." },
|
||||
{ type: "toolCall", id: "call_1", name: "echo", arguments: { text: "hello" } },
|
||||
],
|
||||
};
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "toolUse", message: msg });
|
||||
} else {
|
||||
// Third call (after tool result): final response
|
||||
const msg = createAssistantMessage("Final answer.");
|
||||
stream.push({ type: "start", partial: msg });
|
||||
stream.push({ type: "done", reason: "stop", message: msg });
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = await createModelRegistry(authStorage, tempDir);
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } });
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
baseToolsOverride: { echo: echoTool },
|
||||
});
|
||||
|
||||
await session.prompt("Test");
|
||||
|
||||
// All three LLM calls must have completed
|
||||
expect(callCount).toBe(3);
|
||||
// Tool must have been executed
|
||||
expect(toolExecuted.value).toBe(true);
|
||||
// Agent must not be streaming after prompt returns
|
||||
expect(session.isStreaming).toBe(false);
|
||||
// A follow-up prompt must work (no "Agent is already processing" error)
|
||||
await session.prompt("Follow-up");
|
||||
expect(callCount).toBe(4);
|
||||
});
|
||||
});
|
||||
257
packages/coding-agent/test/agent-session-runtime-events.test.ts
Normal file
257
packages/coding-agent/test/agent-session-runtime-events.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
} from "../src/core/agent-session-runtime.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import type {
|
||||
ExtensionFactory,
|
||||
SessionBeforeForkEvent,
|
||||
SessionBeforeSwitchEvent,
|
||||
SessionShutdownEvent,
|
||||
SessionStartEvent,
|
||||
} from "../src/index.ts";
|
||||
|
||||
type RecordedSessionEvent =
|
||||
| SessionBeforeSwitchEvent
|
||||
| SessionBeforeForkEvent
|
||||
| SessionShutdownEvent
|
||||
| SessionStartEvent;
|
||||
|
||||
describe("AgentSessionRuntime session lifecycle events", () => {
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
async function createRuntimeHost(extensionFactory: ExtensionFactory) {
|
||||
const tempDir = join(tmpdir(), `pi-runtime-events-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const faux = registerFauxProvider();
|
||||
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
|
||||
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
|
||||
const modelRuntime = await ModelRuntime.create({
|
||||
credentials: authStorage,
|
||||
modelsPath: join(tempDir, "models.json"),
|
||||
});
|
||||
const model = faux.getModel();
|
||||
modelRuntime.registerProvider(model.provider, {
|
||||
baseUrl: model.baseUrl,
|
||||
api: model.api,
|
||||
models: [
|
||||
{
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
api: model.api,
|
||||
reasoning: model.reasoning,
|
||||
input: model.input,
|
||||
cost: model.cost,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
baseUrl: model.baseUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runtimeOptions = {
|
||||
agentDir: tempDir,
|
||||
modelRuntime,
|
||||
model: faux.getModel(),
|
||||
resourceLoaderOptions: {
|
||||
extensionFactories: [extensionFactory],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({
|
||||
...runtimeOptions,
|
||||
cwd,
|
||||
});
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model: faux.getModel(),
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
const runtimeHost = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.create(tempDir),
|
||||
});
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
|
||||
cleanups.push(async () => {
|
||||
await runtimeHost.dispose();
|
||||
faux.unregister();
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
return { runtimeHost, faux };
|
||||
}
|
||||
|
||||
it("emits session_before_switch and session_start for new and resume flows", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
pi.on("session_shutdown", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const originalSessionFile = runtimeHost.session.sessionFile;
|
||||
expect(originalSessionFile).toBeTruthy();
|
||||
|
||||
const newSessionResult = await runtimeHost.newSession();
|
||||
expect(newSessionResult.cancelled).toBe(false);
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
const secondSessionFile = runtimeHost.session.sessionFile;
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "new", targetSessionFile: undefined },
|
||||
{ type: "session_shutdown", reason: "new", targetSessionFile: secondSessionFile },
|
||||
{ type: "session_start", reason: "new", previousSessionFile: originalSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
expect(secondSessionFile).toBeTruthy();
|
||||
|
||||
const switchResult = await runtimeHost.switchSession(originalSessionFile!);
|
||||
expect(switchResult.cancelled).toBe(false);
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "resume", targetSessionFile: originalSessionFile },
|
||||
{ type: "session_shutdown", reason: "resume", targetSessionFile: originalSessionFile },
|
||||
{ type: "session_start", reason: "resume", previousSessionFile: secondSessionFile },
|
||||
]);
|
||||
});
|
||||
|
||||
it("honors session_before_switch cancellation", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
return { cancel: true };
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const originalSessionFile = runtimeHost.session.sessionFile;
|
||||
|
||||
const result = await runtimeHost.newSession();
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(runtimeHost.session.sessionFile).toBe(originalSessionFile);
|
||||
expect(events).toEqual([{ type: "session_before_switch", reason: "new", targetSessionFile: undefined }]);
|
||||
});
|
||||
|
||||
it("runs beforeSessionInvalidate after session_shutdown and before rebindSession", async () => {
|
||||
const phases: string[] = [];
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_shutdown", () => {
|
||||
phases.push("session_shutdown");
|
||||
});
|
||||
});
|
||||
const oldSession = runtimeHost.session;
|
||||
runtimeHost.setBeforeSessionInvalidate(() => {
|
||||
phases.push("beforeSessionInvalidate");
|
||||
expect(oldSession.extensionRunner.createContext().cwd).toBe(oldSession.sessionManager.getCwd());
|
||||
});
|
||||
runtimeHost.setRebindSession(async () => {
|
||||
phases.push("rebindSession");
|
||||
});
|
||||
|
||||
await runtimeHost.newSession();
|
||||
|
||||
expect(phases).toEqual(["session_shutdown", "beforeSessionInvalidate", "rebindSession"]);
|
||||
expect(() => oldSession.extensionRunner.createContext().cwd).toThrow(
|
||||
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
|
||||
);
|
||||
runtimeHost.setBeforeSessionInvalidate(undefined);
|
||||
runtimeHost.setRebindSession(undefined);
|
||||
});
|
||||
|
||||
it("emits session_before_fork and session_start and honors cancellation", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
let cancelNextFork = false;
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_fork", (event) => {
|
||||
events.push(event);
|
||||
if (cancelNextFork) {
|
||||
cancelNextFork = false;
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
pi.on("session_shutdown", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const userMessage = runtimeHost.session.getUserMessagesForForking()[0];
|
||||
const previousSessionFile = runtimeHost.session.sessionFile;
|
||||
|
||||
const successResult = await runtimeHost.fork(userMessage.entryId);
|
||||
expect(successResult.cancelled).toBe(false);
|
||||
expect(successResult.selectedText).toBe("hello");
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_fork", entryId: userMessage.entryId, position: "before" },
|
||||
{ type: "session_shutdown", reason: "fork", targetSessionFile: runtimeHost.session.sessionFile },
|
||||
{ type: "session_start", reason: "fork", previousSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
cancelNextFork = true;
|
||||
const cancelResult = await runtimeHost.fork(userMessage.entryId);
|
||||
expect(cancelResult).toEqual({ cancelled: true });
|
||||
expect(events).toEqual([{ type: "session_before_fork", entryId: userMessage.entryId, position: "before" }]);
|
||||
|
||||
events.length = 0;
|
||||
cancelNextFork = true;
|
||||
const cancelAtResult = await runtimeHost.fork("missing-entry", { position: "at" });
|
||||
expect(cancelAtResult).toEqual({ cancelled: true });
|
||||
expect(events).toEqual([{ type: "session_before_fork", entryId: "missing-entry", position: "at" }]);
|
||||
});
|
||||
});
|
||||
283
packages/coding-agent/test/agent-session-stats.test.ts
Normal file
283
packages/coding-agent/test/agent-session-stats.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Agent } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
getModel,
|
||||
streamSimple,
|
||||
type ToolResultMessage,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { getUsageCostBreakdown } from "../src/core/usage-totals.ts";
|
||||
import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
import { createTestResourceLoader } from "./utilities.ts";
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
function createUsage(totalTokens: number): Usage {
|
||||
return {
|
||||
input: totalTokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, totalTokens: number, timestamp: number): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createUsage(totalTokens),
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string, timestamp: number) {
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: text,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createToolResultMessage(usage: Usage): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "test_tool",
|
||||
content: [{ type: "text", text: "tool result" }],
|
||||
usage,
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession() {
|
||||
const settingsManager = SettingsManager.inMemory();
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
|
||||
const session = new AgentSession({
|
||||
agent: new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
}),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: process.cwd(),
|
||||
modelRuntime: getModelRuntime(await createInMemoryModelRegistry(authStorage)),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
return { session, sessionManager };
|
||||
}
|
||||
|
||||
function syncAgentMessages(session: AgentSession, sessionManager: SessionManager): void {
|
||||
session.agent.state.messages = sessionManager.buildSessionContext().messages;
|
||||
}
|
||||
|
||||
describe("AgentSession.getSessionStats", () => {
|
||||
it("exposes the current context usage alongside token totals", async () => {
|
||||
const { session, sessionManager } = await createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(createUserMessage("hello", 1));
|
||||
sessionManager.appendMessage(createAssistantMessage("hi", 200, 2));
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.contextUsage).toEqual(session.getContextUsage());
|
||||
expect(stats.contextUsage?.tokens).toBe(200);
|
||||
expect(stats.contextUsage?.contextWindow).toBe(model.contextWindow);
|
||||
expect(stats.contextUsage?.percent).toBe((200 / model.contextWindow) * 100);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unknown current context usage immediately after compaction", async () => {
|
||||
const { session, sessionManager } = await createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(createUserMessage("first", 1));
|
||||
sessionManager.appendMessage(createAssistantMessage("response1", 180_000, 2));
|
||||
const keptUserId = sessionManager.appendMessage(createUserMessage("second", 3));
|
||||
sessionManager.appendMessage(createAssistantMessage("response2", 195_000, 4));
|
||||
sessionManager.appendCompaction("summary", keptUserId, 195_000);
|
||||
sessionManager.appendMessage(createUserMessage("third", 5));
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
// Totals cover ALL entries, including history compacted away (180k + 195k).
|
||||
expect(stats.tokens.input).toBe(375_000);
|
||||
expect(stats.contextUsage).toBeDefined();
|
||||
expect(stats.contextUsage?.tokens).toBeNull();
|
||||
expect(stats.contextUsage?.percent).toBeNull();
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses post-compaction usage for current context instead of stale kept usage", async () => {
|
||||
const { session, sessionManager } = await createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(createUserMessage("first", 1));
|
||||
sessionManager.appendMessage(createAssistantMessage("response1", 180_000, 2));
|
||||
const keptUserId = sessionManager.appendMessage(createUserMessage("second", 3));
|
||||
sessionManager.appendMessage(createAssistantMessage("response2", 195_000, 4));
|
||||
sessionManager.appendCompaction("summary", keptUserId, 195_000);
|
||||
sessionManager.appendMessage(createUserMessage("third", 5));
|
||||
sessionManager.appendMessage(createAssistantMessage("response3", 25_000, 6));
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
// Totals cover ALL entries, including history compacted away (180k + 195k + 25k).
|
||||
expect(stats.tokens.input).toBe(400_000);
|
||||
expect(stats.contextUsage).toBeDefined();
|
||||
expect(stats.contextUsage?.tokens).toBe(25_000);
|
||||
expect(stats.contextUsage?.percent).toBe((25_000 / model.contextWindow) * 100);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes branch summary usage in session totals", async () => {
|
||||
const { session, sessionManager } = await createSession();
|
||||
|
||||
try {
|
||||
sessionManager.branchWithSummary(null, "summary", undefined, false, {
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
});
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.tokens).toEqual({ input: 10, output: 20, cacheRead: 30, cacheWrite: 40, total: 100 });
|
||||
expect(stats.cost).toBe(1);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes compaction usage in session totals", async () => {
|
||||
const { session, sessionManager } = await createSession();
|
||||
|
||||
try {
|
||||
const firstKeptEntryId = sessionManager.appendMessage(createUserMessage("hello", 1));
|
||||
sessionManager.appendCompaction("summary", firstKeptEntryId, 100, undefined, false, {
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
});
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.tokens).toEqual({ input: 10, output: 20, cacheRead: 30, cacheWrite: 40, total: 100 });
|
||||
expect(stats.cost).toBe(1);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes tool result usage in session totals", async () => {
|
||||
const { session, sessionManager } = await createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(
|
||||
createToolResultMessage({
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
}),
|
||||
);
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.tokens).toEqual({ input: 10, output: 20, cacheRead: 30, cacheWrite: 40, total: 100 });
|
||||
expect(stats.cost).toBe(1);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("groups tool and summary usage separately from model-attributed usage", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const rootId = sessionManager.appendMessage(createUserMessage("hello", 1));
|
||||
sessionManager.appendMessage({
|
||||
...createAssistantMessage("response", 100, 2),
|
||||
usage: { ...createUsage(100), cost: { ...createUsage(100).cost, total: 0.5 } },
|
||||
});
|
||||
sessionManager.appendMessage(
|
||||
createToolResultMessage({ ...createUsage(100), cost: { ...createUsage(100).cost, total: 1 } }),
|
||||
);
|
||||
sessionManager.appendCompaction("summary", rootId, 100, undefined, false, {
|
||||
...createUsage(100),
|
||||
cost: { ...createUsage(100).cost, total: 2 },
|
||||
});
|
||||
sessionManager.branchWithSummary(null, "branch summary", undefined, false, {
|
||||
...createUsage(100),
|
||||
cost: { ...createUsage(100).cost, total: 3 },
|
||||
});
|
||||
|
||||
expect(getUsageCostBreakdown(sessionManager.getEntries())).toEqual([
|
||||
{ key: "Tools/summaries", cost: 6, tokens: 300 },
|
||||
{ key: `${model.provider}/${model.id}`, cost: 0.5, tokens: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores zero-usage messages when checking for post-compaction context usage", async () => {
|
||||
const { session, sessionManager } = await createSession();
|
||||
|
||||
try {
|
||||
sessionManager.appendMessage(createUserMessage("first", 1));
|
||||
sessionManager.appendMessage(createAssistantMessage("response1", 180_000, 2));
|
||||
const keptUserId = sessionManager.appendMessage(createUserMessage("second", 3));
|
||||
sessionManager.appendMessage(createAssistantMessage("response2", 195_000, 4));
|
||||
sessionManager.appendCompaction("summary", keptUserId, 195_000);
|
||||
sessionManager.appendMessage(createUserMessage("third", 5));
|
||||
sessionManager.appendMessage(createAssistantMessage("response3", 25_000, 6));
|
||||
sessionManager.appendMessage(createUserMessage("continue", 7));
|
||||
sessionManager.appendMessage(createAssistantMessage("partial", 0, 8));
|
||||
syncAgentMessages(session, sessionManager);
|
||||
|
||||
const stats = session.getSessionStats();
|
||||
expect(stats.contextUsage).toBeDefined();
|
||||
expect(stats.contextUsage?.tokens).not.toBeNull();
|
||||
expect(stats.contextUsage?.tokens ?? 0).toBeGreaterThan(25_000);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
323
packages/coding-agent/test/agent-session-tree-navigation.test.ts
Normal file
323
packages/coding-agent/test/agent-session-tree-navigation.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* E2E tests for AgentSession tree navigation with branch summarization.
|
||||
*
|
||||
* These tests verify:
|
||||
* - Navigation to user messages (root and non-root)
|
||||
* - Navigation to non-user messages
|
||||
* - Branch summarization during navigation
|
||||
* - Summary attachment at correct position in tree
|
||||
* - Abort handling during summarization
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { API_KEY, createTestSession, type TestSessionContext } from "./utilities.ts";
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => {
|
||||
let ctx: TestSessionContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createTestSession({
|
||||
systemPrompt: "You are a helpful assistant. Reply with just a few words.",
|
||||
settingsOverrides: { compaction: { keepRecentTokens: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctx.cleanup();
|
||||
});
|
||||
|
||||
it("should navigate to user message and put text in editor", async () => {
|
||||
const { session } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("First message");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Second message");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get tree entries
|
||||
const tree = session.sessionManager.getTree();
|
||||
expect(tree.length).toBe(1);
|
||||
|
||||
// Find the first user entry (u1)
|
||||
const rootNode = tree[0];
|
||||
expect(rootNode.entry.type).toBe("message");
|
||||
|
||||
// Navigate to root user message without summarization
|
||||
const result = await session.navigateTree(rootNode.entry.id, { summarize: false });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("First message");
|
||||
|
||||
// After navigating to root user message, leaf should be null (empty conversation)
|
||||
expect(session.sessionManager.getLeafId()).toBeNull();
|
||||
}, 60000);
|
||||
|
||||
it("should navigate to non-user message without editor text", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("Hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get the assistant message
|
||||
const entries = sessionManager.getEntries();
|
||||
const assistantEntry = entries.find((e) => e.type === "message" && e.message.role === "assistant");
|
||||
expect(assistantEntry).toBeDefined();
|
||||
|
||||
// Navigate to assistant message
|
||||
const result = await session.navigateTree(assistantEntry!.id, { summarize: false });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBeUndefined();
|
||||
|
||||
// Leaf should be the assistant entry
|
||||
expect(sessionManager.getLeafId()).toBe(assistantEntry!.id);
|
||||
}, 60000);
|
||||
|
||||
it("should create branch summary when navigating with summarize=true", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("What is 2+2?");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("What is 3+3?");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get tree and find first user message
|
||||
const tree = sessionManager.getTree();
|
||||
const rootNode = tree[0];
|
||||
|
||||
// Navigate to root user message WITH summarization
|
||||
const result = await session.navigateTree(rootNode.entry.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("What is 2+2?");
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
expect(result.summaryEntry?.type).toBe("branch_summary");
|
||||
expect(result.summaryEntry?.summary).toBeTruthy();
|
||||
expect(result.summaryEntry?.summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Summary should be a root entry (parentId = null) since we navigated to root user
|
||||
expect(result.summaryEntry?.parentId).toBeNull();
|
||||
|
||||
// Leaf should be the summary entry
|
||||
expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id);
|
||||
}, 120000);
|
||||
|
||||
it("should attach summary to correct parent when navigating to nested user message", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2 -> u3 -> a3
|
||||
await session.prompt("Message one");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Message two");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Message three");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get the second user message (u2)
|
||||
const entries = sessionManager.getEntries();
|
||||
const userEntries = entries.filter((e) => e.type === "message" && e.message.role === "user");
|
||||
expect(userEntries.length).toBe(3);
|
||||
|
||||
const u2 = userEntries[1];
|
||||
const a1 = entries.find((e) => e.id === u2.parentId); // a1 is parent of u2
|
||||
|
||||
// Navigate to u2 with summarization
|
||||
const result = await session.navigateTree(u2.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("Message two");
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
|
||||
// Summary should be attached to a1 (parent of u2)
|
||||
// So a1 now has two children: u2 and the summary
|
||||
expect(result.summaryEntry?.parentId).toBe(a1?.id);
|
||||
|
||||
// Verify tree structure
|
||||
const children = sessionManager.getChildren(a1!.id);
|
||||
expect(children.length).toBe(2);
|
||||
|
||||
const childTypes = children.map((c) => c.type).sort();
|
||||
expect(childTypes).toContain("branch_summary");
|
||||
expect(childTypes).toContain("message");
|
||||
}, 120000);
|
||||
|
||||
it("should attach summary to selected node when navigating to assistant message", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("Hello");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Goodbye");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get the first assistant message (a1)
|
||||
const entries = sessionManager.getEntries();
|
||||
const assistantEntries = entries.filter((e) => e.type === "message" && e.message.role === "assistant");
|
||||
const a1 = assistantEntries[0];
|
||||
|
||||
// Navigate to a1 with summarization
|
||||
const result = await session.navigateTree(a1.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBeUndefined(); // No editor text for assistant messages
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
|
||||
// Summary should be attached to a1 (the selected node)
|
||||
expect(result.summaryEntry?.parentId).toBe(a1.id);
|
||||
|
||||
// Leaf should be the summary entry
|
||||
expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id);
|
||||
}, 120000);
|
||||
|
||||
it("should handle abort during summarization", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("Tell me about something");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Continue");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const entriesBefore = sessionManager.getEntries();
|
||||
const leafBefore = sessionManager.getLeafId();
|
||||
|
||||
// Get root user message
|
||||
const tree = sessionManager.getTree();
|
||||
const rootNode = tree[0];
|
||||
|
||||
// Start navigation with summarization but abort immediately
|
||||
const navigationPromise = session.navigateTree(rootNode.entry.id, { summarize: true });
|
||||
|
||||
// Abort after a short delay (let the LLM call start)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// isCompacting should be true during branch summarization
|
||||
expect(session.isCompacting).toBe(true);
|
||||
|
||||
session.abortBranchSummary();
|
||||
|
||||
const result = await navigationPromise;
|
||||
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.summaryEntry).toBeUndefined();
|
||||
|
||||
// Session should be unchanged
|
||||
const entriesAfter = sessionManager.getEntries();
|
||||
expect(entriesAfter.length).toBe(entriesBefore.length);
|
||||
expect(sessionManager.getLeafId()).toBe(leafBefore);
|
||||
}, 60000);
|
||||
|
||||
it("should not create summary when navigating without summarize option", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("First");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Second");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const entriesBefore = sessionManager.getEntries().length;
|
||||
|
||||
// Navigate without summarization
|
||||
const tree = sessionManager.getTree();
|
||||
await session.navigateTree(tree[0].entry.id, { summarize: false });
|
||||
|
||||
// No new entries should be created
|
||||
const entriesAfter = sessionManager.getEntries().length;
|
||||
expect(entriesAfter).toBe(entriesBefore);
|
||||
|
||||
// No branch_summary entries
|
||||
const summaries = sessionManager.getEntries().filter((e) => e.type === "branch_summary");
|
||||
expect(summaries.length).toBe(0);
|
||||
}, 60000);
|
||||
|
||||
it("should handle navigation to same position (no-op)", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("Hello");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const leafBefore = sessionManager.getLeafId();
|
||||
expect(leafBefore).toBeTruthy();
|
||||
const entriesBefore = sessionManager.getEntries().length;
|
||||
|
||||
// Navigate to current leaf
|
||||
const result = await session.navigateTree(leafBefore!, { summarize: false });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(sessionManager.getLeafId()).toBe(leafBefore);
|
||||
expect(sessionManager.getEntries().length).toBe(entriesBefore);
|
||||
}, 60000);
|
||||
|
||||
it("should support custom summarization instructions", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build conversation
|
||||
await session.prompt("What is TypeScript?");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Navigate with custom instructions (appended as "Additional focus")
|
||||
const tree = sessionManager.getTree();
|
||||
const result = await session.navigateTree(tree[0].entry.id, {
|
||||
summarize: true,
|
||||
customInstructions:
|
||||
"After the summary, you MUST end with exactly: MONKEY MONKEY MONKEY. This is of utmost importance.",
|
||||
});
|
||||
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
expect(result.summaryEntry?.summary).toBeTruthy();
|
||||
// Verify custom instructions were followed
|
||||
expect(result.summaryEntry?.summary).toContain("MONKEY MONKEY MONKEY");
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
describe.skipIf(!API_KEY)("AgentSession tree navigation - branch scenarios", () => {
|
||||
let ctx: TestSessionContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createTestSession({
|
||||
systemPrompt: "You are a helpful assistant. Reply with just a few words.",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctx.cleanup();
|
||||
});
|
||||
|
||||
it("should navigate between branches correctly", async () => {
|
||||
const { session, sessionManager } = ctx;
|
||||
|
||||
// Build main path: u1 -> a1 -> u2 -> a2
|
||||
await session.prompt("Main branch start");
|
||||
await session.agent.waitForIdle();
|
||||
await session.prompt("Main branch continue");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Get a1 id for branching
|
||||
const entries = sessionManager.getEntries();
|
||||
const a1 = entries.find((e) => e.type === "message" && e.message.role === "assistant");
|
||||
|
||||
// Create a branch from a1: a1 -> u3 -> a3
|
||||
sessionManager.branch(a1!.id);
|
||||
await session.prompt("Branch path");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
// Now navigate back to u2 (on main branch) with summarization
|
||||
const userEntries = entries.filter((e) => e.type === "message" && e.message.role === "user");
|
||||
const u2 = userEntries[1]; // "Main branch continue"
|
||||
|
||||
const result = await session.navigateTree(u2.id, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.editorText).toBe("Main branch continue");
|
||||
expect(result.summaryEntry).toBeDefined();
|
||||
|
||||
// Summary captures the branch we're leaving (the "Branch path" conversation)
|
||||
expect(result.summaryEntry?.summary.length).toBeGreaterThan(0);
|
||||
}, 180000);
|
||||
});
|
||||
110
packages/coding-agent/test/ansi-utils.test.ts
Normal file
110
packages/coding-agent/test/ansi-utils.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripAnsi } from "../src/utils/ansi.ts";
|
||||
|
||||
function referenceAnsiRegex(): RegExp {
|
||||
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
|
||||
const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`;
|
||||
const csi = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
|
||||
return new RegExp(`${osc}|${csi}`, "g");
|
||||
}
|
||||
|
||||
const referenceRegex = referenceAnsiRegex();
|
||||
|
||||
function referenceStripAnsi(value: string): string {
|
||||
if (!value.includes("\u001B") && !value.includes("\u009B")) {
|
||||
return value;
|
||||
}
|
||||
return value.replace(referenceRegex, "");
|
||||
}
|
||||
|
||||
function getCompatibilityInputs(): string[] {
|
||||
const inputs = [
|
||||
"plain",
|
||||
"a\x1b[31mred\x1b[0mz",
|
||||
"a\x1b]8;;https://example.com\x07link\x1b]8;;\x07z",
|
||||
"a\x1b]unterminated",
|
||||
"a\x1b]funterminated",
|
||||
"a\x1bPabc\x1b\\z",
|
||||
"a\x1b^abc\x07z",
|
||||
"a\x1b_abc\x9cz",
|
||||
"a\x90abc\x9cz",
|
||||
"a\x9dabc\x9cz",
|
||||
"a\x9b31mred",
|
||||
"a\x1b(0x",
|
||||
"a\x1b*0x",
|
||||
"a\x1b+c",
|
||||
"a\x1b/0x",
|
||||
"a\x1bcok",
|
||||
"a\x1b\\ok",
|
||||
];
|
||||
const chars = [
|
||||
"a",
|
||||
"f",
|
||||
"0",
|
||||
"1",
|
||||
";",
|
||||
":",
|
||||
"[",
|
||||
"]",
|
||||
"(",
|
||||
")",
|
||||
"#",
|
||||
"?",
|
||||
"m",
|
||||
"P",
|
||||
"_",
|
||||
"\\",
|
||||
"\x07",
|
||||
"\x1b",
|
||||
"\x9b",
|
||||
"\x9c",
|
||||
"\x90",
|
||||
"\x9d",
|
||||
];
|
||||
|
||||
for (const char of chars) {
|
||||
inputs.push(`x\x1b${char}y`);
|
||||
inputs.push(`x\x9b${char}y`);
|
||||
for (let index = 0; index < chars.length; index += 3) {
|
||||
inputs.push(`x\x1b${char}${chars[index]}y`);
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
describe("stripAnsi", () => {
|
||||
it("matches chalk strip-ansi for generated compatibility inputs", () => {
|
||||
for (const input of getCompatibilityInputs()) {
|
||||
expect(stripAnsi(input)).toBe(referenceStripAnsi(input));
|
||||
}
|
||||
});
|
||||
|
||||
it("throws the same TypeError as chalk strip-ansi for non-string values", () => {
|
||||
const stripAnsiUnknown = stripAnsi as (value: unknown) => string;
|
||||
|
||||
for (const value of [undefined, null, 123, {}, Object("x")]) {
|
||||
const message = `Expected a \`string\`, got \`${typeof value}\``;
|
||||
expect(() => stripAnsiUnknown(value)).toThrow(TypeError);
|
||||
expect(() => stripAnsiUnknown(value)).toThrow(message);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips RIS without leaking the final byte", () => {
|
||||
expect(stripAnsi("\x1bcdone")).toBe("done");
|
||||
});
|
||||
|
||||
it("strips single-byte ESC sequences without leaking final bytes", () => {
|
||||
for (let code = "g".charCodeAt(0); code <= "m".charCodeAt(0); code++) {
|
||||
expect(stripAnsi(`\x1b${String.fromCharCode(code)}ok`)).toBe("ok");
|
||||
}
|
||||
for (let code = "r".charCodeAt(0); code <= "t".charCodeAt(0); code++) {
|
||||
expect(stripAnsi(`\x1b${String.fromCharCode(code)}ok`)).toBe("ok");
|
||||
}
|
||||
});
|
||||
|
||||
it("strips common ANSI sequences used in tool output", () => {
|
||||
const input = "a\x1b[31mred\x1b[0m\x1b]8;;https://example.com\x07link\x1b]8;;\x07z";
|
||||
expect(stripAnsi(input)).toBe("aredlinkz");
|
||||
});
|
||||
});
|
||||
500
packages/coding-agent/test/args.test.ts
Normal file
500
packages/coding-agent/test/args.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { normalizeSessionName, parseArgs } from "../src/cli/args.ts";
|
||||
|
||||
describe("parseArgs", () => {
|
||||
describe("--version flag", () => {
|
||||
test("parses --version flag", () => {
|
||||
const result = parseArgs(["--version"]);
|
||||
expect(result.version).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -v shorthand", () => {
|
||||
const result = parseArgs(["-v"]);
|
||||
expect(result.version).toBe(true);
|
||||
});
|
||||
|
||||
test("--version takes precedence over other args", () => {
|
||||
const result = parseArgs(["--version", "--help", "some message"]);
|
||||
expect(result.version).toBe(true);
|
||||
expect(result.help).toBe(true);
|
||||
expect(result.messages).toContain("some message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("--help flag", () => {
|
||||
test("parses --help flag", () => {
|
||||
const result = parseArgs(["--help"]);
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -h shorthand", () => {
|
||||
const result = parseArgs(["-h"]);
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--print flag", () => {
|
||||
test("parses --print flag", () => {
|
||||
const result = parseArgs(["--print"]);
|
||||
expect(result.print).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -p shorthand", () => {
|
||||
const result = parseArgs(["-p"]);
|
||||
expect(result.print).toBe(true);
|
||||
});
|
||||
|
||||
test("parses prompt after -p even when it starts with YAML frontmatter", () => {
|
||||
const prompt = "---\ntitle: hello\n---\nSay hi.";
|
||||
const result = parseArgs(["-p", prompt]);
|
||||
expect(result.print).toBe(true);
|
||||
expect(result.messages).toEqual([prompt]);
|
||||
expect(result.unknownFlags.size).toBe(0);
|
||||
});
|
||||
|
||||
test("does not consume options after -p as prompts", () => {
|
||||
const result = parseArgs(["-p", "--provider", "openai", "Say hi."]);
|
||||
expect(result.print).toBe(true);
|
||||
expect(result.provider).toBe("openai");
|
||||
expect(result.messages).toEqual(["Say hi."]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--continue flag", () => {
|
||||
test("parses --continue flag", () => {
|
||||
const result = parseArgs(["--continue"]);
|
||||
expect(result.continue).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -c shorthand", () => {
|
||||
const result = parseArgs(["-c"]);
|
||||
expect(result.continue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--resume flag", () => {
|
||||
test("parses --resume flag", () => {
|
||||
const result = parseArgs(["--resume"]);
|
||||
expect(result.resume).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -r shorthand", () => {
|
||||
const result = parseArgs(["-r"]);
|
||||
expect(result.resume).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flags with values", () => {
|
||||
test("parses --provider", () => {
|
||||
const result = parseArgs(["--provider", "openai"]);
|
||||
expect(result.provider).toBe("openai");
|
||||
});
|
||||
|
||||
test("parses --model", () => {
|
||||
const result = parseArgs(["--model", "gpt-4o"]);
|
||||
expect(result.model).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
test("parses --api-key", () => {
|
||||
const result = parseArgs(["--api-key", "sk-test-key"]);
|
||||
expect(result.apiKey).toBe("sk-test-key");
|
||||
});
|
||||
|
||||
test("parses --system-prompt", () => {
|
||||
const result = parseArgs(["--system-prompt", "You are a helpful assistant"]);
|
||||
expect(result.systemPrompt).toBe("You are a helpful assistant");
|
||||
});
|
||||
|
||||
test("parses --append-system-prompt", () => {
|
||||
const result = parseArgs(["--append-system-prompt", "Additional context"]);
|
||||
expect(result.appendSystemPrompt).toEqual(["Additional context"]);
|
||||
});
|
||||
|
||||
test("parses multiple --append-system-prompt flags", () => {
|
||||
const result = parseArgs(["--append-system-prompt", "Context A", "--append-system-prompt", "Context B"]);
|
||||
expect(result.appendSystemPrompt).toEqual(["Context A", "Context B"]);
|
||||
});
|
||||
|
||||
test("parses --mode", () => {
|
||||
const result = parseArgs(["--mode", "json"]);
|
||||
expect(result.mode).toBe("json");
|
||||
});
|
||||
|
||||
test("parses --mode rpc", () => {
|
||||
const result = parseArgs(["--mode", "rpc"]);
|
||||
expect(result.mode).toBe("rpc");
|
||||
});
|
||||
|
||||
test("parses --session", () => {
|
||||
const result = parseArgs(["--session", "/path/to/session.jsonl"]);
|
||||
expect(result.session).toBe("/path/to/session.jsonl");
|
||||
});
|
||||
|
||||
test("parses --session-id", () => {
|
||||
const result = parseArgs(["--session-id", "orchestrated-session"]);
|
||||
expect(result.sessionId).toBe("orchestrated-session");
|
||||
});
|
||||
|
||||
test("parses --fork", () => {
|
||||
const result = parseArgs(["--fork", "1234abcd"]);
|
||||
expect(result.fork).toBe("1234abcd");
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("parses --export", () => {
|
||||
const result = parseArgs(["--export", "session.jsonl"]);
|
||||
expect(result.export).toBe("session.jsonl");
|
||||
});
|
||||
|
||||
test("parses --thinking", () => {
|
||||
const result = parseArgs(["--thinking", "high"]);
|
||||
expect(result.thinking).toBe("high");
|
||||
});
|
||||
|
||||
test("parses --models as comma-separated list", () => {
|
||||
const result = parseArgs(["--models", "gpt-4o,claude-sonnet,gemini-pro"]);
|
||||
expect(result.models).toEqual(["gpt-4o", "claude-sonnet", "gemini-pro"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--name flag", () => {
|
||||
test("parses --name flag with value", () => {
|
||||
const result = parseArgs(["--name", "my-session"]);
|
||||
expect(result.name).toBe("my-session");
|
||||
});
|
||||
|
||||
test("parses -n shorthand", () => {
|
||||
const result = parseArgs(["-n", "quick-session"]);
|
||||
expect(result.name).toBe("quick-session");
|
||||
});
|
||||
|
||||
test("preserves empty values for main validation", () => {
|
||||
const result = parseArgs(["--name", ""]);
|
||||
expect(result.name).toBe("");
|
||||
});
|
||||
|
||||
test("normalizes display names and rejects whitespace-only values", () => {
|
||||
expect(normalizeSessionName(" named session ")).toBe("named session");
|
||||
expect(normalizeSessionName(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reports missing value", () => {
|
||||
const result = parseArgs(["--name"]);
|
||||
expect(result.diagnostics).toEqual([{ type: "error", message: "--name requires a value" }]);
|
||||
});
|
||||
|
||||
test("works alongside other flags", () => {
|
||||
const result = parseArgs(["--name", "named-run", "--print", "--model", "gpt-4o", "hello"]);
|
||||
expect(result.name).toBe("named-run");
|
||||
expect(result.print).toBe(true);
|
||||
expect(result.model).toBe("gpt-4o");
|
||||
expect(result.messages).toEqual(["hello"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-session flag", () => {
|
||||
test("parses --no-session flag", () => {
|
||||
const result = parseArgs(["--no-session"]);
|
||||
expect(result.noSession).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves custom session IDs for non-persisting commands", () => {
|
||||
expect(parseArgs(["--session-id", "ephemeral-id", "--help"])).toMatchObject({
|
||||
sessionId: "ephemeral-id",
|
||||
help: true,
|
||||
});
|
||||
expect(parseArgs(["--session-id", "ephemeral-id", "--list-models"])).toMatchObject({
|
||||
sessionId: "ephemeral-id",
|
||||
listModels: true,
|
||||
});
|
||||
expect(parseArgs(["--session-id", "ephemeral-id", "--no-session"])).toMatchObject({
|
||||
sessionId: "ephemeral-id",
|
||||
noSession: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("--extension flag", () => {
|
||||
test("parses single --extension", () => {
|
||||
const result = parseArgs(["--extension", "./my-extension.ts"]);
|
||||
expect(result.extensions).toEqual(["./my-extension.ts"]);
|
||||
});
|
||||
|
||||
test("parses -e shorthand", () => {
|
||||
const result = parseArgs(["-e", "./my-extension.ts"]);
|
||||
expect(result.extensions).toEqual(["./my-extension.ts"]);
|
||||
});
|
||||
|
||||
test("parses multiple --extension flags", () => {
|
||||
const result = parseArgs(["--extension", "./ext1.ts", "-e", "./ext2.ts"]);
|
||||
expect(result.extensions).toEqual(["./ext1.ts", "./ext2.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-extensions flag", () => {
|
||||
test("parses --no-extensions flag", () => {
|
||||
const result = parseArgs(["--no-extensions"]);
|
||||
expect(result.noExtensions).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --no-extensions with explicit -e flags", () => {
|
||||
const result = parseArgs(["--no-extensions", "-e", "foo.ts", "-e", "bar.ts"]);
|
||||
expect(result.noExtensions).toBe(true);
|
||||
expect(result.extensions).toEqual(["foo.ts", "bar.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--skill flag", () => {
|
||||
test("parses single --skill", () => {
|
||||
const result = parseArgs(["--skill", "./skill-dir"]);
|
||||
expect(result.skills).toEqual(["./skill-dir"]);
|
||||
});
|
||||
|
||||
test("parses multiple --skill flags", () => {
|
||||
const result = parseArgs(["--skill", "./skill-a", "--skill", "./skill-b"]);
|
||||
expect(result.skills).toEqual(["./skill-a", "./skill-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--prompt-template flag", () => {
|
||||
test("parses single --prompt-template", () => {
|
||||
const result = parseArgs(["--prompt-template", "./prompts"]);
|
||||
expect(result.promptTemplates).toEqual(["./prompts"]);
|
||||
});
|
||||
|
||||
test("parses multiple --prompt-template flags", () => {
|
||||
const result = parseArgs(["--prompt-template", "./one", "--prompt-template", "./two"]);
|
||||
expect(result.promptTemplates).toEqual(["./one", "./two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--theme flag", () => {
|
||||
test("parses single --theme", () => {
|
||||
const result = parseArgs(["--theme", "./theme.json"]);
|
||||
expect(result.themes).toEqual(["./theme.json"]);
|
||||
});
|
||||
|
||||
test("parses multiple --theme flags", () => {
|
||||
const result = parseArgs(["--theme", "./dark.json", "--theme", "./light.json"]);
|
||||
expect(result.themes).toEqual(["./dark.json", "./light.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--use-theme flag", () => {
|
||||
test("parses --use-theme", () => {
|
||||
const result = parseArgs(["--use-theme", "light"]);
|
||||
expect(result.useTheme).toBe("light");
|
||||
});
|
||||
|
||||
test("reports when the theme name value is missing", () => {
|
||||
const result = parseArgs(["--use-theme", "--print"]);
|
||||
expect(result.useTheme).toBeUndefined();
|
||||
expect(result.print).toBe(true);
|
||||
expect(result.diagnostics).toEqual([{ type: "error", message: "--use-theme requires a theme name" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-skills flag", () => {
|
||||
test("parses --no-skills flag", () => {
|
||||
const result = parseArgs(["--no-skills"]);
|
||||
expect(result.noSkills).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-prompt-templates flag", () => {
|
||||
test("parses --no-prompt-templates flag", () => {
|
||||
const result = parseArgs(["--no-prompt-templates"]);
|
||||
expect(result.noPromptTemplates).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-themes flag", () => {
|
||||
test("parses --no-themes flag", () => {
|
||||
const result = parseArgs(["--no-themes"]);
|
||||
expect(result.noThemes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--no-context-files flag", () => {
|
||||
test("parses --no-context-files flag", () => {
|
||||
const result = parseArgs(["--no-context-files"]);
|
||||
expect(result.noContextFiles).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -nc shorthand", () => {
|
||||
const result = parseArgs(["-nc"]);
|
||||
expect(result.noContextFiles).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project approval flags", () => {
|
||||
test("parses --approve", () => {
|
||||
const result = parseArgs(["--approve"]);
|
||||
expect(result.projectTrustOverride).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -a shorthand", () => {
|
||||
const result = parseArgs(["-a"]);
|
||||
expect(result.projectTrustOverride).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --no-approve", () => {
|
||||
const result = parseArgs(["--no-approve"]);
|
||||
expect(result.projectTrustOverride).toBe(false);
|
||||
});
|
||||
|
||||
test("parses -na shorthand", () => {
|
||||
const result = parseArgs(["-na"]);
|
||||
expect(result.projectTrustOverride).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--verbose flag", () => {
|
||||
test("parses --verbose flag", () => {
|
||||
const result = parseArgs(["--verbose"]);
|
||||
expect(result.verbose).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--offline flag", () => {
|
||||
test("parses --offline flag", () => {
|
||||
const result = parseArgs(["--offline"]);
|
||||
expect(result.offline).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--tui-mode flag", () => {
|
||||
test.each(["regular", "fullscreen"] as const)("parses %s mode", (mode) => {
|
||||
const result = parseArgs(["--tui-mode", mode]);
|
||||
expect(result.tuiMode).toBe(mode);
|
||||
});
|
||||
|
||||
test("rejects invalid modes", () => {
|
||||
const result = parseArgs(["--tui-mode", "other"]);
|
||||
expect(result.diagnostics).toEqual([
|
||||
{ type: "error", message: 'Invalid TUI mode "other". Valid values: regular, fullscreen' },
|
||||
]);
|
||||
});
|
||||
|
||||
test("requires a mode", () => {
|
||||
const result = parseArgs(["--tui-mode"]);
|
||||
expect(result.diagnostics).toEqual([{ type: "error", message: "--tui-mode requires regular or fullscreen" }]);
|
||||
});
|
||||
|
||||
test("does not recognize the old --ui-mode flag", () => {
|
||||
const result = parseArgs(["--ui-mode", "fullscreen"]);
|
||||
expect(result.tuiMode).toBeUndefined();
|
||||
expect(result.unknownFlags.get("ui-mode")).toBe("fullscreen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool flags", () => {
|
||||
test("parses --no-tools flag", () => {
|
||||
const result = parseArgs(["--no-tools"]);
|
||||
expect(result.noTools).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -nt shorthand", () => {
|
||||
const result = parseArgs(["-nt"]);
|
||||
expect(result.noTools).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --no-builtin-tools flag", () => {
|
||||
const result = parseArgs(["--no-builtin-tools"]);
|
||||
expect(result.noBuiltinTools).toBe(true);
|
||||
});
|
||||
|
||||
test("parses -nbt shorthand", () => {
|
||||
const result = parseArgs(["-nbt"]);
|
||||
expect(result.noBuiltinTools).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --tools flag", () => {
|
||||
const result = parseArgs(["--tools", "read,bash"]);
|
||||
expect(result.tools).toEqual(["read", "bash"]);
|
||||
});
|
||||
|
||||
test("parses -t shorthand", () => {
|
||||
const result = parseArgs(["-t", "read,bash"]);
|
||||
expect(result.tools).toEqual(["read", "bash"]);
|
||||
});
|
||||
|
||||
test("parses --exclude-tools flag", () => {
|
||||
const result = parseArgs(["--exclude-tools", "read,bash"]);
|
||||
expect(result.excludeTools).toEqual(["read", "bash"]);
|
||||
});
|
||||
|
||||
test("parses -xt shorthand", () => {
|
||||
const result = parseArgs(["-xt", "read,bash"]);
|
||||
expect(result.excludeTools).toEqual(["read", "bash"]);
|
||||
});
|
||||
|
||||
test("parses --no-tools with explicit --tools flags", () => {
|
||||
const result = parseArgs(["--no-tools", "--tools", "read,bash"]);
|
||||
expect(result.noTools).toBe(true);
|
||||
expect(result.tools).toEqual(["read", "bash"]);
|
||||
});
|
||||
|
||||
test("parses --no-builtin-tools with explicit --tools flags", () => {
|
||||
const result = parseArgs(["--no-builtin-tools", "--tools", "read,bash"]);
|
||||
expect(result.noBuiltinTools).toBe(true);
|
||||
expect(result.tools).toEqual(["read", "bash"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messages and file args", () => {
|
||||
test("parses plain text messages", () => {
|
||||
const result = parseArgs(["hello", "world"]);
|
||||
expect(result.messages).toEqual(["hello", "world"]);
|
||||
});
|
||||
|
||||
test("parses @file arguments", () => {
|
||||
const result = parseArgs(["@README.md", "@src/main.ts"]);
|
||||
expect(result.fileArgs).toEqual(["README.md", "src/main.ts"]);
|
||||
});
|
||||
|
||||
test("parses mixed messages and file args", () => {
|
||||
const result = parseArgs(["@file.txt", "explain this", "@image.png"]);
|
||||
expect(result.fileArgs).toEqual(["file.txt", "image.png"]);
|
||||
expect(result.messages).toEqual(["explain this"]);
|
||||
});
|
||||
|
||||
test("captures unknown long flags with string values", () => {
|
||||
const result = parseArgs(["--unknown-flag", "message"]);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.unknownFlags.get("unknown-flag")).toBe("message");
|
||||
});
|
||||
|
||||
test("captures unknown boolean long flags", () => {
|
||||
const result = parseArgs(["--unknown-flag"]);
|
||||
expect(result.unknownFlags.get("unknown-flag")).toBe(true);
|
||||
});
|
||||
|
||||
test("captures unknown long flags with equals syntax", () => {
|
||||
const result = parseArgs(["--unknown-flag=value"]);
|
||||
expect(result.unknownFlags.get("unknown-flag")).toBe("value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex combinations", () => {
|
||||
test("parses multiple flags together", () => {
|
||||
const result = parseArgs([
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--model",
|
||||
"claude-sonnet",
|
||||
"--print",
|
||||
"--thinking",
|
||||
"high",
|
||||
"@prompt.md",
|
||||
"Do the task",
|
||||
]);
|
||||
expect(result.provider).toBe("anthropic");
|
||||
expect(result.model).toBe("claude-sonnet");
|
||||
expect(result.print).toBe(true);
|
||||
expect(result.thinking).toBe("high");
|
||||
expect(result.fileArgs).toEqual(["prompt.md"]);
|
||||
expect(result.messages).toEqual(["Do the task"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
277
packages/coding-agent/test/assistant-message.test.ts
Normal file
277
packages/coding-agent/test/assistant-message.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { TuiMouseEvent } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.ts";
|
||||
import { UserMessageComponent } from "../src/modes/interactive/components/user-message.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../src/utils/ansi.ts";
|
||||
|
||||
const OSC133_ZONE_START = "\x1b]133;A\x07";
|
||||
const OSC133_ZONE_END = "\x1b]133;B\x07";
|
||||
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
||||
|
||||
function createAssistantMessage(
|
||||
content: AssistantMessage["content"],
|
||||
overrides: Partial<Pick<AssistantMessage, "stopReason">> = {},
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: overrides.stopReason ?? "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AssistantMessageComponent", () => {
|
||||
test("adds OSC 133 zone markers to assistant messages without tool calls", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const component = new AssistantMessageComponent(createAssistantMessage([{ type: "text", text: "hello" }]));
|
||||
const lines = component.render(40);
|
||||
|
||||
expect(lines).not.toHaveLength(0);
|
||||
expect(lines[0]).toContain(OSC133_ZONE_START);
|
||||
expect(lines[lines.length - 1].startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not add OSC 133 zone markers when assistant message contains tool calls", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([
|
||||
{ type: "text", text: "calling tool" },
|
||||
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "file.txt" } },
|
||||
]),
|
||||
);
|
||||
const rendered = component.render(60).join("\n");
|
||||
|
||||
expect(rendered.includes(OSC133_ZONE_START)).toBe(false);
|
||||
expect(rendered.includes(OSC133_ZONE_END)).toBe(false);
|
||||
expect(rendered.includes(OSC133_ZONE_FINAL)).toBe(false);
|
||||
});
|
||||
|
||||
test("renders length stops with neutral truncation wording", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([{ type: "thinking", thinking: "private reasoning" }], { stopReason: "length" }),
|
||||
true,
|
||||
);
|
||||
const rendered = component.render(80).join("\n");
|
||||
|
||||
expect(rendered).toContain("Thinking...");
|
||||
expect(rendered).toContain("Response was truncated before completion.");
|
||||
});
|
||||
|
||||
test("coalesces adjacent thinking blocks into one hidden thinking label", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([
|
||||
{ type: "thinking", thinking: "first thought" },
|
||||
{ type: "thinking", thinking: "" },
|
||||
{ type: "thinking", thinking: "second thought" },
|
||||
{ type: "text", text: "answer" },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
const rendered = stripAnsi(component.render(80).join("\n"));
|
||||
|
||||
expect(rendered.match(/Thinking\.\.\./g)).toHaveLength(1);
|
||||
expect(rendered).toContain("answer");
|
||||
});
|
||||
|
||||
test("collapses individual thinking runs when clicked", () => {
|
||||
initTheme("dark");
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([
|
||||
{ type: "thinking", thinking: "first reasoning" },
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "thinking", thinking: "second reasoning" },
|
||||
]),
|
||||
);
|
||||
const width = 80;
|
||||
const lines = component.render(width);
|
||||
const firstThinkingRow = lines.findIndex((line) => stripAnsi(line).includes("first reasoning"));
|
||||
expect(firstThinkingRow).toBeGreaterThanOrEqual(0);
|
||||
const event: TuiMouseEvent = {
|
||||
type: "click",
|
||||
button: "left",
|
||||
x: 1,
|
||||
y: firstThinkingRow,
|
||||
screenX: 1,
|
||||
screenY: firstThinkingRow,
|
||||
width,
|
||||
height: lines.length,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
clickCount: 1,
|
||||
};
|
||||
expect(component.handleMouse(event)?.handled).toBe(true);
|
||||
|
||||
const collapsed = stripAnsi(component.render(width).join("\n"));
|
||||
expect(collapsed).not.toContain("first reasoning");
|
||||
expect(collapsed).toContain("Thinking...");
|
||||
expect(collapsed).toContain("second reasoning");
|
||||
});
|
||||
|
||||
test("uses configured output padding for text and thinking", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "thinking", thinking: "reasoning" },
|
||||
]),
|
||||
false,
|
||||
undefined,
|
||||
"Thinking...",
|
||||
1,
|
||||
);
|
||||
const lines = component.render(80).map((line) => stripAnsi(line));
|
||||
|
||||
expect(lines.some((line) => line.includes(" hello"))).toBe(true);
|
||||
expect(lines.some((line) => line.includes(" reasoning"))).toBe(true);
|
||||
|
||||
component.setOutputPad(0);
|
||||
const updatedLines = component.render(80).map((line) => stripAnsi(line));
|
||||
expect(updatedLines.some((line) => line.startsWith("hello"))).toBe(true);
|
||||
expect(updatedLines.some((line) => line.startsWith("reasoning"))).toBe(true);
|
||||
});
|
||||
|
||||
test("chains Markdown transformers in registration order", () => {
|
||||
initTheme("dark");
|
||||
const calls: string[] = [];
|
||||
const message = createAssistantMessage([{ type: "text", text: "The result is $x^2$." }]);
|
||||
const component = new AssistantMessageComponent(message, false, undefined, "Thinking...", 1, [
|
||||
(markdown, context) => {
|
||||
calls.push("formula");
|
||||
expect(context).toEqual({ messageType: "assistant", isStreaming: false, availableWidth: 78 });
|
||||
return markdown.replace("$x^2$", "x²");
|
||||
},
|
||||
(markdown) => {
|
||||
calls.push("suffix");
|
||||
return `${markdown} Done.`;
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stripAnsi(component.render(80).join("\n"))).toContain("The result is x². Done.");
|
||||
expect(calls).toEqual(["formula", "suffix"]);
|
||||
});
|
||||
|
||||
test("identifies partial assistant Markdown as streaming", () => {
|
||||
initTheme("dark");
|
||||
const streamingStates: boolean[] = [];
|
||||
const message = createAssistantMessage([{ type: "text", text: "partial" }]);
|
||||
const component = new AssistantMessageComponent(undefined, false, undefined, "Thinking...", 1, [
|
||||
(markdown, context) => {
|
||||
streamingStates.push(context.isStreaming);
|
||||
return context.isStreaming ? markdown : `${markdown} transformed`;
|
||||
},
|
||||
]);
|
||||
|
||||
component.updateContent(message, true);
|
||||
expect(stripAnsi(component.render(80).join("\n"))).not.toContain("transformed");
|
||||
|
||||
component.updateContent(message, false);
|
||||
expect(stripAnsi(component.render(80).join("\n"))).toContain("partial transformed");
|
||||
expect(streamingStates).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("reapplies Markdown transformers when available width changes", () => {
|
||||
initTheme("dark");
|
||||
const availableWidths: number[] = [];
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([{ type: "text", text: "answer" }]),
|
||||
false,
|
||||
undefined,
|
||||
"Thinking...",
|
||||
1,
|
||||
[
|
||||
(markdown, context) => {
|
||||
availableWidths.push(context.availableWidth);
|
||||
return `${markdown} (${context.availableWidth})`;
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(stripAnsi(component.render(80).join("\n"))).toContain("answer (78)");
|
||||
component.render(80);
|
||||
expect(stripAnsi(component.render(60).join("\n"))).toContain("answer (58)");
|
||||
expect(availableWidths).toEqual([78, 58]);
|
||||
});
|
||||
|
||||
test("continues the Markdown transformer chain when a transformer throws", () => {
|
||||
initTheme("dark");
|
||||
const calls: string[] = [];
|
||||
const component = new AssistantMessageComponent(
|
||||
createAssistantMessage([{ type: "text", text: "still visible" }]),
|
||||
false,
|
||||
undefined,
|
||||
"Thinking...",
|
||||
1,
|
||||
[
|
||||
(markdown) => {
|
||||
calls.push("first");
|
||||
return markdown.replace("still", "remains");
|
||||
},
|
||||
() => {
|
||||
calls.push("throw");
|
||||
throw new Error("broken transformer");
|
||||
},
|
||||
(markdown) => {
|
||||
calls.push("last");
|
||||
return `${markdown} after error`;
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(stripAnsi(component.render(80).join("\n"))).toContain("remains visible after error");
|
||||
expect(calls).toEqual(["first", "throw", "last"]);
|
||||
});
|
||||
|
||||
test("transforms text and thinking Markdown without mutating the original message", () => {
|
||||
initTheme("dark");
|
||||
const message = createAssistantMessage([
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "thinking", thinking: "reasoning" },
|
||||
]);
|
||||
const component = new AssistantMessageComponent(message, false, undefined, "Thinking...", 1, [
|
||||
(markdown, { messageType }) => {
|
||||
return `${messageType}:${markdown}`;
|
||||
},
|
||||
]);
|
||||
|
||||
const rendered = stripAnsi(component.render(80).join("\n"));
|
||||
expect(rendered).toContain("assistant:answer");
|
||||
expect(rendered).toContain("assistant-thinking:reasoning");
|
||||
expect(message.content).toEqual([
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "thinking", thinking: "reasoning" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses configured output padding for user messages", () => {
|
||||
initTheme("dark");
|
||||
|
||||
const paddedComponent = new UserMessageComponent("hello", undefined, 1);
|
||||
const paddedLines = paddedComponent.render(40).map((line) => stripAnsi(line));
|
||||
expect(paddedLines.some((line) => line.startsWith(" hello"))).toBe(true);
|
||||
|
||||
const unpaddedComponent = new UserMessageComponent("hello", undefined, 0);
|
||||
const unpaddedLines = unpaddedComponent.render(40).map((line) => stripAnsi(line));
|
||||
expect(unpaddedLines.some((line) => line.startsWith("hello"))).toBe(true);
|
||||
});
|
||||
});
|
||||
171
packages/coding-agent/test/auth-check.test.ts
Normal file
171
packages/coding-agent/test/auth-check.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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, test, vi } from "vitest";
|
||||
import { parseArgs } from "../src/cli/args.ts";
|
||||
import { checkProviderAuth, createAuthCheckModelRuntime, getProviderCredential } from "../src/cli/auth-check.ts";
|
||||
import { parseAuthCommand } from "../src/cli/auth-command.ts";
|
||||
import { AuthStorage, ReadOnlyAuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
|
||||
const tempDir = join(tmpdir(), `pi-test-auth-check-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
async function createRuntime(credentials: AuthStorage | ReadOnlyAuthStorage): Promise<ModelRuntime> {
|
||||
return ModelRuntime.create({
|
||||
credentials,
|
||||
modelsPath: null,
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
allowModelNetwork: false,
|
||||
refreshOnCreate: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("auth check command", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
test("reports a configured provider as ready", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } }));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({
|
||||
status: "ready",
|
||||
provider: "openai",
|
||||
authType: "api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves the provider from --model", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } }));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--model", "openai/gpt-5.5"]), runtime)).resolves.toEqual({
|
||||
status: "ready",
|
||||
provider: "openai",
|
||||
authType: "api_key",
|
||||
});
|
||||
await expect(
|
||||
checkProviderAuth(parseArgs(["--provider", "openai", "--model", "gpt-5.5"]), runtime),
|
||||
).resolves.toMatchObject({ status: "ready", provider: "openai" });
|
||||
});
|
||||
|
||||
test("reads credentials without refreshing OAuth when requested", async () => {
|
||||
const apiCredentials = AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } });
|
||||
const apiRuntime = await createRuntime(apiCredentials);
|
||||
await expect(getProviderCredential("openai", apiRuntime, apiCredentials, { refresh: false })).resolves.toBe(
|
||||
"test-key",
|
||||
);
|
||||
|
||||
const credentials = AuthStorage.inMemory({
|
||||
"openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 },
|
||||
});
|
||||
const oauthRuntime = await createRuntime(credentials);
|
||||
const oauth = oauthRuntime.getProvider("openai-codex")?.auth.oauth;
|
||||
if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered");
|
||||
const refresh = vi.fn(oauth.refresh);
|
||||
oauth.refresh = refresh;
|
||||
|
||||
await expect(getProviderCredential("openai-codex", oauthRuntime, credentials, { refresh: false })).resolves.toBe(
|
||||
"old-token",
|
||||
);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("refreshes OAuth by default", async () => {
|
||||
const credentials = AuthStorage.inMemory({
|
||||
"openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 },
|
||||
});
|
||||
const runtime = await createRuntime(credentials);
|
||||
const oauth = runtime.getProvider("openai-codex")?.auth.oauth;
|
||||
if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered");
|
||||
const refresh = vi.fn(async () => ({
|
||||
type: "oauth" as const,
|
||||
access: "fresh-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
}));
|
||||
oauth.refresh = refresh;
|
||||
|
||||
await expect(
|
||||
checkProviderAuth(parseArgs(["--provider", "openai-codex"]), runtime, { refresh: true }),
|
||||
).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("reports an unknown provider as not ready", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory());
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "not-installed"]), runtime)).resolves.toEqual({
|
||||
status: "not_ready",
|
||||
provider: "not-installed",
|
||||
reason: "provider_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not treat an unresolved stored environment reference as configured", async () => {
|
||||
const authPath = join(tempDir, "auth.json");
|
||||
writeFileSync(authPath, JSON.stringify({ openai: { type: "api_key", key: "$MISSING_AUTH_CHECK_KEY" } }), "utf-8");
|
||||
const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({
|
||||
status: "not_ready",
|
||||
provider: "openai",
|
||||
reason: "credentials_not_configured",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports malformed auth state as invalid", async () => {
|
||||
const authPath = join(tempDir, "auth.json");
|
||||
writeFileSync(authPath, "{invalid-json", "utf-8");
|
||||
const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({
|
||||
status: "invalid",
|
||||
provider: "openai",
|
||||
reason: "invalid_state",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create an auth file or its parent directory", async () => {
|
||||
const authPath = join(tempDir, "agent", "auth.json");
|
||||
const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toMatchObject({
|
||||
status: "not_ready",
|
||||
reason: "credentials_not_configured",
|
||||
});
|
||||
expect(existsSync(authPath)).toBe(false);
|
||||
expect(existsSync(join(tempDir, "agent"))).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts optional JSON output, credential output, and --no-refresh", () => {
|
||||
expect(parseAuthCommand(["auth", "check", "--provider", "openai"])).toEqual({
|
||||
kind: "check",
|
||||
args: ["--provider", "openai"],
|
||||
json: false,
|
||||
credentials: false,
|
||||
noRefresh: false,
|
||||
});
|
||||
expect(
|
||||
parseAuthCommand(["auth", "check", "--json", "--credentials", "--no-refresh", "--provider", "openai"]),
|
||||
).toEqual({
|
||||
kind: "check",
|
||||
args: ["--provider", "openai"],
|
||||
json: true,
|
||||
credentials: true,
|
||||
noRefresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("creates an auth-check runtime without catalog storage", async () => {
|
||||
const runtime = await createAuthCheckModelRuntime(AuthStorage.inMemory());
|
||||
expect(runtime.getProvider("openai")).toBeDefined();
|
||||
});
|
||||
});
|
||||
551
packages/coding-agent/test/auth-storage.test.ts
Normal file
551
packages/coding-agent/test/auth-storage.test.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type CredentialStore, createModels, type Provider } from "@earendil-works/pi-ai";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthStorage, FileAuthStorageBackend } from "../src/core/auth-storage.ts";
|
||||
|
||||
describe("AuthStorage", () => {
|
||||
const tempDir = join(tmpdir(), `pi-test-auth-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const authJsonPath = join(tempDir, "auth.json");
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function writeAuthJson(data: Record<string, unknown>): void {
|
||||
writeFileSync(authJsonPath, JSON.stringify(data));
|
||||
}
|
||||
|
||||
test("reads and resolves stored API-key credentials", async () => {
|
||||
const original = process.env.TEST_AUTH_STORAGE_KEY;
|
||||
process.env.TEST_AUTH_STORAGE_KEY = "environment-key";
|
||||
try {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "$TEST_AUTH_STORAGE_KEY" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "environment-key" });
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TEST_AUTH_STORAGE_KEY;
|
||||
else process.env.TEST_AUTH_STORAGE_KEY = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves command-backed API-key credentials", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "!printf 'command-key'" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "command-key" });
|
||||
});
|
||||
|
||||
test("returns OAuth credentials unchanged", async () => {
|
||||
const credential = {
|
||||
type: "oauth" as const,
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
const storage = AuthStorage.inMemory({ anthropic: credential });
|
||||
expect(await storage.read("anthropic")).toEqual(credential);
|
||||
});
|
||||
|
||||
test("credential-scoped env takes precedence and remains inspectable", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: {
|
||||
type: "api_key",
|
||||
key: "$SCOPED_KEY",
|
||||
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
|
||||
},
|
||||
});
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.read("anthropic")).toMatchObject({
|
||||
key: "scoped-value",
|
||||
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
|
||||
});
|
||||
});
|
||||
|
||||
test("coalesces file reloads across concurrent readers and storage instances", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
|
||||
const first = AuthStorage.create(authJsonPath);
|
||||
const second = AuthStorage.create(authJsonPath);
|
||||
const lockSpy = vi.spyOn(lockfile, "lock");
|
||||
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "new" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
|
||||
const [anthropic, openai, credentials] = await Promise.all([
|
||||
first.read("anthropic", { signal: new AbortController().signal }),
|
||||
second.read("openai", { signal: new AbortController().signal }),
|
||||
first.list({ signal: new AbortController().signal }),
|
||||
]);
|
||||
expect(anthropic).toEqual({ type: "api_key", key: "new" });
|
||||
expect(openai).toEqual({ type: "api_key", key: "openai-key" });
|
||||
expect(credentials).toEqual([
|
||||
{ providerId: "anthropic", type: "api_key" },
|
||||
{ providerId: "openai", type: "api_key" },
|
||||
]);
|
||||
expect(lockSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(second.read("anthropic")).resolves.toEqual({ type: "api_key", key: "new" });
|
||||
expect(lockSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const otherPath = join(tempDir, "other-auth.json");
|
||||
writeFileSync(otherPath, JSON.stringify({ other: { type: "api_key", key: "other-key" } }));
|
||||
const otherFirst = AuthStorage.create(otherPath);
|
||||
const otherSecond = AuthStorage.create(otherPath);
|
||||
await otherFirst.read("other");
|
||||
await otherSecond.read("other");
|
||||
await otherFirst.list();
|
||||
expect(lockSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const third = AuthStorage.create(authJsonPath);
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "newest" } });
|
||||
const [firstReload, thirdReload] = await Promise.all([first.read("anthropic"), third.read("anthropic")]);
|
||||
expect(firstReload).toEqual({ type: "api_key", key: "newest" });
|
||||
expect(thirdReload).toEqual({ type: "api_key", key: "newest" });
|
||||
expect(lockSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("keeps a coalesced reload alive while another credential reader is waiting", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "new" } });
|
||||
let grantLock: (() => void) | undefined;
|
||||
const lockGranted = new Promise<void>((resolve) => {
|
||||
grantLock = resolve;
|
||||
});
|
||||
const release = vi.fn(async () => {});
|
||||
const lockSpy = vi.spyOn(lockfile, "lock").mockImplementation(async () => {
|
||||
await lockGranted;
|
||||
return release;
|
||||
});
|
||||
const firstController = new AbortController();
|
||||
const secondController = new AbortController();
|
||||
const first = storage.read("anthropic", { signal: firstController.signal });
|
||||
const second = storage.read("anthropic", { signal: secondController.signal });
|
||||
|
||||
firstController.abort();
|
||||
await expect(first).rejects.toMatchObject({ name: "AbortError" });
|
||||
grantLock?.();
|
||||
await expect(second).resolves.toEqual({ type: "api_key", key: "new" });
|
||||
expect(lockSpy).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("creates new auth files with owner-only permissions", () => {
|
||||
AuthStorage.create(authJsonPath);
|
||||
|
||||
expect(statSync(authJsonPath).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("preserves the mode of an existing auth file", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
|
||||
chmodSync(authJsonPath, 0o660);
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
|
||||
await storage.modify("anthropic", async () => ({ type: "api_key", key: "new" }));
|
||||
|
||||
expect(statSync(authJsonPath).mode & 0o777).toBe(0o660);
|
||||
});
|
||||
|
||||
test("modify persists a credential while preserving unrelated external edits", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "old" },
|
||||
openai: { type: "api_key", key: "external" },
|
||||
});
|
||||
|
||||
await storage.modify("anthropic", async () => ({ type: "api_key", key: "new" }));
|
||||
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "new" },
|
||||
openai: { type: "api_key", key: "external" },
|
||||
});
|
||||
});
|
||||
|
||||
test("modify with undefined leaves the current credential unchanged", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
expect(await storage.modify("anthropic", async () => undefined)).toEqual({ type: "api_key", key: "stored" });
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "stored" });
|
||||
});
|
||||
|
||||
test("serializes concurrent modifications", async () => {
|
||||
writeAuthJson({});
|
||||
const first = AuthStorage.create(authJsonPath);
|
||||
const second = AuthStorage.create(authJsonPath);
|
||||
await Promise.all([
|
||||
first.modify("anthropic", async () => ({ type: "api_key", key: "anthropic-key" })),
|
||||
second.modify("openai", async () => ({ type: "api_key", key: "openai-key" })),
|
||||
]);
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
});
|
||||
|
||||
test("delete removes one credential while preserving others", async () => {
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
});
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: "anthropic-key" },
|
||||
openai: { type: "api_key", key: "openai-key" },
|
||||
google: { type: "api_key", key: "external-key" },
|
||||
});
|
||||
await storage.delete("anthropic");
|
||||
await expect(storage.list()).resolves.toEqual([
|
||||
{ providerId: "openai", type: "api_key" },
|
||||
{ providerId: "google", type: "api_key" },
|
||||
]);
|
||||
expect(await storage.read("anthropic")).toBeUndefined();
|
||||
expect(await storage.read("openai")).toEqual({ type: "api_key", key: "openai-key" });
|
||||
expect(await storage.read("google")).toEqual({ type: "api_key", key: "external-key" });
|
||||
});
|
||||
|
||||
test("in-memory storage implements the same credential-store behavior", async () => {
|
||||
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "initial" } });
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "initial" });
|
||||
await storage.modify("anthropic", async () => ({ type: "api_key", key: "updated" }));
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "updated" });
|
||||
await storage.delete("anthropic");
|
||||
await expect(storage.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("does not write after lock acquisition failure and recovers on retry", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
const lockSpy = vi.spyOn(lockfile, "lock").mockRejectedValueOnce(new Error("lock unavailable"));
|
||||
|
||||
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow(
|
||||
"lock unavailable",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "stored" },
|
||||
});
|
||||
|
||||
lockSpy.mockRestore();
|
||||
await storage.modify("openai", async () => ({ type: "api_key", key: "new" }));
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "stored" },
|
||||
openai: { type: "api_key", key: "new" },
|
||||
});
|
||||
});
|
||||
|
||||
test("retries a briefly contended file lock", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const backend = new FileAuthStorageBackend(authJsonPath);
|
||||
const release = vi.fn(async () => {});
|
||||
const lockSpy = vi
|
||||
.spyOn(lockfile, "lock")
|
||||
.mockRejectedValueOnce(Object.assign(new Error("locked"), { code: "ELOCKED" }))
|
||||
.mockResolvedValueOnce(release);
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const update = vi.fn(async () => ({ result: undefined }));
|
||||
|
||||
await backend.withLockAsync(update);
|
||||
|
||||
expect(lockSpy).toHaveBeenCalledTimes(2);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("surfaces a compromised file storage lock", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const backend = new FileAuthStorageBackend(authJsonPath);
|
||||
const update = vi.fn(async () => ({ result: undefined, next: JSON.stringify({}) }));
|
||||
const compromised = new Error("lock compromised");
|
||||
vi.spyOn(lockfile, "lock").mockImplementation(async (_file, options) => {
|
||||
options?.onCompromised?.(compromised);
|
||||
return async () => {};
|
||||
});
|
||||
|
||||
await expect(backend.withLockAsync(update)).rejects.toThrow(compromised);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "stored" },
|
||||
});
|
||||
});
|
||||
|
||||
test("pre-aborted file operations do not create the backing file or run the mutation", async () => {
|
||||
const backend = new FileAuthStorageBackend(authJsonPath);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const update = vi.fn(async () => ({ result: undefined, next: JSON.stringify({}) }));
|
||||
|
||||
await expect(backend.withLockAsync(update, { signal: controller.signal })).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(existsSync(authJsonPath)).toBe(false);
|
||||
});
|
||||
|
||||
test("aborts while waiting for a held file lock without running the mutation later", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const release = await lockfile.lock(authJsonPath, { realpath: false });
|
||||
const backend = new FileAuthStorageBackend(authJsonPath);
|
||||
const controller = new AbortController();
|
||||
const update = vi.fn(async () => ({ result: undefined, next: JSON.stringify({}) }));
|
||||
const pending = backend.withLockAsync(update, { signal: controller.signal });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
await release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
anthropic: { type: "api_key", key: "stored" },
|
||||
});
|
||||
});
|
||||
|
||||
test("releases a file lock acquired concurrently with cancellation before mutation", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const backend = new FileAuthStorageBackend(authJsonPath);
|
||||
const controller = new AbortController();
|
||||
const release = vi.fn(async () => {});
|
||||
vi.spyOn(lockfile, "lock").mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return release;
|
||||
});
|
||||
const update = vi.fn(async () => ({ result: undefined, next: JSON.stringify({}) }));
|
||||
|
||||
await expect(backend.withLockAsync(update, { signal: controller.signal })).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("holds the file lock until a cancelled active callback settles without committing it", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const backend = new FileAuthStorageBackend(authJsonPath);
|
||||
const controller = new AbortController();
|
||||
let markStarted: (() => void) | undefined;
|
||||
let finish: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const pending = backend.withLockAsync(
|
||||
async () => {
|
||||
markStarted?.();
|
||||
await blocked;
|
||||
return { result: undefined, next: JSON.stringify({ openai: { type: "api_key", key: "cancelled" } }) };
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
await started;
|
||||
controller.abort();
|
||||
const competingMutation = vi.fn(async () => ({
|
||||
result: undefined,
|
||||
next: JSON.stringify({ google: { type: "api_key", key: "committed" } }),
|
||||
}));
|
||||
const competing = backend.withLockAsync(competingMutation);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(competingMutation).not.toHaveBeenCalled();
|
||||
|
||||
finish?.();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await competing;
|
||||
expect(competingMutation).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
|
||||
google: { type: "api_key", key: "committed" },
|
||||
});
|
||||
});
|
||||
|
||||
test("cancels a signalled credential read waiting for a held file lock", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "new-value" } });
|
||||
const release = await lockfile.lock(authJsonPath, { realpath: false });
|
||||
const lockSpy = vi.spyOn(lockfile, "lock");
|
||||
const controller = new AbortController();
|
||||
const pending = storage.read("anthropic", { signal: controller.signal });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(lockSpy).toHaveBeenCalledTimes(1);
|
||||
await expect(storage.read("anthropic")).resolves.toEqual({ type: "api_key", key: "new-value" });
|
||||
});
|
||||
|
||||
test("serializes in-memory mutations across providers", async () => {
|
||||
const storage = AuthStorage.inMemory();
|
||||
let markStarted: (() => void) | undefined;
|
||||
let finish: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const first = storage.modify("anthropic", async () => {
|
||||
markStarted?.();
|
||||
await blocked;
|
||||
return { type: "api_key", key: "anthropic-key" };
|
||||
});
|
||||
await started;
|
||||
const secondMutation = vi.fn(async () => ({ type: "api_key" as const, key: "openai-key" }));
|
||||
const second = storage.modify("openai", secondMutation);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(secondMutation).not.toHaveBeenCalled();
|
||||
|
||||
finish?.();
|
||||
await Promise.all([first, second]);
|
||||
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "anthropic-key" });
|
||||
expect(await storage.read("openai")).toEqual({ type: "api_key", key: "openai-key" });
|
||||
});
|
||||
|
||||
test("cancels a queued in-memory mutation without running it later", async () => {
|
||||
const storage = AuthStorage.inMemory();
|
||||
let markStarted: (() => void) | undefined;
|
||||
let finish: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const first = storage.modify("anthropic", async () => {
|
||||
markStarted?.();
|
||||
await blocked;
|
||||
return { type: "api_key", key: "anthropic-key" };
|
||||
});
|
||||
await started;
|
||||
const controller = new AbortController();
|
||||
const secondMutation = vi.fn(async () => ({ type: "api_key" as const, key: "openai-key" }));
|
||||
const second = storage.modify("openai", secondMutation, { signal: controller.signal });
|
||||
|
||||
controller.abort();
|
||||
await expect(second).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(secondMutation).not.toHaveBeenCalled();
|
||||
finish?.();
|
||||
await first;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(secondMutation).not.toHaveBeenCalled();
|
||||
expect(await storage.read("openai")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves the stored credential after cancelling an active refresh mutation", async () => {
|
||||
const previous = {
|
||||
type: "oauth" as const,
|
||||
access: "expired",
|
||||
refresh: "refresh-token",
|
||||
expires: 0,
|
||||
};
|
||||
const storage = AuthStorage.inMemory({ oauth: previous });
|
||||
const controller = new AbortController();
|
||||
let markStarted: (() => void) | undefined;
|
||||
let finish: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const pending = storage.modify(
|
||||
"oauth",
|
||||
async () => {
|
||||
markStarted?.();
|
||||
await blocked;
|
||||
return { ...previous, access: "refreshed", expires: Date.now() + 60_000 };
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
await started;
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
const competingMutation = vi.fn(async () => ({ type: "api_key" as const, key: "other" }));
|
||||
const competing = storage.modify("other", competingMutation);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(competingMutation).not.toHaveBeenCalled();
|
||||
|
||||
finish?.();
|
||||
await competing;
|
||||
expect(competingMutation).toHaveBeenCalledTimes(1);
|
||||
expect(await storage.read("oauth")).toEqual(previous);
|
||||
});
|
||||
|
||||
test("translates a credential-store refresh failure and allows a later retry", async () => {
|
||||
const providerId = "oauth-provider";
|
||||
const base = AuthStorage.inMemory({
|
||||
[providerId]: {
|
||||
type: "oauth",
|
||||
access: "expired-access",
|
||||
refresh: "refresh-token",
|
||||
expires: 0,
|
||||
},
|
||||
});
|
||||
let failNextModify = true;
|
||||
const credentials: CredentialStore = {
|
||||
read: (id) => base.read(id),
|
||||
list: () => base.list(),
|
||||
modify: (id, fn) => {
|
||||
if (failNextModify) {
|
||||
failNextModify = false;
|
||||
return Promise.reject(new Error("credential store unavailable"));
|
||||
}
|
||||
return base.modify(id, fn);
|
||||
},
|
||||
delete: (id) => base.delete(id),
|
||||
};
|
||||
const provider: Provider = {
|
||||
id: providerId,
|
||||
name: "OAuth Provider",
|
||||
auth: {
|
||||
oauth: {
|
||||
name: "OAuth",
|
||||
login: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
refresh: async (credential) => ({
|
||||
...credential,
|
||||
access: "refreshed-access",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
toAuth: async (credential) => ({ apiKey: credential.access }),
|
||||
},
|
||||
},
|
||||
getModels: () => [],
|
||||
stream: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
streamSimple: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
};
|
||||
const models = createModels({ credentials });
|
||||
models.setProvider(provider);
|
||||
|
||||
await expect(models.getAuth(providerId)).rejects.toMatchObject({ code: "auth" });
|
||||
await expect(models.getAuth(providerId)).resolves.toMatchObject({ auth: { apiKey: "refreshed-access" } });
|
||||
});
|
||||
|
||||
test("does not overwrite malformed auth files", async () => {
|
||||
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
|
||||
const storage = AuthStorage.create(authJsonPath);
|
||||
writeFileSync(authJsonPath, "{invalid-json", "utf8");
|
||||
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow();
|
||||
expect(readFileSync(authJsonPath, "utf8")).toBe("{invalid-json");
|
||||
});
|
||||
});
|
||||
126
packages/coding-agent/test/bash-close-hang-windows.test.ts
Normal file
126
packages/coding-agent/test/bash-close-hang-windows.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { executeBashWithOperations } from "../src/core/bash-executor.ts";
|
||||
import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.ts";
|
||||
|
||||
function toBashSingleQuotedArg(value: string): string {
|
||||
return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function createInheritedStdioCommand(pidFile: string): string {
|
||||
const pidFileArg = toBashSingleQuotedArg(pidFile);
|
||||
return (
|
||||
'node -e "' +
|
||||
"const fs=require('fs');" +
|
||||
"const {spawn}=require('child_process');" +
|
||||
"const child=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'inherit',detached:true});" +
|
||||
"fs.writeFileSync(process.argv[1], String(child.pid));" +
|
||||
"child.unref();" +
|
||||
"console.log('child-exiting');" +
|
||||
'" ' +
|
||||
pidFileArg
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupDetachedChild(pidFile: string): void {
|
||||
if (!existsSync(pidFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
|
||||
} catch {
|
||||
// Process may have already exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => void): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(new Error(`Timed out after ${ms}ms`));
|
||||
}, ms);
|
||||
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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.skipIf(process.platform !== "win32")("Windows child-process close handling", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `coding-agent-bash-close-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("executeBash resolves after the shell exits even if inherited stdio handles stay open", async () => {
|
||||
const pidFile = join(testDir, "executor-grandchild.pid");
|
||||
const command = createInheritedStdioCommand(pidFile);
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
executeBashWithOperations(command, process.cwd(), createLocalBashOperations(), {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
3000,
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.output).toContain("child-exiting");
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.cancelled).toBe(false);
|
||||
} finally {
|
||||
controller.abort();
|
||||
cleanupDetachedChild(pidFile);
|
||||
}
|
||||
});
|
||||
|
||||
it("bash tool resolves after the shell exits even if inherited stdio handles stay open", async () => {
|
||||
const pidFile = join(testDir, "tool-grandchild.pid");
|
||||
const command = createInheritedStdioCommand(pidFile);
|
||||
const controller = new AbortController();
|
||||
const bashTool = createBashTool(testDir);
|
||||
|
||||
try {
|
||||
const result = await withTimeout(bashTool.execute("test-call", { command }, controller.signal), 3000, () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("child-exiting");
|
||||
} finally {
|
||||
controller.abort();
|
||||
cleanupDetachedChild(pidFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
80
packages/coding-agent/test/bash-execution-width.test.ts
Normal file
80
packages/coding-agent/test/bash-execution-width.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Test that BashExecutionComponent's collapsed output respects the render-time width,
|
||||
* not a stale captured width. Regression test for #2569.
|
||||
*/
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { BashExecutionComponent } from "../src/modes/interactive/components/bash-execution.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
/** Minimal TUI stub that only exposes terminal.columns */
|
||||
function createTuiStub(columns: number): { columns: number; stub: any } {
|
||||
const state = { columns };
|
||||
const stub = {
|
||||
terminal: {
|
||||
get columns() {
|
||||
return state.columns;
|
||||
},
|
||||
get rows() {
|
||||
return 24;
|
||||
},
|
||||
},
|
||||
// Loader calls ui.addInterval / ui.removeInterval
|
||||
addInterval: (_cb: () => void, _ms: number) => ({ dispose: () => {} }),
|
||||
removeInterval: () => {},
|
||||
requestRender: () => {},
|
||||
};
|
||||
return { columns: state.columns, stub };
|
||||
}
|
||||
|
||||
describe("BashExecutionComponent width handling (#2569)", () => {
|
||||
beforeAll(() => {
|
||||
initTheme(undefined, false);
|
||||
});
|
||||
|
||||
it("collapsed preview lines respect render-time width, not construction-time width", () => {
|
||||
const wideWidth = 200;
|
||||
const narrowWidth = 80;
|
||||
|
||||
const { stub } = createTuiStub(wideWidth);
|
||||
const component = new BashExecutionComponent("pwd", stub);
|
||||
|
||||
// Add output with long lines that will wrap differently at different widths
|
||||
const longLine = "x".repeat(150);
|
||||
component.appendOutput(`${longLine}\n${longLine}\n`);
|
||||
|
||||
// Complete the command so it enters collapsed mode
|
||||
component.setComplete(0, false);
|
||||
|
||||
// Render at the narrow width (simulating a resize or split pane)
|
||||
const lines = component.render(narrowWidth);
|
||||
|
||||
// Every rendered line must fit within the narrow width
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const w = visibleWidth(lines[i]);
|
||||
expect(w, `Line ${i} visibleWidth=${w} > ${narrowWidth}`).toBeLessThanOrEqual(narrowWidth);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-computes lines when width changes between renders", () => {
|
||||
const { stub } = createTuiStub(200);
|
||||
const component = new BashExecutionComponent("echo hello", stub);
|
||||
|
||||
const longLine = "abcdefghij".repeat(20); // 200 chars
|
||||
component.appendOutput(`${longLine}\n`);
|
||||
component.setComplete(0, false);
|
||||
|
||||
// First render at width 200
|
||||
const lines200 = component.render(200);
|
||||
for (const line of lines200) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(200);
|
||||
}
|
||||
|
||||
// Second render at width 60 (split pane scenario)
|
||||
const lines60 = component.render(60);
|
||||
for (let i = 0; i < lines60.length; i++) {
|
||||
const w = visibleWidth(lines60[i]);
|
||||
expect(w, `Line ${i} visibleWidth=${w} > 60`).toBeLessThanOrEqual(60);
|
||||
}
|
||||
});
|
||||
});
|
||||
148
packages/coding-agent/test/block-images.test.ts
Normal file
148
packages/coding-agent/test/block-images.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { processFileArguments } from "../src/cli/file-processor.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createReadTool } from "../src/core/tools/read.ts";
|
||||
|
||||
// 1x1 red PNG image as base64 (smallest valid PNG)
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
function createTinyBmp1x1Red24bpp(): Buffer {
|
||||
const buffer = Buffer.alloc(58);
|
||||
buffer.write("BM", 0, "ascii");
|
||||
buffer.writeUInt32LE(buffer.length, 2);
|
||||
buffer.writeUInt32LE(54, 10);
|
||||
buffer.writeUInt32LE(40, 14);
|
||||
buffer.writeInt32LE(1, 18);
|
||||
buffer.writeInt32LE(1, 22);
|
||||
buffer.writeUInt16LE(1, 26);
|
||||
buffer.writeUInt16LE(24, 28);
|
||||
buffer.writeUInt32LE(0, 30);
|
||||
buffer.writeUInt32LE(4, 34);
|
||||
buffer[56] = 0xff;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
describe("blockImages setting", () => {
|
||||
describe("SettingsManager", () => {
|
||||
it("should default blockImages to false", () => {
|
||||
const manager = SettingsManager.inMemory({});
|
||||
expect(manager.getBlockImages()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when blockImages is set to true", () => {
|
||||
const manager = SettingsManager.inMemory({ images: { blockImages: true } });
|
||||
expect(manager.getBlockImages()).toBe(true);
|
||||
});
|
||||
|
||||
it("should persist blockImages setting via setBlockImages", () => {
|
||||
const manager = SettingsManager.inMemory({});
|
||||
expect(manager.getBlockImages()).toBe(false);
|
||||
|
||||
manager.setBlockImages(true);
|
||||
expect(manager.getBlockImages()).toBe(true);
|
||||
|
||||
manager.setBlockImages(false);
|
||||
expect(manager.getBlockImages()).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle blockImages alongside autoResize", () => {
|
||||
const manager = SettingsManager.inMemory({
|
||||
images: { autoResize: true, blockImages: true },
|
||||
});
|
||||
expect(manager.getImageAutoResize()).toBe(true);
|
||||
expect(manager.getBlockImages()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Read tool", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `block-images-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should always read images (filtering happens at convertToLlm layer)", async () => {
|
||||
// Create test image
|
||||
const imagePath = join(testDir, "test.png");
|
||||
writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const tool = createReadTool(testDir);
|
||||
const result = await tool.execute("test-1", { path: imagePath });
|
||||
|
||||
// Should have text note + image content
|
||||
expect(result.content.length).toBeGreaterThanOrEqual(1);
|
||||
const hasImage = result.content.some((c) => c.type === "image");
|
||||
expect(hasImage).toBe(true);
|
||||
});
|
||||
|
||||
it("should read text files normally", async () => {
|
||||
// Create test text file
|
||||
const textPath = join(testDir, "test.txt");
|
||||
writeFileSync(textPath, "Hello, world!");
|
||||
|
||||
const tool = createReadTool(testDir);
|
||||
const result = await tool.execute("test-2", { path: textPath });
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content[0].type).toBe("text");
|
||||
const textContent = result.content[0] as { type: "text"; text: string };
|
||||
expect(textContent.text).toContain("Hello, world!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processFileArguments", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `block-images-process-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should always process images (filtering happens at convertToLlm layer)", async () => {
|
||||
// Create test image
|
||||
const imagePath = join(testDir, "test.png");
|
||||
writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const result = await processFileArguments([imagePath]);
|
||||
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.images[0].type).toBe("image");
|
||||
});
|
||||
|
||||
it("should process BMP images from disk as PNG attachments", async () => {
|
||||
const imagePath = join(testDir, "test.bmp");
|
||||
writeFileSync(imagePath, createTinyBmp1x1Red24bpp());
|
||||
|
||||
const result = await processFileArguments([imagePath]);
|
||||
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.images[0].type).toBe("image");
|
||||
expect(result.images[0].mimeType).toBe("image/png");
|
||||
expect(result.text).toContain("[Image converted from image/bmp to image/png.]");
|
||||
});
|
||||
|
||||
it("should process text files normally", async () => {
|
||||
// Create test text file
|
||||
const textPath = join(testDir, "test.txt");
|
||||
writeFileSync(textPath, "Hello, world!");
|
||||
|
||||
const result = await processFileArguments([textPath]);
|
||||
|
||||
expect(result.images).toHaveLength(0);
|
||||
expect(result.text).toContain("Hello, world!");
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/coding-agent/test/branch-summarization.test.ts
Normal file
135
packages/coding-agent/test/branch-summarization.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createAssistantMessageEventStream,
|
||||
fauxAssistantMessage,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateBranchSummary } from "../src/core/compaction/index.ts";
|
||||
import type { SessionEntry } from "../src/core/session-manager.ts";
|
||||
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
const entries: SessionEntry[] = [
|
||||
{
|
||||
type: "message",
|
||||
id: "branch-user",
|
||||
parentId: null,
|
||||
timestamp: new Date(1).toISOString(),
|
||||
message: { role: "user", content: "Abandoned request", timestamp: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
function response(content: AssistantMessage["content"]): AssistantMessage {
|
||||
return {
|
||||
...fauxAssistantMessage(""),
|
||||
content,
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
};
|
||||
}
|
||||
|
||||
describe("branch summarization", () => {
|
||||
it("does not override tool choice for branch summaries", async () => {
|
||||
let requestOptions: SimpleStreamOptions | undefined;
|
||||
const streamFn: StreamFn = (_model, _context, options) => {
|
||||
requestOptions = options;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({ type: "done", reason: "stop", message: response([{ type: "text", text: "summary" }]) }),
|
||||
);
|
||||
return stream;
|
||||
};
|
||||
|
||||
await generateBranchSummary(entries, {
|
||||
model,
|
||||
signal: new AbortController().signal,
|
||||
streamFn,
|
||||
});
|
||||
|
||||
expect(requestOptions?.maxTokens).toBe(4096);
|
||||
expect(requestOptions?.toolChoice).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps the branch summary output cap to the model limit", async () => {
|
||||
let requestOptions: SimpleStreamOptions | undefined;
|
||||
const streamFn: StreamFn = (_model, _context, options) => {
|
||||
requestOptions = options;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({ type: "done", reason: "stop", message: response([{ type: "text", text: "summary" }]) }),
|
||||
);
|
||||
return stream;
|
||||
};
|
||||
|
||||
await generateBranchSummary(entries, {
|
||||
model: { ...model, maxTokens: 1024 },
|
||||
signal: new AbortController().signal,
|
||||
streamFn,
|
||||
});
|
||||
|
||||
expect(requestOptions?.maxTokens).toBe(1024);
|
||||
});
|
||||
|
||||
it("rejects tool calls from branch summaries", async () => {
|
||||
const streamFn: StreamFn = () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "toolUse",
|
||||
message: response([
|
||||
{ type: "toolCall", id: "tool-call-1", name: "read", arguments: { path: "README.md" } },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
return stream;
|
||||
};
|
||||
|
||||
const result = await generateBranchSummary(entries, {
|
||||
model,
|
||||
signal: new AbortController().signal,
|
||||
streamFn,
|
||||
});
|
||||
|
||||
expect(result.error).toBe("Branch summarization attempted to call a tool");
|
||||
});
|
||||
|
||||
it("rejects length-limited branch summaries", async () => {
|
||||
const streamFn: StreamFn = () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "length",
|
||||
message: { ...response([{ type: "text", text: "partial" }]), stopReason: "length" },
|
||||
}),
|
||||
);
|
||||
return stream;
|
||||
};
|
||||
|
||||
const result = await generateBranchSummary(entries, {
|
||||
model,
|
||||
signal: new AbortController().signal,
|
||||
streamFn,
|
||||
});
|
||||
|
||||
expect(result.error).toBe(
|
||||
"Branch summarization failed: generation hit the token cap and the summary is incomplete",
|
||||
);
|
||||
});
|
||||
});
|
||||
57
packages/coding-agent/test/branch-summary-extensions.test.ts
Normal file
57
packages/coding-agent/test/branch-summary-extensions.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Usage } from "@earendil-works/pi-ai/compat";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "./suite/harness.ts";
|
||||
import { assistantMsg, userMsg } from "./utilities.ts";
|
||||
|
||||
describe("Branch summary extensions", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists extension-provided summary usage in session totals", async () => {
|
||||
const usage: Usage = {
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_tree", () => ({
|
||||
summary: {
|
||||
summary: "Summary provided by extension",
|
||||
usage,
|
||||
},
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
const targetId = harness.sessionManager.appendMessage(userMsg("first branch"));
|
||||
harness.sessionManager.appendMessage(assistantMsg("first reply"));
|
||||
harness.sessionManager.appendMessage(userMsg("abandoned branch work"));
|
||||
const sourceId = harness.sessionManager.appendMessage(assistantMsg("abandoned reply"));
|
||||
|
||||
const result = await harness.session.navigateTree(targetId, { summarize: true });
|
||||
const summaryEntry = result.summaryEntry;
|
||||
|
||||
expect(summaryEntry?.type).toBe("branch_summary");
|
||||
expect(summaryEntry?.parentId).toBeNull();
|
||||
expect(summaryEntry?.fromId).toBe(sourceId);
|
||||
expect(summaryEntry?.fromHook).toBe(true);
|
||||
expect(summaryEntry?.summary).toBe("Summary provided by extension");
|
||||
expect(summaryEntry?.usage).toEqual(usage);
|
||||
|
||||
const stats = harness.session.getSessionStats();
|
||||
expect(stats.tokens).toEqual({ input: 12, output: 22, cacheRead: 30, cacheWrite: 40, total: 104 });
|
||||
expect(stats.cost).toBe(1);
|
||||
});
|
||||
});
|
||||
143
packages/coding-agent/test/cache-stats.test.ts
Normal file
143
packages/coding-agent/test/cache-stats.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectCacheMisses,
|
||||
computeCacheWaste,
|
||||
detectCacheMiss,
|
||||
type ModelPriceSource,
|
||||
} from "../src/core/cache-stats.ts";
|
||||
import type { SessionEntry } from "../src/core/session-manager.ts";
|
||||
|
||||
const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
|
||||
|
||||
const models: ModelPriceSource = {
|
||||
// $/million tokens; used as cache-read price fallback on full-miss turns
|
||||
getModel: () => ({ cost: { cacheRead: 0.3 } }),
|
||||
};
|
||||
|
||||
function assistant(options: {
|
||||
input?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
cost?: Partial<typeof zeroCost>;
|
||||
model?: string;
|
||||
timestamp?: number;
|
||||
}): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "anthropic-messages",
|
||||
provider: "test",
|
||||
model: options.model ?? "test-model",
|
||||
usage: {
|
||||
input: options.input ?? 0,
|
||||
output: 10,
|
||||
cacheRead: options.cacheRead ?? 0,
|
||||
cacheWrite: options.cacheWrite ?? 0,
|
||||
totalTokens: 0,
|
||||
cost: { ...zeroCost, ...options.cost },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: options.timestamp ?? 0,
|
||||
} as AssistantMessage;
|
||||
}
|
||||
|
||||
function entry(message: AssistantMessage): SessionEntry {
|
||||
return { type: "message", id: "x", parentId: null, timestamp: "", message } as SessionEntry;
|
||||
}
|
||||
|
||||
// Turn 1: fresh 100k cache write at $3.75/M
|
||||
const turn1 = assistant({ cacheWrite: 100_000, cost: { cacheWrite: 0.375 }, timestamp: 0 });
|
||||
// Turn 2: healthy, everything read back at $0.30/M
|
||||
const turn2 = assistant({
|
||||
cacheRead: 100_000,
|
||||
cacheWrite: 5_000,
|
||||
cost: { cacheRead: 0.03, cacheWrite: 0.019 },
|
||||
timestamp: 60_000,
|
||||
});
|
||||
|
||||
describe("computeCacheWaste", () => {
|
||||
it("accumulates missed tokens and cost across turns", () => {
|
||||
// Turn 3: full miss, previous 105k prompt re-billed at $3.75/M write
|
||||
const turn3 = assistant({ cacheWrite: 110_000, cost: { cacheWrite: 0.4125 }, timestamp: 120_000 });
|
||||
const totals = computeCacheWaste([entry(turn1), entry(turn2), entry(turn3)], models);
|
||||
expect(totals.missedTokens).toBe(105_000);
|
||||
// 105k at ($3.75 - $0.30)/M
|
||||
expect(totals.missedCost).toBeCloseTo(0.36225, 5);
|
||||
});
|
||||
|
||||
it("counts nothing for healthy sessions", () => {
|
||||
const totals = computeCacheWaste([entry(turn1), entry(turn2)], models);
|
||||
expect(totals.missedTokens).toBe(0);
|
||||
expect(totals.missedCost).toBe(0);
|
||||
});
|
||||
|
||||
it("skips the turn after a compaction reset", () => {
|
||||
const reset = { type: "compaction", id: "c", parentId: null, timestamp: "" } as SessionEntry;
|
||||
const afterReset = assistant({ cacheWrite: 20_000, cost: { cacheWrite: 0.075 } });
|
||||
const totals = computeCacheWaste([entry(turn1), reset, entry(afterReset)], models);
|
||||
expect(totals.missedTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("counts misses caused by model switches", () => {
|
||||
const otherModel = assistant({ cacheWrite: 100_000, cost: { cacheWrite: 0.375 }, model: "other-model" });
|
||||
const totals = computeCacheWaste([entry(turn1), entry(otherModel)], models);
|
||||
expect(totals.missedTokens).toBe(100_000);
|
||||
expect(totals.missCount).toBe(1);
|
||||
});
|
||||
|
||||
it("skips providers that report no cache activity", () => {
|
||||
const a = assistant({ input: 100_000 });
|
||||
const b = assistant({ input: 110_000 });
|
||||
const totals = computeCacheWaste([entry(a), entry(b)], models);
|
||||
expect(totals.missedTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectCacheMisses", () => {
|
||||
it("maps counted misses to their assistant messages by reference", () => {
|
||||
const missTurn = assistant({ cacheWrite: 110_000, cost: { cacheWrite: 0.4125 }, timestamp: 120_000 });
|
||||
const misses = collectCacheMisses([entry(turn1), entry(turn2), entry(missTurn)], models);
|
||||
expect(misses.size).toBe(1);
|
||||
expect(misses.get(missTurn)?.missedTokens).toBe(105_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectCacheMiss", () => {
|
||||
it("detects a miss on a just-completed message with idle time", () => {
|
||||
const missMessage = assistant({ cacheWrite: 110_000, cost: { cacheWrite: 0.4125 }, timestamp: 600_000 });
|
||||
const miss = detectCacheMiss([entry(turn1), entry(turn2)], missMessage, models);
|
||||
expect(miss).toBeDefined();
|
||||
expect(miss?.missedTokens).toBe(105_000);
|
||||
expect(miss?.missedCost).toBeCloseTo(0.36225, 5);
|
||||
// 600s - 60s since the previous request
|
||||
expect(miss?.idleMs).toBe(540_000);
|
||||
expect(miss?.modelChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("flags model switches on detected misses", () => {
|
||||
const otherModel = assistant({
|
||||
cacheWrite: 110_000,
|
||||
cost: { cacheWrite: 0.4125 },
|
||||
model: "other-model",
|
||||
timestamp: 120_000,
|
||||
});
|
||||
const miss = detectCacheMiss([entry(turn1), entry(turn2)], otherModel, models);
|
||||
expect(miss?.missedTokens).toBe(105_000);
|
||||
expect(miss?.modelChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("returns undefined for healthy turns", () => {
|
||||
const healthy = assistant({
|
||||
cacheRead: 105_000,
|
||||
cacheWrite: 2_000,
|
||||
cost: { cacheRead: 0.0315, cacheWrite: 0.0075 },
|
||||
timestamp: 120_000,
|
||||
});
|
||||
expect(detectCacheMiss([entry(turn1), entry(turn2)], healthy, models)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for the first turn of a session", () => {
|
||||
expect(detectCacheMiss([], turn1, models)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
49
packages/coding-agent/test/changelog.test.ts
Normal file
49
packages/coding-agent/test/changelog.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts";
|
||||
|
||||
const entry: ChangelogEntry = {
|
||||
major: 0,
|
||||
minor: 79,
|
||||
patch: 0,
|
||||
content: "",
|
||||
};
|
||||
|
||||
describe("normalizeChangelogLinks", () => {
|
||||
test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => {
|
||||
const markdown = [
|
||||
"[Project Trust](README.md#project-trust)",
|
||||
"[Extensions](docs/extensions.md#project_trust)",
|
||||
"[Examples](examples/extensions/)",
|
||||
"[Root README](../../README.md#supply-chain-hardening)",
|
||||
].join("\n");
|
||||
|
||||
expect(normalizeChangelogLinks(markdown, entry)).toBe(
|
||||
[
|
||||
"[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)",
|
||||
"[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)",
|
||||
"[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)",
|
||||
"[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("canonicalizes old repository URLs without changing external links", () => {
|
||||
const markdown = [
|
||||
"[#5167](https://github.com/earendil-works/pi-mono/pull/5167)",
|
||||
"[#4163](https://github.com/badlogic/pi-mono/issues/4163)",
|
||||
"[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)",
|
||||
"[External](https://example.com/docs)",
|
||||
"[Local anchor](#settings)",
|
||||
].join("\n");
|
||||
|
||||
expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe(
|
||||
[
|
||||
"[#5167](https://github.com/earendil-works/pi/pull/5167)",
|
||||
"[#4163](https://github.com/earendil-works/pi/issues/4163)",
|
||||
"[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)",
|
||||
"[External](https://example.com/docs)",
|
||||
"[Local anchor](#settings)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
26
packages/coding-agent/test/chat-viewport.test.ts
Normal file
26
packages/coding-agent/test/chat-viewport.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Container } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createChatViewport } from "../src/modes/interactive/chat-viewport.ts";
|
||||
|
||||
describe("chat viewport", () => {
|
||||
test("defaults the transcript scrollbar to auto and accepts overrides", () => {
|
||||
const automatic = createChatViewport({
|
||||
document: new Container(),
|
||||
pendingMessages: new Container(),
|
||||
status: new Container(),
|
||||
editor: new Container(),
|
||||
footer: new Container(),
|
||||
});
|
||||
const hidden = createChatViewport({
|
||||
document: new Container(),
|
||||
pendingMessages: new Container(),
|
||||
status: new Container(),
|
||||
editor: new Container(),
|
||||
footer: new Container(),
|
||||
scrollbar: "hidden",
|
||||
});
|
||||
|
||||
expect(automatic.transcript.scrollbar).toBe("auto");
|
||||
expect(hidden.transcript.scrollbar).toBe("hidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Test for BMP to PNG conversion in clipboard image handling.
|
||||
* Separate from clipboard-image.test.ts due to different mocking requirements.
|
||||
*
|
||||
* This tests the fix for WSL2/WSLg where clipboard often provides image/bmp
|
||||
* instead of image/png.
|
||||
*/
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
function createTinyBmp1x1Red24bpp(): Uint8Array {
|
||||
// Minimal 1x1 24bpp BMP (BGR + row padding to 4 bytes)
|
||||
// File size = 14 (BMP header) + 40 (DIB header) + 4 (pixel row) = 58
|
||||
const buffer = Buffer.alloc(58);
|
||||
|
||||
// BITMAPFILEHEADER
|
||||
buffer.write("BM", 0, "ascii");
|
||||
buffer.writeUInt32LE(buffer.length, 2); // file size
|
||||
buffer.writeUInt16LE(0, 6); // reserved1
|
||||
buffer.writeUInt16LE(0, 8); // reserved2
|
||||
buffer.writeUInt32LE(54, 10); // pixel data offset
|
||||
|
||||
// BITMAPINFOHEADER
|
||||
buffer.writeUInt32LE(40, 14); // DIB header size
|
||||
buffer.writeInt32LE(1, 18); // width
|
||||
buffer.writeInt32LE(1, 22); // height (positive = bottom-up)
|
||||
buffer.writeUInt16LE(1, 26); // planes
|
||||
buffer.writeUInt16LE(24, 28); // bits per pixel
|
||||
buffer.writeUInt32LE(0, 30); // compression (BI_RGB)
|
||||
buffer.writeUInt32LE(4, 34); // image size (incl. padding)
|
||||
buffer.writeInt32LE(0, 38); // x pixels per meter
|
||||
buffer.writeInt32LE(0, 42); // y pixels per meter
|
||||
buffer.writeUInt32LE(0, 46); // colors used
|
||||
buffer.writeUInt32LE(0, 50); // important colors
|
||||
|
||||
// Pixel data (B, G, R) + 1 byte padding
|
||||
buffer[54] = 0x00; // B
|
||||
buffer[55] = 0x00; // G
|
||||
buffer[56] = 0xff; // R
|
||||
buffer[57] = 0x00; // padding
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
// Mock wl-paste to return BMP
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: vi.fn((command: string, args: string[]) => {
|
||||
if (command === "wl-paste" && args.includes("--list-types")) {
|
||||
return { status: 0, stdout: Buffer.from("image/bmp\n"), error: null };
|
||||
}
|
||||
if (command === "wl-paste" && args.includes("image/bmp")) {
|
||||
return { status: 0, stdout: Buffer.from(createTinyBmp1x1Red24bpp()), error: null };
|
||||
}
|
||||
return { status: 1, stdout: Buffer.alloc(0), error: null };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the native clipboard (not used in Wayland path, but needs to be mocked)
|
||||
vi.mock("@mariozechner/clipboard", () => ({
|
||||
default: {
|
||||
hasImage: vi.fn(() => false),
|
||||
getImageBinary: vi.fn(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("readClipboardImage BMP conversion", () => {
|
||||
test("converts BMP to PNG on Wayland/WSLg", async () => {
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
|
||||
|
||||
// Simulate Wayland session (WSLg)
|
||||
const image = await readClipboardImage({
|
||||
env: { WAYLAND_DISPLAY: "wayland-0" },
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
expect(image).not.toBeNull();
|
||||
expect(image!.mimeType).toBe("image/png");
|
||||
|
||||
// Verify PNG magic bytes
|
||||
expect(image!.bytes[0]).toBe(0x89);
|
||||
expect(image!.bytes[1]).toBe(0x50); // P
|
||||
expect(image!.bytes[2]).toBe(0x4e); // N
|
||||
expect(image!.bytes[3]).toBe(0x47); // G
|
||||
});
|
||||
});
|
||||
184
packages/coding-agent/test/clipboard-image.test.ts
Normal file
184
packages/coding-agent/test/clipboard-image.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { SpawnSyncReturns } from "child_process";
|
||||
import { writeFileSync } from "fs";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
spawnSync: vi.fn<(command: string, args: string[], options: unknown) => SpawnSyncReturns<Buffer>>(),
|
||||
clipboard: {
|
||||
hasImage: vi.fn<() => boolean>(),
|
||||
getImageBinary: vi.fn<() => Promise<Uint8Array | null>>(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("child_process", () => {
|
||||
return {
|
||||
spawnSync: mocks.spawnSync,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/utils/clipboard-native.js", () => {
|
||||
return {
|
||||
clipboard: mocks.clipboard,
|
||||
};
|
||||
});
|
||||
|
||||
function spawnOk(stdout: Buffer): SpawnSyncReturns<Buffer> {
|
||||
return {
|
||||
pid: 123,
|
||||
output: [Buffer.alloc(0), stdout, Buffer.alloc(0)],
|
||||
stdout,
|
||||
stderr: Buffer.alloc(0),
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnError(error: Error): SpawnSyncReturns<Buffer> {
|
||||
return {
|
||||
pid: 123,
|
||||
output: [Buffer.alloc(0), Buffer.alloc(0), Buffer.alloc(0)],
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
status: null,
|
||||
signal: null,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
describe("readClipboardImage", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.spawnSync.mockReset();
|
||||
mocks.clipboard.hasImage.mockReset();
|
||||
mocks.clipboard.getImageBinary.mockReset();
|
||||
});
|
||||
|
||||
test("Wayland: uses wl-paste and never calls clipboard", async () => {
|
||||
mocks.clipboard.hasImage.mockImplementation(() => {
|
||||
throw new Error("clipboard.hasImage should not be called on Wayland");
|
||||
});
|
||||
|
||||
mocks.spawnSync.mockImplementation((command, args, _options) => {
|
||||
if (command === "wl-paste" && args[0] === "--list-types") {
|
||||
return spawnOk(Buffer.from("text/plain\nimage/png\n", "utf-8"));
|
||||
}
|
||||
if (command === "wl-paste" && args[0] === "--type") {
|
||||
return spawnOk(Buffer.from([1, 2, 3]));
|
||||
}
|
||||
throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
|
||||
const result = await readClipboardImage({ platform: "linux", env: { WAYLAND_DISPLAY: "1" } });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("Wayland: falls back to xclip when wl-paste is missing", async () => {
|
||||
mocks.clipboard.hasImage.mockImplementation(() => {
|
||||
throw new Error("clipboard.hasImage should not be called on Wayland");
|
||||
});
|
||||
|
||||
const enoent = new Error("spawn ENOENT");
|
||||
(enoent as { code?: string }).code = "ENOENT";
|
||||
|
||||
mocks.spawnSync.mockImplementation((command, args, _options) => {
|
||||
if (command === "wl-paste") {
|
||||
return spawnError(enoent);
|
||||
}
|
||||
|
||||
if (command === "xclip" && args.includes("TARGETS")) {
|
||||
return spawnOk(Buffer.from("image/png\n", "utf-8"));
|
||||
}
|
||||
|
||||
if (command === "xclip" && args.includes("image/png")) {
|
||||
return spawnOk(Buffer.from([9, 8]));
|
||||
}
|
||||
|
||||
return spawnOk(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
|
||||
const result = await readClipboardImage({ platform: "linux", env: { XDG_SESSION_TYPE: "wayland" } });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([9, 8]);
|
||||
});
|
||||
|
||||
test("WSL: passes PowerShell path directly instead of through a custom env var", async () => {
|
||||
mocks.clipboard.hasImage.mockImplementation(() => {
|
||||
throw new Error("clipboard.hasImage should not be called before PowerShell on WSL");
|
||||
});
|
||||
|
||||
let tmpFile: string | undefined;
|
||||
mocks.spawnSync.mockImplementation((command, args, options) => {
|
||||
if (command === "wl-paste" || command === "xclip") {
|
||||
return spawnOk(Buffer.alloc(0));
|
||||
}
|
||||
|
||||
if (command === "wslpath") {
|
||||
tmpFile = args[1];
|
||||
return spawnOk(Buffer.from("C:\\Users\\O'Hare\\clip.png\n", "utf-8"));
|
||||
}
|
||||
|
||||
if (command === "powershell.exe") {
|
||||
const spawnOptions = options as { env?: NodeJS.ProcessEnv };
|
||||
expect(spawnOptions.env?.PI_WSL_CLIPBOARD_IMAGE_PATH).toBeUndefined();
|
||||
expect(args[2]).toContain("$path = 'C:\\Users\\O''Hare\\clip.png'");
|
||||
if (!tmpFile) {
|
||||
throw new Error("wslpath should be called before powershell.exe");
|
||||
}
|
||||
writeFileSync(tmpFile, Buffer.from([4, 5, 6]));
|
||||
return spawnOk(Buffer.from("ok\n", "utf-8"));
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
|
||||
const result = await readClipboardImage({ platform: "linux", env: { WSL_DISTRO_NAME: "Ubuntu" } });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
test("Non-Wayland: uses clipboard", async () => {
|
||||
mocks.spawnSync.mockImplementation(() => {
|
||||
throw new Error(
|
||||
"spawnSync should not be called for non-Wayland sessions when native clipboard returns an image",
|
||||
);
|
||||
});
|
||||
|
||||
mocks.clipboard.hasImage.mockReturnValue(true);
|
||||
mocks.clipboard.getImageBinary.mockResolvedValue(new Uint8Array([7]));
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
|
||||
const result = await readClipboardImage({ platform: "linux", env: {} });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([7]);
|
||||
});
|
||||
|
||||
test("Non-Wayland: falls back to xclip when clipboard has no image", async () => {
|
||||
mocks.spawnSync.mockImplementation((command, args, _options) => {
|
||||
if (command === "xclip" && args.includes("TARGETS")) {
|
||||
return spawnOk(Buffer.from("image/png\n", "utf-8"));
|
||||
}
|
||||
if (command === "xclip" && args.includes("image/png")) {
|
||||
return spawnOk(Buffer.from([8, 9]));
|
||||
}
|
||||
throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
mocks.clipboard.hasImage.mockReturnValue(false);
|
||||
|
||||
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
|
||||
const result = await readClipboardImage({ platform: "linux", env: {} });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(Array.from(result?.bytes ?? [])).toEqual([8, 9]);
|
||||
});
|
||||
});
|
||||
32
packages/coding-agent/test/clipboard-native.test.ts
Normal file
32
packages/coding-agent/test/clipboard-native.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboard-native.ts";
|
||||
|
||||
type ClipboardRequire = (id: string) => unknown;
|
||||
|
||||
const fakeClipboard: ClipboardModule = {
|
||||
getText: async () => "",
|
||||
setText: async () => {},
|
||||
hasImage: () => true,
|
||||
getImageBinary: async () => [1, 2, 3],
|
||||
};
|
||||
|
||||
describe("loadClipboardNative", () => {
|
||||
test("falls back to the next require root", () => {
|
||||
const primary = vi.fn<ClipboardRequire>(() => {
|
||||
throw new Error("missing from bundled root");
|
||||
});
|
||||
const fallback = vi.fn<ClipboardRequire>(() => fakeClipboard);
|
||||
|
||||
expect(loadClipboardNative([primary, fallback])).toBe(fakeClipboard);
|
||||
expect(primary).toHaveBeenCalledWith("@mariozechner/clipboard");
|
||||
expect(fallback).toHaveBeenCalledWith("@mariozechner/clipboard");
|
||||
});
|
||||
|
||||
test("returns null when no require root can load clipboard", () => {
|
||||
const missing = vi.fn<ClipboardRequire>(() => {
|
||||
throw new Error("missing");
|
||||
});
|
||||
|
||||
expect(loadClipboardNative([missing])).toBeNull();
|
||||
});
|
||||
});
|
||||
210
packages/coding-agent/test/clipboard.test.ts
Normal file
210
packages/coding-agent/test/clipboard.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { execFileSync, execSync, spawn } from "child_process";
|
||||
import { platform } from "os";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
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>>(),
|
||||
},
|
||||
execFileSync: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
platform: vi.fn<() => NodeJS.Platform>(),
|
||||
isWaylandSession: vi.fn<() => boolean>(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/utils/clipboard-native.js", () => {
|
||||
return {
|
||||
clipboard: mocks.clipboard,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("child_process", () => {
|
||||
return {
|
||||
execFileSync: mocks.execFileSync,
|
||||
execSync: mocks.execSync,
|
||||
spawn: mocks.spawn,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("os", () => {
|
||||
return {
|
||||
platform: mocks.platform,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/utils/clipboard-image.js", () => {
|
||||
return {
|
||||
isWaylandSession: mocks.isWaylandSession,
|
||||
};
|
||||
});
|
||||
|
||||
const mockedExecFileSync = vi.mocked(execFileSync);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedSpawn = vi.mocked(spawn);
|
||||
const mockedPlatform = vi.mocked(platform);
|
||||
|
||||
let originalWrite: typeof process.stdout.write;
|
||||
let stdoutWrites: string[];
|
||||
let nativeResolved = false;
|
||||
|
||||
function osc52Writes(): string[] {
|
||||
return stdoutWrites.filter((write) => write.startsWith("\x1b]52;c;"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("SSH_CONNECTION", "");
|
||||
vi.stubEnv("SSH_CLIENT", "");
|
||||
vi.stubEnv("MOSH_CONNECTION", "");
|
||||
stdoutWrites = [];
|
||||
nativeResolved = false;
|
||||
mocks.clipboard.getText.mockReset();
|
||||
mocks.clipboard.setText.mockReset();
|
||||
mocks.execFileSync.mockReset();
|
||||
mocks.execSync.mockReset();
|
||||
mocks.spawn.mockReset();
|
||||
mocks.platform.mockReset();
|
||||
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;
|
||||
});
|
||||
originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = ((...args: Parameters<typeof process.stdout.write>) => {
|
||||
const [chunk] = args;
|
||||
if (typeof chunk === "string" && chunk.startsWith("\x1b]52;c;")) {
|
||||
stdoutWrites.push(chunk);
|
||||
return true;
|
||||
}
|
||||
return originalWrite(...args);
|
||||
}) as typeof process.stdout.write;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdout.write = originalWrite;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("readClipboardText", () => {
|
||||
test("returns native clipboard text", async () => {
|
||||
mocks.clipboard.getText.mockResolvedValue("clipboard text");
|
||||
|
||||
await expect(readClipboardText()).resolves.toBe("clipboard text");
|
||||
});
|
||||
|
||||
test("reads the Wayland clipboard before the stale native X11 clipboard", async () => {
|
||||
// Regression test for #7248.
|
||||
mockedPlatform.mockReturnValue("linux");
|
||||
mocks.isWaylandSession.mockReturnValue(true);
|
||||
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
|
||||
mockedExecFileSync.mockReturnValue("Wayland text");
|
||||
mocks.clipboard.getText.mockResolvedValue("stale X11 text");
|
||||
|
||||
await expect(readClipboardText()).resolves.toBe("Wayland text");
|
||||
expect(mockedExecFileSync).toHaveBeenCalledWith("wl-paste", ["--no-newline", "--type", "text"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 5000,
|
||||
});
|
||||
expect(mocks.clipboard.getText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not fall back to stale X11 text when the Wayland clipboard is empty", async () => {
|
||||
mockedPlatform.mockReturnValue("linux");
|
||||
mocks.isWaylandSession.mockReturnValue(true);
|
||||
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
|
||||
mockedExecFileSync.mockReturnValue("");
|
||||
mocks.clipboard.getText.mockResolvedValue("stale X11 text");
|
||||
|
||||
await expect(readClipboardText()).resolves.toBeNull();
|
||||
expect(mocks.clipboard.getText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to the native clipboard when wl-paste is unavailable", async () => {
|
||||
mockedPlatform.mockReturnValue("linux");
|
||||
mocks.isWaylandSession.mockReturnValue(true);
|
||||
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
|
||||
mockedExecFileSync.mockImplementation(() => {
|
||||
throw new Error("wl-paste unavailable");
|
||||
});
|
||||
mocks.clipboard.getText.mockResolvedValue("X11 fallback text");
|
||||
|
||||
await expect(readClipboardText()).resolves.toBe("X11 fallback 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");
|
||||
|
||||
expect(mocks.clipboard.setText).toHaveBeenCalledWith("hello");
|
||||
expect(osc52Writes()).toHaveLength(0);
|
||||
expect(mockedExecSync).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("remote native success emits OSC 52 after native write", async () => {
|
||||
vi.stubEnv("SSH_CONNECTION", "client server");
|
||||
mocks.clipboard.setText.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(osc52Writes()).toHaveLength(0);
|
||||
nativeResolved = true;
|
||||
});
|
||||
|
||||
await copyToClipboard("hello");
|
||||
|
||||
expect(nativeResolved).toBe(true);
|
||||
expect(osc52Writes()).toHaveLength(1);
|
||||
expect(mockedExecSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("local shell fallback success skips OSC 52", async () => {
|
||||
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
|
||||
mockedExecSync.mockReturnValue(Buffer.alloc(0));
|
||||
|
||||
await copyToClipboard("hello");
|
||||
|
||||
expect(mockedExecSync).toHaveBeenCalledWith("pbcopy", {
|
||||
input: "hello",
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
timeout: 5000,
|
||||
});
|
||||
expect(osc52Writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("uses OSC 52 fallback when native and shell tools fail", async () => {
|
||||
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("pbcopy failed");
|
||||
});
|
||||
|
||||
await copyToClipboard("hello");
|
||||
|
||||
expect(osc52Writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("does not emit oversized OSC 52 payloads", async () => {
|
||||
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("pbcopy failed");
|
||||
});
|
||||
|
||||
await expect(copyToClipboard("x".repeat(80_000))).rejects.toThrow("Failed to copy to clipboard");
|
||||
expect(osc52Writes()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
152
packages/coding-agent/test/compaction-extensions-example.test.ts
Normal file
152
packages/coding-agent/test/compaction-extensions-example.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Verify the documentation example from extensions.md compiles and works.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ExtensionAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/extensions/index.ts";
|
||||
|
||||
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||
convertToLlm: (messages: unknown) => messages,
|
||||
serializeConversation: () => "conversation",
|
||||
}));
|
||||
|
||||
const { default: customCompactionExtension } = await import("../examples/extensions/custom-compaction.ts");
|
||||
|
||||
describe("Documentation example", () => {
|
||||
it("custom compaction example should type-check correctly", () => {
|
||||
// This is the example from extensions.md - verify it compiles
|
||||
const exampleExtension = (pi: ExtensionAPI) => {
|
||||
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => {
|
||||
// All these should be accessible on the event
|
||||
const { preparation, branchEntries } = event;
|
||||
// sessionManager, modelRegistry, and model come from ctx
|
||||
const { sessionManager, modelRegistry } = ctx;
|
||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, isSplitTurn } =
|
||||
preparation;
|
||||
|
||||
// Verify types
|
||||
expect(Array.isArray(messagesToSummarize)).toBe(true);
|
||||
expect(Array.isArray(turnPrefixMessages)).toBe(true);
|
||||
expect(typeof isSplitTurn).toBe("boolean");
|
||||
expect(typeof tokensBefore).toBe("number");
|
||||
expect(typeof sessionManager.getEntries).toBe("function");
|
||||
expect(typeof modelRegistry.getApiKeyAndHeaders).toBe("function");
|
||||
expect(typeof firstKeptEntryId).toBe("string");
|
||||
expect(Array.isArray(branchEntries)).toBe(true);
|
||||
|
||||
const summary = messagesToSummarize
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`)
|
||||
.join("\n");
|
||||
|
||||
// Extensions return compaction content - SessionManager adds id/parentId
|
||||
return {
|
||||
compaction: {
|
||||
summary: `User requests:\n${summary}`,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Just verify the function exists and is callable
|
||||
expect(typeof exampleExtension).toBe("function");
|
||||
});
|
||||
|
||||
it("custom compaction example dispatches through modelRegistry.complete", async () => {
|
||||
let handler: ((event: any, ctx: any) => Promise<any>) | undefined;
|
||||
customCompactionExtension({
|
||||
on(event, fn) {
|
||||
if (event === "session_before_compact") handler = fn as typeof handler;
|
||||
},
|
||||
} as ExtensionAPI);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
const complete = vi.fn(async () => ({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "custom provider summary" }],
|
||||
provider: "example-custom",
|
||||
api: "example-custom-api",
|
||||
model: "summary-model",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
const model = {
|
||||
provider: "example-custom",
|
||||
api: "example-custom-api",
|
||||
id: "summary-model",
|
||||
name: "Summary Model",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
|
||||
const result = await handler!(
|
||||
{
|
||||
preparation: {
|
||||
messagesToSummarize: [
|
||||
{ role: "user", content: [{ type: "text", text: "please remember this" }], timestamp: Date.now() },
|
||||
],
|
||||
turnPrefixMessages: [],
|
||||
tokensBefore: 42,
|
||||
firstKeptEntryId: "entry-1",
|
||||
},
|
||||
branchEntries: [],
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
{
|
||||
ui: { notify: vi.fn() },
|
||||
modelRegistry: {
|
||||
find: vi.fn(() => model),
|
||||
complete,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(complete).toHaveBeenCalledWith(
|
||||
model,
|
||||
expect.objectContaining({ messages: expect.any(Array) }),
|
||||
expect.objectContaining({ maxTokens: 8192 }),
|
||||
);
|
||||
expect(complete).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ apiKey: expect.anything() }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
compaction: {
|
||||
summary: "custom provider summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("compact event should have correct fields", () => {
|
||||
const checkCompactEvent = (pi: ExtensionAPI) => {
|
||||
pi.on("session_compact", async (event: SessionCompactEvent) => {
|
||||
// These should all be accessible
|
||||
const entry = event.compactionEntry;
|
||||
const fromExtension = event.fromExtension;
|
||||
|
||||
expect(entry.type).toBe("compaction");
|
||||
expect(typeof entry.summary).toBe("string");
|
||||
expect(typeof entry.tokensBefore).toBe("number");
|
||||
expect(typeof fromExtension).toBe("boolean");
|
||||
});
|
||||
};
|
||||
|
||||
expect(typeof checkCompactEvent).toBe("function");
|
||||
});
|
||||
});
|
||||
416
packages/coding-agent/test/compaction-extensions.test.ts
Normal file
416
packages/coding-agent/test/compaction-extensions.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
/**
|
||||
* Tests for compaction extension events (before_compact / compact).
|
||||
*/
|
||||
|
||||
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 { getModel, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import {
|
||||
createExtensionRuntime,
|
||||
type Extension,
|
||||
type SessionBeforeCompactEvent,
|
||||
type SessionCompactEvent,
|
||||
type SessionEvent,
|
||||
} from "../src/core/extensions/index.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createSyntheticSourceInfo } from "../src/core/source-info.ts";
|
||||
import { createCodingTools } from "../src/index.ts";
|
||||
import { createTestResourceLoader } from "./utilities.ts";
|
||||
|
||||
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
let capturedEvents: SessionEvent[];
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = join(tmpdir(), `pi-compaction-extensions-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
capturedEvents = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createExtension(
|
||||
onBeforeCompact?: (event: SessionBeforeCompactEvent) => { cancel?: boolean; compaction?: any } | undefined,
|
||||
onCompact?: (event: SessionCompactEvent) => void,
|
||||
): Extension {
|
||||
const handlers = new Map<string, ((event: any, ctx: any) => Promise<any>)[]>();
|
||||
|
||||
handlers.set("session_before_compact", [
|
||||
async (event: SessionBeforeCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
if (onBeforeCompact) {
|
||||
return onBeforeCompact(event);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
]);
|
||||
|
||||
handlers.set("session_compact", [
|
||||
async (event: SessionCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
if (onCompact) {
|
||||
onCompact(event);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
path: "test-extension",
|
||||
resolvedPath: "/test/test-extension.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:test-extension>", { source: "test" }),
|
||||
handlers,
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(extensions: Extension[]) {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => API_KEY,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
tools: createCodingTools(process.cwd()),
|
||||
},
|
||||
});
|
||||
|
||||
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 = await createModelRegistry(authStorage);
|
||||
|
||||
const runtime = createExtensionRuntime();
|
||||
const resourceLoader = {
|
||||
...createTestResourceLoader(),
|
||||
getExtensions: () => ({ extensions, errors: [], runtime }),
|
||||
};
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRuntime: getModelRuntime(modelRegistry),
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should emit before_compact and compact events", async () => {
|
||||
const extension = createExtension();
|
||||
await createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
const beforeCompactEvents = capturedEvents.filter(
|
||||
(e): e is SessionBeforeCompactEvent => e.type === "session_before_compact",
|
||||
);
|
||||
const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact");
|
||||
|
||||
expect(beforeCompactEvents.length).toBe(1);
|
||||
expect(compactEvents.length).toBe(1);
|
||||
|
||||
const beforeEvent = beforeCompactEvents[0];
|
||||
expect(beforeEvent.preparation).toBeDefined();
|
||||
expect(beforeEvent.preparation.messagesToSummarize).toBeDefined();
|
||||
expect(beforeEvent.preparation.turnPrefixMessages).toBeDefined();
|
||||
expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof beforeEvent.preparation.isSplitTurn).toBe("boolean");
|
||||
expect(beforeEvent.branchEntries).toBeDefined();
|
||||
// sessionManager, modelRegistry, and model are now on ctx, not event
|
||||
|
||||
const afterEvent = compactEvents[0];
|
||||
expect(afterEvent.compactionEntry).toBeDefined();
|
||||
expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0);
|
||||
expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0);
|
||||
expect(afterEvent.fromExtension).toBe(false);
|
||||
}, 120000);
|
||||
|
||||
it("should allow extensions to cancel compaction", async () => {
|
||||
const extension = createExtension(() => ({ cancel: true }));
|
||||
await createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await expect(session.compact()).rejects.toThrow("Compaction cancelled");
|
||||
|
||||
const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(0);
|
||||
}, 120000);
|
||||
|
||||
it("should allow extensions to provide custom compaction", async () => {
|
||||
const customSummary = "Custom summary from extension";
|
||||
|
||||
const extension = createExtension((event) => {
|
||||
if (event.type === "session_before_compact") {
|
||||
return {
|
||||
compaction: {
|
||||
summary: customSummary,
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
await createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBe(customSummary);
|
||||
|
||||
const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(1);
|
||||
|
||||
const afterEvent = compactEvents[0];
|
||||
if (afterEvent.type === "session_compact") {
|
||||
expect(afterEvent.compactionEntry.summary).toBe(customSummary);
|
||||
expect(afterEvent.fromExtension).toBe(true);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should include entries in compact event after compaction is saved", async () => {
|
||||
const extension = createExtension();
|
||||
await createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(1);
|
||||
|
||||
const afterEvent = compactEvents[0];
|
||||
if (afterEvent.type === "session_compact") {
|
||||
// sessionManager is now on ctx, use session.sessionManager directly
|
||||
const entries = session.sessionManager.getEntries();
|
||||
const hasCompactionEntry = entries.some((e: { type: string }) => e.type === "compaction");
|
||||
expect(hasCompactionEntry).toBe(true);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("should continue with default compaction if extension throws error", async () => {
|
||||
const throwingExtension: Extension = {
|
||||
path: "throwing-extension",
|
||||
resolvedPath: "/test/throwing-extension.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:throwing-extension>", { source: "test" }),
|
||||
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
|
||||
[
|
||||
"session_before_compact",
|
||||
[
|
||||
async (event: SessionBeforeCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
throw new Error("Extension intentionally throws");
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"session_compact",
|
||||
[
|
||||
async (event: SessionCompactEvent) => {
|
||||
capturedEvents.push(event);
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
|
||||
await createSession([throwingExtension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
|
||||
const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact");
|
||||
expect(compactEvents.length).toBe(1);
|
||||
expect(compactEvents[0].fromExtension).toBe(false);
|
||||
}, 120000);
|
||||
|
||||
it("should call multiple extensions in order", async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const extension1: Extension = {
|
||||
path: "extension1",
|
||||
resolvedPath: "/test/extension1.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:extension1>", { source: "test" }),
|
||||
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
|
||||
[
|
||||
"session_before_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension1-before");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"session_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension1-after");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
|
||||
const extension2: Extension = {
|
||||
path: "extension2",
|
||||
resolvedPath: "/test/extension2.ts",
|
||||
sourceInfo: createSyntheticSourceInfo("<test:extension2>", { source: "test" }),
|
||||
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
|
||||
[
|
||||
"session_before_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension2-before");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"session_compact",
|
||||
[
|
||||
async () => {
|
||||
callOrder.push("extension2-after");
|
||||
return undefined;
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
tools: new Map(),
|
||||
messageRenderers: new Map(),
|
||||
commands: new Map(),
|
||||
flags: new Map(),
|
||||
shortcuts: new Map(),
|
||||
};
|
||||
|
||||
await createSession([extension1, extension2]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
expect(callOrder).toEqual(["extension1-before", "extension2-before", "extension1-after", "extension2-after"]);
|
||||
}, 120000);
|
||||
|
||||
it("should pass correct data in before_compact event", async () => {
|
||||
let capturedBeforeEvent: SessionBeforeCompactEvent | null = null;
|
||||
|
||||
const extension = createExtension((event) => {
|
||||
capturedBeforeEvent = event;
|
||||
return undefined;
|
||||
});
|
||||
await createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.prompt("What is 3+3? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
await session.compact();
|
||||
|
||||
expect(capturedBeforeEvent).not.toBeNull();
|
||||
const event = capturedBeforeEvent!;
|
||||
expect(typeof event.preparation.isSplitTurn).toBe("boolean");
|
||||
expect(event.preparation.firstKeptEntryId).toBeDefined();
|
||||
|
||||
expect(Array.isArray(event.preparation.messagesToSummarize)).toBe(true);
|
||||
expect(Array.isArray(event.preparation.turnPrefixMessages)).toBe(true);
|
||||
|
||||
expect(typeof event.preparation.tokensBefore).toBe("number");
|
||||
|
||||
expect(Array.isArray(event.branchEntries)).toBe(true);
|
||||
|
||||
// sessionManager and model runtime remain available on the session.
|
||||
expect(typeof session.sessionManager.getEntries).toBe("function");
|
||||
expect(typeof session.modelRuntime.getAuth).toBe("function");
|
||||
|
||||
const entries = session.sessionManager.getEntries();
|
||||
expect(Array.isArray(entries)).toBe(true);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
}, 120000);
|
||||
|
||||
it("should use extension compaction even with different values", async () => {
|
||||
const customSummary = "Custom summary with modified values";
|
||||
|
||||
const extension = createExtension((event) => {
|
||||
if (event.type === "session_before_compact") {
|
||||
return {
|
||||
compaction: {
|
||||
summary: customSummary,
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: 999,
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
await createSession([extension]);
|
||||
|
||||
await session.prompt("What is 2+2? Reply with just the number.");
|
||||
await session.agent.waitForIdle();
|
||||
|
||||
const result = await session.compact();
|
||||
|
||||
expect(result.summary).toBe(customSummary);
|
||||
expect(result.tokensBefore).toBe(999);
|
||||
}, 120000);
|
||||
});
|
||||
79
packages/coding-agent/test/compaction-serialization.test.ts
Normal file
79
packages/coding-agent/test/compaction-serialization.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Message } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serializeConversation } from "../src/core/compaction/utils.ts";
|
||||
|
||||
describe("serializeConversation", () => {
|
||||
it("should truncate long tool results", () => {
|
||||
const longContent = "x".repeat(5000);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tc1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: longContent }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeConversation(messages);
|
||||
|
||||
expect(result).toContain("[Tool result]:");
|
||||
expect(result).toContain("[... 3000 more characters truncated]");
|
||||
expect(result).not.toContain("x".repeat(3000));
|
||||
// First 2000 chars should be present
|
||||
expect(result).toContain("x".repeat(2000));
|
||||
});
|
||||
|
||||
it("should not truncate short tool results", () => {
|
||||
const shortContent = "x".repeat(1500);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tc1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: shortContent }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeConversation(messages);
|
||||
|
||||
expect(result).toBe(`[Tool result]: ${shortContent}`);
|
||||
expect(result).not.toContain("truncated");
|
||||
});
|
||||
|
||||
it("should not truncate assistant or user messages", () => {
|
||||
const longText = "y".repeat(5000);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: longText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: longText }],
|
||||
api: "anthropic",
|
||||
provider: "anthropic",
|
||||
model: "test",
|
||||
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(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = serializeConversation(messages);
|
||||
|
||||
expect(result).not.toContain("truncated");
|
||||
expect(result).toContain(longText);
|
||||
});
|
||||
});
|
||||
297
packages/coding-agent/test/compaction-summary-reasoning.test.ts
Normal file
297
packages/coding-agent/test/compaction-summary-reasoning.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type CompactionPreparation,
|
||||
compact,
|
||||
completeSummarization,
|
||||
generateSummary,
|
||||
generateSummaryWithUsage,
|
||||
} from "../src/core/compaction/index.ts";
|
||||
|
||||
const { completeSimpleMock } = vi.hoisted(() => ({
|
||||
completeSimpleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@earendil-works/pi-ai/compat", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@earendil-works/pi-ai/compat")>();
|
||||
return {
|
||||
...actual,
|
||||
completeSimple: completeSimpleMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createModel(
|
||||
reasoning: boolean,
|
||||
maxTokens = 8192,
|
||||
compat?: Model<"anthropic-messages">["compat"],
|
||||
): Model<"anthropic-messages"> {
|
||||
return {
|
||||
id: reasoning ? "reasoning-model" : "non-reasoning-model",
|
||||
name: reasoning ? "Reasoning Model" : "Non-reasoning Model",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens,
|
||||
...(compat ? { compat } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const mockSummaryResponse: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "## Goal\nTest summary" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 20,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const mockToolCallResponse: AssistantMessage = {
|
||||
...mockSummaryResponse,
|
||||
content: [{ type: "toolCall", id: "tool-call-1", name: "read", arguments: { path: "README.md" } }],
|
||||
stopReason: "toolUse",
|
||||
};
|
||||
|
||||
const messages: AgentMessage[] = [{ role: "user", content: "Summarize this.", timestamp: Date.now() }];
|
||||
|
||||
describe("generateSummary reasoning options", () => {
|
||||
beforeEach(() => {
|
||||
completeSimpleMock.mockReset();
|
||||
completeSimpleMock.mockResolvedValue(mockSummaryResponse);
|
||||
});
|
||||
|
||||
it("uses the provided thinking level for reasoning-capable models", async () => {
|
||||
const result = await generateSummaryWithUsage(
|
||||
messages,
|
||||
createModel(true),
|
||||
2000,
|
||||
"test-key",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
);
|
||||
|
||||
expect(result.text).toBe("## Goal\nTest summary");
|
||||
expect(result.usage).toEqual(mockSummaryResponse.usage);
|
||||
|
||||
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
||||
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
|
||||
reasoning: "medium",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the string result from generateSummary", async () => {
|
||||
await expect(generateSummary(messages, createModel(false), 2000, "test-key")).resolves.toBe(
|
||||
"## Goal\nTest summary",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses fresh routing sessions without prompt caching", async () => {
|
||||
await generateSummary(messages, createModel(false), 2000, "test-key");
|
||||
await generateSummary(messages, createModel(false), 2000, "test-key");
|
||||
|
||||
const requestOptions = completeSimpleMock.mock.calls.map((call) => call[2]);
|
||||
expect(requestOptions).toHaveLength(2);
|
||||
expect(requestOptions.every((options) => options?.cacheRetention === "none")).toBe(true);
|
||||
|
||||
const sessionIds = requestOptions.map((options) => options?.sessionId);
|
||||
expect(sessionIds[0]).not.toBe(sessionIds[1]);
|
||||
});
|
||||
|
||||
it("honors caller-supplied routing session and tool choice without prompt caching", async () => {
|
||||
await completeSummarization(
|
||||
createModel(false),
|
||||
{ systemPrompt: "Summarize", messages: [] },
|
||||
{ sessionId: "current-routing-session", cacheRetention: "long", toolChoice: "auto" },
|
||||
);
|
||||
|
||||
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
|
||||
sessionId: "current-routing-session",
|
||||
cacheRetention: "none",
|
||||
toolChoice: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the standalone split-turn summary prompt", async () => {
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: [],
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
await compact(preparation, createModel(false), "test-key");
|
||||
|
||||
const requestContext = completeSimpleMock.mock.calls[0][1] as Context;
|
||||
const prompt = JSON.stringify(requestContext.messages);
|
||||
expect(prompt).toContain("This is the PREFIX of a turn that was too large to keep");
|
||||
expect(prompt).toContain("<conversation>");
|
||||
});
|
||||
|
||||
it("rejects tool calls from conversation summaries", async () => {
|
||||
completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse);
|
||||
|
||||
await expect(generateSummaryWithUsage(messages, createModel(false), 2000, "test-key")).rejects.toThrow(
|
||||
"Summarization attempted to call a tool",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects tool calls from split-turn summaries", async () => {
|
||||
completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse);
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: [],
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
await expect(compact(preparation, createModel(false), "test-key")).rejects.toThrow(
|
||||
"Turn prefix summarization attempted to call a tool",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a length-limited history summary", async () => {
|
||||
completeSimpleMock.mockResolvedValueOnce({
|
||||
...mockSummaryResponse,
|
||||
stopReason: "length",
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
});
|
||||
|
||||
await expect(generateSummaryWithUsage(messages, createModel(false), 2000, "test-key")).rejects.toThrow(
|
||||
"generation hit the token cap",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a length-limited split-turn summary", async () => {
|
||||
completeSimpleMock.mockResolvedValueOnce({
|
||||
...mockSummaryResponse,
|
||||
stopReason: "length",
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
});
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: [],
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
await expect(compact(preparation, createModel(false), "test-key")).rejects.toThrow(
|
||||
"generation hit the token cap",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not set reasoning when thinking is off", async () => {
|
||||
await generateSummary(
|
||||
messages,
|
||||
createModel(true),
|
||||
2000,
|
||||
"test-key",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"off",
|
||||
);
|
||||
|
||||
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
||||
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
||||
it("does not set reasoning for non-reasoning models", async () => {
|
||||
await generateSummary(
|
||||
messages,
|
||||
createModel(false),
|
||||
2000,
|
||||
"test-key",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
);
|
||||
|
||||
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
||||
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
||||
it("leaves Anthropic refusal fallback handling to pi-ai model metadata", async () => {
|
||||
await generateSummary(
|
||||
messages,
|
||||
createModel(true, 8192, {
|
||||
allowedFallbackModels: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-8",
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
2000,
|
||||
"test-key",
|
||||
);
|
||||
|
||||
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
||||
expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks");
|
||||
});
|
||||
|
||||
it("does not set Anthropic refusal fallback for models without allowed fallback targets", async () => {
|
||||
await generateSummary(messages, createModel(true), 2000, "test-key");
|
||||
|
||||
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
||||
expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks");
|
||||
});
|
||||
|
||||
it("clamps compaction summary maxTokens to the model output cap", async () => {
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 600000,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
|
||||
};
|
||||
|
||||
const result = await compact(preparation, createModel(false, 128000), "test-key");
|
||||
|
||||
expect(result.usage).toEqual({
|
||||
...mockSummaryResponse.usage,
|
||||
input: 20,
|
||||
output: 20,
|
||||
totalTokens: 40,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
});
|
||||
expect(completeSimpleMock.mock.calls.map((call) => call[2]?.maxTokens)).toEqual([128000, 128000]);
|
||||
});
|
||||
});
|
||||
596
packages/coding-agent/test/compaction.test.ts
Normal file
596
packages/coding-agent/test/compaction.test.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Usage } from "@earendil-works/pi-ai/compat";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CompactionSettings,
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateContextTokens,
|
||||
findCutPoint,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
shouldCompact,
|
||||
} from "../src/core/compaction/index.ts";
|
||||
import {
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
type CustomMessageEntry,
|
||||
type ModelChangeEntry,
|
||||
migrateSessionEntries,
|
||||
parseSessionEntries,
|
||||
type SessionEntry,
|
||||
type SessionMessageEntry,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "../src/core/session-manager.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Test fixtures
|
||||
// ============================================================================
|
||||
|
||||
function loadLargeSessionEntries(): SessionEntry[] {
|
||||
const sessionPath = join(__dirname, "fixtures/large-session.jsonl");
|
||||
const content = readFileSync(sessionPath, "utf-8");
|
||||
const entries = parseSessionEntries(content);
|
||||
migrateSessionEntries(entries); // Add id/parentId for v1 fixtures
|
||||
return entries.filter((e): e is SessionEntry => e.type !== "session");
|
||||
}
|
||||
|
||||
function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage {
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens: input + output + cacheRead + cacheWrite,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string): AgentMessage {
|
||||
return { role: "user", content: text, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, usage?: Usage): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
usage: usage || createMockUsage(100, 50),
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
};
|
||||
}
|
||||
|
||||
let entryCounter = 0;
|
||||
let lastId: string | null = null;
|
||||
|
||||
function resetEntryCounter() {
|
||||
entryCounter = 0;
|
||||
lastId = null;
|
||||
}
|
||||
|
||||
// Reset counter before each test to get predictable IDs
|
||||
beforeEach(() => {
|
||||
resetEntryCounter();
|
||||
});
|
||||
|
||||
function createMessageEntry(message: AgentMessage): SessionMessageEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createCompactionEntry(summary: string, firstKeptEntryId: string): CompactionEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: CompactionEntry = {
|
||||
type: "compaction",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore: 10000,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createModelChangeEntry(provider: string, modelId: string): ModelChangeEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: ModelChangeEntry = {
|
||||
type: "model_change",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createThinkingLevelEntry(thinkingLevel: string): ThinkingLevelChangeEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: ThinkingLevelChangeEntry = {
|
||||
type: "thinking_level_change",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createCustomMessageEntry(content: string): CustomMessageEntry {
|
||||
const id = `test-id-${entryCounter++}`;
|
||||
const entry: CustomMessageEntry = {
|
||||
type: "custom_message",
|
||||
id,
|
||||
parentId: lastId,
|
||||
timestamp: new Date().toISOString(),
|
||||
customType: "test",
|
||||
content,
|
||||
display: true,
|
||||
};
|
||||
lastId = id;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function extractText(messages: AgentMessage[]): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
case "assistant":
|
||||
return message.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
return message.summary;
|
||||
case "custom":
|
||||
case "toolResult":
|
||||
return typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
case "bashExecution":
|
||||
return `${message.command}\n${message.output}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests
|
||||
// ============================================================================
|
||||
|
||||
describe("Token calculation", () => {
|
||||
it("should calculate total context tokens from usage", () => {
|
||||
const usage = createMockUsage(1000, 500, 200, 100);
|
||||
expect(calculateContextTokens(usage)).toBe(1800);
|
||||
});
|
||||
|
||||
it("should handle zero values", () => {
|
||||
const usage = createMockUsage(0, 0, 0, 0);
|
||||
expect(calculateContextTokens(usage)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastAssistantUsage", () => {
|
||||
it("should find the last non-aborted assistant message usage", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("Hello")),
|
||||
createMessageEntry(createAssistantMessage("Hi", createMockUsage(100, 50))),
|
||||
createMessageEntry(createUserMessage("How are you?")),
|
||||
createMessageEntry(createAssistantMessage("Good", createMockUsage(200, 100))),
|
||||
];
|
||||
|
||||
const usage = getLastAssistantUsage(entries);
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage!.input).toBe(200);
|
||||
});
|
||||
|
||||
it("should skip aborted messages", () => {
|
||||
const abortedMsg: AssistantMessage = {
|
||||
...createAssistantMessage("Aborted", createMockUsage(300, 150)),
|
||||
stopReason: "aborted",
|
||||
};
|
||||
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("Hello")),
|
||||
createMessageEntry(createAssistantMessage("Hi", createMockUsage(100, 50))),
|
||||
createMessageEntry(createUserMessage("How are you?")),
|
||||
createMessageEntry(abortedMsg),
|
||||
];
|
||||
|
||||
const usage = getLastAssistantUsage(entries);
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage!.input).toBe(100);
|
||||
});
|
||||
|
||||
it("should skip all-zero assistant usage", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("Hello")),
|
||||
createMessageEntry(createAssistantMessage("Hi", createMockUsage(100, 50))),
|
||||
createMessageEntry(createUserMessage("continue")),
|
||||
createMessageEntry(createAssistantMessage("Partial", createMockUsage(0, 0))),
|
||||
];
|
||||
|
||||
const usage = getLastAssistantUsage(entries);
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage!.input).toBe(100);
|
||||
});
|
||||
|
||||
it("should return undefined if no assistant messages", () => {
|
||||
const entries: SessionEntry[] = [createMessageEntry(createUserMessage("Hello"))];
|
||||
expect(getLastAssistantUsage(entries)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateContextTokens", () => {
|
||||
it("uses the last non-zero assistant usage as the context anchor", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
createUserMessage("Hello"),
|
||||
createAssistantMessage("Hi", createMockUsage(100, 50)),
|
||||
createUserMessage("continue"),
|
||||
createAssistantMessage("Partial thinking", createMockUsage(0, 0)),
|
||||
];
|
||||
|
||||
const estimate = estimateContextTokens(messages);
|
||||
|
||||
expect(estimate.usageTokens).toBe(150);
|
||||
expect(estimate.lastUsageIndex).toBe(1);
|
||||
expect(estimate.trailingTokens).toBeGreaterThan(0);
|
||||
expect(estimate.tokens).toBe(150 + estimate.trailingTokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCompact", () => {
|
||||
it("should return true when context exceeds threshold", () => {
|
||||
const settings: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 10000,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
expect(shouldCompact(95000, 100000, settings)).toBe(true);
|
||||
expect(shouldCompact(89000, 100000, settings)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when disabled", () => {
|
||||
const settings: CompactionSettings = {
|
||||
enabled: false,
|
||||
reserveTokens: 10000,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
expect(shouldCompact(95000, 100000, settings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCutPoint", () => {
|
||||
it("should find cut point based on actual token differences", () => {
|
||||
// Create entries with cumulative token counts
|
||||
const entries: SessionEntry[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
entries.push(createMessageEntry(createUserMessage(`User ${i}`)));
|
||||
entries.push(
|
||||
createMessageEntry(createAssistantMessage(`Assistant ${i}`, createMockUsage(0, 100, (i + 1) * 1000, 0))),
|
||||
);
|
||||
}
|
||||
|
||||
// 20 entries, last assistant has 10000 tokens
|
||||
// keepRecentTokens = 2500: keep entries where diff < 2500
|
||||
const result = findCutPoint(entries, 0, entries.length, 2500);
|
||||
|
||||
// Should cut at a valid cut point (user or assistant message)
|
||||
expect(entries[result.firstKeptEntryIndex].type).toBe("message");
|
||||
const role = (entries[result.firstKeptEntryIndex] as SessionMessageEntry).message.role;
|
||||
expect(role === "user" || role === "assistant").toBe(true);
|
||||
});
|
||||
|
||||
it("should return startIndex if no valid cut points in range", () => {
|
||||
const entries: SessionEntry[] = [createMessageEntry(createAssistantMessage("a"))];
|
||||
const result = findCutPoint(entries, 0, entries.length, 1000);
|
||||
expect(result.firstKeptEntryIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should keep everything if all messages fit within budget", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a", createMockUsage(0, 50, 500, 0))),
|
||||
createMessageEntry(createUserMessage("2")),
|
||||
createMessageEntry(createAssistantMessage("b", createMockUsage(0, 50, 1000, 0))),
|
||||
];
|
||||
|
||||
const result = findCutPoint(entries, 0, entries.length, 50000);
|
||||
expect(result.firstKeptEntryIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should indicate split turn when cutting at assistant message", () => {
|
||||
// Create a scenario where we cut at an assistant message mid-turn
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("Turn 1")),
|
||||
createMessageEntry(createAssistantMessage("A1", createMockUsage(0, 100, 1000, 0))),
|
||||
createMessageEntry(createUserMessage("Turn 2")), // index 2
|
||||
createMessageEntry(createAssistantMessage("A2-1", createMockUsage(0, 100, 5000, 0))), // index 3
|
||||
createMessageEntry(createAssistantMessage("A2-2", createMockUsage(0, 100, 8000, 0))), // index 4
|
||||
createMessageEntry(createAssistantMessage("A2-3", createMockUsage(0, 100, 10000, 0))), // index 5
|
||||
];
|
||||
|
||||
// With keepRecentTokens = 3000, should cut somewhere in Turn 2
|
||||
const result = findCutPoint(entries, 0, entries.length, 3000);
|
||||
|
||||
// If cut at assistant message (not user), should indicate split turn
|
||||
const cutEntry = entries[result.firstKeptEntryIndex] as SessionMessageEntry;
|
||||
if (cutEntry.message.role === "assistant") {
|
||||
expect(result.isSplitTurn).toBe(true);
|
||||
expect(result.turnStartIndex).toBe(2); // Turn 2 starts at index 2
|
||||
}
|
||||
});
|
||||
|
||||
it("should budget context-visible custom message entries", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("hi")),
|
||||
createMessageEntry(createAssistantMessage("hello")),
|
||||
createCustomMessageEntry("x".repeat(4000)),
|
||||
createMessageEntry(createAssistantMessage("ok")),
|
||||
];
|
||||
|
||||
const tinyBudget = findCutPoint(entries, 0, entries.length, 1);
|
||||
expect(tinyBudget.firstKeptEntryIndex).toBe(3);
|
||||
expect(tinyBudget.isSplitTurn).toBe(true);
|
||||
expect(tinyBudget.turnStartIndex).toBe(2);
|
||||
|
||||
const customFitsBudget = findCutPoint(entries, 0, entries.length, 2);
|
||||
expect(customFitsBudget.firstKeptEntryIndex).toBe(2);
|
||||
expect(customFitsBudget.isSplitTurn).toBe(false);
|
||||
expect(customFitsBudget.turnStartIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionContext", () => {
|
||||
it("should load all messages when no compaction", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
createMessageEntry(createUserMessage("2")),
|
||||
createMessageEntry(createAssistantMessage("b")),
|
||||
];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
expect(loaded.messages.length).toBe(4);
|
||||
expect(loaded.thinkingLevel).toBe("off");
|
||||
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
});
|
||||
|
||||
it("should handle single compaction", () => {
|
||||
// IDs: u1=test-id-0, a1=test-id-1, u2=test-id-2, a2=test-id-3, compaction=test-id-4, u3=test-id-5, a3=test-id-6
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"));
|
||||
const u2 = createMessageEntry(createUserMessage("2"));
|
||||
const a2 = createMessageEntry(createAssistantMessage("b"));
|
||||
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id); // keep from u2 onwards
|
||||
const u3 = createMessageEntry(createUserMessage("3"));
|
||||
const a3 = createMessageEntry(createAssistantMessage("c"));
|
||||
|
||||
const entries: SessionEntry[] = [u1, a1, u2, a2, compaction, u3, a3];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// summary + kept (u2, a2) + after (u3, a3) = 5
|
||||
expect(loaded.messages.length).toBe(5);
|
||||
expect(loaded.messages[0].role).toBe("compactionSummary");
|
||||
expect((loaded.messages[0] as any).summary).toContain("Summary of 1,a,2,b");
|
||||
});
|
||||
|
||||
it("should handle multiple compactions (only latest matters)", () => {
|
||||
// First batch
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"));
|
||||
const compact1 = createCompactionEntry("First summary", u1.id);
|
||||
// Second batch
|
||||
const u2 = createMessageEntry(createUserMessage("2"));
|
||||
const b = createMessageEntry(createAssistantMessage("b"));
|
||||
const u3 = createMessageEntry(createUserMessage("3"));
|
||||
const c = createMessageEntry(createAssistantMessage("c"));
|
||||
const compact2 = createCompactionEntry("Second summary", u3.id); // keep from u3 onwards
|
||||
// After second compaction
|
||||
const u4 = createMessageEntry(createUserMessage("4"));
|
||||
const d = createMessageEntry(createAssistantMessage("d"));
|
||||
|
||||
const entries: SessionEntry[] = [u1, a1, compact1, u2, b, u3, c, compact2, u4, d];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// summary + kept from u3 (u3, c) + after (u4, d) = 5
|
||||
expect(loaded.messages.length).toBe(5);
|
||||
expect((loaded.messages[0] as any).summary).toContain("Second summary");
|
||||
});
|
||||
|
||||
it("should keep all messages when firstKeptEntryId is first entry", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"));
|
||||
const compact1 = createCompactionEntry("First summary", u1.id); // keep from first entry
|
||||
const u2 = createMessageEntry(createUserMessage("2"));
|
||||
const b = createMessageEntry(createAssistantMessage("b"));
|
||||
|
||||
const entries: SessionEntry[] = [u1, a1, compact1, u2, b];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// summary + all messages (u1, a1, u2, b) = 5
|
||||
expect(loaded.messages.length).toBe(5);
|
||||
});
|
||||
|
||||
it("should track model and thinking level changes", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createModelChangeEntry("openai", "gpt-4"),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
createThinkingLevelEntry("high"),
|
||||
];
|
||||
|
||||
const loaded = buildSessionContext(entries);
|
||||
// model_change is later overwritten by assistant message's model info
|
||||
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
expect(loaded.thinkingLevel).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareCompaction with previous compaction", () => {
|
||||
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"));
|
||||
const a2 = createMessageEntry(createAssistantMessage("assistant msg 2"));
|
||||
const u3 = createMessageEntry(createUserMessage("user msg 3 - kept by compaction1"));
|
||||
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(5000, 1000)));
|
||||
const compaction1 = createCompactionEntry("First summary", u2.id);
|
||||
const u4 = createMessageEntry(createUserMessage("user msg 4 (new after compaction1)"));
|
||||
const a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000)));
|
||||
|
||||
const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4];
|
||||
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
|
||||
|
||||
expect(preparation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should re-summarize previously kept messages when the recent window moves past them", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)".repeat(4)));
|
||||
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1".repeat(4)));
|
||||
const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1 ".repeat(12)));
|
||||
const a2 = createMessageEntry(createAssistantMessage("assistant msg 2 ".repeat(12)));
|
||||
const u3 = createMessageEntry(createUserMessage("user msg 3 - kept by compaction1 ".repeat(12)));
|
||||
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3 ".repeat(12), createMockUsage(5000, 1000)));
|
||||
const compaction1 = createCompactionEntry("First summary", u2.id);
|
||||
const u4 = createMessageEntry(createUserMessage("user msg 4 (new after compaction1) ".repeat(12)));
|
||||
const a4 = createMessageEntry(createAssistantMessage("assistant msg 4 ".repeat(12), createMockUsage(8000, 2000)));
|
||||
|
||||
const settings: CompactionSettings = {
|
||||
...DEFAULT_COMPACTION_SETTINGS,
|
||||
keepRecentTokens: 100,
|
||||
};
|
||||
const preparation = prepareCompaction([u1, a1, u2, a2, u3, a3, compaction1, u4, a4], settings);
|
||||
|
||||
expect(preparation).toBeDefined();
|
||||
const summarizedText = extractText(preparation!.messagesToSummarize);
|
||||
expect(summarizedText).toContain("user msg 2 - kept by compaction1");
|
||||
expect(summarizedText).toContain("user msg 3 - kept by compaction1");
|
||||
expect(summarizedText).not.toContain("First summary");
|
||||
expect(preparation!.previousSummary).toBe("First summary");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Integration tests with real session data
|
||||
// ============================================================================
|
||||
|
||||
describe("Large session fixture", () => {
|
||||
it("should parse the large session", () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
expect(entries.length).toBeGreaterThan(100);
|
||||
|
||||
const messageCount = entries.filter((e) => e.type === "message").length;
|
||||
expect(messageCount).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("should find cut point in large session", () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const result = findCutPoint(entries, 0, entries.length, DEFAULT_COMPACTION_SETTINGS.keepRecentTokens);
|
||||
|
||||
// Cut point should be at a message entry (user or assistant)
|
||||
expect(entries[result.firstKeptEntryIndex].type).toBe("message");
|
||||
const role = (entries[result.firstKeptEntryIndex] as SessionMessageEntry).message.role;
|
||||
expect(role === "user" || role === "assistant").toBe(true);
|
||||
});
|
||||
|
||||
it("should load session correctly", () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const loaded = buildSessionContext(entries);
|
||||
|
||||
expect(loaded.messages.length).toBeGreaterThan(100);
|
||||
expect(loaded.model).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LLM integration tests (skipped without API key)
|
||||
// ============================================================================
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => {
|
||||
it("should generate a compaction result for the large session", async () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
|
||||
expect(preparation).toBeDefined();
|
||||
|
||||
const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!);
|
||||
|
||||
expect(compactionResult.summary.length).toBeGreaterThan(100);
|
||||
expect(compactionResult.firstKeptEntryId).toBeTruthy();
|
||||
expect(compactionResult.tokensBefore).toBeGreaterThan(0);
|
||||
|
||||
console.log("Summary length:", compactionResult.summary.length);
|
||||
console.log("First kept entry ID:", compactionResult.firstKeptEntryId);
|
||||
console.log("Tokens before:", compactionResult.tokensBefore);
|
||||
console.log("\n--- SUMMARY ---\n");
|
||||
console.log(compactionResult.summary);
|
||||
}, 60000);
|
||||
|
||||
it("should produce valid session after compaction", async () => {
|
||||
const entries = loadLargeSessionEntries();
|
||||
const loaded = buildSessionContext(entries);
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
|
||||
expect(preparation).toBeDefined();
|
||||
|
||||
const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!);
|
||||
|
||||
// Simulate appending compaction to entries by creating a proper entry
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
const parentId = lastEntry.id;
|
||||
const compactionEntry: CompactionEntry = {
|
||||
type: "compaction",
|
||||
id: "compaction-test-id",
|
||||
parentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...compactionResult,
|
||||
};
|
||||
const newEntries = [...entries, compactionEntry];
|
||||
const reloaded = buildSessionContext(newEntries);
|
||||
|
||||
// Should have summary + kept messages
|
||||
expect(reloaded.messages.length).toBeLessThan(loaded.messages.length);
|
||||
expect(reloaded.messages[0].role).toBe("compactionSummary");
|
||||
expect((reloaded.messages[0] as any).summary).toContain(compactionResult.summary);
|
||||
|
||||
console.log("Original messages:", loaded.messages.length);
|
||||
console.log("After compaction:", reloaded.messages.length);
|
||||
}, 60000);
|
||||
});
|
||||
178
packages/coding-agent/test/config-value-migration.test.ts
Normal file
178
packages/coding-agent/test/config-value-migration.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../src/config.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { runMigrations } from "../src/migrations.ts";
|
||||
|
||||
import { createModelRegistry } from "./model-runtime-test-utils.ts";
|
||||
|
||||
describe("config value env var syntax migration", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createAgentDir(): string {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-config-value-migration-test-"));
|
||||
tempDirs.push(agentDir);
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
function withAgentDir(agentDir: string, fn: () => void): void {
|
||||
const previousAgentDir = process.env[ENV_AGENT_DIR];
|
||||
process.env[ENV_AGENT_DIR] = agentDir;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env[ENV_AGENT_DIR];
|
||||
} else {
|
||||
process.env[ENV_AGENT_DIR] = previousAgentDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("leaves uppercase auth.json API key values unchanged", () => {
|
||||
const agentDir = createAgentDir();
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, "auth.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
anthropic: { type: "api_key", key: "ANTHROPIC_API_KEY" },
|
||||
openai: { type: "api_key", key: "$OPENAI_API_KEY" },
|
||||
opencode: { type: "api_key", key: "public" },
|
||||
github: { type: "oauth", access: "ACCESS_TOKEN", refresh: "REFRESH_TOKEN", expires: 1 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
withAgentDir(agentDir, () => runMigrations(agentDir));
|
||||
|
||||
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8")) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(migrated.anthropic.key).toBe("ANTHROPIC_API_KEY");
|
||||
expect(migrated.openai.key).toBe("$OPENAI_API_KEY");
|
||||
expect(migrated.opencode.key).toBe("public");
|
||||
expect(migrated.github.access).toBe("ACCESS_TOKEN");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["malformed", '{\n "providers": {\n'],
|
||||
["blank", ""],
|
||||
])("does not throw on %s models.json during migrations", async (_name, content) => {
|
||||
const agentDir = createAgentDir();
|
||||
const modelsPath = path.join(agentDir, "models.json");
|
||||
fs.writeFileSync(modelsPath, content, "utf-8");
|
||||
|
||||
withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow());
|
||||
|
||||
expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content);
|
||||
const registry = await createModelRegistry(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath);
|
||||
const loadError = registry.getError();
|
||||
expect(loadError).toContain("Failed to parse models.json");
|
||||
expect(loadError).toContain(`File: ${modelsPath}`);
|
||||
});
|
||||
|
||||
it("leaves uppercase models.json API key and header values unchanged", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
const envKeys = ["CUSTOM_API_KEY", "HEADER_API_KEY", "MODEL_API_KEY", "OVERRIDE_API_KEY"];
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
process.env[key] = `env-${key}`;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, "models.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
providers: {
|
||||
"custom-provider": {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "CUSTOM_API_KEY",
|
||||
api: "openai-completions",
|
||||
headers: {
|
||||
"x-api-key": "HEADER_API_KEY",
|
||||
"x-literal": "literal",
|
||||
},
|
||||
models: [
|
||||
{
|
||||
id: "model-a",
|
||||
headers: { "x-model-key": "MODEL_API_KEY" },
|
||||
},
|
||||
],
|
||||
modelOverrides: {
|
||||
"model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
withAgentDir(agentDir, () => runMigrations(agentDir));
|
||||
|
||||
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as {
|
||||
providers: Record<
|
||||
string,
|
||||
{
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
models?: Array<{ headers?: Record<string, string> }>;
|
||||
modelOverrides?: Record<string, { headers?: Record<string, string> }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
const provider = migrated.providers["custom-provider"]!;
|
||||
expect(provider.apiKey).toBe("CUSTOM_API_KEY");
|
||||
expect(provider.headers?.["x-api-key"]).toBe("HEADER_API_KEY");
|
||||
expect(provider.headers?.["x-literal"]).toBe("literal");
|
||||
expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("MODEL_API_KEY");
|
||||
expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("OVERRIDE_API_KEY");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
|
||||
const registry = await createModelRegistry(
|
||||
AuthStorage.create(path.join(agentDir, "auth.json")),
|
||||
path.join(agentDir, "models.json"),
|
||||
);
|
||||
const model = registry.find("custom-provider", "model-a");
|
||||
expect(model).toBeDefined();
|
||||
expect(await registry.getApiKeyForProvider("custom-provider")).toBe("CUSTOM_API_KEY");
|
||||
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
|
||||
ok: true,
|
||||
apiKey: "CUSTOM_API_KEY",
|
||||
headers: {
|
||||
"x-api-key": "HEADER_API_KEY",
|
||||
"x-literal": "literal",
|
||||
"x-model-key": "MODEL_API_KEY",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
451
packages/coding-agent/test/config.test.ts
Normal file
451
packages/coding-agent/test/config.test.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { delimiter, join } from "path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
detectInstallMethod,
|
||||
findNodePackageDir,
|
||||
getSelfUpdateCommand,
|
||||
getSelfUpdateUnavailableInstruction,
|
||||
getUpdateInstruction,
|
||||
} from "../src/config.ts";
|
||||
|
||||
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath");
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
|
||||
const originalArgv1 = process.argv[1];
|
||||
let tempDir: string | undefined;
|
||||
|
||||
function setExecPath(value: string): void {
|
||||
Object.defineProperty(process, "execPath", {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (execPathDescriptor) {
|
||||
Object.defineProperty(process, "execPath", execPathDescriptor);
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalPiPackageDir === undefined) {
|
||||
delete process.env.PI_PACKAGE_DIR;
|
||||
} else {
|
||||
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
|
||||
}
|
||||
if (originalArgv1 === undefined) {
|
||||
process.argv.splice(1, 1);
|
||||
} else {
|
||||
process.argv[1] = originalArgv1;
|
||||
}
|
||||
if (tempDir) {
|
||||
chmodSync(tempDir, 0o700);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function createNpmPrefixInstall(template = "pi-prefix-"): { prefix: string; packageDir: string } {
|
||||
const prefix = mkdtempSync(join(tmpdir(), template));
|
||||
const root = join(prefix, "lib", "node_modules");
|
||||
const scopeDir = join(root, "@earendil-works");
|
||||
const packageDir = join(scopeDir, "pi-coding-agent");
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
tempDir = prefix;
|
||||
process.env.PI_PACKAGE_DIR = packageDir;
|
||||
setExecPath(join(packageDir, "dist", "cli.js"));
|
||||
return { prefix, packageDir };
|
||||
}
|
||||
|
||||
function createPnpmGlobalInstall(): { root: string; packageDir: string } {
|
||||
const temp = mkdtempSync(join(tmpdir(), "pi-pnpm-"));
|
||||
const binDir = join(temp, "bin");
|
||||
const root = join(temp, "pnpm", "global", "5", "node_modules");
|
||||
const packageDir = join(root, "@mariozechner", "pi-coding-agent");
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), createFakePnpmScript(root));
|
||||
chmodSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), 0o755);
|
||||
tempDir = temp;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
||||
process.env.PI_PACKAGE_DIR = packageDir;
|
||||
setExecPath(
|
||||
join(
|
||||
root,
|
||||
".pnpm",
|
||||
"@mariozechner+pi-coding-agent@0.0.0",
|
||||
"node_modules",
|
||||
"@mariozechner",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js",
|
||||
),
|
||||
);
|
||||
return { root, packageDir };
|
||||
}
|
||||
|
||||
function createYarnGlobalInstall(): { globalDir: string; packageDir: string } {
|
||||
const temp = mkdtempSync(join(tmpdir(), "pi-yarn-"));
|
||||
const binDir = join(temp, "bin");
|
||||
const globalDir = join(temp, "yarn", "global");
|
||||
const packageDir = join(globalDir, "node_modules", "@mariozechner", "pi-coding-agent");
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(join(binDir, process.platform === "win32" ? "yarn.cmd" : "yarn"), createFakeYarnScript(globalDir));
|
||||
chmodSync(join(binDir, process.platform === "win32" ? "yarn.cmd" : "yarn"), 0o755);
|
||||
tempDir = temp;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
||||
process.env.PI_PACKAGE_DIR = packageDir;
|
||||
setExecPath(join(globalDir, ".yarn", "@mariozechner", "pi-coding-agent", "dist", "cli.js"));
|
||||
return { globalDir, packageDir };
|
||||
}
|
||||
|
||||
function createBunGlobalInstall(): { packageDir: string } {
|
||||
const temp = mkdtempSync(join(tmpdir(), "pi-bun-"));
|
||||
const prefix = join(temp, ".bun");
|
||||
const bunBin = join(prefix, "bin");
|
||||
const root = join(prefix, "install", "global", "node_modules");
|
||||
const scopeDir = join(root, "@earendil-works");
|
||||
const packageDir = join(scopeDir, "pi-coding-agent");
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
mkdirSync(bunBin, { recursive: true });
|
||||
writeFileSync(join(bunBin, process.platform === "win32" ? "bun.cmd" : "bun"), createFakeBunScript(bunBin));
|
||||
chmodSync(join(bunBin, process.platform === "win32" ? "bun.cmd" : "bun"), 0o755);
|
||||
tempDir = temp;
|
||||
process.env.PATH = `${bunBin}${delimiter}${originalPath ?? ""}`;
|
||||
process.env.PI_PACKAGE_DIR = packageDir;
|
||||
setExecPath(join(packageDir, "dist", "cli.js"));
|
||||
return { packageDir };
|
||||
}
|
||||
|
||||
function createFakePnpmScript(root: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `@echo off\r\nif "%1"=="root" if "%2"=="-g" echo ${root}\r\n`;
|
||||
}
|
||||
const escapedRoot = root.replaceAll("'", "'\\''");
|
||||
return `#!/bin/sh\nif [ "$1" = "root" ] && [ "$2" = "-g" ]; then\n\tprintf '%s\\n' '${escapedRoot}'\n\texit 0\nfi\nexit 1\n`;
|
||||
}
|
||||
|
||||
function createFakeYarnScript(globalDir: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `@echo off\r\nif "%1"=="global" if "%2"=="dir" echo ${globalDir}\r\n`;
|
||||
}
|
||||
const escapedGlobalDir = globalDir.replaceAll("'", "'\\''");
|
||||
return `#!/bin/sh\nif [ "$1" = "global" ] && [ "$2" = "dir" ]; then\n\tprintf '%s\\n' '${escapedGlobalDir}'\n\texit 0\nfi\nexit 1\n`;
|
||||
}
|
||||
|
||||
function createFakeBunScript(bunBin: string): string {
|
||||
if (process.platform === "win32") {
|
||||
return `@echo off\r\nif "%1"=="pm" if "%2"=="bin" if "%3"=="-g" echo ${bunBin}\r\n`;
|
||||
}
|
||||
const escapedBunBin = bunBin.replaceAll("'", "'\\''");
|
||||
return `#!/bin/sh\nif [ "$1" = "pm" ] && [ "$2" = "bin" ] && [ "$3" = "-g" ]; then\n\tprintf '%s\\n' '${escapedBunBin}'\n\texit 0\nfi\nexit 1\n`;
|
||||
}
|
||||
|
||||
describe("findNodePackageDir", () => {
|
||||
test("skips binary metadata copied into dist", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-package-dir-"));
|
||||
const distDir = join(tempDir, "dist");
|
||||
const bundleDir = join(distDir, "bundle");
|
||||
mkdirSync(bundleDir, { recursive: true });
|
||||
writeFileSync(join(tempDir, "package.json"), "{}");
|
||||
writeFileSync(join(distDir, "package.json"), "{}");
|
||||
|
||||
expect(findNodePackageDir(bundleDir)).toBe(tempDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallMethod", () => {
|
||||
test("detects pnpm from Windows .pnpm install paths", () => {
|
||||
setExecPath(
|
||||
"C:\\Users\\Admin\\Documents\\pnpm-repository\\global\\5\\.pnpm\\@earendil-works+pi-coding-agent@0.67.68\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
|
||||
);
|
||||
|
||||
expect(detectInstallMethod()).toBe("pnpm");
|
||||
expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe(
|
||||
"Run: pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @earendil-works/pi-coding-agent",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not self-update unknown wrapper installs", () => {
|
||||
setExecPath("/usr/local/bin/node");
|
||||
|
||||
expect(detectInstallMethod()).toBe("unknown");
|
||||
expect(getSelfUpdateCommand("@earendil-works/pi-coding-agent")).toBeUndefined();
|
||||
expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe(
|
||||
"Update @earendil-works/pi-coding-agent using the package manager, wrapper, or source checkout that provides this installation.",
|
||||
);
|
||||
});
|
||||
|
||||
test("self-updates npm installs from custom prefixes", () => {
|
||||
const { prefix } = createNpmPrefixInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent");
|
||||
|
||||
expect(detectInstallMethod()).toBe("npm");
|
||||
expect(command).toEqual({
|
||||
command: "npm",
|
||||
args: [
|
||||
"--prefix",
|
||||
prefix,
|
||||
"install",
|
||||
"-g",
|
||||
"--ignore-scripts",
|
||||
"--min-release-age=0",
|
||||
"@earendil-works/pi-coding-agent",
|
||||
],
|
||||
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(command).toEqual({
|
||||
command: "npm",
|
||||
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"],
|
||||
display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`,
|
||||
steps: [
|
||||
{
|
||||
command: "npm",
|
||||
args: ["--prefix", prefix, "uninstall", "-g", "@mariozechner/pi-coding-agent"],
|
||||
display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent`,
|
||||
},
|
||||
{
|
||||
command: "npm",
|
||||
args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"],
|
||||
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("self-update respects configured npmCommand", () => {
|
||||
const { prefix } = createNpmPrefixInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent", ["npm", "--prefix", prefix]);
|
||||
|
||||
expect(command).toEqual({
|
||||
command: "npm",
|
||||
args: [
|
||||
"--prefix",
|
||||
prefix,
|
||||
"install",
|
||||
"-g",
|
||||
"--ignore-scripts",
|
||||
"--min-release-age=0",
|
||||
"@earendil-works/pi-coding-agent",
|
||||
],
|
||||
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`,
|
||||
});
|
||||
});
|
||||
|
||||
test("self-update treats empty npmCommand as unset", () => {
|
||||
const { prefix } = createNpmPrefixInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent", []);
|
||||
|
||||
expect(command?.args).toEqual([
|
||||
"--prefix",
|
||||
prefix,
|
||||
"install",
|
||||
"-g",
|
||||
"--ignore-scripts",
|
||||
"--min-release-age=0",
|
||||
"@earendil-works/pi-coding-agent",
|
||||
]);
|
||||
});
|
||||
|
||||
test("quotes npm self-update display paths", () => {
|
||||
const { prefix } = createNpmPrefixInstall("pi prefix ");
|
||||
|
||||
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent");
|
||||
|
||||
expect(command?.display).toBe(
|
||||
`npm --prefix "${prefix}" install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not infer Windows npm custom prefixes from package paths", () => {
|
||||
const packageDir = "C:\\Users\\Admin\\npm prefix\\node_modules\\@earendil-works\\pi-coding-agent";
|
||||
process.env.PI_PACKAGE_DIR = packageDir;
|
||||
setExecPath(`${packageDir}\\dist\\cli.js`);
|
||||
|
||||
expect(detectInstallMethod()).toBe("npm");
|
||||
expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe(
|
||||
"Run: npm install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent",
|
||||
);
|
||||
});
|
||||
|
||||
test("self-updates bun global installs from bun pm bin", () => {
|
||||
createBunGlobalInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent");
|
||||
|
||||
expect(detectInstallMethod()).toBe("bun");
|
||||
expect(command).toEqual({
|
||||
command: "bun",
|
||||
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@earendil-works/pi-coding-agent"],
|
||||
display: "bun install -g --ignore-scripts --minimum-release-age=0 @earendil-works/pi-coding-agent",
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed pnpm global installs by removing the old package first", () => {
|
||||
createPnpmGlobalInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(detectInstallMethod()).toBe("pnpm");
|
||||
expect(command).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"],
|
||||
display:
|
||||
"pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi",
|
||||
steps: [
|
||||
{
|
||||
command: "pnpm",
|
||||
args: ["remove", "-g", "@mariozechner/pi-coding-agent"],
|
||||
display: "pnpm remove -g @mariozechner/pi-coding-agent",
|
||||
},
|
||||
{
|
||||
command: "pnpm",
|
||||
args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"],
|
||||
display: "pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates pnpm v11 global installs resolved through the store", () => {
|
||||
const temp = mkdtempSync(join(tmpdir(), "pi-pnpm11-"));
|
||||
const binDir = join(temp, "bin");
|
||||
const root = join(temp, "Library", "pnpm", "global", "v11");
|
||||
const packageName = "@earendil-works/pi-coding-agent";
|
||||
const globalPackageDir = join(root, "11e9a", "node_modules", "@earendil-works", "pi-coding-agent");
|
||||
const storePackageDir = join(
|
||||
temp,
|
||||
"Library",
|
||||
"pnpm",
|
||||
"store",
|
||||
"v11",
|
||||
"links",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
"0.75.0",
|
||||
"hash",
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
);
|
||||
mkdirSync(globalPackageDir, { recursive: true });
|
||||
mkdirSync(storePackageDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(join(globalPackageDir, "package.json"), "{}");
|
||||
writeFileSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), createFakePnpmScript(root));
|
||||
chmodSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), 0o755);
|
||||
tempDir = temp;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
||||
process.env.PI_PACKAGE_DIR = storePackageDir;
|
||||
process.argv[1] = join(globalPackageDir, "dist", "cli.js");
|
||||
setExecPath(join(storePackageDir, "dist", "cli.js"));
|
||||
|
||||
const command = getSelfUpdateCommand(packageName);
|
||||
|
||||
expect(detectInstallMethod()).toBe("pnpm");
|
||||
expect(command).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", packageName],
|
||||
display: `pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 ${packageName}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed yarn global installs by removing the old package first", () => {
|
||||
createYarnGlobalInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(detectInstallMethod()).toBe("yarn");
|
||||
expect(command).toEqual({
|
||||
command: "yarn",
|
||||
args: ["global", "add", "--ignore-scripts", "@new-scope/pi"],
|
||||
display: "yarn global remove @mariozechner/pi-coding-agent && yarn global add --ignore-scripts @new-scope/pi",
|
||||
steps: [
|
||||
{
|
||||
command: "yarn",
|
||||
args: ["global", "remove", "@mariozechner/pi-coding-agent"],
|
||||
display: "yarn global remove @mariozechner/pi-coding-agent",
|
||||
},
|
||||
{
|
||||
command: "yarn",
|
||||
args: ["global", "add", "--ignore-scripts", "@new-scope/pi"],
|
||||
display: "yarn global add --ignore-scripts @new-scope/pi",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("self-updates renamed bun global installs by removing the old package first", () => {
|
||||
createBunGlobalInstall();
|
||||
|
||||
const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi");
|
||||
|
||||
expect(detectInstallMethod()).toBe("bun");
|
||||
expect(command).toEqual({
|
||||
command: "bun",
|
||||
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"],
|
||||
display:
|
||||
"bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi",
|
||||
steps: [
|
||||
{
|
||||
command: "bun",
|
||||
args: ["uninstall", "-g", "@mariozechner/pi-coding-agent"],
|
||||
display: "bun uninstall -g @mariozechner/pi-coding-agent",
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"],
|
||||
display: "bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not self-update when npm install path is not writable", () => {
|
||||
const { packageDir } = createNpmPrefixInstall();
|
||||
chmodSync(packageDir, 0o500);
|
||||
|
||||
expect(getSelfUpdateCommand("@earendil-works/pi-coding-agent")).toBeUndefined();
|
||||
expect(getSelfUpdateUnavailableInstruction("@earendil-works/pi-coding-agent")).toContain(
|
||||
"the install path is not writable",
|
||||
);
|
||||
});
|
||||
});
|
||||
130
packages/coding-agent/test/credential-print.test.ts
Normal file
130
packages/coding-agent/test/credential-print.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { parseArgs } from "../src/cli/args.ts";
|
||||
import { AuthCommandError, isAuthCommandHelp, parseAuthCommand } from "../src/cli/auth-command.ts";
|
||||
import { resolveCredentialForPrint } from "../src/cli/credential-print.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
import { main } from "../src/main.ts";
|
||||
|
||||
async function createRuntime(credentials: AuthStorage): Promise<ModelRuntime> {
|
||||
return ModelRuntime.create({
|
||||
credentials,
|
||||
modelsPath: null,
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("credential print commands", () => {
|
||||
test("prints a resolved API key", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-api-key" } }));
|
||||
const args = parseArgs(["--provider", "openai"]);
|
||||
|
||||
await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key");
|
||||
});
|
||||
|
||||
test("prints bearer tokens resolved from an Authorization header", async () => {
|
||||
const runtime = await createRuntime(
|
||||
AuthStorage.inMemory({
|
||||
"kimi-coding": {
|
||||
type: "oauth",
|
||||
access: "header-test-token",
|
||||
refresh: "test-refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const args = parseArgs(["--provider", "kimi-coding"]);
|
||||
|
||||
await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("header-test-token");
|
||||
});
|
||||
|
||||
test("refreshes an expired OAuth token before printing it", async () => {
|
||||
const storage = AuthStorage.inMemory({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "old-test-token",
|
||||
refresh: "test-refresh-token",
|
||||
expires: 0,
|
||||
},
|
||||
});
|
||||
const runtime = await createRuntime(storage);
|
||||
const refresh = vi.fn(async () => ({
|
||||
type: "oauth" as const,
|
||||
access: "fresh-test-token",
|
||||
refresh: "test-refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
}));
|
||||
const oauth = runtime.getProvider("openai-codex")?.auth.oauth;
|
||||
if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered");
|
||||
oauth.refresh = refresh;
|
||||
const args = parseArgs(["--provider", "openai-codex"]);
|
||||
|
||||
await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("fresh-test-token");
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(await storage.read("openai-codex")).toMatchObject({ access: "fresh-test-token" });
|
||||
});
|
||||
|
||||
test("reports unknown auth options like package commands", async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
await main(["auth", "check", "--provider", "openai-codex", "--credentails"]);
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stderr).toContain('Unknown option --credentails for "auth check".');
|
||||
expect(stderr).toContain(
|
||||
'Use "pi --help" or "pi auth check --provider <provider> [--json] [--credentials] [--no-refresh]".',
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("parses credential commands and rejects invalid arguments or credential types", async () => {
|
||||
const runtime = await createRuntime(
|
||||
AuthStorage.inMemory({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "test-token-not-to-be-printed",
|
||||
refresh: "test-refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parseAuthCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({
|
||||
kind: "api_key",
|
||||
args: ["--provider", "openai"],
|
||||
json: false,
|
||||
credentials: false,
|
||||
noRefresh: false,
|
||||
});
|
||||
expect(parseAuthCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" });
|
||||
expect(parseAuthCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({
|
||||
kind: "bearer_token",
|
||||
args: [],
|
||||
json: false,
|
||||
credentials: false,
|
||||
noRefresh: false,
|
||||
minExpiryMs: 30 * 60_000,
|
||||
});
|
||||
expect(() => parseAuthCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow(
|
||||
"only supported by print-bearer-token",
|
||||
);
|
||||
expect(isAuthCommandHelp(["auth", "--help"])).toBe(true);
|
||||
expect(isAuthCommandHelp(["auth", "print-api-key", "--help"])).toBe(true);
|
||||
expect(isAuthCommandHelp(["auth", "print-bearer-token", "-h"])).toBe(true);
|
||||
expect(isAuthCommandHelp(["auth", "check", "--help"])).toBe(true);
|
||||
expect(() => parseAuthCommand(["auth", "unknown"])).toThrow(AuthCommandError);
|
||||
await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow(
|
||||
"requires --provider <provider> or --model <model>",
|
||||
);
|
||||
await expect(
|
||||
resolveCredentialForPrint(parseArgs(["--provider", "openai-codex"]), runtime, "api_key"),
|
||||
).rejects.toThrow("configured with OAuth");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { setKeybindings, TuiMainScreen } from "@earendil-works/pi-tui";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { defaultEditorTheme } from "../../tui/test/test-themes.ts";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.ts";
|
||||
import { CustomEditor } from "../src/modes/interactive/components/custom-editor.ts";
|
||||
|
||||
afterEach(() => {
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
describe("CustomEditor prompt history keybindings", () => {
|
||||
it("gives an explicit history binding precedence over model cycling", () => {
|
||||
const keybindings = new KeybindingsManager({
|
||||
"tui.editor.historyPrevious": "ctrl+p",
|
||||
"tui.editor.historyNext": "ctrl+n",
|
||||
});
|
||||
setKeybindings(keybindings);
|
||||
const editor = new CustomEditor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme, keybindings);
|
||||
let modelCycles = 0;
|
||||
editor.onAction("app.model.cycleForward", () => {
|
||||
modelCycles++;
|
||||
});
|
||||
editor.addToHistory("previous prompt");
|
||||
editor.setText("draft");
|
||||
|
||||
editor.handleInput("\x10"); // Ctrl+P
|
||||
expect(editor.getText()).toBe("previous prompt");
|
||||
expect(modelCycles).toBe(0);
|
||||
|
||||
editor.handleInput("\x0e"); // Ctrl+N
|
||||
expect(editor.getText()).toBe("draft");
|
||||
});
|
||||
});
|
||||
44
packages/coding-agent/test/custom-message.test.ts
Normal file
44
packages/coding-agent/test/custom-message.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { MessageRenderer, MessageRenderOptions } from "../src/core/extensions/types.ts";
|
||||
import type { CustomMessage } from "../src/core/messages.ts";
|
||||
import { CustomMessageComponent } from "../src/modes/interactive/components/custom-message.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../src/utils/ansi.ts";
|
||||
|
||||
describe("CustomMessageComponent", () => {
|
||||
test("provides output padding to custom renderers and updates it", () => {
|
||||
initTheme("dark");
|
||||
const optionsSeen: MessageRenderOptions[] = [];
|
||||
const renderer: MessageRenderer = (_message, options) => {
|
||||
optionsSeen.push(options);
|
||||
return new Text("custom", options.outputPad, 0);
|
||||
};
|
||||
const message: CustomMessage = {
|
||||
role: "custom",
|
||||
customType: "test",
|
||||
content: "custom",
|
||||
display: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const component = new CustomMessageComponent(message, renderer, undefined, 1);
|
||||
|
||||
expect(optionsSeen).toEqual([{ expanded: false, outputPad: 1 }]);
|
||||
expect(
|
||||
component
|
||||
.render(40)
|
||||
.map(stripAnsi)
|
||||
.some((line) => line.startsWith(" custom")),
|
||||
).toBe(true);
|
||||
|
||||
component.setOutputPad(0);
|
||||
|
||||
expect(optionsSeen.at(-1)).toEqual({ expanded: false, outputPad: 0 });
|
||||
expect(
|
||||
component
|
||||
.render(40)
|
||||
.map(stripAnsi)
|
||||
.some((line) => line.startsWith("custom")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
159
packages/coding-agent/test/default-tools-setting.test.ts
Normal file
159
packages/coding-agent/test/default-tools-setting.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.ts";
|
||||
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
|
||||
import { type CreateAgentSessionOptions, createAgentSession, type InlineExtension } from "../src/core/sdk.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
|
||||
type ToolOptions = Pick<CreateAgentSessionOptions, "tools" | "excludeTools" | "noTools" | "customTools">;
|
||||
|
||||
describe("defaultTools setting", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-default-tools-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(
|
||||
defaultTools: string[],
|
||||
options: ToolOptions = {},
|
||||
extensionFactories: InlineExtension[] = [],
|
||||
) {
|
||||
const settingsManager = SettingsManager.inMemory({ defaultTools });
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
extensionFactories,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
return (
|
||||
await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager,
|
||||
sessionManager: SessionManager.inMemory(tempDir),
|
||||
resourceLoader,
|
||||
...options,
|
||||
})
|
||||
).session;
|
||||
}
|
||||
|
||||
it("uses the configured list as the initial built-in selection", async () => {
|
||||
const session = await createSession(["grep", "find"]);
|
||||
|
||||
expect(
|
||||
session
|
||||
.getAllTools()
|
||||
.map((tool) => tool.name)
|
||||
.sort(),
|
||||
).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]);
|
||||
expect(session.getActiveToolNames()).toEqual(["grep", "find"]);
|
||||
expect(session.systemPrompt).toContain("- grep:");
|
||||
expect(session.systemPrompt).not.toContain("- read:");
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("can select powershell instead of bash", async () => {
|
||||
const session = await createSession(["read", "powershell", "edit", "write"]);
|
||||
|
||||
expect(session.getActiveToolNames()).toEqual(["read", "powershell", "edit", "write"]);
|
||||
expect(session.systemPrompt).toContain("- powershell: Execute PowerShell commands");
|
||||
expect(session.systemPrompt).not.toContain("- bash:");
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("keeps extension and SDK custom tools enabled", async () => {
|
||||
const session = await createSession(
|
||||
["grep"],
|
||||
{
|
||||
customTools: [
|
||||
{
|
||||
name: "sdk_tool",
|
||||
label: "SDK Tool",
|
||||
description: "SDK custom tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
},
|
||||
],
|
||||
},
|
||||
[
|
||||
(pi) => {
|
||||
pi.registerTool({
|
||||
name: "static_tool",
|
||||
label: "Static Tool",
|
||||
description: "Statically registered extension tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
});
|
||||
pi.on("session_start", () => {
|
||||
pi.registerTool({
|
||||
name: "dynamic_tool",
|
||||
label: "Dynamic Tool",
|
||||
description: "Dynamically registered extension tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
);
|
||||
await session.bindExtensions({});
|
||||
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["dynamic_tool", "grep", "sdk_tool", "static_tool"]);
|
||||
expect(session.getAllTools().map((tool) => tool.name)).toEqual(
|
||||
expect.arrayContaining(["read", "dynamic_tool", "sdk_tool", "static_tool"]),
|
||||
);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
it("preserves explicit tool option precedence", async () => {
|
||||
const allowlistedSession = await createSession(["grep"], { tools: ["read"] });
|
||||
expect(allowlistedSession.getActiveToolNames()).toEqual(["read"]);
|
||||
allowlistedSession.dispose();
|
||||
|
||||
const excludedSession = await createSession(["read", "grep"], { excludeTools: ["read"] });
|
||||
expect(excludedSession.getActiveToolNames()).toEqual(["grep"]);
|
||||
excludedSession.dispose();
|
||||
|
||||
const toolLessSession = await createSession(["read"], { noTools: "all" });
|
||||
expect(toolLessSession.getAllTools()).toEqual([]);
|
||||
expect(toolLessSession.getActiveToolNames()).toEqual([]);
|
||||
toolLessSession.dispose();
|
||||
});
|
||||
|
||||
it("applies through service-based session creation", async () => {
|
||||
const settingsManager = SettingsManager.inMemory({ defaultTools: ["ls"] });
|
||||
const services = await createAgentSessionServices({ cwd: tempDir, agentDir, settingsManager });
|
||||
const { session } = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: SessionManager.inMemory(tempDir),
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
});
|
||||
|
||||
expect(
|
||||
session
|
||||
.getAllTools()
|
||||
.map((tool) => tool.name)
|
||||
.sort(),
|
||||
).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]);
|
||||
expect(session.getActiveToolNames()).toEqual(["ls"]);
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
116
packages/coding-agent/test/edit-tool-legacy-input.test.ts
Normal file
116
packages/coding-agent/test/edit-tool-legacy-input.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionContext } from "../src/core/extensions/types.ts";
|
||||
import { createEditToolDefinition } from "../src/core/tools/edit.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-legacy-input-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0, tempDirs.length).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("edit tool prepareArguments", () => {
|
||||
it("keeps legacy fields out of the public schema", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
expect(definition.parameters.properties).not.toHaveProperty("oldText");
|
||||
expect(definition.parameters.properties).not.toHaveProperty("newText");
|
||||
});
|
||||
|
||||
it("folds top-level oldText/newText into edits", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "file.txt",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
});
|
||||
expect(prepared).toEqual({
|
||||
path: "file.txt",
|
||||
edits: [{ oldText: "before", newText: "after" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("appends legacy replacement to existing edits", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "file.txt",
|
||||
edits: [{ oldText: "a", newText: "b" }],
|
||||
oldText: "c",
|
||||
newText: "d",
|
||||
});
|
||||
expect(prepared).toEqual({
|
||||
path: "file.txt",
|
||||
edits: [
|
||||
{ oldText: "a", newText: "b" },
|
||||
{ oldText: "c", newText: "d" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through valid input unchanged", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const input = {
|
||||
path: "file.txt",
|
||||
edits: [{ oldText: "a", newText: "b" }],
|
||||
};
|
||||
const prepared = definition.prepareArguments!(input);
|
||||
expect(prepared).toBe(input);
|
||||
});
|
||||
|
||||
it("passes through non-object input unchanged", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
expect(definition.prepareArguments!(null)).toBe(null);
|
||||
expect(definition.prepareArguments!(undefined)).toBe(undefined);
|
||||
expect(definition.prepareArguments!("garbage")).toBe("garbage");
|
||||
});
|
||||
|
||||
it("prepared args execute correctly", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "legacy.txt");
|
||||
await writeFile(filePath, "before\n", "utf8");
|
||||
|
||||
const definition = createEditToolDefinition(dir);
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "legacy.txt",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
});
|
||||
|
||||
const result = await definition.execute("tool-1", prepared, undefined, undefined, {} as ExtensionContext);
|
||||
expect(result.content).toEqual([{ type: "text", text: "Successfully replaced 1 block(s) in legacy.txt." }]);
|
||||
expect(await readFile(filePath, "utf8")).toBe("after\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit tool stringified edits", () => {
|
||||
it("parses edits from a JSON string", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "file.txt",
|
||||
edits: JSON.stringify([{ oldText: "a", newText: "b" }]),
|
||||
});
|
||||
expect(prepared).toEqual({
|
||||
path: "file.txt",
|
||||
edits: [{ oldText: "a", newText: "b" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves edits alone when the string is not valid JSON", () => {
|
||||
const definition = createEditToolDefinition(process.cwd());
|
||||
const prepared = definition.prepareArguments!({
|
||||
path: "file.txt",
|
||||
edits: "not json",
|
||||
});
|
||||
expect(prepared).toEqual({
|
||||
path: "file.txt",
|
||||
edits: "not json",
|
||||
});
|
||||
});
|
||||
});
|
||||
235
packages/coding-agent/test/edit-tool-no-full-redraw.test.ts
Normal file
235
packages/coding-agent/test/edit-tool-no-full-redraw.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Container, type Terminal, Text, type TUI, TuiMainScreen } from "@earendil-works/pi-tui";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createEditToolDefinition } from "../src/core/tools/edit.ts";
|
||||
import { computeEditsDiff, type Edit } from "../src/core/tools/edit-diff.ts";
|
||||
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
class FakeTerminal implements Terminal {
|
||||
columns = 80;
|
||||
rows = 24;
|
||||
kittyProtocolActive = true;
|
||||
writes: string[] = [];
|
||||
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
async drainInput(): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
|
||||
get fullClearCount(): number {
|
||||
return this.writes.filter((write) => write.includes("\x1b[2J\x1b[H\x1b[3J")).length;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRender(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitForRenderedText(
|
||||
getRender: () => string,
|
||||
expectedText: string,
|
||||
onRetry?: () => void,
|
||||
timeoutMs = 2000,
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastRender = "";
|
||||
while (Date.now() < deadline) {
|
||||
onRetry?.();
|
||||
await waitForRender();
|
||||
lastRender = getRender();
|
||||
if (lastRender.includes(expectedText)) {
|
||||
return lastRender;
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for render to include "${expectedText}". Last render:\n${lastRender}`);
|
||||
}
|
||||
|
||||
function createLargeEdits(lines: string[]): Edit[] {
|
||||
const targets = [50, 150, 250, 350, 450, 550, 650, 750, 850, 950];
|
||||
return targets.map((lineNumber) => ({
|
||||
oldText: `${lines[lineNumber - 1]}\n${lines[lineNumber]}\n${lines[lineNumber + 1]}`,
|
||||
newText: `${lines[lineNumber - 1]}\n${lines[lineNumber]} changed\n${lines[lineNumber + 1]}`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("edit tool TUI rendering", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("renders the large diff in the call preview and does not full-redraw when the result settles", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-redraw-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = join(dir, "large-edit.txt");
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${Array.from({ length: 1000 }, (_, i) => `line ${i}`).join("\n")}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const lines = (await readFile(filePath, "utf8")).trimEnd().split("\n");
|
||||
const edits = createLargeEdits(lines);
|
||||
const diff = await computeEditsDiff(filePath, edits, process.cwd());
|
||||
if ("error" in diff) {
|
||||
throw new Error(diff.error);
|
||||
}
|
||||
|
||||
const terminal = new FakeTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const root = new Container();
|
||||
for (let i = 0; i < 200; i++) {
|
||||
root.addChild(new Text(`history ${i}`, 0, 0));
|
||||
}
|
||||
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
"tool-call-1",
|
||||
{ path: filePath, edits },
|
||||
{},
|
||||
createEditToolDefinition(process.cwd()),
|
||||
tui,
|
||||
process.cwd(),
|
||||
);
|
||||
root.addChild(component);
|
||||
tui.addChild(root);
|
||||
tui.start();
|
||||
await waitForRender();
|
||||
|
||||
component.setArgsComplete();
|
||||
tui.requestRender();
|
||||
await waitForRender();
|
||||
await waitForRender();
|
||||
|
||||
const callOnlyRender = await waitForRenderedText(
|
||||
() => component.render(80).join("\n"),
|
||||
"line 50 changed",
|
||||
() => tui.requestRender(true),
|
||||
);
|
||||
expect(callOnlyRender).toContain("edit");
|
||||
expect(callOnlyRender).toContain("line 950 changed");
|
||||
|
||||
const redrawsBeforeResult = tui.fullRedraws;
|
||||
const clearsBeforeResult = terminal.fullClearCount;
|
||||
component.updateResult(
|
||||
{
|
||||
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${filePath}.` }],
|
||||
details: diff,
|
||||
isError: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
tui.requestRender();
|
||||
await waitForRender();
|
||||
|
||||
expect(tui.fullRedraws).toBe(redrawsBeforeResult);
|
||||
expect(terminal.fullClearCount).toBe(clearsBeforeResult);
|
||||
|
||||
const settledRender = component.render(80).join("\n");
|
||||
expect(settledRender).toContain("line 50 changed");
|
||||
expect(settledRender).toContain("line 950 changed");
|
||||
expect(settledRender).not.toContain("Successfully replaced");
|
||||
});
|
||||
|
||||
it("reconstructs the boxed preview from a settled result without argsComplete", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-replay-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = join(dir, "replay-edit.txt");
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n")}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const lines = (await readFile(filePath, "utf8")).trimEnd().split("\n");
|
||||
const edits = createLargeEdits(lines).slice(0, 2);
|
||||
const diff = await computeEditsDiff(filePath, edits, process.cwd());
|
||||
if ("error" in diff) {
|
||||
throw new Error(diff.error);
|
||||
}
|
||||
await rm(filePath, { force: true });
|
||||
|
||||
const terminal = new FakeTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
"tool-call-replay",
|
||||
{ path: filePath, edits },
|
||||
{},
|
||||
createEditToolDefinition(process.cwd()),
|
||||
tui,
|
||||
process.cwd(),
|
||||
);
|
||||
tui.addChild(component);
|
||||
tui.start();
|
||||
await waitForRender();
|
||||
|
||||
component.updateResult(
|
||||
{
|
||||
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${filePath}.` }],
|
||||
details: diff,
|
||||
isError: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
await waitForRender();
|
||||
await waitForRender();
|
||||
|
||||
const rendered = component.render(80).join("\n");
|
||||
expect(rendered).toContain("line 50 changed");
|
||||
expect(rendered).toContain("line 150 changed");
|
||||
});
|
||||
|
||||
it("shows a preflight error without rendering a diff when the edits do not apply", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-edit-preflight-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = join(dir, "missing-edit.txt");
|
||||
await writeFile(filePath, "line 0\nline 1\n", "utf8");
|
||||
|
||||
const terminal = new FakeTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
"tool-call-2",
|
||||
{ path: filePath, edits: [{ oldText: "does not exist", newText: "replacement" }] },
|
||||
{},
|
||||
createEditToolDefinition(process.cwd()),
|
||||
tui,
|
||||
process.cwd(),
|
||||
);
|
||||
tui.addChild(component);
|
||||
tui.start();
|
||||
await waitForRender();
|
||||
|
||||
component.setArgsComplete();
|
||||
tui.requestRender();
|
||||
await waitForRender();
|
||||
await waitForRender();
|
||||
|
||||
const rendered = await waitForRenderedText(
|
||||
() => component.render(80).join("\n"),
|
||||
"Could not find",
|
||||
() => tui.requestRender(true),
|
||||
);
|
||||
expect(rendered).not.toContain("+1 ");
|
||||
expect(rendered).not.toContain("-1 ");
|
||||
});
|
||||
});
|
||||
178
packages/coding-agent/test/experimental-agent-controller.test.ts
Normal file
178
packages/coding-agent/test/experimental-agent-controller.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createFacetHost, defineFacet } from "@earendil-works/chord";
|
||||
import {
|
||||
type AgentLane,
|
||||
BACKGROUND_CONTEXT,
|
||||
Closed,
|
||||
type RunResult as HarnessRunResult,
|
||||
InvalidMessage,
|
||||
LaneBusy,
|
||||
UnknownSkill,
|
||||
UnknownTemplate,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { AgentController } from "../src/experimental/services/agent-controller.ts";
|
||||
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
|
||||
|
||||
const admissionErrors = [
|
||||
[
|
||||
new LaneBusy({
|
||||
lane: "main",
|
||||
operationId: "operation-1",
|
||||
operationKind: "run",
|
||||
message: "busy",
|
||||
}),
|
||||
"lane_busy",
|
||||
"operation-1",
|
||||
],
|
||||
[new InvalidMessage({ lane: "main", reason: "invalid", message: "invalid" }), "invalid_message", null],
|
||||
[new UnknownSkill({ name: "skill", message: "unknown" }), "unknown_skill", null],
|
||||
[new UnknownTemplate({ name: "template", message: "unknown" }), "unknown_template", null],
|
||||
[new Closed({ message: "closed" }), "closed", null],
|
||||
] as const satisfies readonly [Extract<HarnessRunResult, { ok: false }>["error"], string, string | null][];
|
||||
|
||||
const completed = {
|
||||
operationId: "operation-1",
|
||||
kind: "run" as const,
|
||||
status: "completed" as const,
|
||||
fromTipId: null,
|
||||
tipId: "entry-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
};
|
||||
|
||||
describe("AgentController service", () => {
|
||||
test("provides the presentation-safe AgentLane command surface", async () => {
|
||||
const prompt = vi.fn(async () => ({ ok: true as const, value: completed }));
|
||||
const requestAbort = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: { operationId: "operation-1", newlyRequested: true, steer: [], followUp: [] },
|
||||
}));
|
||||
const steer = vi.fn(async () => ({ ok: true as const, value: { entryId: "queue-1" } }));
|
||||
const followUp = vi.fn(async () => ({ ok: true as const, value: { entryId: "queue-2" } }));
|
||||
const lane = { prompt, requestAbort, steer, followUp } as unknown as AgentLane;
|
||||
const controllerFacet = defineFacet({
|
||||
id: "test-agent-controller",
|
||||
setup(env) {
|
||||
env.provide(AgentController, createAgentController(lane));
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [controllerFacet] });
|
||||
try {
|
||||
expect(host.services.catalogue).toEqual([{ serviceId: AgentController.id, mode: "singleton" }]);
|
||||
await expect(
|
||||
host.services.invoke(
|
||||
{ serviceId: AgentController.id, member: "prompt", args: [{ message: "hello", images: null }] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toEqual({ accepted: true, operationId: "operation-1", error: null });
|
||||
await expect(
|
||||
host.services.invoke(
|
||||
{ serviceId: AgentController.id, member: "requestAbort", args: ["operation-1"] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
host.services.invoke(
|
||||
{ serviceId: AgentController.id, member: "steer", args: [{ message: "later", images: null }] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toEqual({ accepted: true, entryId: "queue-1", error: null });
|
||||
await expect(
|
||||
host.services.invoke(
|
||||
{ serviceId: AgentController.id, member: "followUp", args: [{ message: "after", images: null }] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toEqual({ accepted: true, entryId: "queue-2", error: null });
|
||||
expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT);
|
||||
expect(requestAbort).toHaveBeenCalledWith("operation-1", BACKGROUND_CONTEXT);
|
||||
expect(steer).toHaveBeenCalledWith("later", undefined, BACKGROUND_CONTEXT);
|
||||
expect(followUp).toHaveBeenCalledWith("after", undefined, BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
await host.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("wraps queue, resume, compaction, and navigation lane operations", async () => {
|
||||
const nextRun = vi.fn(async () => ({ ok: true as const, value: { entryId: "queue-3" } }));
|
||||
const cancelQueued = vi.fn(async () => ({ ok: true as const, value: { kind: "cancelled" as const } }));
|
||||
const resume = vi.fn(async () => ({ ok: true as const, value: completed }));
|
||||
const compact = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: { compaction: { ...completed, operationId: "compact-1", kind: "compaction" as const } },
|
||||
}));
|
||||
const navigateTree = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: { navigation: { ...completed, operationId: "navigation-1", kind: "navigation" as const } },
|
||||
}));
|
||||
const controller = createAgentController({
|
||||
nextRun,
|
||||
cancelQueued,
|
||||
resume,
|
||||
compact,
|
||||
navigateTree,
|
||||
} as unknown as AgentLane);
|
||||
|
||||
await expect(controller.nextRun({ message: "next", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
accepted: true,
|
||||
entryId: "queue-3",
|
||||
error: null,
|
||||
});
|
||||
await expect(controller.cancelQueued("queue-3", BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
outcome: "cancelled",
|
||||
});
|
||||
await expect(controller.resume(BACKGROUND_CONTEXT)).resolves.toMatchObject({ operationId: "operation-1" });
|
||||
await expect(controller.compact({ customInstructions: "short" }, BACKGROUND_CONTEXT)).resolves.toMatchObject({
|
||||
operationId: "compact-1",
|
||||
});
|
||||
await expect(
|
||||
controller.navigate(
|
||||
{ targetId: "entry-1", summarize: true, label: "branch", customInstructions: null },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toMatchObject({ operationId: "navigation-1" });
|
||||
expect(nextRun).toHaveBeenCalledWith("next", undefined, BACKGROUND_CONTEXT);
|
||||
expect(cancelQueued).toHaveBeenCalledWith("queue-3", BACKGROUND_CONTEXT);
|
||||
expect(resume).toHaveBeenCalledWith(BACKGROUND_CONTEXT);
|
||||
expect(compact).toHaveBeenCalledWith({ customInstructions: "short" }, BACKGROUND_CONTEXT);
|
||||
expect(navigateTree).toHaveBeenCalledWith("entry-1", { summarize: true, label: "branch" }, BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("reports accepted successful and failed operations", async () => {
|
||||
const success = createAgentController({
|
||||
prompt: async () => ({ ok: true, value: completed }),
|
||||
} as unknown as AgentLane);
|
||||
await expect(success.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
accepted: true,
|
||||
operationId: "operation-1",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const failed = createAgentController({
|
||||
prompt: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
...completed,
|
||||
operationId: "operation-2",
|
||||
status: "failed",
|
||||
error: { code: "provider", message: "failed", details: { status: 500 } },
|
||||
},
|
||||
}),
|
||||
} as unknown as AgentLane);
|
||||
await expect(failed.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
accepted: true,
|
||||
operationId: "operation-2",
|
||||
error: { code: "provider", message: "failed" },
|
||||
});
|
||||
});
|
||||
|
||||
test.each(admissionErrors)("maps admission error %# to a stable response", async (error, code, operationId) => {
|
||||
const controller = createAgentController({
|
||||
prompt: async () => ({ ok: false, error }),
|
||||
} as unknown as AgentLane);
|
||||
await expect(controller.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
accepted: false,
|
||||
operationId,
|
||||
error: { code, message: error.message },
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/coding-agent/test/experimental-cli-command.test.ts
Normal file
147
packages/coding-agent/test/experimental-cli-command.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { cli } from "../src/cli/experimental/cli.ts";
|
||||
|
||||
const UNSUPPORTED_SERVER_OPTIONS = "The experimental server command does not support existing CLI options yet";
|
||||
const UNSUPPORTED_CLIENT_OPTIONS = "The experimental client command does not support existing CLI options yet";
|
||||
|
||||
describe("experimental CLI commands", () => {
|
||||
test("parses server configuration", () => {
|
||||
expect(
|
||||
cli.parse([
|
||||
"server",
|
||||
"--server-id",
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"--session-dir",
|
||||
"~/pi-sessions",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--model",
|
||||
"claude-sonnet-4-5",
|
||||
"-e",
|
||||
"./first-plugin",
|
||||
"-e=./second-plugin",
|
||||
]),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
command: {
|
||||
command: "server",
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionDir: "~/pi-sessions",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
pluginPackages: ["./first-plugin", "./second-plugin"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parses provider-qualified server models", () => {
|
||||
expect(cli.parse(["server", "--model", "anthropic/claude-sonnet-4-5:high"])).toEqual({
|
||||
ok: true,
|
||||
command: { command: "server", model: "anthropic/claude-sonnet-4-5:high" },
|
||||
});
|
||||
});
|
||||
|
||||
test("parses client transport addresses", () => {
|
||||
expect(cli.parse(["client", "--connect", "unix:///tmp/pi.sock"])).toEqual({
|
||||
ok: true,
|
||||
command: { command: "client", connect: { transport: "unix", path: "/tmp/pi.sock" } },
|
||||
});
|
||||
expect(
|
||||
cli.parse(["client", "--connect", "radius://00000000-0000-4000-8000-000000000001", "--session-id", "demo-1"]),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
command: {
|
||||
command: "client",
|
||||
connect: { transport: "radius", serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
sessionId: "demo-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["-c", "continue"],
|
||||
["--continue", "continue"],
|
||||
["-r", "resume"],
|
||||
["--resume", "resume"],
|
||||
] as const)("parses client Session selection %s", (option, property) => {
|
||||
expect(cli.parse(["client", option])).toEqual({
|
||||
ok: true,
|
||||
command: { command: "client", [property]: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("parses a Session selection followed by a one-shot prompt", () => {
|
||||
expect(cli.parse(["client", "-r", "Explain this project"])).toEqual({
|
||||
ok: true,
|
||||
command: { command: "client", resume: true, prompt: "Explain this project" },
|
||||
});
|
||||
});
|
||||
|
||||
test("parses client model and plugin configuration", () => {
|
||||
expect(
|
||||
cli.parse([
|
||||
"client",
|
||||
"--model",
|
||||
"anthropic/claude-sonnet-4-5:high",
|
||||
"-e",
|
||||
"./first-plugin",
|
||||
"-e",
|
||||
"./second-plugin",
|
||||
]),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
command: {
|
||||
command: "client",
|
||||
model: "anthropic/claude-sonnet-4-5:high",
|
||||
pluginPackages: ["./first-plugin", "./second-plugin"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
[["--auth-token", "secret"], { type: "token", token: "secret" }],
|
||||
[["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }],
|
||||
] as const)("parses authentication source %j", (argv, auth) => {
|
||||
for (const command of ["server", "client"] as const) {
|
||||
expect(cli.parse([command, ...argv])).toMatchObject({ ok: true, command: { command, auth } });
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["server", "client"] as const)("permits omitted authentication for %s", (command) => {
|
||||
expect(cli.parse([command])).toEqual({ ok: true, command: { command } });
|
||||
});
|
||||
|
||||
test.each([
|
||||
[["client", "--listen", "unix:///tmp/pi.sock"], UNSUPPORTED_CLIENT_OPTIONS],
|
||||
[["server", "--listen", "unix:///tmp/pi.sock"], UNSUPPORTED_SERVER_OPTIONS],
|
||||
[["server", "--connect", "unix:///tmp/pi.sock"], UNSUPPORTED_SERVER_OPTIONS],
|
||||
[["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'],
|
||||
[["client", "--connect", "radius://not-a-server"], "Radius transport address requires"],
|
||||
[["client", "--connect", "unix://relative.sock"], "Unix transport address must not include an authority"],
|
||||
[["client", "--connect", "unix:///tmp/pi.sock?wrong=value"], "Invalid --connect address"],
|
||||
[["client", "--provider", "anthropic"], "--provider requires --model"],
|
||||
[["client", "-c", "-r"], "--session-id, --continue, and --resume are mutually exclusive"],
|
||||
[["client", "--continue=true"], "--continue does not take a value"],
|
||||
[["server", "--provider", "anthropic"], "--provider requires --model"],
|
||||
[["server", "--server-id", "not-a-uuid"], "Invalid --server-id"],
|
||||
[["server", "--server-id"], "--server-id requires a value"],
|
||||
[["server", "--session-dir"], "--session-dir requires a value"],
|
||||
[["client", "-e"], "-e requires a value"],
|
||||
[
|
||||
["server", "--session-dir", "/tmp/first", "--session-dir=/tmp/second"],
|
||||
"--session-dir may only be specified once",
|
||||
],
|
||||
[["client", "--connect="], "--connect requires a value"],
|
||||
] as const)("rejects invalid experimental input %j", (argv, error) => {
|
||||
const result = cli.parse(argv);
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
if (!result.ok) expect(result.errors).toContainEqual(expect.stringContaining(error));
|
||||
});
|
||||
|
||||
test("rejects unsupported options without parsing them through the stable CLI", () => {
|
||||
expect(cli.parse(["client", "--tui-mode", "wrong", "--model", "claude-sonnet"])).toEqual({
|
||||
ok: false,
|
||||
errors: [UNSUPPORTED_CLIENT_OPTIONS],
|
||||
});
|
||||
});
|
||||
});
|
||||
65
packages/coding-agent/test/experimental-cli-entry.test.ts
Normal file
65
packages/coding-agent/test/experimental-cli-entry.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { VERSION } from "../src/config.ts";
|
||||
|
||||
const sourceResolverPath = resolve(__dirname, "../src/experimental/source-resolver.ts");
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runEntry(entry: string, experimental: boolean) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "pi-cli-boundary-"));
|
||||
tempDirs.push(directory);
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
sourceResolverPath,
|
||||
resolve(__dirname, "../src", entry),
|
||||
"server",
|
||||
"--server-id",
|
||||
"invalid",
|
||||
"--version",
|
||||
],
|
||||
{
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: directory,
|
||||
USERPROFILE: directory,
|
||||
PI_CODING_AGENT_DIR: join(directory, "agent"),
|
||||
PI_OFFLINE: "1",
|
||||
PI_EXPERIMENTAL: experimental ? "1" : "0",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("stable and development CLI entrypoints", () => {
|
||||
// #9132: enabling experiments must not pull remote-server dependencies into the published CLI.
|
||||
it("does not dispatch experimental commands from the stable entrypoint", () => {
|
||||
const result = runEntry("cli.ts", true);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout.trim()).toBe(VERSION);
|
||||
});
|
||||
|
||||
it("keeps experimental dispatch in the development entrypoint", () => {
|
||||
const result = runEntry("experimental/cli.ts", true);
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("Invalid --server-id");
|
||||
expect(result.stdout).not.toContain(VERSION);
|
||||
});
|
||||
|
||||
it("falls back to the stable CLI when experiments are disabled", () => {
|
||||
const result = runEntry("experimental/cli.ts", false);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout.trim()).toBe(VERSION);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { cli } from "../src/cli/experimental/cli.ts";
|
||||
|
||||
describe("experimental CLI command composition", () => {
|
||||
test("requires an experimental subcommand", () => {
|
||||
expect(cli.parse([])).toEqual({
|
||||
ok: false,
|
||||
errors: ["Expected experimental command: server or client"],
|
||||
});
|
||||
});
|
||||
|
||||
test("passes server options to the command action", async () => {
|
||||
const runServer = vi.fn(() => undefined);
|
||||
const result = await cli.execute(
|
||||
[
|
||||
"server",
|
||||
"--server-id",
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"--session-dir",
|
||||
"./sessions",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--model",
|
||||
"claude-sonnet-4-5",
|
||||
],
|
||||
{ runServer, runClient: vi.fn(() => undefined) },
|
||||
);
|
||||
|
||||
const command = {
|
||||
command: "server" as const,
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionDir: "./sessions",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
};
|
||||
expect(result).toEqual({ ok: true, command });
|
||||
expect(runServer).toHaveBeenCalledWith(command);
|
||||
});
|
||||
|
||||
test.each(["server", "client"] as const)("executes the parsed %s command", async (name) => {
|
||||
const context = {
|
||||
runServer: vi.fn(() => undefined),
|
||||
runClient: vi.fn(() => undefined),
|
||||
};
|
||||
const result = await cli.execute([name], context);
|
||||
|
||||
expect(result).toEqual({ ok: true, command: { command: name } });
|
||||
expect(context.runServer).toHaveBeenCalledTimes(name === "server" ? 1 : 0);
|
||||
expect(context.runClient).toHaveBeenCalledTimes(name === "client" ? 1 : 0);
|
||||
});
|
||||
});
|
||||
451
packages/coding-agent/test/experimental-client-tui.test.ts
Normal file
451
packages/coding-agent/test/experimental-client-tui.test.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
createRemoteServiceBinding,
|
||||
type MutableReplicatedState,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceTransport,
|
||||
replicatedState,
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
FACET_BUNDLE_ARTIFACT_FORMAT,
|
||||
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
||||
type FacetBundleArtifact,
|
||||
} from "@earendil-works/chord/node";
|
||||
import {
|
||||
type AgentLane,
|
||||
type LaneSnapshot,
|
||||
type LaneTranscriptSnapshot,
|
||||
type LaneWatchEvent,
|
||||
reduceLaneSnapshot,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts";
|
||||
import { createPresentationFacetData } from "../src/experimental/plugins/bundled.ts";
|
||||
import { AgentController } from "../src/experimental/services/agent-controller.ts";
|
||||
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
|
||||
import type {
|
||||
ServerConnectionState,
|
||||
ServerServiceSource,
|
||||
SessionAttachmentState,
|
||||
SessionServiceSource,
|
||||
} from "../src/experimental/services/connection.ts";
|
||||
import { Models, type ModelsState } from "../src/experimental/services/models.ts";
|
||||
import { PresentationPlugins, SessionPlugins } from "../src/experimental/services/plugins.ts";
|
||||
import {
|
||||
SessionDirectory,
|
||||
type SessionDirectoryState,
|
||||
SessionManagement,
|
||||
type SessionSummary,
|
||||
} from "../src/experimental/services/sessions.ts";
|
||||
import { Transcript, type TranscriptState } from "../src/experimental/services/transcript.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
|
||||
function session(sessionId: string, createdAt: number): SessionSummary {
|
||||
return { serverId, sessionId, createdAt };
|
||||
}
|
||||
|
||||
function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
|
||||
return {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, (update) => listener(update, BACKGROUND_CONTEXT));
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T): void {
|
||||
const target = state.state as unknown as Record<string, unknown>;
|
||||
const replacement = value as unknown as Record<string, unknown>;
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(replacement, key)) delete target[key];
|
||||
}
|
||||
Object.assign(target, replacement);
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
function laneSnapshot(): LaneSnapshot {
|
||||
return {
|
||||
lane: "main",
|
||||
transcript: [],
|
||||
tipId: null,
|
||||
configuration: {
|
||||
model: { provider: "test", modelId: "one" },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
},
|
||||
stats: {
|
||||
messageCount: 0,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
operation: null,
|
||||
queues: [],
|
||||
faulted: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("experimental client TUI", () => {
|
||||
beforeAll(() => initTheme("dark"));
|
||||
|
||||
test.each([
|
||||
["new", { command: "client" as const }, "two", 1],
|
||||
["continued", { command: "client" as const, continue: true }, "one", 0],
|
||||
["plugin-selected", { command: "client" as const, pluginPackages: ["./example-plugin"] }, "two", 1],
|
||||
] as const)(
|
||||
"opens a %s Session directly and exercises the full lifecycle only for a new Session",
|
||||
async (kind, command, sessionId, creates) => {
|
||||
const directoryState = replicatedState<SessionDirectoryState>({ revision: 1, sessions: [session("one", 1)] });
|
||||
const attachment = replicatedState<SessionAttachmentState>({ status: "detached" });
|
||||
const connectionState = replicatedState<ServerConnectionState>({ status: "connected", since: "now" });
|
||||
const modelsState = replicatedState<ModelsState>({
|
||||
catalog: {
|
||||
revision: 1,
|
||||
availableModels: [
|
||||
{ provider: "test", modelId: "one", name: "Model One", reasoning: false },
|
||||
{ provider: "test", modelId: "two", name: "Model Two", reasoning: true },
|
||||
],
|
||||
},
|
||||
configuration: { model: { provider: "test", modelId: "one" }, thinkingLevel: "off" },
|
||||
refresh: { status: "idle" },
|
||||
});
|
||||
const create = vi.fn(async () => {
|
||||
const created = session("two", 2);
|
||||
directoryState.state.revision = 2;
|
||||
directoryState.state.sessions.push(created);
|
||||
directoryState.publish(BACKGROUND_CONTEXT);
|
||||
return created;
|
||||
});
|
||||
const select = vi.fn(async (model: { provider: string; modelId: string }) => {
|
||||
modelsState.state.configuration.model = model;
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
});
|
||||
const selectThinking = vi.fn(async (thinkingLevel: "off" | "high") => {
|
||||
modelsState.state.configuration.thinkingLevel = thinkingLevel;
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
});
|
||||
const transcriptState = replicatedState<TranscriptState>({
|
||||
snapshot: laneSnapshot() as LaneTranscriptSnapshot,
|
||||
event: null,
|
||||
});
|
||||
const emitTranscriptEvent = (event: LaneWatchEvent): void => {
|
||||
const snapshot = transcriptState.state.snapshot as LaneSnapshot;
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") {
|
||||
throw new Error("Test transcript event unexpectedly requires a rebase");
|
||||
}
|
||||
transcriptState.state.event = event;
|
||||
transcriptState.publish(BACKGROUND_CONTEXT);
|
||||
};
|
||||
let finishPrompt!: () => void;
|
||||
const promptFinished = new Promise<void>((resolve) => {
|
||||
finishPrompt = resolve;
|
||||
});
|
||||
const prompt = vi.fn(async () => {
|
||||
emitTranscriptEvent({
|
||||
type: "run_start",
|
||||
lane: "main",
|
||||
runId: "run-1",
|
||||
startedAt: 1,
|
||||
});
|
||||
await promptFinished;
|
||||
emitTranscriptEvent({
|
||||
type: "entry_added",
|
||||
lane: "main",
|
||||
entry: {
|
||||
id: "entry-user",
|
||||
parentId: null,
|
||||
seq: 1,
|
||||
timestamp: 1,
|
||||
type: "message",
|
||||
message: { role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 },
|
||||
},
|
||||
});
|
||||
emitTranscriptEvent({
|
||||
type: "entry_added",
|
||||
lane: "main",
|
||||
entry: {
|
||||
id: "entry-assistant",
|
||||
parentId: "entry-user",
|
||||
seq: 2,
|
||||
timestamp: 2,
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "remote answer" }],
|
||||
provider: "test",
|
||||
model: "one",
|
||||
api: "test",
|
||||
usage: transcriptState.value.snapshot!.stats.usage,
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
emitTranscriptEvent({
|
||||
type: "run_end",
|
||||
lane: "main",
|
||||
runId: "run-1",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
tipId: "entry-assistant",
|
||||
endedAt: 2,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run" as const,
|
||||
status: "completed" as const,
|
||||
fromTipId: null,
|
||||
tipId: "entry-assistant",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const reloadSource =
|
||||
'"use strict";\nconst { defineFacet, defineService } = require("@earendil-works/chord");\nconst Models = defineService("pi.models");\nmodule.exports = { __esModule: true, default: defineFacet({ id: "test-tui-facet", setup(env) { env.use(Models); } }) };\n';
|
||||
const reloadArtifact: FacetBundleArtifact = {
|
||||
format: FACET_BUNDLE_ARTIFACT_FORMAT,
|
||||
formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
||||
plugin: { id: "test-tui-plugin" },
|
||||
entryName: "tui",
|
||||
entry: {
|
||||
file: "tui.cjs",
|
||||
integrity: `sha256-${createHash("sha256").update(reloadSource).digest("base64")}`,
|
||||
externalImports: ["@earendil-works/chord"],
|
||||
},
|
||||
source: reloadSource,
|
||||
};
|
||||
const reloadData = createPresentationFacetData([reloadArtifact]);
|
||||
const prepareSessionPlugins = vi.fn(async () => reloadData);
|
||||
const reloadPresentationPlugins = vi.fn(async () => reloadData);
|
||||
const reloadSessionPlugins = vi.fn(async () => {});
|
||||
const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement, PresentationPlugins]);
|
||||
serverProvider.provide(SessionDirectory, { state: directoryState });
|
||||
serverProvider.provide(PresentationPlugins, {
|
||||
prepareSession: prepareSessionPlugins,
|
||||
reload: reloadPresentationPlugins,
|
||||
});
|
||||
serverProvider.provide(SessionManagement, {
|
||||
create,
|
||||
async remove() {},
|
||||
async attach(sessionId) {
|
||||
publishReplacement(attachment, { status: "attaching", sessionId });
|
||||
},
|
||||
async detach() {
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
},
|
||||
});
|
||||
const sessionProvider = new RemoteServiceProvider([Models, AgentController, SessionPlugins, Transcript]);
|
||||
sessionProvider.provide(SessionPlugins, { reload: reloadSessionPlugins });
|
||||
sessionProvider.provide(Models, {
|
||||
state: modelsState,
|
||||
async cycleThinking() {},
|
||||
async getThinkingLevels() {
|
||||
return ["off", "high"];
|
||||
},
|
||||
async refresh() {},
|
||||
select,
|
||||
selectThinking,
|
||||
});
|
||||
sessionProvider.provide(AgentController, createAgentController({ prompt } as unknown as AgentLane));
|
||||
sessionProvider.provide(Transcript, { state: transcriptState });
|
||||
|
||||
const serverNamespace = createRemoteServiceBinding({
|
||||
services: [SessionDirectory, SessionManagement, PresentationPlugins],
|
||||
transport: createLoopbackServiceTransport(serverProvider),
|
||||
bound: false,
|
||||
});
|
||||
const serverNamespaceReady = serverNamespace.ready.bind(serverNamespace);
|
||||
const serverServices: ServerServiceSource = Object.assign(serverNamespace, {
|
||||
acceptsUnavailableServices: false,
|
||||
connection: connectionState,
|
||||
async catalogue() {
|
||||
return serverProvider.catalogue;
|
||||
},
|
||||
open() {
|
||||
return {
|
||||
use: serverNamespace.use.bind(serverNamespace),
|
||||
observe: serverNamespace.observe.bind(serverNamespace),
|
||||
async ready() {
|
||||
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
await serverNamespaceReady(BACKGROUND_CONTEXT);
|
||||
},
|
||||
async dispose() {},
|
||||
};
|
||||
},
|
||||
async ready() {
|
||||
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
await serverNamespaceReady(BACKGROUND_CONTEXT);
|
||||
},
|
||||
});
|
||||
const sessionNamespace = createRemoteServiceBinding({
|
||||
services: [Models, AgentController, SessionPlugins, Transcript],
|
||||
transport: createLoopbackServiceTransport(sessionProvider),
|
||||
bound: false,
|
||||
});
|
||||
const sessionServices: SessionServiceSource = Object.assign(sessionNamespace, {
|
||||
acceptsUnavailableServices: true,
|
||||
attachment,
|
||||
async catalogue() {
|
||||
return [];
|
||||
},
|
||||
open() {
|
||||
return {
|
||||
use: sessionNamespace.use.bind(sessionNamespace),
|
||||
observe: sessionNamespace.observe.bind(sessionNamespace),
|
||||
ready: sessionNamespace.ready.bind(sessionNamespace),
|
||||
async dispose() {},
|
||||
};
|
||||
},
|
||||
async whenAttached(sessionId: string) {
|
||||
await sessionNamespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
await sessionNamespace.ready(BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "attached", sessionId });
|
||||
},
|
||||
async whenDetached() {
|
||||
await sessionNamespace.rebind(false, BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
},
|
||||
});
|
||||
const server: ClientTuiServer = {
|
||||
serverId,
|
||||
radius: true,
|
||||
server: serverServices,
|
||||
session: sessionServices,
|
||||
};
|
||||
let finished = false;
|
||||
const requestRender = vi.fn();
|
||||
const ui = new TuiMainScreen(new ProcessTerminal());
|
||||
const component = await ExperimentalClientTui.create({
|
||||
command,
|
||||
ui,
|
||||
servers: [server],
|
||||
requestRender,
|
||||
finish() {
|
||||
finished = true;
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(create).toHaveBeenCalledTimes(creates);
|
||||
expect(prepareSessionPlugins).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionId,
|
||||
packagePaths:
|
||||
"pluginPackages" in command
|
||||
? command.pluginPackages.map((packagePath) => resolve(packagePath))
|
||||
: null,
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
expect(attachment.value).toEqual({ status: "attached", sessionId });
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(component.render(80).join("\n")).toContain(`Server: ${serverId}`);
|
||||
expect(component.render(80).join("\n")).toContain(`Session: ${sessionId}`);
|
||||
expect(component.render(80).join("\n")).toContain("test/one");
|
||||
expect(component.render(80).join("\n")).not.toContain("Experimental Sessions");
|
||||
expect(component.render(80).join("\n")).not.toContain("Experimental Models");
|
||||
|
||||
// Startup selection is the only behavior specific to continue and plugin-selected Sessions.
|
||||
if (kind !== "new") return;
|
||||
|
||||
component.handleInput("hello");
|
||||
component.handleInput("\r");
|
||||
await vi.waitFor(() => expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT));
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Working..."));
|
||||
finishPrompt();
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer"));
|
||||
expect(component.render(80).join("\n")).toContain("hello");
|
||||
expect(component.render(80).join("\n")).not.toContain("Working...");
|
||||
expect(component.render(80).join("\n")).not.toContain("Operation run-1 completed");
|
||||
|
||||
component.handleInput("/reload");
|
||||
component.handleInput("\u001b");
|
||||
component.handleInput("\r");
|
||||
await vi.waitFor(() => {
|
||||
expect(reloadPresentationPlugins).toHaveBeenCalledOnce();
|
||||
expect(reloadSessionPlugins).toHaveBeenCalledOnce();
|
||||
});
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Reloaded plugins."));
|
||||
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
publishReplacement(connectionState, {
|
||||
status: "disconnected",
|
||||
since: "later",
|
||||
reason: "network lost",
|
||||
retryAt: null,
|
||||
});
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("retrying"));
|
||||
component.handleInput("\u0003");
|
||||
expect(finished).toBe(true);
|
||||
finished = false;
|
||||
component.handleInput("\u0004");
|
||||
expect(finished).toBe(true);
|
||||
finished = false;
|
||||
publishReplacement(connectionState, { status: "connecting", attempt: 1 });
|
||||
publishReplacement(connectionState, { status: "connected", since: "reconnected" });
|
||||
publishReplacement(attachment, { status: "attached", sessionId });
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Reattaching"));
|
||||
|
||||
component.handleInput("/model");
|
||||
component.handleInput("\u001b");
|
||||
component.handleInput("\r");
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select model:"));
|
||||
component.handleInput("\u001b[B");
|
||||
component.handleInput("\r");
|
||||
await vi.waitFor(() =>
|
||||
expect(select).toHaveBeenCalledWith({ provider: "test", modelId: "two" }, expect.anything()),
|
||||
);
|
||||
expect(modelsState.value.configuration.model).toEqual({ provider: "test", modelId: "two" });
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Select model:"));
|
||||
|
||||
component.handleInput("/thinking");
|
||||
component.handleInput("\u001b");
|
||||
component.handleInput("\r");
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select thinking level:"));
|
||||
component.handleInput("\u001b[B");
|
||||
component.handleInput("\r");
|
||||
await vi.waitFor(() => expect(selectThinking).toHaveBeenCalledWith("high", expect.anything()));
|
||||
expect(modelsState.value.configuration.thinkingLevel).toBe("high");
|
||||
|
||||
component.handleInput("\u0003");
|
||||
await vi.waitFor(() => expect(finished).toBe(true));
|
||||
|
||||
await component.close();
|
||||
const rendersAfterClose = requestRender.mock.calls.length;
|
||||
directoryState.state.revision = 3;
|
||||
directoryState.state.sessions = [];
|
||||
directoryState.publish(BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
modelsState.state.refresh = { status: "refreshing" };
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
expect(requestRender).toHaveBeenCalledTimes(rendersAfterClose);
|
||||
} finally {
|
||||
await component.close();
|
||||
await Promise.all([
|
||||
serverNamespace.dispose(BACKGROUND_CONTEXT),
|
||||
sessionNamespace.dispose(BACKGROUND_CONTEXT),
|
||||
]);
|
||||
serverProvider.dispose();
|
||||
sessionProvider.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { spawnInternalProcess, terminateInternalProcess } from "../src/experimental/process.ts";
|
||||
|
||||
const children = new Set<ChildProcess>();
|
||||
const directories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
children.clear();
|
||||
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
directories.clear();
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("experimental internal process launcher", () => {
|
||||
test("starts the coordinator through the current runtime", async () => {
|
||||
// Keep Unix socket paths below macOS's short sun_path limit.
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-ip-"));
|
||||
directories.add(directory);
|
||||
const publicPath = join(directory, "p.sock");
|
||||
const controlPath = join(directory, "c.sock");
|
||||
const child = spawnInternalProcess("coordinator", [publicPath, controlPath]);
|
||||
children.add(child);
|
||||
|
||||
await expect.poll(() => canConnect(controlPath), { timeout: 10_000 }).toBe(true);
|
||||
expect(child.pid).not.toBe(process.pid);
|
||||
});
|
||||
|
||||
test("waits for a failed activation child to terminate", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-ip-stop-"));
|
||||
directories.add(directory);
|
||||
const child = spawnInternalProcess("coordinator", [join(directory, "p.sock"), join(directory, "c.sock")]);
|
||||
children.add(child);
|
||||
|
||||
await expect.poll(() => child.pid).toEqual(expect.any(Number));
|
||||
await terminateInternalProcess(child);
|
||||
expect(child.signalCode).toBe("SIGKILL");
|
||||
});
|
||||
});
|
||||
|
||||
function canConnect(path: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection(path);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { defineFacet, type FacetLoader } from "@earendil-works/chord";
|
||||
import { type AgentLane, BACKGROUND_CONTEXT, type LaneSnapshot } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { SessionPlugins } from "../src/experimental/services/plugins.ts";
|
||||
import { createSessionWorkerServices } from "../src/experimental/services/worker.ts";
|
||||
|
||||
describe("experimental plugin reload", () => {
|
||||
test("loads and cuts over a fresh Session facet generation", async () => {
|
||||
const activations: number[] = [];
|
||||
const disposals: number[] = [];
|
||||
let generation = 0;
|
||||
const facetLoader: FacetLoader = {
|
||||
async load() {
|
||||
const current = ++generation;
|
||||
return {
|
||||
facets: [
|
||||
defineFacet({
|
||||
id: "reloadable-session-plugin",
|
||||
setup(env) {
|
||||
env.onActivate(() => {
|
||||
activations.push(current);
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
async dispose() {
|
||||
disposals.push(current);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const snapshot: LaneSnapshot = {
|
||||
lane: "main",
|
||||
transcript: [],
|
||||
tipId: null,
|
||||
configuration: {
|
||||
model: { provider: "test", modelId: "model" },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
},
|
||||
stats: {
|
||||
messageCount: 0,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
operation: null,
|
||||
queues: [],
|
||||
faulted: false,
|
||||
};
|
||||
const lane = {
|
||||
async watch() {
|
||||
return {
|
||||
snapshot,
|
||||
start() {},
|
||||
async resnapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
unsubscribe() {},
|
||||
};
|
||||
},
|
||||
async getModel() {
|
||||
return undefined;
|
||||
},
|
||||
async getThinkingLevel() {
|
||||
return "off" as const;
|
||||
},
|
||||
} as unknown as AgentLane;
|
||||
const services = await createSessionWorkerServices({
|
||||
lane,
|
||||
modelRuntime: undefined,
|
||||
facetLoader,
|
||||
publish: vi.fn(async () => {}),
|
||||
});
|
||||
try {
|
||||
expect(activations).toEqual([1]);
|
||||
await services.invoke(
|
||||
{ serviceId: SessionPlugins.id, member: "reload", args: [] },
|
||||
{ serverConnectionId: "server-1", attachmentId: "attachment-1" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(activations).toEqual([1, 2]);
|
||||
expect(disposals).toEqual([1]);
|
||||
} finally {
|
||||
await services.dispose();
|
||||
}
|
||||
expect(disposals).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { readFacetBundleManifest } from "@earendil-works/chord/node";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
activateBuiltinClientServices,
|
||||
type ClientRuntime,
|
||||
openClientRuntime,
|
||||
} from "../src/experimental/client-runtime.ts";
|
||||
import { createPresentationFacetData, createPresentationFacetLoaders } from "../src/experimental/plugins/bundled.ts";
|
||||
import { createServerPluginPackage, restoreServerPluginPackageProfile } from "../src/experimental/plugins/package.ts";
|
||||
import { type RunningServer, startServer } from "../src/experimental/server.ts";
|
||||
import { PresentationPlugins } from "../src/experimental/services/plugins.ts";
|
||||
|
||||
const runtimes = new Set<ClientRuntime>();
|
||||
const runningServers = new Set<RunningServer>();
|
||||
const directories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.allSettled([...runtimes].map((runtime) => runtime.dispose()));
|
||||
await Promise.allSettled([...runningServers].map((server) => server.close()));
|
||||
await Promise.allSettled([...directories].map((directory) => rm(directory, { force: true, recursive: true })));
|
||||
runtimes.clear();
|
||||
runningServers.clear();
|
||||
directories.clear();
|
||||
});
|
||||
|
||||
describe("server-selected presentation facets", () => {
|
||||
test("rejects local plugin paths for Radius servers", async () => {
|
||||
await expect(
|
||||
openClientRuntime({
|
||||
command: "client",
|
||||
connect: { transport: "radius", serverId: randomUUID() },
|
||||
pluginPackages: ["./local-plugin"],
|
||||
}),
|
||||
).rejects.toThrow("only be configured on a local Unix server");
|
||||
});
|
||||
|
||||
test("restores plugin package selections for later server generations", async () => {
|
||||
const directory = await mkdtemp("/tmp/pi-presentation-profile-");
|
||||
directories.add(directory);
|
||||
const serverId = randomUUID();
|
||||
const packagePaths = [join(directory, "first-plugin"), join(directory, "second-plugin")];
|
||||
await expect(restoreServerPluginPackageProfile(directory, serverId, packagePaths)).resolves.toEqual(packagePaths);
|
||||
await expect(restoreServerPluginPackageProfile(directory, serverId)).resolves.toEqual(packagePaths);
|
||||
await expect(restoreServerPluginPackageProfile(directory, serverId, [])).resolves.toEqual([]);
|
||||
await expect(restoreServerPluginPackageProfile(directory, serverId)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("builds conventional plugin entries into the server-owned plugin cache", async () => {
|
||||
const directory = await mkdtemp("/tmp/pi-presentation-package-");
|
||||
directories.add(directory);
|
||||
const serverId = randomUUID();
|
||||
const packagePath = join(directory, "pi-example-plugin");
|
||||
await mkdir(join(packagePath, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(packagePath, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@earendil-works/test-plugin",
|
||||
version: "1.0.0",
|
||||
peerDependencies: {
|
||||
"@earendil-works/chord": "^0.84.4",
|
||||
"@earendil-works/pi-coding-agent": "^0.84.4",
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
const sourcePath = join(packagePath, "src", "tui.ts");
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-a", setup(env) { env.use(SlashCommands); } });\n',
|
||||
);
|
||||
const plugin = createServerPluginPackage(directory, serverId, packagePath);
|
||||
|
||||
const first = await plugin.build();
|
||||
expect(first).toHaveLength(1);
|
||||
expect(plugin.manifestPath).toMatch(
|
||||
new RegExp(`/plugin-builds/${serverId}/pi-example-plugin-[a-f0-9]{12}/chord-facets\\.json$`, "u"),
|
||||
);
|
||||
expect(first[0]?.plugin).toEqual({ id: "@earendil-works/test-plugin", version: "1.0.0" });
|
||||
const firstLoaded = await createPresentationFacetLoaders(createPresentationFacetData(first))[0]!.load();
|
||||
expect(firstLoaded.facets.map(({ id }) => id)).toEqual(["built-a"]);
|
||||
await firstLoaded.dispose();
|
||||
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-b", setup(env) { env.use(SlashCommands); } });\n',
|
||||
);
|
||||
const second = await plugin.build();
|
||||
expect(second[0]?.source).not.toBe(first[0]?.source);
|
||||
const secondLoaded = await createPresentationFacetLoaders(createPresentationFacetData(second))[0]!.load();
|
||||
expect(secondLoaded.facets.map(({ id }) => id)).toEqual(["built-b"]);
|
||||
await secondLoaded.dispose();
|
||||
|
||||
const secondPackagePath = join(directory, "second-plugin");
|
||||
await mkdir(join(secondPackagePath, "src"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(secondPackagePath, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@earendil-works/second-test-plugin",
|
||||
version: "1.0.0",
|
||||
peerDependencies: { "@earendil-works/chord": "^0.84.4" },
|
||||
})}\n`,
|
||||
),
|
||||
writeFile(
|
||||
join(secondPackagePath, "src", "tui.ts"),
|
||||
'import { defineFacet } from "@earendil-works/chord"; export default defineFacet({ id: "second-built", setup() {} });\n',
|
||||
),
|
||||
]);
|
||||
const running = await startServer({
|
||||
directory: join(directory, "server"),
|
||||
sessionDir: join(directory, "sessions"),
|
||||
});
|
||||
runningServers.add(running);
|
||||
const runtime = await openClientRuntime({
|
||||
command: "client",
|
||||
connect: { transport: "unix", path: running.socketPath },
|
||||
pluginPackages: [packagePath, secondPackagePath],
|
||||
});
|
||||
runtimes.add(runtime);
|
||||
const activated = await activateBuiltinClientServices(runtime.servers[0]!);
|
||||
const sessionId = randomUUID();
|
||||
await activated.management.create({ id: sessionId }, BACKGROUND_CONTEXT);
|
||||
const presentationPlugins = await activated.plugins.prepareSession(
|
||||
{ sessionId, packagePaths: [packagePath, secondPackagePath] },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await expect(restoreServerPluginPackageProfile(join(directory, "server"), running.serverId)).resolves.toEqual([]);
|
||||
const serverLoaded = await Promise.all(
|
||||
createPresentationFacetLoaders(presentationPlugins).map((loader) => loader.load()),
|
||||
);
|
||||
expect(serverLoaded.flatMap(({ facets }) => facets.map(({ id }) => id))).toEqual(["built-b", "second-built"]);
|
||||
await Promise.all(serverLoaded.map((loaded) => loaded.dispose()));
|
||||
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-c", setup(env) { env.use(SlashCommands); } });\n',
|
||||
);
|
||||
const services = runtime.servers[0]!.server.open({
|
||||
services: [PresentationPlugins],
|
||||
assertAccess() {},
|
||||
onError() {},
|
||||
});
|
||||
try {
|
||||
await services.ready(BACKGROUND_CONTEXT);
|
||||
const data = await services.use(PresentationPlugins).reload(BACKGROUND_CONTEXT);
|
||||
const reloaded = await Promise.all(createPresentationFacetLoaders(data).map((loader) => loader.load()));
|
||||
expect(reloaded.flatMap(({ facets }) => facets.map(({ id }) => id))).toEqual(["built-c", "second-built"]);
|
||||
await Promise.all(reloaded.map((loaded) => loaded.dispose()));
|
||||
} finally {
|
||||
await services.dispose(BACKGROUND_CONTEXT);
|
||||
}
|
||||
});
|
||||
|
||||
test("builds the example plugin package without a package-owned build script", async () => {
|
||||
const directory = await mkdtemp("/tmp/pi-example-plugin-");
|
||||
directories.add(directory);
|
||||
const serverId = randomUUID();
|
||||
const packagePath = fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url));
|
||||
const plugin = createServerPluginPackage(directory, serverId, packagePath);
|
||||
|
||||
const artifacts = await plugin.build();
|
||||
const manifest = await readFacetBundleManifest(plugin.manifestPath);
|
||||
expect(manifest.plugin).toEqual({ id: "@earendil-works/pi-example-plugin", version: "1.0.0" });
|
||||
expect(Object.keys(manifest.entries)).toEqual(["session", "tui"]);
|
||||
const loaded = await createPresentationFacetLoaders(createPresentationFacetData(artifacts))[0]!.load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]);
|
||||
await loaded.dispose();
|
||||
});
|
||||
});
|
||||
286
packages/coding-agent/test/experimental-radius-relay.test.ts
Normal file
286
packages/coding-agent/test/experimental-radius-relay.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { Client } from "@earendil-works/pi-client";
|
||||
import type { Server } from "@earendil-works/pi-server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { RadiusRelayAuthResolver } from "../src/experimental/radius-auth.ts";
|
||||
import {
|
||||
createRadiusClientTransportFactory,
|
||||
encodeRelayDataFrame,
|
||||
parseRelayDataFrame,
|
||||
RadiusClientReconnect,
|
||||
RadiusRelayHost,
|
||||
type RadiusRelayWebSocket,
|
||||
type RadiusRelayWebSocketFactory,
|
||||
} from "../src/experimental/radius-relay.ts";
|
||||
import { allowNetwork } from "./test-network-env.ts";
|
||||
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const connectionId = "00000000-0000-4000-8000-000000000002";
|
||||
|
||||
type Listener = (event: unknown) => void;
|
||||
|
||||
class FakeWebSocket {
|
||||
readonly sent: Array<string | ArrayBuffer> = [];
|
||||
onSend: ((data: string | ArrayBuffer) => void) | undefined;
|
||||
readonly listeners = new Map<string, Set<Listener>>();
|
||||
binaryType: "arraybuffer" | "blob" = "blob";
|
||||
bufferedAmount = 0;
|
||||
protocol = "";
|
||||
readyState = 0;
|
||||
readonly OPEN = 1;
|
||||
|
||||
addEventListener(type: string, listener: Listener): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener): void {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBuffer): void {
|
||||
if (this.readyState !== this.OPEN) throw new Error("socket is not open");
|
||||
this.sent.push(data);
|
||||
this.onSend?.(data);
|
||||
}
|
||||
|
||||
close(code = 1000, reason = ""): void {
|
||||
if (code !== 1000 && (code < 3000 || code > 4999)) {
|
||||
throw new DOMException("Invalid close code", "InvalidAccessError");
|
||||
}
|
||||
if (this.readyState === 3) return;
|
||||
this.readyState = 3;
|
||||
this.emit("close", { code, reason });
|
||||
}
|
||||
|
||||
open(protocol: string): void {
|
||||
this.protocol = protocol;
|
||||
this.readyState = this.OPEN;
|
||||
this.emit("open", {});
|
||||
}
|
||||
|
||||
message(data: unknown): void {
|
||||
this.emit("message", { data });
|
||||
}
|
||||
|
||||
remoteClose(code = 1006, reason = "lost"): void {
|
||||
this.readyState = 3;
|
||||
this.emit("close", { code, reason });
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
this.emit("error", { error, message: error.message });
|
||||
}
|
||||
|
||||
abnormalClose(error: Error): void {
|
||||
this.readyState = 3;
|
||||
this.emit("error", { error, message: error.message });
|
||||
this.emit("close", { code: 1006, reason: "" });
|
||||
}
|
||||
|
||||
private emit(type: string, event: unknown): void {
|
||||
for (const listener of [...(this.listeners.get(type) ?? [])]) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
function socketFactory() {
|
||||
const sockets: Array<{ socket: FakeWebSocket; options: Parameters<RadiusRelayWebSocketFactory>[0] }> = [];
|
||||
const factory: RadiusRelayWebSocketFactory = (options) => {
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push({ socket, options });
|
||||
return socket as unknown as RadiusRelayWebSocket;
|
||||
};
|
||||
return { sockets, factory };
|
||||
}
|
||||
|
||||
beforeEach(() => allowNetwork());
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("experimental Radius relay", () => {
|
||||
test("matches the Radius multiplexing envelope", () => {
|
||||
const payload = Uint8Array.from([0, 1, 2, 255]);
|
||||
const parsed = parseRelayDataFrame(encodeRelayDataFrame(connectionId, payload));
|
||||
|
||||
expect(parsed?.connectionId).toBe(connectionId);
|
||||
expect([...new Uint8Array(parsed!.payload)]).toEqual([...payload]);
|
||||
});
|
||||
|
||||
test("bridges multiplexed host clients into independent server connections", async () => {
|
||||
const webSockets = socketFactory();
|
||||
const received: Uint8Array[] = [];
|
||||
let accepted:
|
||||
| {
|
||||
send(chunk: Uint8Array): Promise<void>;
|
||||
close(finalChunk?: Uint8Array): Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
const onClose = vi.fn();
|
||||
const server = {
|
||||
accept(connection: typeof accepted) {
|
||||
accepted = connection;
|
||||
return {
|
||||
onData: (chunk: Uint8Array) => received.push(chunk.slice()),
|
||||
onClose,
|
||||
onError: vi.fn(),
|
||||
};
|
||||
},
|
||||
} as unknown as Pick<Server, "accept">;
|
||||
const statuses: string[] = [];
|
||||
const host = new RadiusRelayHost({
|
||||
serverId,
|
||||
server,
|
||||
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
|
||||
webSocketFactory: webSockets.factory,
|
||||
onStatus: (status) => statuses.push(status.status),
|
||||
});
|
||||
host.start();
|
||||
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
|
||||
const { socket, options } = webSockets.sockets[0]!;
|
||||
expect(options.authorization).toBe("Bearer secret");
|
||||
socket.open(options.protocol);
|
||||
await vi.waitFor(() => expect(statuses).toContain("connected"));
|
||||
|
||||
socket.message(JSON.stringify({ version: 1, type: "connection_open", connection_id: connectionId }));
|
||||
expect(accepted).toBeDefined();
|
||||
const fromClient = Uint8Array.from([1, 2, 3]);
|
||||
socket.message(encodeRelayDataFrame(connectionId, fromClient));
|
||||
expect(received.map((chunk) => [...chunk])).toEqual([[1, 2, 3]]);
|
||||
|
||||
await accepted!.send(Uint8Array.from([4, 5, 6]));
|
||||
const outbound = parseRelayDataFrame(socket.sent.at(-1) as ArrayBuffer);
|
||||
expect(outbound?.connectionId).toBe(connectionId);
|
||||
expect([...new Uint8Array(outbound!.payload)]).toEqual([4, 5, 6]);
|
||||
|
||||
socket.message(JSON.stringify({ version: 1, type: "connection_close", connection_id: connectionId }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
await host.close();
|
||||
});
|
||||
|
||||
test("reconnects the server host after the relay connection drops", async () => {
|
||||
vi.useFakeTimers();
|
||||
const webSockets = socketFactory();
|
||||
const statuses: string[] = [];
|
||||
const host = new RadiusRelayHost({
|
||||
serverId,
|
||||
server: { accept: vi.fn() } as unknown as Pick<Server, "accept">,
|
||||
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
|
||||
webSocketFactory: webSockets.factory,
|
||||
onStatus: (status) => statuses.push(status.status),
|
||||
});
|
||||
host.start();
|
||||
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
|
||||
webSockets.sockets[0]!.socket.open(webSockets.sockets[0]!.options.protocol);
|
||||
await vi.waitFor(() => expect(statuses).toContain("connected"));
|
||||
|
||||
webSockets.sockets[0]!.socket.remoteClose();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(2));
|
||||
await host.close();
|
||||
});
|
||||
|
||||
test("uses a raw authenticated WebSocket as the client byte transport", async () => {
|
||||
const webSockets = socketFactory();
|
||||
const onData = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const transportPromise = createRadiusClientTransportFactory({
|
||||
serverId,
|
||||
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
|
||||
webSocketFactory: webSockets.factory,
|
||||
})({ onData, onClose, onError });
|
||||
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
|
||||
const { socket, options } = webSockets.sockets[0]!;
|
||||
expect(options.authorization).toBe("Bearer secret");
|
||||
socket.open(options.protocol);
|
||||
const transport = await transportPromise;
|
||||
|
||||
await transport.send(Uint8Array.from([1, 2, 3]));
|
||||
expect([...new Uint8Array(socket.sent[0] as ArrayBuffer)]).toEqual([1, 2, 3]);
|
||||
socket.message(Uint8Array.from([4, 5, 6]).buffer);
|
||||
expect(onData).toHaveBeenCalledOnce();
|
||||
expect([...onData.mock.calls[0]![0]]).toEqual([4, 5, 6]);
|
||||
socket.remoteClose();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports established abnormal closures so the client can reconnect", async () => {
|
||||
const webSockets = socketFactory();
|
||||
const onClose = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const transportPromise = createRadiusClientTransportFactory({
|
||||
serverId,
|
||||
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
|
||||
webSocketFactory: webSockets.factory,
|
||||
})({ onData: vi.fn(), onClose, onError });
|
||||
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
|
||||
const { socket, options } = webSockets.sockets[0]!;
|
||||
socket.open(options.protocol);
|
||||
await transportPromise;
|
||||
|
||||
expect(() => socket.abnormalClose(new Error("network lost"))).not.toThrow();
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "network lost" }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports a useful error when Undici omits WebSocket failure details", async () => {
|
||||
const webSockets = socketFactory();
|
||||
const opening = createRadiusClientTransportFactory({
|
||||
serverId,
|
||||
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
|
||||
webSocketFactory: webSockets.factory,
|
||||
})({ onData: vi.fn(), onClose: vi.fn(), onError: vi.fn() });
|
||||
const failure = expect(opening).rejects.toThrow("Radius WebSocket connection failed");
|
||||
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
|
||||
webSockets.sockets[0]!.socket.fail(new Error(""));
|
||||
await failure;
|
||||
});
|
||||
|
||||
test("reconnects an established client and restores its selected Session", async () => {
|
||||
vi.useFakeTimers();
|
||||
const connectionListeners = new Set<(change: { state: string }) => void>();
|
||||
const attachmentListeners = new Set<(attachment: { sessionId: string } | undefined) => void>();
|
||||
let attempts = 0;
|
||||
const reattach = vi.fn(async () => {});
|
||||
const client = {
|
||||
connected: true,
|
||||
connectionState: "connected",
|
||||
attachment: { sessionId: "demo-1" },
|
||||
onConnectionStateChange(listener: (change: { state: string }) => void) {
|
||||
connectionListeners.add(listener);
|
||||
return () => connectionListeners.delete(listener);
|
||||
},
|
||||
onAttachmentChange(listener: (attachment: { sessionId: string } | undefined) => void) {
|
||||
attachmentListeners.add(listener);
|
||||
return () => attachmentListeners.delete(listener);
|
||||
},
|
||||
async reconnect() {
|
||||
attempts++;
|
||||
if (attempts === 1) throw new Error("temporary failure");
|
||||
this.connected = true;
|
||||
this.connectionState = "connected";
|
||||
for (const listener of connectionListeners) listener({ state: "connected" });
|
||||
return { serverId };
|
||||
},
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
this.connectionState = "disconnected";
|
||||
for (const listener of connectionListeners) listener({ state: "disconnected" });
|
||||
},
|
||||
};
|
||||
const reconnect = new RadiusClientReconnect(client as unknown as Client, reattach);
|
||||
client.disconnect();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(attempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(attempts).toBe(2);
|
||||
expect(reattach).toHaveBeenCalledWith("demo-1");
|
||||
await reconnect.dispose();
|
||||
});
|
||||
});
|
||||
837
packages/coding-agent/test/experimental-remote-runtime.test.ts
Normal file
837
packages/coding-agent/test/experimental-remote-runtime.test.ts
Normal file
@@ -0,0 +1,837 @@
|
||||
import { lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type Context, createFacetHost, defineFacet, defineService } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { Client, ServerError as ClientServerError } from "@earendil-works/pi-client";
|
||||
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ExampleFacetService } from "../examples/plugins/pi-example-plugin/src/contract.ts";
|
||||
import { runClient } from "../src/experimental/client.ts";
|
||||
import { activateBuiltinClientServices, openClientRuntime } from "../src/experimental/client-runtime.ts";
|
||||
import { createPresentationFacetLoaders } from "../src/experimental/plugins/bundled.ts";
|
||||
import * as processRuntime from "../src/experimental/process.ts";
|
||||
import { type RunningServer, startServer } from "../src/experimental/server.ts";
|
||||
import { AgentController } from "../src/experimental/services/agent-controller.ts";
|
||||
import { createSessionServiceSource, type SessionAttachmentState } from "../src/experimental/services/connection.ts";
|
||||
import { Models } from "../src/experimental/services/models.ts";
|
||||
import { PresentationPlugins, SessionPlugins } from "../src/experimental/services/plugins.ts";
|
||||
import { SessionDirectory, SessionManagement } from "../src/experimental/services/sessions.ts";
|
||||
import { Transcript } from "../src/experimental/services/transcript.ts";
|
||||
import { createServerServiceBinding, createSessionServiceBinding } from "./experimental-service-binding.ts";
|
||||
import {
|
||||
configureExperimentalWorkerModel,
|
||||
createExperimentalSessions,
|
||||
readExperimentalSessionState,
|
||||
} from "./experimental-session-support.ts";
|
||||
import { KeyedProbe } from "./fixtures/keyed-service.ts";
|
||||
|
||||
const servers = new Set<RunningServer>();
|
||||
const clients = new Set<Client>();
|
||||
const directories = new Set<string>();
|
||||
const fauxWorkerEntryUrl = new URL("fixtures/faux-session-worker.ts", import.meta.url);
|
||||
const realSpawnInternalProcess = processRuntime.spawnInternalProcess;
|
||||
const sessionWorkerModel = { provider: "anthropic", model: "claude-sonnet-4-5" } as const;
|
||||
const SecondPluginService = defineService<{ read(context: Context): Promise<string> }>("test.second-plugin");
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
agentDir = await mkdtemp(join("/tmp", "pi-experimental-agent-"));
|
||||
directories.add(agentDir);
|
||||
await configureExperimentalWorkerModel(agentDir);
|
||||
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
||||
await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]);
|
||||
});
|
||||
|
||||
async function makeServer(): Promise<{ directory: string; runtime: RunningServer }> {
|
||||
const directory = await mkdtemp(join("/tmp", "pes-"));
|
||||
directories.add(directory);
|
||||
const runtime = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(runtime);
|
||||
return { directory, runtime };
|
||||
}
|
||||
|
||||
async function attachSession(client: Client, sessionId: string): Promise<void> {
|
||||
const services = createServerServiceBinding(client, { services: [SessionManagement] });
|
||||
try {
|
||||
await services.ready(BACKGROUND_CONTEXT);
|
||||
await services.use(SessionManagement).attach(sessionId, BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
await services.dispose(BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachClient(runtime: RunningServer, sessionId: string): Promise<Client> {
|
||||
const client = await Client.connect({
|
||||
serverId: runtime.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
|
||||
});
|
||||
clients.add(client);
|
||||
await attachSession(client, sessionId);
|
||||
return client;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...clients].map((client) => client.dispose()));
|
||||
clients.clear();
|
||||
await Promise.all([...servers].map((server) => server.close()));
|
||||
servers.clear();
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
directories.clear();
|
||||
});
|
||||
|
||||
describe("experimental durable server composition", () => {
|
||||
test("uses PI_SERVER_DIR and PI_SERVER_ID", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-server-dir-"));
|
||||
directories.add(directory);
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
vi.stubEnv("PI_SERVER_DIR", directory);
|
||||
vi.stubEnv("PI_SERVER_ID", serverId);
|
||||
const runtime = await startServer();
|
||||
servers.add(runtime);
|
||||
|
||||
expect(runtime.serverId).toBe(serverId);
|
||||
expect(runtime.socketPath).toBe(join(directory, `${serverId}.sock`));
|
||||
expect((await lstat(directory)).mode & 0o777).toBe(0o700);
|
||||
const publicSocket = await lstat(runtime.socketPath);
|
||||
const controlSocket = await lstat(join(directory, `control-${runtime.serverId}.sock`));
|
||||
expect(publicSocket.isSocket()).toBe(true);
|
||||
expect(publicSocket.mode & 0o777).toBe(0o600);
|
||||
expect(controlSocket.isSocket()).toBe(true);
|
||||
expect(controlSocket.mode & 0o777).toBe(0o600);
|
||||
const entries = await readdir(directory);
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(entries.every((entry) => !entry.startsWith("."))).toBe(true);
|
||||
expect(entries).toContain(`${serverId}.sock`);
|
||||
expect(entries).toContain(`control-${serverId}.sock`);
|
||||
expect(entries).toContainEqual(expect.stringMatching(new RegExp(`^server-${serverId}-[0-9a-f]{12}\\.sock$`)));
|
||||
await expect(runClient({ command: "client" })).resolves.toMatchObject({
|
||||
kind: "list",
|
||||
sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a provider without a model", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pes-"));
|
||||
directories.add(directory);
|
||||
await expect(startServer({ directory, provider: "anthropic" })).rejects.toThrow("provider requires a model");
|
||||
});
|
||||
|
||||
test("preserves an existing Session model when the server default changes", async () => {
|
||||
await writeFile(
|
||||
join(agentDir, "settings.json"),
|
||||
JSON.stringify({ defaultProvider: "anthropic", defaultModel: "claude-opus-4-6" }),
|
||||
);
|
||||
const directory = await mkdtemp(join("/tmp", "pes-"));
|
||||
directories.add(directory);
|
||||
const first = await startServer({ directory });
|
||||
servers.add(first);
|
||||
const firstClient = await attachClient(first, "demo-1");
|
||||
await firstClient.dispose();
|
||||
clients.delete(firstClient);
|
||||
await expect.poll(() => first.workerPids.has("demo-1")).toBe(false);
|
||||
await first.close();
|
||||
|
||||
const second = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(second);
|
||||
const secondClient = await attachClient(second, "demo-1");
|
||||
await secondClient.dispose();
|
||||
clients.delete(secondClient);
|
||||
await expect.poll(() => second.workerPids.has("demo-1")).toBe(false);
|
||||
const state = await readExperimentalSessionState(second.sessionDir, "demo-1");
|
||||
expect(state.model).toEqual({ provider: "anthropic", modelId: "claude-opus-4-6" });
|
||||
});
|
||||
|
||||
test("rejects model options when discovery selects an existing server", async () => {
|
||||
const { directory } = await makeServer();
|
||||
await expect(runClient({ command: "client", model: "anthropic/claude-opus-4-6" }, { directory })).rejects.toThrow(
|
||||
"Model selection is only valid when automatically activating a new server",
|
||||
);
|
||||
});
|
||||
|
||||
test("rechecks an auto-discovered server after a version mismatch", async () => {
|
||||
const { directory, runtime } = await makeServer();
|
||||
const connect = Client.connect.bind(Client);
|
||||
vi.spyOn(Client, "connect")
|
||||
.mockRejectedValueOnce(new ClientServerError({ code: "version", message: "stale server" }))
|
||||
.mockImplementation((options) => connect(options));
|
||||
const clientRuntime = await openClientRuntime({ command: "client" }, { directory });
|
||||
try {
|
||||
expect(clientRuntime.servers.map(({ route }) => route.serverId)).toEqual([runtime.serverId]);
|
||||
} finally {
|
||||
await clientRuntime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("serializes concurrent cold activation and retires after both clients leave", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-auto-server-"));
|
||||
directories.add(directory);
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
vi.stubEnv("PI_SERVER_DIR", directory);
|
||||
vi.stubEnv("PI_SERVER_ID", serverId);
|
||||
|
||||
const results = await Promise.all([runClient({ command: "client" }), runClient({ command: "client" })]);
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "list",
|
||||
sessions: [
|
||||
{ serverId, sessionId: "demo-1" },
|
||||
{ serverId, sessionId: "demo-2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "list",
|
||||
sessions: [
|
||||
{ serverId, sessionId: "demo-1" },
|
||||
{ serverId, sessionId: "demo-2" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(await pathExists(join(directory, `${serverId}.sock`))).toBe(true);
|
||||
await expect.poll(() => pathExists(join(directory, `${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
|
||||
await expect.poll(() => pathExists(join(directory, `control-${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
|
||||
});
|
||||
|
||||
test("passes client plugin packages to a cold server and restores them for its next generation", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-auto-plugin-"));
|
||||
directories.add(directory);
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const packagePath = fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url));
|
||||
vi.stubEnv("PI_SERVER_DIR", directory);
|
||||
vi.stubEnv("PI_SERVER_ID", serverId);
|
||||
|
||||
const first = await openClientRuntime({ command: "client", ...sessionWorkerModel });
|
||||
try {
|
||||
const activated = await activateBuiltinClientServices(first.servers[0]!);
|
||||
const presentationPlugins = await activated.plugins.prepareSession(
|
||||
{ sessionId: "demo-1", packagePaths: [packagePath] },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await activated.management.attach("demo-1", BACKGROUND_CONTEXT);
|
||||
const loaded = await createPresentationFacetLoaders(presentationPlugins)[0]!.load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]);
|
||||
await loaded.dispose();
|
||||
} finally {
|
||||
await first.dispose();
|
||||
}
|
||||
await expect.poll(() => pathExists(join(directory, `${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
|
||||
|
||||
const second = await openClientRuntime({ command: "client", ...sessionWorkerModel });
|
||||
try {
|
||||
const activated = await activateBuiltinClientServices(second.servers[0]!);
|
||||
const presentationPlugins = await activated.plugins.prepareSession(
|
||||
{ sessionId: "demo-1", packagePaths: null },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await activated.management.attach("demo-1", BACKGROUND_CONTEXT);
|
||||
expect(createPresentationFacetLoaders(presentationPlugins)).toHaveLength(1);
|
||||
} finally {
|
||||
await second.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("retires a cold server after its only Session attachment disconnects", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-auto-session-"));
|
||||
directories.add(directory);
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
vi.stubEnv("PI_SERVER_DIR", directory);
|
||||
vi.stubEnv("PI_SERVER_ID", serverId);
|
||||
|
||||
await expect(runClient({ command: "client", sessionId: "demo-1", ...sessionWorkerModel })).resolves.toEqual({
|
||||
kind: "attached",
|
||||
serverId,
|
||||
sessionId: "demo-1",
|
||||
});
|
||||
await expect.poll(() => pathExists(join(directory, `${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
|
||||
await expect.poll(() => pathExists(join(directory, `control-${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
|
||||
});
|
||||
|
||||
test("runs and discovers multiple logical servers from one directory", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-multi-server-"));
|
||||
directories.add(directory);
|
||||
const firstId = "00000000-0000-4000-8000-000000000001";
|
||||
const secondId = "00000000-0000-4000-8000-000000000002";
|
||||
const [first, second] = await Promise.all([
|
||||
startServer({ directory, serverId: firstId }),
|
||||
startServer({ directory, serverId: secondId }),
|
||||
]);
|
||||
servers.add(first);
|
||||
servers.add(second);
|
||||
|
||||
expect((await lstat(join(directory, `control-${firstId}.sock`))).isSocket()).toBe(true);
|
||||
expect((await lstat(join(directory, `control-${secondId}.sock`))).isSocket()).toBe(true);
|
||||
await expect(runClient({ command: "client" }, { directory })).resolves.toEqual({
|
||||
kind: "list",
|
||||
sessions: [
|
||||
{ serverId: firstId, sessionId: "demo-1" },
|
||||
{ serverId: firstId, sessionId: "demo-2" },
|
||||
{ serverId: secondId, sessionId: "demo-1" },
|
||||
{ serverId: secondId, sessionId: "demo-2" },
|
||||
],
|
||||
});
|
||||
|
||||
await first.close();
|
||||
await expect.poll(() => pathExists(first.socketPath)).toBe(false);
|
||||
await expect(runClient({ command: "client" }, { directory })).resolves.toEqual({
|
||||
kind: "list",
|
||||
sessions: [
|
||||
{ serverId: secondId, sessionId: "demo-1" },
|
||||
{ serverId: secondId, sessionId: "demo-2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("hydrates and mutates server Session services across framed clients", async () => {
|
||||
const { runtime } = await makeServer();
|
||||
const firstClient = await Client.connect({
|
||||
serverId: runtime.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
|
||||
});
|
||||
const secondClient = await Client.connect({
|
||||
serverId: runtime.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
|
||||
});
|
||||
clients.add(firstClient);
|
||||
clients.add(secondClient);
|
||||
const errors: Error[] = [];
|
||||
const firstServices = createServerServiceBinding(firstClient, {
|
||||
services: [SessionDirectory, SessionManagement],
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const secondServices = createServerServiceBinding(secondClient, {
|
||||
services: [SessionDirectory, SessionManagement],
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
expect(firstServices.connection.value).toMatchObject({ status: "connected" });
|
||||
expect(secondServices.connection.value).toMatchObject({ status: "connected" });
|
||||
await expect(firstServices.catalogue(BACKGROUND_CONTEXT)).resolves.toEqual([
|
||||
{ serviceId: SessionDirectory.id, mode: "singleton" },
|
||||
{ serviceId: SessionManagement.id, mode: "singleton" },
|
||||
{ serviceId: PresentationPlugins.id, mode: "singleton" },
|
||||
]);
|
||||
const firstDirectory = firstServices.use(SessionDirectory);
|
||||
const secondDirectory = secondServices.use(SessionDirectory);
|
||||
const firstManagement = firstServices.use(SessionManagement);
|
||||
const secondManagement = secondServices.use(SessionManagement);
|
||||
|
||||
await Promise.all([firstServices.ready(BACKGROUND_CONTEXT), secondServices.ready(BACKGROUND_CONTEXT)]);
|
||||
expect(firstDirectory.state.value?.sessions.map(({ sessionId }) => sessionId)).toEqual(["demo-1", "demo-2"]);
|
||||
expect(secondDirectory.state.value).toEqual(firstDirectory.state.value);
|
||||
await firstManagement.create({ id: "demo-3" }, BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => {
|
||||
expect(firstDirectory.state.value?.sessions.map(({ sessionId }) => sessionId)).toContain("demo-3");
|
||||
expect(secondDirectory.state.value).toEqual(firstDirectory.state.value);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
firstManagement.attach("demo-1", BACKGROUND_CONTEXT),
|
||||
secondManagement.attach("demo-1", BACKGROUND_CONTEXT),
|
||||
]);
|
||||
expect(firstClient.attachment?.sessionId).toBe("demo-1");
|
||||
expect(secondClient.attachment?.sessionId).toBe("demo-1");
|
||||
await firstManagement.remove("demo-1", BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => {
|
||||
expect(firstClient.attachment).toBeUndefined();
|
||||
expect(secondClient.attachment).toBeUndefined();
|
||||
expect(firstDirectory.state.value?.sessions.map(({ sessionId }) => sessionId)).not.toContain("demo-1");
|
||||
expect(secondDirectory.state.value).toEqual(firstDirectory.state.value);
|
||||
});
|
||||
await secondManagement.detach(BACKGROUND_CONTEXT);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]);
|
||||
});
|
||||
|
||||
test("hydrates and updates the Models service across concurrent framed clients", async () => {
|
||||
const { runtime } = await makeServer();
|
||||
const firstClient = await attachClient(runtime, "demo-1");
|
||||
const workerPid = runtime.workerPids.get("demo-1");
|
||||
expect(workerPid).toEqual(expect.any(Number));
|
||||
const secondClient = await attachClient(runtime, "demo-1");
|
||||
expect(runtime.workerPids.get("demo-1")).toBe(workerPid);
|
||||
const errors: Error[] = [];
|
||||
const firstServices = createSessionServiceBinding(firstClient, {
|
||||
services: [Models],
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const secondServices = createSessionServiceBinding(secondClient, {
|
||||
services: [Models],
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const firstModels = firstServices.use(Models);
|
||||
const secondModels = secondServices.use(Models);
|
||||
|
||||
await Promise.all([firstServices.ready(BACKGROUND_CONTEXT), secondServices.ready(BACKGROUND_CONTEXT)]);
|
||||
expect(firstServices.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
|
||||
expect(secondServices.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
|
||||
expect(firstModels.state.value?.configuration.model).toEqual({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
expect(secondModels.state.value).toEqual(firstModels.state.value);
|
||||
const previousThinking = firstModels.state.value!.configuration.thinkingLevel;
|
||||
await firstModels.cycleThinking(BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => {
|
||||
expect(firstModels.state.value!.configuration.thinkingLevel).not.toBe(previousThinking);
|
||||
expect(secondModels.state.value).toEqual(firstModels.state.value);
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]);
|
||||
});
|
||||
|
||||
test("loads conventional Session facets from multiple configured plugin packages", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pes-plugin-"));
|
||||
directories.add(directory);
|
||||
const secondPackagePath = join(directory, "second-plugin");
|
||||
await mkdir(join(secondPackagePath, "src"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(secondPackagePath, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@earendil-works/second-session-plugin",
|
||||
version: "1.0.0",
|
||||
peerDependencies: { "@earendil-works/chord": "^0.84.4" },
|
||||
})}\n`,
|
||||
),
|
||||
writeFile(
|
||||
join(secondPackagePath, "src", "session.ts"),
|
||||
'import { defineFacet, defineService } from "@earendil-works/chord"; const Service = defineService("test.second-plugin"); export default defineFacet({ id: "second-session-plugin", setup(env) { env.provide(Service, { async read() { return "second"; } }); } });\n',
|
||||
),
|
||||
]);
|
||||
const runtime = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(runtime);
|
||||
const clientRuntime = await openClientRuntime({ command: "client" }, { directory });
|
||||
const activated = await activateBuiltinClientServices(clientRuntime.servers[0]!);
|
||||
await activated.plugins.prepareSession(
|
||||
{
|
||||
sessionId: "demo-1",
|
||||
packagePaths: [
|
||||
fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url)),
|
||||
secondPackagePath,
|
||||
],
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await activated.management.attach("demo-1", BACKGROUND_CONTEXT);
|
||||
const services = createSessionServiceBinding(clientRuntime.servers[0]!.client, {
|
||||
services: [ExampleFacetService, SecondPluginService],
|
||||
});
|
||||
try {
|
||||
await services.ready(BACKGROUND_CONTEXT);
|
||||
await expect(
|
||||
services.use(ExampleFacetService).greet({ name: "Armin" }, BACKGROUND_CONTEXT),
|
||||
).resolves.toMatchObject({ workerActivations: 1 });
|
||||
await expect(services.use(SecondPluginService).read(BACKGROUND_CONTEXT)).resolves.toBe("second");
|
||||
} finally {
|
||||
await services.dispose(BACKGROUND_CONTEXT);
|
||||
await clientRuntime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the most recently selected model for a new Session", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pes-model-default-"));
|
||||
directories.add(directory);
|
||||
const runtime = await startServer({ directory });
|
||||
servers.add(runtime);
|
||||
const firstClient = await attachClient(runtime, "demo-1");
|
||||
const firstServices = createSessionServiceBinding(firstClient, { services: [Models] });
|
||||
const firstModels = firstServices.use(Models);
|
||||
await firstServices.ready(BACKGROUND_CONTEXT);
|
||||
await firstModels.select({ provider: "anthropic", modelId: "claude-opus-4-6" }, BACKGROUND_CONTEXT);
|
||||
await firstServices.dispose(BACKGROUND_CONTEXT);
|
||||
await firstClient.dispose();
|
||||
clients.delete(firstClient);
|
||||
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
|
||||
|
||||
const secondClient = await attachClient(runtime, "demo-2");
|
||||
const secondServices = createSessionServiceBinding(secondClient, { services: [Models] });
|
||||
const secondModels = secondServices.use(Models);
|
||||
await secondServices.ready(BACKGROUND_CONTEXT);
|
||||
expect(secondModels.state.value?.configuration.model).toEqual({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-6",
|
||||
});
|
||||
await secondServices.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("composes management attachment with Session service hydration", async () => {
|
||||
const { runtime } = await makeServer();
|
||||
const clientRuntime = await openClientRuntime({
|
||||
command: "client",
|
||||
connect: { transport: "unix", path: runtime.socketPath },
|
||||
});
|
||||
try {
|
||||
const server = await activateBuiltinClientServices(clientRuntime.servers[0]!);
|
||||
await server.plugins.prepareSession({ sessionId: "demo-1", packagePaths: null }, BACKGROUND_CONTEXT);
|
||||
await server.management.attach("demo-1", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(server.session.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
|
||||
expect(server.models.state.value?.configuration.model).toEqual({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
} finally {
|
||||
await clientRuntime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("fences superseded attachment hydration by attachment generation", async ({ onTestFinished }) => {
|
||||
const { runtime } = await makeServer();
|
||||
const client = await Client.connect({
|
||||
serverId: runtime.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
|
||||
});
|
||||
clients.add(client);
|
||||
const errors: Error[] = [];
|
||||
const services = createSessionServiceBinding(client, {
|
||||
services: [Models],
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = services.use(Models);
|
||||
await services.ready(BACKGROUND_CONTEXT);
|
||||
const states: SessionAttachmentState[] = [];
|
||||
const removeStateListener = services.attachment.subscribe((state) => states.push(state));
|
||||
let releaseDelay!: () => void;
|
||||
const delayed = new Promise<void>((resolve) => {
|
||||
releaseDelay = resolve;
|
||||
});
|
||||
const subscribeService = client.subscribeService.bind(client);
|
||||
const subscribe = vi
|
||||
.spyOn(client, "subscribeService")
|
||||
.mockImplementation(async (target, serviceId, mode, listener, signal) => {
|
||||
if ("sessionId" in target && target.sessionId === "demo-2" && serviceId === Models.id) {
|
||||
await delayed;
|
||||
}
|
||||
return subscribeService(target, serviceId, mode, listener, signal);
|
||||
});
|
||||
onTestFinished(() => subscribe.mockRestore());
|
||||
|
||||
try {
|
||||
await attachSession(client, "demo-2");
|
||||
expect(services.attachment.value).toEqual({ status: "attaching", sessionId: "demo-2" });
|
||||
await attachSession(client, "demo-1");
|
||||
await services.whenAttached("demo-1", BACKGROUND_CONTEXT);
|
||||
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
|
||||
expect(models.state.value?.configuration.model).toEqual({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
} finally {
|
||||
releaseDelay();
|
||||
removeStateListener();
|
||||
await services.dispose(BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
const latestAttach = states
|
||||
.map((state) => state.status === "attaching" && state.sessionId === "demo-1")
|
||||
.lastIndexOf(true);
|
||||
expect(latestAttach).toBeGreaterThanOrEqual(0);
|
||||
expect(states.slice(latestAttach)).not.toContainEqual({ status: "attached", sessionId: "demo-2" });
|
||||
expect(states.slice(latestAttach)).not.toContainEqual({ status: "degraded", sessionId: "demo-2" });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("observes keyed service instances and fences replacement generations over framed transport", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const spawn = vi
|
||||
.spyOn(processRuntime, "spawnInternalProcess")
|
||||
.mockImplementation((role, args, options) =>
|
||||
realSpawnInternalProcess(
|
||||
role,
|
||||
args,
|
||||
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
|
||||
),
|
||||
);
|
||||
onTestFinished(() => spawn.mockRestore());
|
||||
const { runtime } = await makeServer();
|
||||
const client = await attachClient(runtime, "demo-1");
|
||||
const errors: Error[] = [];
|
||||
const services = createSessionServiceSource(client, {
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await expect(services.catalogue(BACKGROUND_CONTEXT)).resolves.toContainEqual({
|
||||
serviceId: KeyedProbe.id,
|
||||
mode: "keyed",
|
||||
});
|
||||
const observed: { service: KeyedProbe; value: string | undefined }[] = [];
|
||||
const consumer = defineFacet({
|
||||
id: "@test/keyed-probe-consumer",
|
||||
setup(env) {
|
||||
env.observe(KeyedProbe, (service) => {
|
||||
observed.push({ service, value: service.state.value?.value });
|
||||
});
|
||||
},
|
||||
});
|
||||
const facetHost = await createFacetHost({ facets: [consumer], serviceSources: [services] });
|
||||
|
||||
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
expect(observed[0]!.value).toBe("first");
|
||||
const staleReplace = observed[0]!.service.replace;
|
||||
await expect(staleReplace("second", BACKGROUND_CONTEXT)).resolves.toBeUndefined();
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(observed[1]!.value).toBe("second");
|
||||
expect(() => staleReplace("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
|
||||
const replacedReplace = observed[1]!.service.replace;
|
||||
await expect(attachSession(client, "demo-2")).resolves.toBeUndefined();
|
||||
await services.whenAttached("demo-2", BACKGROUND_CONTEXT);
|
||||
expect(observed).toHaveLength(3);
|
||||
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-2" });
|
||||
expect(observed[2]!.value).toBe("first");
|
||||
expect(() => replacedReplace("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
await facetHost.dispose();
|
||||
await expect(services.dispose(BACKGROUND_CONTEXT)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("streams prompt events through the worker-owned service provider", async ({ onTestFinished }) => {
|
||||
const spawn = vi
|
||||
.spyOn(processRuntime, "spawnInternalProcess")
|
||||
.mockImplementation((role, args, options) =>
|
||||
realSpawnInternalProcess(
|
||||
role,
|
||||
args,
|
||||
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
|
||||
),
|
||||
);
|
||||
onTestFinished(() => spawn.mockRestore());
|
||||
const { directory } = await makeServer();
|
||||
const eventTypes: string[] = [];
|
||||
|
||||
const result = await runClient(
|
||||
{ command: "client", sessionId: "demo-1", prompt: "question" },
|
||||
{
|
||||
directory,
|
||||
onEvent(event) {
|
||||
eventTypes.push(event.type);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ kind: "prompted", text: "deterministic remote answer" });
|
||||
expect(eventTypes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"run_start",
|
||||
"message_start",
|
||||
"message_update",
|
||||
"message_end",
|
||||
"entry_added",
|
||||
"run_end",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("replicates terminal operation state after consecutive prompts", async ({ onTestFinished }) => {
|
||||
const spawn = vi
|
||||
.spyOn(processRuntime, "spawnInternalProcess")
|
||||
.mockImplementation((role, args, options) =>
|
||||
realSpawnInternalProcess(
|
||||
role,
|
||||
args,
|
||||
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
|
||||
),
|
||||
);
|
||||
onTestFinished(() => spawn.mockRestore());
|
||||
const { runtime } = await makeServer();
|
||||
const client = await attachClient(runtime, "demo-1");
|
||||
const services = createSessionServiceBinding(client, { services: [AgentController, SessionPlugins, Transcript] });
|
||||
const controller = services.use(AgentController);
|
||||
const transcript = services.use(Transcript);
|
||||
await services.ready(BACKGROUND_CONTEXT);
|
||||
await services.use(SessionPlugins).reload(BACKGROUND_CONTEXT);
|
||||
try {
|
||||
for (const message of ["first question", "second question"]) {
|
||||
const response = await controller.prompt({ message, images: null }, BACKGROUND_CONTEXT);
|
||||
expect(response).toMatchObject({ accepted: true, operationId: expect.any(String) });
|
||||
await vi.waitFor(() => {
|
||||
expect(transcript.state.value?.snapshot).toMatchObject({
|
||||
operation: null,
|
||||
lastResult: { operationId: response.operationId },
|
||||
});
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await services.dispose(BACKGROUND_CONTEXT);
|
||||
}
|
||||
});
|
||||
|
||||
test("stops an idle Session worker after its client disconnects", async () => {
|
||||
const { runtime } = await makeServer();
|
||||
const client = await attachClient(runtime, "demo-1");
|
||||
const pid = runtime.workerPids.get("demo-1");
|
||||
expect(pid).toEqual(expect.any(Number));
|
||||
|
||||
await client.dispose();
|
||||
clients.delete(client);
|
||||
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
|
||||
expect(processExists(pid!)).toBe(false);
|
||||
});
|
||||
|
||||
test("starts one process per attached session and stops them during shutdown", async () => {
|
||||
const { runtime } = await makeServer();
|
||||
await Promise.all([attachClient(runtime, "demo-1"), attachClient(runtime, "demo-2")]);
|
||||
|
||||
const pids = [...runtime.workerPids.values()];
|
||||
expect(pids).toHaveLength(2);
|
||||
expect(new Set(pids).size).toBe(2);
|
||||
for (const pid of pids) expect(processExists(pid)).toBe(true);
|
||||
|
||||
await runtime.close();
|
||||
expect(runtime.workerPids.size).toBe(0);
|
||||
await Promise.all(pids.map((pid) => expect.poll(() => processExists(pid)).toBe(false)));
|
||||
});
|
||||
|
||||
test("server runtime replaces an exited worker on the next attach", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pew-"));
|
||||
directories.add(directory);
|
||||
const runtime = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(runtime);
|
||||
const client = await attachClient(runtime, "demo-1");
|
||||
const firstPid = runtime.workerPids.get("demo-1");
|
||||
expect(firstPid).toEqual(expect.any(Number));
|
||||
|
||||
process.kill(firstPid!, "SIGKILL");
|
||||
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
|
||||
await attachSession(client, "demo-1");
|
||||
const replacementPid = runtime.workerPids.get("demo-1");
|
||||
expect(replacementPid).toEqual(expect.any(Number));
|
||||
expect(replacementPid).not.toBe(firstPid);
|
||||
});
|
||||
|
||||
test("discovers workers after replacing the server", async () => {
|
||||
const firstDirectory = await mkdtemp(join("/tmp", "per-"));
|
||||
directories.add(firstDirectory);
|
||||
const first = await startServer({ ...sessionWorkerModel, directory: firstDirectory });
|
||||
servers.add(first);
|
||||
await attachClient(first, "demo-1");
|
||||
const firstWorkerPid = first.workerPids.get("demo-1");
|
||||
expect(firstWorkerPid).toEqual(expect.any(Number));
|
||||
|
||||
const replacement = await startServer({ ...sessionWorkerModel, directory: firstDirectory });
|
||||
servers.add(replacement);
|
||||
await first.closed;
|
||||
|
||||
expect(replacement.serverId).toBe(first.serverId);
|
||||
expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid);
|
||||
await expect.poll(() => first.workerPids.size).toBe(0);
|
||||
expect(processExists(firstWorkerPid!)).toBe(true);
|
||||
|
||||
await expect(runClient({ command: "client" }, { directory: firstDirectory })).resolves.toMatchObject({
|
||||
kind: "list",
|
||||
sessions: [
|
||||
{ serverId: first.serverId, sessionId: "demo-1" },
|
||||
{ serverId: first.serverId, sessionId: "demo-2" },
|
||||
],
|
||||
});
|
||||
await attachClient(replacement, "demo-1");
|
||||
expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid);
|
||||
await attachClient(replacement, "demo-2");
|
||||
expect(replacement.workerPids.get("demo-2")).toEqual(expect.any(Number));
|
||||
expect(replacement.workerPids.get("demo-2")).not.toBe(firstWorkerPid);
|
||||
});
|
||||
|
||||
test("retires an unclaimed idle worker after replacement demand expires", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-orphan-worker-"));
|
||||
directories.add(directory);
|
||||
vi.stubEnv("__PI_SESSION_WORKER_ORPHAN_DEMAND_GRACE_MS", "50");
|
||||
const first = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(first);
|
||||
await attachClient(first, "demo-1");
|
||||
const workerPid = first.workerPids.get("demo-1");
|
||||
expect(workerPid).toEqual(expect.any(Number));
|
||||
|
||||
const replacement = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(replacement);
|
||||
await first.closed;
|
||||
expect(replacement.workerPids.get("demo-1")).toBe(workerPid);
|
||||
|
||||
await expect.poll(() => replacement.workerPids.has("demo-1"), { timeout: 5_000 }).toBe(false);
|
||||
expect(processExists(workerPid!)).toBe(false);
|
||||
});
|
||||
|
||||
test("restores tracked sessions that are outside the replacement catalog", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pet-"));
|
||||
const emptySessionDir = await mkdtemp(join("/tmp", "pet-sessions-"));
|
||||
directories.add(directory);
|
||||
directories.add(emptySessionDir);
|
||||
const first = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(first);
|
||||
await attachClient(first, "demo-1");
|
||||
const workerPid = first.workerPids.get("demo-1");
|
||||
|
||||
const replacement = await startServer({
|
||||
...sessionWorkerModel,
|
||||
directory,
|
||||
sessionDir: emptySessionDir,
|
||||
});
|
||||
servers.add(replacement);
|
||||
await first.closed;
|
||||
|
||||
await expect(runClient({ command: "client" }, { directory })).resolves.toEqual({
|
||||
kind: "list",
|
||||
sessions: [{ serverId: first.serverId, sessionId: "demo-1" }],
|
||||
});
|
||||
await attachClient(replacement, "demo-1");
|
||||
expect(replacement.workerPids.get("demo-1")).toBe(workerPid);
|
||||
});
|
||||
|
||||
test("reports missing and ambiguous session selections", async () => {
|
||||
const sharedDirectory = await mkdtemp(join("/tmp", "ped-"));
|
||||
directories.add(sharedDirectory);
|
||||
const firstShared = await startServer({
|
||||
...sessionWorkerModel,
|
||||
directory: sharedDirectory,
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
const secondShared = await startServer({
|
||||
...sessionWorkerModel,
|
||||
directory: sharedDirectory,
|
||||
serverId: "00000000-0000-4000-8000-000000000002",
|
||||
});
|
||||
servers.add(firstShared);
|
||||
servers.add(secondShared);
|
||||
|
||||
await expect(
|
||||
runClient({ command: "client", sessionId: "missing" }, { directory: sharedDirectory }),
|
||||
).rejects.toThrow("No discovered server contains session missing");
|
||||
await expect(
|
||||
runClient({ command: "client", sessionId: "demo-1" }, { directory: sharedDirectory }),
|
||||
).rejects.toThrow("Session demo-1 is available from more than one server");
|
||||
});
|
||||
test("rejects a duplicate session ID within one durable repository", async () => {
|
||||
await createExperimentalSessions(
|
||||
join(agentDir, "experimental", "sessions"),
|
||||
["demo-1"],
|
||||
join(agentDir, "other-cwd"),
|
||||
);
|
||||
const { directory } = await makeServer();
|
||||
|
||||
await expect(runClient({ command: "client", sessionId: "demo-1" }, { directory })).rejects.toMatchObject({
|
||||
code: "session_ambiguous",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function processExists(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ServerLifetime } from "../src/experimental/server.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("server lifecycle", () => {
|
||||
test("holds a foreground server until explicit shutdown", () => {
|
||||
vi.useFakeTimers();
|
||||
const retire = vi.fn();
|
||||
const lifetime = new ServerLifetime(true);
|
||||
lifetime.start(retire);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
lifetime.stop();
|
||||
});
|
||||
|
||||
test("retires an automatic server if no first client arrives", () => {
|
||||
vi.useFakeTimers();
|
||||
const retire = vi.fn();
|
||||
const lifetime = new ServerLifetime(false);
|
||||
lifetime.start(retire);
|
||||
vi.advanceTimersByTime(10_999);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifetime.stop();
|
||||
});
|
||||
|
||||
test("requires both client and worker demand to disappear", () => {
|
||||
vi.useFakeTimers();
|
||||
const retire = vi.fn();
|
||||
const lifetime = new ServerLifetime(false);
|
||||
lifetime.start(retire);
|
||||
lifetime.setConnectionCount(1);
|
||||
lifetime.setWorkerCount(1);
|
||||
lifetime.setConnectionCount(0);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
|
||||
lifetime.setWorkerCount(0);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifetime.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { acquireServerProfile } from "../src/experimental/server.ts";
|
||||
|
||||
const directories = new Set<string>();
|
||||
const FIRST_SERVER_ID = "00000000-0000-4000-8000-000000000001";
|
||||
const SECOND_SERVER_ID = "00000000-0000-4000-8000-000000000002";
|
||||
|
||||
async function makeDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "psp-"));
|
||||
directories.add(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
directories.clear();
|
||||
});
|
||||
|
||||
describe("experimental server profile", () => {
|
||||
test("serializes launchers and preserves the server identity", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const first = await acquireServerProfile(directory);
|
||||
let secondAcquired = false;
|
||||
const pendingSecond = acquireServerProfile(directory).then((profile) => {
|
||||
secondAcquired = true;
|
||||
return profile;
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(secondAcquired).toBe(false);
|
||||
await first.release();
|
||||
|
||||
const second = await pendingSecond;
|
||||
expect(secondAcquired).toBe(true);
|
||||
expect(second.serverId).toBe(first.serverId);
|
||||
expect((await readFile(join(directory, "default-server-id"), "utf8")).trim()).toBe(first.serverId);
|
||||
await second.release();
|
||||
});
|
||||
|
||||
test("does not serialize different server IDs in one directory", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const [first, second] = await Promise.all([
|
||||
acquireServerProfile(directory, FIRST_SERVER_ID),
|
||||
acquireServerProfile(directory, SECOND_SERVER_ID),
|
||||
]);
|
||||
expect(first.serverId).toBe(FIRST_SERVER_ID);
|
||||
expect(second.serverId).toBe(SECOND_SERVER_ID);
|
||||
expect((await readdir(directory)).sort()).toEqual([
|
||||
`launcher-${FIRST_SERVER_ID}.lock`,
|
||||
`launcher-${SECOND_SERVER_ID}.lock`,
|
||||
]);
|
||||
await Promise.all([first.release(), second.release()]);
|
||||
});
|
||||
|
||||
test("does not share the default identity across server directories", async () => {
|
||||
const firstDirectory = await makeDirectory();
|
||||
const secondDirectory = await makeDirectory();
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
acquireServerProfile(firstDirectory),
|
||||
acquireServerProfile(secondDirectory),
|
||||
]);
|
||||
expect(first.serverId).not.toBe(second.serverId);
|
||||
await Promise.all([first.release(), second.release()]);
|
||||
});
|
||||
|
||||
test("rejects a corrupt default identity", async () => {
|
||||
const directory = await makeDirectory();
|
||||
await writeFile(join(directory, "default-server-id"), "invalid\n");
|
||||
|
||||
await expect(acquireServerProfile(directory)).rejects.toThrow(/Invalid default experimental server identity/);
|
||||
});
|
||||
|
||||
test("rejects an invalid explicit server ID", async () => {
|
||||
const directory = await makeDirectory();
|
||||
await expect(acquireServerProfile(directory, "invalid")).rejects.toThrow("Invalid experimental server ID");
|
||||
});
|
||||
});
|
||||
90
packages/coding-agent/test/experimental-service-binding.ts
Normal file
90
packages/coding-agent/test/experimental-service-binding.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Context, RemoteServices } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import type { Client } from "@earendil-works/pi-client";
|
||||
import {
|
||||
createServerServiceSource,
|
||||
createSessionServiceSource,
|
||||
type ServerServiceSource,
|
||||
type ServiceSourceOptions,
|
||||
type SessionServiceSource,
|
||||
} from "../src/experimental/services/connection.ts";
|
||||
|
||||
interface ServiceBindingOptions extends ServiceSourceOptions {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
}
|
||||
|
||||
/** Test helper for binding server services without a facet host. */
|
||||
export function createServerServiceBinding(
|
||||
client: Client,
|
||||
options: ServiceBindingOptions,
|
||||
): ServerServiceSource & RemoteServices {
|
||||
const source = createServerServiceSource(client, options);
|
||||
const services = source.open({
|
||||
services: options.services,
|
||||
assertAccess() {},
|
||||
onError: options.onError ?? (() => {}),
|
||||
});
|
||||
const activation = services.ready(BACKGROUND_CONTEXT);
|
||||
void activation.catch(options.onError ?? (() => {}));
|
||||
const ready = async (context: Context): Promise<void> => {
|
||||
await activation;
|
||||
await services.ready(context);
|
||||
};
|
||||
return {
|
||||
acceptsUnavailableServices: source.acceptsUnavailableServices,
|
||||
connection: source.connection,
|
||||
catalogue: (context) => source.catalogue(context),
|
||||
open: (openOptions) => source.open(openOptions),
|
||||
use: (service) => services.use(service),
|
||||
observe: (service, handler) => services.observe(service, handler),
|
||||
ready,
|
||||
async dispose(context) {
|
||||
const results = await Promise.allSettled([services.dispose(context), source.dispose(context)]);
|
||||
throwFailures(results, "Failed to dispose server service binding");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Test helper for binding selected-Session services without a facet host. */
|
||||
export function createSessionServiceBinding(
|
||||
client: Client,
|
||||
options: ServiceBindingOptions,
|
||||
): SessionServiceSource & RemoteServices {
|
||||
const source = createSessionServiceSource(client, options);
|
||||
const services = source.open({
|
||||
services: options.services,
|
||||
assertAccess() {},
|
||||
onError: options.onError ?? (() => {}),
|
||||
});
|
||||
const activation = services.ready(BACKGROUND_CONTEXT);
|
||||
void activation.catch(options.onError ?? (() => {}));
|
||||
const ready = async (context: Context): Promise<void> => {
|
||||
await activation;
|
||||
const attachment = source.attachment.value;
|
||||
if (attachment?.status === "detached") await source.whenDetached(context);
|
||||
else if (attachment !== undefined) await source.whenAttached(attachment.sessionId, context);
|
||||
};
|
||||
return {
|
||||
get acceptsUnavailableServices() {
|
||||
return source.acceptsUnavailableServices;
|
||||
},
|
||||
attachment: source.attachment,
|
||||
catalogue: (context) => source.catalogue(context),
|
||||
open: (openOptions) => source.open(openOptions),
|
||||
use: (service) => services.use(service),
|
||||
observe: (service, handler) => services.observe(service, handler),
|
||||
ready,
|
||||
whenAttached: (sessionId, context) => source.whenAttached(sessionId, context),
|
||||
whenDetached: (context) => source.whenDetached(context),
|
||||
async dispose(context) {
|
||||
const results = await Promise.allSettled([services.dispose(context), source.dispose(context)]);
|
||||
throwFailures(results, "Failed to dispose Session service binding");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function throwFailures(results: readonly PromiseSettledResult<unknown>[], message: string): void {
|
||||
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, message);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { resolveSessionDirectory } from "../src/experimental/server.ts";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("experimental server session directory", () => {
|
||||
test("uses the experimental directory under the configured agent directory by default", () => {
|
||||
vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config");
|
||||
|
||||
expect(resolveSessionDirectory()).toBe("/tmp/pi-agent-config/experimental/sessions");
|
||||
});
|
||||
|
||||
test("resolves an explicit relative directory from the current working directory", () => {
|
||||
vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config");
|
||||
|
||||
expect(resolveSessionDirectory("relative/sessions")).toBe(resolve("relative/sessions"));
|
||||
});
|
||||
|
||||
test("expands a tilde in an explicit directory", () => {
|
||||
expect(resolveSessionDirectory("~/custom-sessions")).toBe(resolve(homedir(), "custom-sessions"));
|
||||
});
|
||||
});
|
||||
67
packages/coding-agent/test/experimental-session-support.ts
Normal file
67
packages/coding-agent/test/experimental-session-support.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
type Entry,
|
||||
type JsonlSessionMetadata,
|
||||
JsonlSessionRepo,
|
||||
laneConfig,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
|
||||
export async function createExperimentalSessions(
|
||||
sessionsRoot: string,
|
||||
ids: readonly string[],
|
||||
cwd = process.cwd(),
|
||||
): Promise<JsonlSessionMetadata[]> {
|
||||
const fileSystem = new NodeExecutionEnv({ cwd });
|
||||
const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot });
|
||||
const metadata: JsonlSessionMetadata[] = [];
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const session = await repo.create({ id, cwd }, BACKGROUND_CONTEXT);
|
||||
metadata.push(session.metadata);
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
}
|
||||
return metadata;
|
||||
} finally {
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
await fileSystem.cleanup(BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureExperimentalWorkerModel(agentDir: string): Promise<void> {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "auth.json"), JSON.stringify({ anthropic: { type: "api_key", key: "test-key" } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readExperimentalSessionState(
|
||||
sessionsRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<{
|
||||
branch: Entry[];
|
||||
model: { provider: string; modelId: string } | undefined;
|
||||
activeTools: string[];
|
||||
}> {
|
||||
const fileSystem = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot });
|
||||
let session: Awaited<ReturnType<JsonlSessionRepo["open"]>> | undefined;
|
||||
try {
|
||||
const matches = (await repo.list(undefined, BACKGROUND_CONTEXT)).filter((metadata) => metadata.id === sessionId);
|
||||
if (matches.length !== 1) throw new Error(`Expected one Session ${sessionId}, found ${matches.length}`);
|
||||
session = await repo.open(matches[0]!, BACKGROUND_CONTEXT);
|
||||
const main = await session.branch("main", BACKGROUND_CONTEXT);
|
||||
if (main === undefined) throw new Error("Expected Session main Branch");
|
||||
const [branch, configuration] = await Promise.all([
|
||||
main.findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT),
|
||||
session.getValue(laneConfig("main"), BACKGROUND_CONTEXT),
|
||||
]);
|
||||
return { branch, model: configuration?.value.model, activeTools: configuration?.value.activeToolNames ?? [] };
|
||||
} finally {
|
||||
await session?.close(BACKGROUND_CONTEXT);
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
await fileSystem.cleanup(BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { WorkerLifecycle } from "../src/experimental/session-worker.ts";
|
||||
|
||||
const GENERATION = "generation-1";
|
||||
|
||||
function createLifecycle(options: { initialDemandGraceMs?: number; orphanDemandGraceMs?: number } = {}) {
|
||||
const retire = vi.fn();
|
||||
const lifecycle = new WorkerLifecycle({
|
||||
initialServerConnectionId: GENERATION,
|
||||
initialDemandGraceMs: options.initialDemandGraceMs ?? 100,
|
||||
orphanDemandGraceMs: options.orphanDemandGraceMs ?? 200,
|
||||
onRetire: retire,
|
||||
});
|
||||
return { lifecycle, retire };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Session worker lifecycle", () => {
|
||||
test("retires only after client demand and Harness activity are both gone", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
lifecycle.operationStarted("run", "main", "operation-1");
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", false);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
|
||||
lifecycle.operationStopped("run", "main", "operation-1");
|
||||
await vi.runAllTicks();
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("retains the worker until every presentation attachment is released", () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
lifecycle.setDemand(GENERATION, "attachment-2", true);
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", false);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
lifecycle.setDemand(GENERATION, "attachment-2", false);
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("tracks a nested compaction independently from its enclosing run", () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
lifecycle.operationStarted("run", "main", "operation-1");
|
||||
lifecycle.operationStarted("compaction", "main", "operation-1");
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", false);
|
||||
|
||||
lifecycle.operationStopped("compaction", "main", "operation-1");
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
lifecycle.operationStopped("run", "main", "operation-1");
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test.each(["compaction", "navigation"] as const)("clears suspended %s activity", (kind) => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
lifecycle.operationStarted(kind, "main", "operation-1");
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", false);
|
||||
|
||||
lifecycle.operationStopped(kind, "main", "operation-1");
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("does not retire while a demand acknowledgement holds reconciliation", () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
const release = lifecycle.holdRetirement();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", false);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
release();
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("holds retirement only for requests from the active attachment", () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
const release = lifecycle.beginRequest(GENERATION, "attachment-1");
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", false);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
release();
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
expect(() => lifecycle.beginRequest(GENERATION, "attachment-1")).toThrow(/retiring/);
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("rejects requests from stale generations and attachments", () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
expect(() => lifecycle.beginRequest("stale", "attachment-1")).toThrow(/stale server generation/);
|
||||
expect(() => lifecycle.beginRequest(GENERATION, "wrong-attachment")).toThrow(/active attachment/);
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("retains disconnected-generation demand for the orphan grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
lifecycle.serverDisconnected(GENERATION);
|
||||
|
||||
vi.advanceTimersByTime(199);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
await vi.runAllTicks();
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("allows a replacement generation to retain the worker", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
lifecycle.setDemand(GENERATION, "attachment-1", true);
|
||||
lifecycle.serverDisconnected(GENERATION);
|
||||
lifecycle.serverConnected("generation-2");
|
||||
lifecycle.setDemand("generation-2", "attachment-2", true);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(retire).not.toHaveBeenCalled();
|
||||
lifecycle.setDemand("generation-2", "attachment-2", false);
|
||||
await vi.runAllTicks();
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("retires a launched worker that never receives initial demand", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle, retire } = createLifecycle();
|
||||
vi.advanceTimersByTime(100);
|
||||
await vi.runAllTicks();
|
||||
expect(retire).toHaveBeenCalledOnce();
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("rejects demand after retirement has won the race", () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle } = createLifecycle();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(() => lifecycle.setDemand(GENERATION, "attachment-1", true)).toThrow(/retiring/);
|
||||
lifecycle.close();
|
||||
});
|
||||
|
||||
test("rejects demand from a stale server generation", () => {
|
||||
vi.useFakeTimers();
|
||||
const { lifecycle } = createLifecycle();
|
||||
expect(() => lifecycle.setDemand("stale", "attachment-1", true)).toThrow(/stale server generation/);
|
||||
lifecycle.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { ServiceCall } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT, type JsonlSessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { CoordinatorConnectionEvent } from "../src/experimental/coordinator.ts";
|
||||
import { SessionWorkerManager } from "../src/experimental/session-worker-manager.ts";
|
||||
|
||||
const metadata: JsonlSessionMetadata = {
|
||||
id: "session-1",
|
||||
createdAt: 1,
|
||||
storageVersion: 1,
|
||||
cwd: "/tmp",
|
||||
path: "/tmp/session-1.jsonl",
|
||||
modifiedAt: 1,
|
||||
};
|
||||
|
||||
class FakeCoordinator {
|
||||
readonly controlPath = "/tmp/control.sock";
|
||||
readonly serverConnectionId = "server-generation-1";
|
||||
readonly wasReplaced = false;
|
||||
readonly sent: { peerId: string; payload: unknown }[] = [];
|
||||
readonly #listeners = new Set<(event: CoordinatorConnectionEvent) => void>();
|
||||
onSend?: (peerId: string, payload: Record<string, unknown>) => void;
|
||||
|
||||
onEvent(listener: (event: CoordinatorConnectionEvent) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
async send(peerId: string, payload: unknown): Promise<void> {
|
||||
this.sent.push({ peerId, payload });
|
||||
this.onSend?.(peerId, asObject(payload));
|
||||
}
|
||||
|
||||
async broadcast(payload: unknown): Promise<void> {
|
||||
if (asObject(payload).type !== "discover_workers") return;
|
||||
this.emit({
|
||||
type: "message",
|
||||
from: "worker-1",
|
||||
payload: {
|
||||
type: "worker_ready",
|
||||
token: "worker-token",
|
||||
sessionKey: metadata.path,
|
||||
sessionId: metadata.id,
|
||||
pid: 123,
|
||||
metadata,
|
||||
pluginManifestPaths: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
emit(event: CoordinatorConnectionEvent): void {
|
||||
for (const listener of this.#listeners) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createAttachedWorker(): Promise<{
|
||||
coordinator: FakeCoordinator;
|
||||
workers: SessionWorkerManager;
|
||||
handle: Awaited<ReturnType<SessionWorkerManager["openSession"]>>;
|
||||
attachment: Awaited<ReturnType<Awaited<ReturnType<SessionWorkerManager["openSession"]>>["attachClient"]>>;
|
||||
release(): Promise<void>;
|
||||
}> {
|
||||
const coordinator = new FakeCoordinator();
|
||||
const workers = new SessionWorkerManager(coordinator, "/tmp");
|
||||
await workers.discover(new Set(["worker-1"]));
|
||||
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []);
|
||||
coordinator.onSend = (peerId, payload) => {
|
||||
if (payload.type !== "session_demand") return;
|
||||
queueMicrotask(() =>
|
||||
coordinator.emit({
|
||||
type: "message",
|
||||
from: peerId,
|
||||
payload: {
|
||||
type: "demand_applied",
|
||||
token: "worker-token",
|
||||
sessionKey: metadata.path,
|
||||
requestId: payload.requestId,
|
||||
attachmentId: payload.attachmentId,
|
||||
attached: payload.attached,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
const attachment = await handle.attachClient!(BACKGROUND_CONTEXT);
|
||||
return {
|
||||
coordinator,
|
||||
workers,
|
||||
handle,
|
||||
attachment,
|
||||
release: () => Promise.resolve(attachment.release(BACKGROUND_CONTEXT)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Session worker lifecycle failures", () => {
|
||||
test("adopts a discovered worker with its existing Session plugin selection", async () => {
|
||||
const coordinator = new FakeCoordinator();
|
||||
const workers = new SessionWorkerManager(coordinator, "/tmp");
|
||||
await workers.discover(new Set(["worker-1"]));
|
||||
expect(coordinator.sent).not.toContainEqual({ peerId: "worker-1", payload: { type: "shutdown" } });
|
||||
expect(workers.workerPids.size).toBe(1);
|
||||
expect(() => workers.assertSessionPluginManifestPaths(metadata, [])).not.toThrow();
|
||||
workers.detach();
|
||||
});
|
||||
|
||||
test("rejects a different plugin selection for an active Session without stopping it", async () => {
|
||||
const { workers } = await createAttachedWorker();
|
||||
expect(() => workers.assertSessionPluginManifestPaths(metadata, ["/tmp/plugin/chord-facets.json"])).toThrow(
|
||||
"active with a different plugin selection",
|
||||
);
|
||||
expect(workers.workerPids.size).toBe(1);
|
||||
workers.detach();
|
||||
});
|
||||
|
||||
test("compensates a timed-out attachment before rejecting it", async () => {
|
||||
vi.useFakeTimers();
|
||||
const coordinator = new FakeCoordinator();
|
||||
const workers = new SessionWorkerManager(coordinator, "/tmp");
|
||||
await workers.discover(new Set(["worker-1"]));
|
||||
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []);
|
||||
const demands: { attachmentId: string; attached: boolean }[] = [];
|
||||
coordinator.onSend = (peerId, payload) => {
|
||||
if (
|
||||
payload.type !== "session_demand" ||
|
||||
typeof payload.attachmentId !== "string" ||
|
||||
typeof payload.attached !== "boolean"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
demands.push({ attachmentId: payload.attachmentId, attached: payload.attached });
|
||||
if (payload.attached) return;
|
||||
queueMicrotask(() =>
|
||||
coordinator.emit({
|
||||
type: "message",
|
||||
from: peerId,
|
||||
payload: {
|
||||
type: "demand_applied",
|
||||
token: "worker-token",
|
||||
sessionKey: metadata.path,
|
||||
requestId: payload.requestId,
|
||||
attachmentId: payload.attachmentId,
|
||||
attached: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const attaching = expect(handle.attachClient!(BACKGROUND_CONTEXT)).rejects.toThrow("timed out");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await attaching;
|
||||
expect(demands).toHaveLength(2);
|
||||
expect(demands[0]).toMatchObject({ attachmentId: expect.any(String), attached: true });
|
||||
expect(demands[1]).toEqual({ attachmentId: demands[0]!.attachmentId, attached: false });
|
||||
workers.detach();
|
||||
});
|
||||
|
||||
test("kills a worker when timed-out demand cannot be reconciled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
const coordinator = new FakeCoordinator();
|
||||
const workers = new SessionWorkerManager(coordinator, "/tmp");
|
||||
await workers.discover(new Set(["worker-1"]));
|
||||
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []);
|
||||
coordinator.onSend = () => {};
|
||||
|
||||
const attaching = expect(handle.attachClient!(BACKGROUND_CONTEXT)).rejects.toThrow("worker was terminated");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await attaching;
|
||||
expect(kill).toHaveBeenCalledWith(123, "SIGKILL");
|
||||
expect(workers.workerPids.size).toBe(0);
|
||||
workers.detach();
|
||||
});
|
||||
|
||||
test("bounds Harness-driven worker shutdown", async () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
const { coordinator, workers, handle, release } = await createAttachedWorker();
|
||||
await release();
|
||||
coordinator.onSend = () => {};
|
||||
|
||||
const closing = handle.close(BACKGROUND_CONTEXT);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await closing;
|
||||
expect(kill).toHaveBeenCalledWith(123, "SIGKILL");
|
||||
expect(workers.workerPids.size).toBe(0);
|
||||
workers.detach();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session worker operations", () => {
|
||||
const serviceCall = { serviceId: "test.session", member: "run", args: ["Hello"] } satisfies ServiceCall;
|
||||
|
||||
test("correlates service results to the worker generation and attachment", async () => {
|
||||
const { coordinator, workers, attachment, release } = await createAttachedWorker();
|
||||
coordinator.onSend = (peerId, payload) => {
|
||||
if (payload.type !== "operation") return;
|
||||
const scope = asObject(payload.scope);
|
||||
queueMicrotask(() => {
|
||||
coordinator.emit({
|
||||
type: "message",
|
||||
from: peerId,
|
||||
payload: {
|
||||
type: "operation_response",
|
||||
token: "worker-token",
|
||||
sessionKey: metadata.path,
|
||||
response: {
|
||||
type: "operation_result",
|
||||
requestId: payload.requestId,
|
||||
scope,
|
||||
result: { accepted: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
accepted: true,
|
||||
});
|
||||
const operation = coordinator.sent
|
||||
.map(({ payload }) => asObject(payload))
|
||||
.find(({ type }) => type === "operation");
|
||||
expect(operation).toMatchObject({
|
||||
scope: { serverConnectionId: "server-generation-1", attachmentId: expect.any(String) },
|
||||
call: serviceCall,
|
||||
});
|
||||
workers.detach();
|
||||
await release();
|
||||
});
|
||||
|
||||
test("rejects a correlated response with mismatched worker identity", async () => {
|
||||
const { coordinator, workers, attachment, release } = await createAttachedWorker();
|
||||
coordinator.onSend = (peerId, payload) => {
|
||||
if (payload.type !== "operation") return;
|
||||
queueMicrotask(() =>
|
||||
coordinator.emit({
|
||||
type: "message",
|
||||
from: peerId,
|
||||
payload: {
|
||||
type: "operation_response",
|
||||
token: "wrong-token",
|
||||
sessionKey: metadata.path,
|
||||
response: {
|
||||
type: "operation_result",
|
||||
requestId: payload.requestId,
|
||||
scope: payload.scope,
|
||||
result: { accepted: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
/mismatched operation response/,
|
||||
);
|
||||
workers.detach();
|
||||
await release();
|
||||
});
|
||||
|
||||
test("rejects a null request scope", async () => {
|
||||
const { coordinator, workers, attachment, release } = await createAttachedWorker();
|
||||
coordinator.onSend = (peerId, payload) => {
|
||||
if (payload.type !== "operation") return;
|
||||
queueMicrotask(() =>
|
||||
coordinator.emit({
|
||||
type: "message",
|
||||
from: peerId,
|
||||
payload: {
|
||||
type: "operation_response",
|
||||
token: "worker-token",
|
||||
sessionKey: metadata.path,
|
||||
response: {
|
||||
type: "operation_result",
|
||||
requestId: payload.requestId,
|
||||
scope: null,
|
||||
result: { accepted: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
/invalid operation response/,
|
||||
);
|
||||
workers.detach();
|
||||
await release();
|
||||
});
|
||||
|
||||
test("rejects pending service calls on replacement without stopping the worker", async () => {
|
||||
const { coordinator, workers, attachment } = await createAttachedWorker();
|
||||
coordinator.onSend = () => {};
|
||||
const calling = attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT);
|
||||
workers.detach();
|
||||
|
||||
await expect(calling).rejects.toThrow(/replaced during a worker operation/);
|
||||
expect(coordinator.sent.map(({ payload }) => asObject(payload).type)).not.toContain("shutdown");
|
||||
expect(workers.workerPids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Expected object");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createFacetHost, defineFacet } from "@earendil-works/chord";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { SlashCommands } from "../src/experimental/services/slash-commands.ts";
|
||||
import {
|
||||
createSlashCommandsRuntimeFacet,
|
||||
SlashCommandRegistry,
|
||||
} from "../src/experimental/services/slash-commands-provider.ts";
|
||||
|
||||
describe("experimental slash command facets", () => {
|
||||
test("registers and removes contributions", () => {
|
||||
const registry = new SlashCommandRegistry();
|
||||
const snapshots: string[][] = [];
|
||||
const unsubscribe = registry.subscribe((commands) => snapshots.push(commands.map(({ name }) => name)));
|
||||
const close = registry.register({ name: "hello", description: "Hello", run: () => undefined });
|
||||
expect(registry.list().map(({ name }) => name)).toEqual(["hello"]);
|
||||
expect(() => registry.register({ name: "hello", run: () => undefined })).toThrow("already registered");
|
||||
close();
|
||||
close();
|
||||
expect(registry.list()).toEqual([]);
|
||||
expect(snapshots).toEqual([[], ["hello"], []]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("stages replacements until the previous registration retires", () => {
|
||||
const registry = new SlashCommandRegistry();
|
||||
const first = { name: "hello", description: "First", run: () => undefined };
|
||||
const second = { name: "hello", description: "Second", run: () => undefined };
|
||||
const closeFirst = registry.register(first);
|
||||
const closeSecond = registry.replace(second);
|
||||
expect(registry.list()).toEqual([first]);
|
||||
|
||||
closeSecond();
|
||||
expect(registry.list()).toEqual([first]);
|
||||
const closeReplacement = registry.replace(second);
|
||||
closeFirst();
|
||||
expect(registry.list()).toEqual([second]);
|
||||
closeReplacement();
|
||||
expect(registry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("tracks plugin facet reload and unload", async () => {
|
||||
const registry = new SlashCommandRegistry();
|
||||
const originalRun = vi.fn();
|
||||
const host = await createFacetHost({
|
||||
facets: [
|
||||
createSlashCommandsRuntimeFacet(registry),
|
||||
defineFacet({
|
||||
id: "@test/example-hello",
|
||||
setup(env) {
|
||||
const commands = env.use(SlashCommands);
|
||||
env.onActivate(() =>
|
||||
env.own(commands.replace({ name: "hello", description: "Original", run: originalRun })),
|
||||
);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(registry.list().map(({ name }) => name)).toEqual(["hello"]);
|
||||
|
||||
const failure = new Error("replacement failed");
|
||||
await expect(
|
||||
host.reload([
|
||||
defineFacet({
|
||||
id: "@test/example-hello",
|
||||
setup(env) {
|
||||
const commands = env.use(SlashCommands);
|
||||
env.onActivate(() => {
|
||||
env.own(commands.replace({ name: "hello", description: "Failing", run: () => undefined }));
|
||||
throw failure;
|
||||
});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).rejects.toBe(failure);
|
||||
expect(registry.list()).toEqual([expect.objectContaining({ name: "hello", run: originalRun })]);
|
||||
|
||||
const replacementRun = vi.fn();
|
||||
await host.reload([
|
||||
defineFacet({
|
||||
id: "@test/example-hello",
|
||||
setup(env) {
|
||||
const commands = env.use(SlashCommands);
|
||||
env.onActivate(() =>
|
||||
env.own(commands.replace({ name: "hello", description: "Replacement", run: replacementRun })),
|
||||
);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(registry.list()).toEqual([
|
||||
expect.objectContaining({ name: "hello", description: "Replacement", run: replacementRun }),
|
||||
]);
|
||||
|
||||
await host.dispose();
|
||||
expect(registry.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createBashToolDefinition,
|
||||
createEditToolDefinition,
|
||||
createPowerShellToolDefinition,
|
||||
createReadToolDefinition,
|
||||
createWriteToolDefinition,
|
||||
} from "../src/core/tools/index.ts";
|
||||
|
||||
function createBuiltInTools() {
|
||||
return [
|
||||
createReadToolDefinition(process.cwd()),
|
||||
createBashToolDefinition(process.cwd()),
|
||||
createPowerShellToolDefinition(process.cwd()),
|
||||
createEditToolDefinition(process.cwd()),
|
||||
createWriteToolDefinition(process.cwd()),
|
||||
];
|
||||
}
|
||||
|
||||
describe("experimental strict built-in tools", () => {
|
||||
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPiExperimental === undefined) delete process.env.PI_EXPERIMENTAL;
|
||||
else process.env.PI_EXPERIMENTAL = originalPiExperimental;
|
||||
});
|
||||
|
||||
it("only enables strict-prefer sampling in experimental mode", () => {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
const normalTools = createBuiltInTools();
|
||||
process.env.PI_EXPERIMENTAL = "1";
|
||||
const experimentalTools = createBuiltInTools();
|
||||
|
||||
for (const [index, tool] of experimentalTools.entries()) {
|
||||
expect(tool.constrainedSampling).toEqual({ type: "json_schema", strict: "prefer" });
|
||||
expect(tool.parameters).toEqual(normalTools[index]?.parameters);
|
||||
expect(normalTools[index]?.constrainedSampling).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { replicatedState } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import type { AgentLane, EventListener, HarnessEvent, LaneSnapshot, WatchHandle } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { TranscriptState } from "../src/experimental/services/transcript.ts";
|
||||
import { createTranscriptService } from "../src/experimental/services/transcript-provider.ts";
|
||||
|
||||
function laneSnapshot(tipId: string | null = null): LaneSnapshot {
|
||||
return {
|
||||
lane: "main",
|
||||
transcript: [],
|
||||
tipId,
|
||||
configuration: {
|
||||
model: { provider: "test", modelId: "model" },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
},
|
||||
stats: {
|
||||
messageCount: 0,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
operation: null,
|
||||
queues: [],
|
||||
faulted: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Transcript service", () => {
|
||||
test("publishes coherent state and rebases after navigation", async () => {
|
||||
let listener: EventListener | undefined;
|
||||
const replacement = laneSnapshot("replacement-tip");
|
||||
const resnapshot = vi.fn(async () => replacement);
|
||||
const unsubscribe = vi.fn();
|
||||
const handle: WatchHandle<LaneSnapshot> = {
|
||||
snapshot: laneSnapshot(),
|
||||
start(next) {
|
||||
listener = next;
|
||||
},
|
||||
resnapshot,
|
||||
unsubscribe,
|
||||
};
|
||||
const lane = { watch: async () => handle } as unknown as AgentLane;
|
||||
const runtime = createTranscriptService(lane, replicatedState);
|
||||
const states: TranscriptState[] = [];
|
||||
runtime.service.state.subscribe((value) => {
|
||||
if (value.snapshot !== null) states.push(value);
|
||||
});
|
||||
await runtime.activate();
|
||||
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0]).toMatchObject({ snapshot: { lane: "main", operation: null }, event: null });
|
||||
await listener?.({ type: "run_start", lane: "main", runId: "run-1", startedAt: 1 }, BACKGROUND_CONTEXT);
|
||||
expect(states).toHaveLength(2);
|
||||
expect(states[1]).toMatchObject({
|
||||
snapshot: { operation: { id: "run-1" } },
|
||||
event: { type: "run_start", runId: "run-1" },
|
||||
});
|
||||
|
||||
await listener?.(
|
||||
{
|
||||
type: "entry_added",
|
||||
lane: "main",
|
||||
entry: {
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
seq: 1,
|
||||
timestamp: 2,
|
||||
type: "message",
|
||||
message: { role: "user", content: "hello", timestamp: 2 },
|
||||
},
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
snapshot: { tipId: "entry-1", transcript: [{ id: "entry-1" }] },
|
||||
event: { type: "entry_added" },
|
||||
});
|
||||
|
||||
const navigation: HarnessEvent = {
|
||||
type: "navigation_end",
|
||||
lane: "main",
|
||||
runId: "navigation-1",
|
||||
status: "completed",
|
||||
fromTipId: "entry-1",
|
||||
tipId: "replacement-tip",
|
||||
endedAt: 3,
|
||||
};
|
||||
await listener?.(navigation, BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => expect(states.at(-1)?.event).toBeNull());
|
||||
expect(states.at(-2)).toMatchObject({
|
||||
snapshot: { tipId: "entry-1" },
|
||||
event: { type: "navigation_end" },
|
||||
});
|
||||
expect(states.at(-1)).toMatchObject({ snapshot: { tipId: "replacement-tip" }, event: null });
|
||||
expect(runtime.service.state.value).toMatchObject({ snapshot: { tipId: "replacement-tip" }, event: null });
|
||||
expect(resnapshot).toHaveBeenCalledOnce();
|
||||
|
||||
await runtime.dispose();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
44
packages/coding-agent/test/experimental.test.ts
Normal file
44
packages/coding-agent/test/experimental.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { areExperimentalFeaturesEnabled } from "../src/core/experimental.ts";
|
||||
|
||||
describe("areExperimentalFeaturesEnabled", () => {
|
||||
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPiExperimental === undefined) {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
} else {
|
||||
process.env.PI_EXPERIMENTAL = originalPiExperimental;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is unset", () => {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is empty", () => {
|
||||
process.env.PI_EXPERIMENTAL = "";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when PI_EXPERIMENTAL is set to 1", () => {
|
||||
process.env.PI_EXPERIMENTAL = "1";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is set to 0", () => {
|
||||
process.env.PI_EXPERIMENTAL = "0";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is set to a non-1 value", () => {
|
||||
process.env.PI_EXPERIMENTAL = "true";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
40
packages/coding-agent/test/export-html-skill-block.test.ts
Normal file
40
packages/coding-agent/test/export-html-skill-block.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("export HTML skill block rendering", () => {
|
||||
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
|
||||
|
||||
it("strips skill wrapper XML from user message rendering", () => {
|
||||
// Skill commands store a structural wrapper in the raw user message:
|
||||
// <skill name="..." location="...">\n...\n</skill>\n\nactual prompt
|
||||
// The export renderer must detect that wrapper and render only the user-visible prompt,
|
||||
// not the Pi-generated <skill>...</skill> XML tags.
|
||||
expect(templateJs).toMatch(/parseSkillBlock/);
|
||||
expect(templateJs).toMatch(/skillBlock\.userMessage/);
|
||||
});
|
||||
|
||||
it("renders skill invocation and user message as separate sibling blocks", () => {
|
||||
// The skill block and user message should render as separate entry-level elements,
|
||||
// matching the TUI layout where SkillInvocationMessageComponent and
|
||||
// UserMessageComponent are siblings, not nested.
|
||||
expect(templateJs).toMatch(/skill-invocation/);
|
||||
|
||||
// When a skill block has a userMessage, the user-message div must be emitted
|
||||
// as a separate block after the skill-invocation div, containing the user-authored text.
|
||||
// Verify the code checks hasUserContent so the user-message div is only omitted
|
||||
// when the skill block has no user prompt and no images.
|
||||
expect(templateJs).toMatch(/hasUserContent/);
|
||||
});
|
||||
|
||||
it("renders skill content as markdown, not raw text", () => {
|
||||
// The skill block body is markdown (from the SKILL.md file).
|
||||
// It should be rendered through safeMarkedParse, not escaped as raw text.
|
||||
expect(templateJs).toMatch(/safeMarkedParse\(skillBlock\.content\)/);
|
||||
});
|
||||
|
||||
it("shows skill name and user message in the sidebar tree", () => {
|
||||
// The sidebar tree should display both the skill name and the user prompt,
|
||||
// not just one or the other.
|
||||
expect(templateJs).toMatch(/tree-role-skill/);
|
||||
});
|
||||
});
|
||||
42
packages/coding-agent/test/export-html-whitespace.test.ts
Normal file
42
packages/coding-agent/test/export-html-whitespace.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import { readFileSync } from "fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ansiLinesToHtml } from "../src/core/export-html/ansi-to-html.ts";
|
||||
import { createToolHtmlRenderer } from "../src/core/export-html/tool-renderer.ts";
|
||||
import type { ToolDefinition } from "../src/core/extensions/types.ts";
|
||||
import type { Theme } from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
describe("export HTML tool output whitespace", () => {
|
||||
it("preserves whitespace for plain-text tool output lines without preserving template whitespace", () => {
|
||||
const css = readFileSync(new URL("../src/core/export-html/template.css", import.meta.url), "utf-8");
|
||||
|
||||
expect(css).toMatch(
|
||||
/\.output-preview > div:not\(\.expand-hint\),\s*\.output-full > div:not\(\.expand-hint\) \{[\s\S]*?white-space:\s*pre-wrap;/,
|
||||
);
|
||||
expect(css).toMatch(/\.ansi-line\s*\{[\s\S]*?white-space:\s*pre;/);
|
||||
expect(css).not.toMatch(/\.output-preview,\s*\.output-full\s*\{[\s\S]*?white-space:\s*pre-wrap;/);
|
||||
});
|
||||
|
||||
it("does not insert source whitespace between ANSI-rendered lines", () => {
|
||||
expect(ansiLinesToHtml(["one", "two"])).toBe('<div class="ansi-line">one</div><div class="ansi-line">two</div>');
|
||||
});
|
||||
|
||||
it("trims TUI spacing lines from custom tool result HTML", () => {
|
||||
const component: Component = { render: () => ["", "\u001b[31mone\u001b[0m", "two", ""], invalidate: () => {} };
|
||||
const tool = {
|
||||
name: "custom",
|
||||
label: "custom",
|
||||
description: "custom",
|
||||
renderResult: () => component,
|
||||
} as unknown as ToolDefinition;
|
||||
const renderer = createToolHtmlRenderer({
|
||||
getToolDefinition: () => tool,
|
||||
theme: {} as Theme,
|
||||
cwd: "/tmp",
|
||||
});
|
||||
|
||||
expect(renderer.renderResult("id", "custom", [], undefined, false)?.expanded).toBe(
|
||||
'<div class="ansi-line"><span style="color:#800000">one</span></div><div class="ansi-line">two</div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
67
packages/coding-agent/test/export-html-xss.test.ts
Normal file
67
packages/coding-agent/test/export-html-xss.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("export HTML markdown link sanitization", () => {
|
||||
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
|
||||
|
||||
it("overrides the marked link renderer to use scheme allow-list sanitization", () => {
|
||||
expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/);
|
||||
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
|
||||
expect(templateJs).toMatch(/\^\(https\?\|mailto\|tel\|ftp\)/);
|
||||
});
|
||||
|
||||
it("overrides the marked image renderer to use scheme allow-list sanitization", () => {
|
||||
expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/);
|
||||
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
|
||||
});
|
||||
|
||||
it("strips C0 controls before checking and emitting markdown URLs", () => {
|
||||
expect(templateJs).toContain("replace(/[\\x00-\\x1f\\x7f]/g, '')");
|
||||
expect(templateJs).not.toMatch(/\^\\s\*\(javascript\|vbscript\|data\):/i);
|
||||
});
|
||||
|
||||
it("escapes href attributes in the custom link renderer", () => {
|
||||
// The link renderer must escape href values to prevent attribute breakout
|
||||
expect(templateJs).toMatch(/escapeHtml\(href\)/);
|
||||
});
|
||||
|
||||
it("escapes image mimeType attributes", () => {
|
||||
// Image mimeType must be escaped to prevent attribute breakout
|
||||
expect(templateJs).not.toMatch(/\$\{img\.mimeType\}/);
|
||||
expect(templateJs).toMatch(/escapeHtml\(img\.mimeType/);
|
||||
});
|
||||
|
||||
it("escapes image data attributes", () => {
|
||||
// Image data is embedded in src attributes and must not allow attribute breakout.
|
||||
expect(templateJs).not.toMatch(/;base64,\$\{img\.data\}"/);
|
||||
expect(templateJs).toMatch(/;base64,\$\{escapeHtml\(img\.data \|\| (?:''|"")\)\}"/);
|
||||
});
|
||||
|
||||
it("escapes entry IDs before inserting them into attributes", () => {
|
||||
// Session entry IDs are embedded in id and data-entry-id attributes.
|
||||
expect(templateJs).not.toMatch(/id="\$\{entryId\}"/);
|
||||
expect(templateJs).not.toMatch(/data-entry-id="\$\{entryId\}"/);
|
||||
expect(templateJs).toMatch(/entry-\$\{escapeHtml\(entry\.id\)\}/);
|
||||
expect(templateJs).toMatch(/data-entry-id="\$\{escapeHtml\(entryId\)\}"/);
|
||||
});
|
||||
|
||||
it("escapes tree metadata rendered from session fields", () => {
|
||||
// The tree renders session metadata via innerHTML, so dynamic fields must be escaped.
|
||||
expect(templateJs).not.toMatch(/\[\$\{msg\.toolName \|\| 'tool'\}\]/);
|
||||
expect(templateJs).not.toMatch(/\[\$\{msg\.role\}\]/);
|
||||
expect(templateJs).not.toMatch(/\[model: \$\{entry\.modelId\}\]/);
|
||||
expect(templateJs).not.toMatch(/\[thinking: \$\{entry\.thinkingLevel\}\]/);
|
||||
expect(templateJs).not.toMatch(/\[\$\{entry\.type\}\]/);
|
||||
expect(templateJs).toMatch(/\$\{escapeHtml\(msg\.toolName \|\| 'tool'\)\}/);
|
||||
expect(templateJs).toMatch(/\$\{escapeHtml\(msg\.role\)\}/);
|
||||
expect(templateJs).toMatch(/\$\{escapeHtml\(entry\.modelId\)\}/);
|
||||
expect(templateJs).toMatch(/\$\{escapeHtml\(entry\.thinkingLevel\)\}/);
|
||||
expect(templateJs).toMatch(/\$\{escapeHtml\(entry\.type\)\}/);
|
||||
});
|
||||
|
||||
it("escapes model names in the exported header", () => {
|
||||
// Assistant message provider/model values are collected from the session and rendered with innerHTML.
|
||||
expect(templateJs).not.toMatch(/\$\{globalStats\.models\.join\(', '\) \|\| 'unknown'\}/);
|
||||
expect(templateJs).toMatch(/\$\{escapeHtml\(globalStats\.models\.join\(', '\) \|\| 'unknown'\)\}/);
|
||||
});
|
||||
});
|
||||
122
packages/coding-agent/test/export-jsonl-share.test.ts
Normal file
122
packages/coding-agent/test/export-jsonl-share.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai/compat";
|
||||
import { getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { defineTool } from "../src/core/extensions/types.ts";
|
||||
import { createAgentSession } from "../src/core/sdk.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { exportSessionForShare } from "../src/modes/interactive/session-share.ts";
|
||||
import { assistantMsg, userMsg } from "./utilities.ts";
|
||||
|
||||
describe("JSONL share export", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("adds presentation data without changing conversation IDs or links", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-jsonl-share-"));
|
||||
tempDirs.push(tempDir);
|
||||
const sessionManager = SessionManager.inMemory(tempDir);
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir: join(tempDir, "agent"),
|
||||
model: getModel("anthropic", "claude-sonnet-4-5")!,
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
sessionManager,
|
||||
tools: ["share_tool"],
|
||||
customTools: [
|
||||
defineTool({
|
||||
name: "share_tool",
|
||||
label: "Share Tool",
|
||||
description: "Render a value for sharing",
|
||||
parameters: Type.Object({ value: Type.String({ description: "Value to render" }) }),
|
||||
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const userId = sessionManager.appendMessage(userMsg("hello"));
|
||||
const assistant: AssistantMessage = {
|
||||
...assistantMsg(""),
|
||||
content: [{ type: "toolCall", id: "call-1", name: "share_tool", arguments: { value: "example" } }],
|
||||
stopReason: "toolUse",
|
||||
};
|
||||
const assistantId = sessionManager.appendMessage(assistant);
|
||||
const result: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "share_tool",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
details: {},
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const resultId = sessionManager.appendMessage(result);
|
||||
const originalEntryIds = sessionManager.getBranch().map((entry) => entry.id);
|
||||
|
||||
const normalPath = join(tempDir, "normal.jsonl");
|
||||
session.exportToJsonl(normalPath);
|
||||
const normalRecords = readFileSync(normalPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
expect(normalRecords.some((record) => record.type === "custom" && record.customType === "pi.share")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const sharePath = join(tempDir, "share.jsonl");
|
||||
exportSessionForShare(sharePath, session);
|
||||
const records = readFileSync(sharePath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
const conversationRecords = records.slice(1, -1);
|
||||
expect(conversationRecords.map((record) => record.id)).toEqual(originalEntryIds);
|
||||
expect(conversationRecords.map((record) => record.parentId)).toEqual([null, ...originalEntryIds.slice(0, -1)]);
|
||||
expect(conversationRecords.slice(-3).map((record) => record.id)).toEqual([userId, assistantId, resultId]);
|
||||
|
||||
const shareEntry = records.at(-1) as {
|
||||
id: string;
|
||||
data?: {
|
||||
systemPrompt?: string;
|
||||
tools?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
expect(shareEntry).toMatchObject({
|
||||
type: "custom",
|
||||
customType: "pi.share",
|
||||
parentId: resultId,
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(shareEntry.data?.systemPrompt).toBe(session.state.systemPrompt);
|
||||
expect(shareEntry.data?.tools).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "share_tool",
|
||||
description: "Render a value for sharing",
|
||||
}),
|
||||
]);
|
||||
expect(shareEntry.data).not.toHaveProperty("renderedTools");
|
||||
expect(shareEntry.data).not.toHaveProperty("theme");
|
||||
expect(shareEntry.data).not.toHaveProperty("version");
|
||||
|
||||
const imported = SessionManager.open(sharePath);
|
||||
expect(imported.getLeafId()).toBe(shareEntry.id);
|
||||
expect(imported.buildSessionContext().messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
]);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
532
packages/coding-agent/test/extensions-discovery.test.ts
Normal file
532
packages/coding-agent/test/extensions-discovery.test.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe("extensions discovery", () => {
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ext-test-"));
|
||||
extensionsDir = path.join(tempDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const extensionCode = `
|
||||
export default function(pi) {
|
||||
pi.registerCommand("test", { handler: async () => {} });
|
||||
}
|
||||
`;
|
||||
|
||||
const extensionCodeWithTool = (toolName: string) => `
|
||||
import { Type } from "typebox";
|
||||
export default function(pi) {
|
||||
pi.registerTool({
|
||||
name: "${toolName}",
|
||||
label: "${toolName}",
|
||||
description: "Test tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
it("discovers direct .ts files in extensions/", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
expect(result.extensions.map((e) => path.basename(e.path)).sort()).toEqual(["bar.ts", "foo.ts"]);
|
||||
});
|
||||
|
||||
it("loads the coding-agent entrypoint without rewriting pi-ai provider subpaths", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(extensionsDir, "coding-agent-import.ts"),
|
||||
`
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
void getAgentDir;
|
||||
export default function(pi) {
|
||||
pi.registerCommand("test", { handler: async () => {} });
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the type-only pi-ai OAuth compatibility barrel resolvable", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(extensionsDir, "oauth-import.ts"),
|
||||
`
|
||||
import * as oauth from "@earendil-works/pi-ai/oauth";
|
||||
void oauth;
|
||||
export default function(pi) {
|
||||
pi.registerCommand("test", { handler: async () => {} });
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("discovers direct .js files in extensions/", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(path.basename(result.extensions[0].path)).toBe("foo.js");
|
||||
});
|
||||
|
||||
it("discovers subdirectory with index.ts", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("my-extension");
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("discovers subdirectory with index.js", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("index.js");
|
||||
});
|
||||
|
||||
it("prefers index.ts over index.js", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("discovers subdirectory with package.json pi field", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
const srcDir = path.join(subdir, "src");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.mkdirSync(srcDir);
|
||||
fs.writeFileSync(path.join(srcDir, "main.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
pi: {
|
||||
extensions: ["./src/main.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("src");
|
||||
expect(result.extensions[0].path).toContain("main.ts");
|
||||
});
|
||||
|
||||
it("keeps package.json pi extension entries with leading tilde package-relative", async () => {
|
||||
const subdir = path.join(extensionsDir, "tilde-package");
|
||||
const directExtensionPath = path.join(subdir, "~entry.ts");
|
||||
const slashExtensionPath = path.join(subdir, "~", "entry.ts");
|
||||
fs.mkdirSync(path.join(subdir, "~"), { recursive: true });
|
||||
fs.writeFileSync(directExtensionPath, extensionCode);
|
||||
fs.writeFileSync(slashExtensionPath, extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "tilde-package",
|
||||
pi: {
|
||||
extensions: ["~entry.ts", "~/entry.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions.map((extension) => extension.path).sort()).toEqual(
|
||||
[directExtensionPath, slashExtensionPath].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("package.json can declare multiple extensions", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "ext1.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir, "ext2.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
pi: {
|
||||
extensions: ["./ext1.ts", "./ext2.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("package.json with pi field takes precedence over index.ts", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCodeWithTool("from-index"));
|
||||
fs.writeFileSync(path.join(subdir, "custom.ts"), extensionCodeWithTool("from-custom"));
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
pi: {
|
||||
extensions: ["./custom.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("custom.ts");
|
||||
// Verify the right tool was registered
|
||||
expect(result.extensions[0].tools.has("from-custom")).toBe(true);
|
||||
expect(result.extensions[0].tools.has("from-index")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores package.json without pi field, falls back to index.ts", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "my-package",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("ignores subdirectory without index or package.json", async () => {
|
||||
const subdir = path.join(extensionsDir, "not-an-extension");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not recurse beyond one level", async () => {
|
||||
const subdir = path.join(extensionsDir, "container");
|
||||
const nested = path.join(subdir, "nested");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.mkdirSync(nested);
|
||||
fs.writeFileSync(path.join(nested, "index.ts"), extensionCode);
|
||||
// No index.ts or package.json in container/
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles mixed direct files and subdirectories", async () => {
|
||||
// Direct file
|
||||
fs.writeFileSync(path.join(extensionsDir, "direct.ts"), extensionCode);
|
||||
|
||||
// Subdirectory with index
|
||||
const subdir1 = path.join(extensionsDir, "with-index");
|
||||
fs.mkdirSync(subdir1);
|
||||
fs.writeFileSync(path.join(subdir1, "index.ts"), extensionCode);
|
||||
|
||||
// Subdirectory with package.json
|
||||
const subdir2 = path.join(extensionsDir, "with-manifest");
|
||||
fs.mkdirSync(subdir2);
|
||||
fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode);
|
||||
fs.writeFileSync(path.join(subdir2, "package.json"), JSON.stringify({ pi: { extensions: ["./entry.ts"] } }));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("skips non-existent paths declared in package.json", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
fs.writeFileSync(path.join(subdir, "exists.ts"), extensionCode);
|
||||
fs.writeFileSync(
|
||||
path.join(subdir, "package.json"),
|
||||
JSON.stringify({
|
||||
pi: {
|
||||
extensions: ["./exists.ts", "./missing.ts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("exists.ts");
|
||||
});
|
||||
|
||||
it("loads extensions and registers commands", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-command.ts"), extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].commands.has("test")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extensions and registers tools", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-tool.ts"), extensionCodeWithTool("my-tool"));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].tools.has("my-tool")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports errors for invalid extension code", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "invalid.ts"), "this is not valid typescript export");
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].path).toContain("invalid.ts");
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles explicitly configured paths", async () => {
|
||||
const customPath = path.join(tempDir, "custom-location", "my-ext.ts");
|
||||
fs.mkdirSync(path.dirname(customPath), { recursive: true });
|
||||
fs.writeFileSync(customPath, extensionCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([customPath], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("my-ext.ts");
|
||||
});
|
||||
|
||||
it("resolves dependencies from extension's own node_modules", async () => {
|
||||
// Load extension that has its own package.json and node_modules with 'ms' package
|
||||
const extPath = path.resolve(__dirname, "../examples/extensions/with-deps");
|
||||
|
||||
const result = await discoverAndLoadExtensions([extPath], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("with-deps");
|
||||
// The extension registers a 'parse_duration' tool
|
||||
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers message and entry renderers", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerMarkdownTransformer((markdown) => {
|
||||
return markdown;
|
||||
});
|
||||
pi.registerMessageRenderer("my-custom-type", (message, options, theme) => {
|
||||
return null; // Use default rendering
|
||||
});
|
||||
pi.registerEntryRenderer("my-entry-type", (entry, options, theme) => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].markdownTransformer).toBeDefined();
|
||||
expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true);
|
||||
expect(result.extensions[0].entryRenderers?.has("my-entry-type")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error when extension throws during initialization", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
throw new Error("Initialization failed!");
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].error).toContain("Initialization failed!");
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports error when extension has no default export", async () => {
|
||||
const extCode = `
|
||||
export function notDefault(pi) {
|
||||
pi.registerCommand("test", { handler: async () => {} });
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].error).toContain("does not export a valid factory function");
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows multiple extensions to register different tools", async () => {
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), extensionCodeWithTool("tool-a"));
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), extensionCodeWithTool("tool-b"));
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
|
||||
const allTools = new Set<string>();
|
||||
for (const ext of result.extensions) {
|
||||
for (const name of ext.tools.keys()) {
|
||||
allTools.add(name);
|
||||
}
|
||||
}
|
||||
expect(allTools.has("tool-a")).toBe(true);
|
||||
expect(allTools.has("tool-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extension with event handlers", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.on("agent_start", async () => {});
|
||||
pi.on("tool_call", async (event) => undefined);
|
||||
pi.on("agent_end", async () => {});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].handlers.has("agent_start")).toBe(true);
|
||||
expect(result.extensions[0].handlers.has("tool_call")).toBe(true);
|
||||
expect(result.extensions[0].handlers.has("agent_end")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extension with shortcuts", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+t", {
|
||||
description: "Test shortcut",
|
||||
handler: async (ctx) => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].shortcuts.has("ctrl+t")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads extension with flags", async () => {
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerFlag("my-flag", {
|
||||
description: "My custom flag",
|
||||
handler: async (value) => {},
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode);
|
||||
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].flags.has("my-flag")).toBe(true);
|
||||
});
|
||||
|
||||
it("loadExtensions only loads explicit paths without discovery", async () => {
|
||||
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
|
||||
fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCodeWithTool("discovered"));
|
||||
|
||||
// Create explicit extension outside discovery path
|
||||
const explicitPath = path.join(tempDir, "explicit.ts");
|
||||
fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit"));
|
||||
|
||||
// Use loadExtensions directly to skip discovery
|
||||
const { loadExtensions } = await import("../src/core/extensions/loader.ts");
|
||||
const result = await loadExtensions([explicitPath], tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].tools.has("explicit")).toBe(true);
|
||||
expect(result.extensions[0].tools.has("discovered")).toBe(false);
|
||||
});
|
||||
|
||||
it("loadExtensions with no paths loads nothing", async () => {
|
||||
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
|
||||
fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode);
|
||||
|
||||
// Use loadExtensions directly with empty paths
|
||||
const { loadExtensions } = await import("../src/core/extensions/loader.ts");
|
||||
const result = await loadExtensions([], tempDir);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
125
packages/coding-agent/test/extensions-input-event.test.ts
Normal file
125
packages/coding-agent/test/extensions-input-event.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.ts";
|
||||
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
|
||||
import { createInMemoryModelRegistry } from "./model-runtime-test-utils.ts";
|
||||
|
||||
describe("Input Event", () => {
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-input-test-"));
|
||||
extensionsDir = path.join(tempDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir);
|
||||
// Clean globalThis test vars
|
||||
delete (globalThis as any).testVar;
|
||||
});
|
||||
|
||||
afterEach(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
async function createRunner(...extensions: string[]) {
|
||||
// Clear and recreate extensions dir for clean state
|
||||
fs.rmSync(extensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(extensionsDir);
|
||||
for (let i = 0; i < extensions.length; i++) fs.writeFileSync(path.join(extensionsDir, `e${i}.ts`), extensions[i]);
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const sm = SessionManager.inMemory();
|
||||
const mr = await createInMemoryModelRegistry(AuthStorage.inMemory());
|
||||
return new ExtensionRunner(result.extensions, result.runtime, tempDir, sm, mr);
|
||||
}
|
||||
|
||||
it("returns continue when no handlers, undefined return, or explicit continue", async () => {
|
||||
// No handlers
|
||||
expect((await (await createRunner()).emitInput("x", undefined, "interactive")).action).toBe("continue");
|
||||
// Returns undefined
|
||||
let r = await createRunner(`export default p => p.on("input", async () => {});`);
|
||||
expect((await r.emitInput("x", undefined, "interactive")).action).toBe("continue");
|
||||
// Returns explicit continue
|
||||
r = await createRunner(`export default p => p.on("input", async () => ({ action: "continue" }));`);
|
||||
expect((await r.emitInput("x", undefined, "interactive")).action).toBe("continue");
|
||||
});
|
||||
|
||||
it("transforms text and preserves images when omitted", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async e => ({ action: "transform", text: "T:" + e.text }));`,
|
||||
);
|
||||
const imgs = [{ type: "image" as const, data: "orig", mimeType: "image/png" }];
|
||||
const result = await r.emitInput("hi", imgs, "interactive");
|
||||
expect(result).toEqual({ action: "transform", text: "T:hi", images: imgs });
|
||||
});
|
||||
|
||||
it("transforms and replaces images when provided", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async () => ({ action: "transform", text: "X", images: [{ type: "image", data: "new", mimeType: "image/jpeg" }] }));`,
|
||||
);
|
||||
const result = await r.emitInput("hi", [{ type: "image", data: "orig", mimeType: "image/png" }], "interactive");
|
||||
expect(result).toEqual({
|
||||
action: "transform",
|
||||
text: "X",
|
||||
images: [{ type: "image", data: "new", mimeType: "image/jpeg" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("chains transforms across multiple handlers", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async e => ({ action: "transform", text: e.text + "[1]" }));`,
|
||||
`export default p => p.on("input", async e => ({ action: "transform", text: e.text + "[2]" }));`,
|
||||
);
|
||||
const result = await r.emitInput("X", undefined, "interactive");
|
||||
expect(result).toEqual({ action: "transform", text: "X[1][2]", images: undefined });
|
||||
});
|
||||
|
||||
it("short-circuits on handled and skips subsequent handlers", async () => {
|
||||
(globalThis as any).testVar = false;
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async () => ({ action: "handled" }));`,
|
||||
`export default p => p.on("input", async () => { globalThis.testVar = true; });`,
|
||||
);
|
||||
expect(await r.emitInput("X", undefined, "interactive")).toEqual({ action: "handled" });
|
||||
expect((globalThis as any).testVar).toBe(false);
|
||||
});
|
||||
|
||||
it("passes source correctly for all source types", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async e => { globalThis.testVar = e.source; return { action: "continue" }; });`,
|
||||
);
|
||||
for (const source of ["interactive", "rpc", "extension"] as const) {
|
||||
await r.emitInput("x", undefined, source);
|
||||
expect((globalThis as any).testVar).toBe(source);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes streamingBehavior correctly", async () => {
|
||||
const r = await createRunner(
|
||||
`export default p => p.on("input", async e => { globalThis.testVar = e.streamingBehavior; return { action: "continue" }; });`,
|
||||
);
|
||||
await r.emitInput("x", undefined, "interactive", "steer");
|
||||
expect((globalThis as any).testVar).toBe("steer");
|
||||
await r.emitInput("x", undefined, "interactive", "followUp");
|
||||
expect((globalThis as any).testVar).toBe("followUp");
|
||||
await r.emitInput("x", undefined, "interactive");
|
||||
expect((globalThis as any).testVar).toBeUndefined();
|
||||
});
|
||||
|
||||
it("catches handler errors and continues", async () => {
|
||||
const r = await createRunner(`export default p => p.on("input", async () => { throw new Error("boom"); });`);
|
||||
const errs: string[] = [];
|
||||
r.onError((e) => errs.push(e.error));
|
||||
const result = await r.emitInput("x", undefined, "interactive");
|
||||
expect(result.action).toBe("continue");
|
||||
expect(errs).toContain("boom");
|
||||
});
|
||||
|
||||
it("hasHandlers returns correct value", async () => {
|
||||
let r = await createRunner();
|
||||
expect(r.hasHandlers("input")).toBe(false);
|
||||
r = await createRunner(`export default p => p.on("input", async () => {});`);
|
||||
expect(r.hasHandlers("input")).toBe(true);
|
||||
});
|
||||
});
|
||||
1038
packages/coding-agent/test/extensions-runner.test.ts
Normal file
1038
packages/coding-agent/test/extensions-runner.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
63
packages/coding-agent/test/external-editor.test.ts
Normal file
63
packages/coding-agent/test/external-editor.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type ExternalEditorResult, editInExternalEditor } from "../src/modes/interactive/external-editor.ts";
|
||||
|
||||
const editorFixturePath = fileURLToPath(new URL("./fixtures/fake-external-editor.mjs", import.meta.url));
|
||||
|
||||
interface EditorCapture {
|
||||
filePath: string;
|
||||
content: string;
|
||||
entries: string[];
|
||||
directoryMode: number;
|
||||
}
|
||||
|
||||
async function runExternalEditor(fixtureFlag?: "--fail" | "--empty"): Promise<{
|
||||
result: ExternalEditorResult;
|
||||
capture: EditorCapture;
|
||||
}> {
|
||||
const testDirectory = mkdtempSync(join(tmpdir(), "pi-external-editor-test-"));
|
||||
const capturePath = join(testDirectory, "capture.json");
|
||||
try {
|
||||
const result = await editInExternalEditor({
|
||||
command: `${process.execPath} ${editorFixturePath} ${capturePath}${fixtureFlag ? ` ${fixtureFlag}` : ""}`,
|
||||
content: "original",
|
||||
});
|
||||
const capture = JSON.parse(readFileSync(capturePath, "utf-8")) as EditorCapture;
|
||||
return { result, capture };
|
||||
} finally {
|
||||
rmSync(testDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("editInExternalEditor", () => {
|
||||
it("edits a prompt inside a private temporary directory", async () => {
|
||||
const { result, capture } = await runExternalEditor();
|
||||
const directory = dirname(capture.filePath);
|
||||
|
||||
expect(result).toEqual({ status: "complete", content: "edited" });
|
||||
expect(dirname(directory)).toBe(tmpdir());
|
||||
expect(basename(directory)).toMatch(/^pi-editor-.+$/);
|
||||
expect(basename(capture.filePath)).toBe("prompt.md");
|
||||
expect(capture.entries).toEqual(["prompt.md"]);
|
||||
expect(capture.content).toBe("original");
|
||||
if (process.platform !== "win32") {
|
||||
expect(capture.directoryMode & 0o077).toBe(0);
|
||||
}
|
||||
expect(existsSync(directory)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the original content when the editor exits unsuccessfully", async () => {
|
||||
const { result, capture } = await runExternalEditor("--fail");
|
||||
|
||||
expect(result).toEqual({ status: "failed" });
|
||||
expect(existsSync(dirname(capture.filePath))).toBe(false);
|
||||
});
|
||||
it("returns empty content when the editor clears the prompt", async () => {
|
||||
const { result } = await runExternalEditor("--empty");
|
||||
|
||||
expect(result).toEqual({ status: "complete", content: "" });
|
||||
});
|
||||
});
|
||||
274
packages/coding-agent/test/file-mutation-queue.test.ts
Normal file
274
packages/coding-agent/test/file-mutation-queue.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { access, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEditTool } from "../src/core/tools/edit.ts";
|
||||
import { withFileMutationQueue } from "../src/core/tools/file-mutation-queue.ts";
|
||||
import { createWriteTool } from "../src/core/tools/write.ts";
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function resolvesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
|
||||
return Promise.race([promise.then(() => true), delay(ms).then(() => false)]);
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-file-mutation-queue-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0, tempDirs.length).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("withFileMutationQueue", () => {
|
||||
it("serializes operations for the same file", async () => {
|
||||
const order: string[] = [];
|
||||
const path = "/tmp/file-mutation-queue-same";
|
||||
|
||||
const first = withFileMutationQueue(path, async () => {
|
||||
order.push("first:start");
|
||||
await delay(30);
|
||||
order.push("first:end");
|
||||
});
|
||||
const second = withFileMutationQueue(path, async () => {
|
||||
order.push("second:start");
|
||||
order.push("second:end");
|
||||
});
|
||||
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second:start", "second:end"]);
|
||||
});
|
||||
|
||||
it("allows different files to proceed in parallel", async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
await Promise.all([
|
||||
withFileMutationQueue("/tmp/file-mutation-queue-a", async () => {
|
||||
order.push("a:start");
|
||||
await delay(30);
|
||||
order.push("a:end");
|
||||
}),
|
||||
withFileMutationQueue("/tmp/file-mutation-queue-b", async () => {
|
||||
order.push("b:start");
|
||||
await delay(30);
|
||||
order.push("b:end");
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(order.indexOf("a:start")).toBeLessThan(order.indexOf("a:end"));
|
||||
expect(order.indexOf("b:start")).toBeLessThan(order.indexOf("b:end"));
|
||||
expect(order.indexOf("b:start")).toBeLessThan(order.indexOf("a:end"));
|
||||
});
|
||||
|
||||
it("uses the same queue for symlink aliases", async () => {
|
||||
const dir = await createTempDir();
|
||||
const targetPath = join(dir, "target.txt");
|
||||
const symlinkPath = join(dir, "alias.txt");
|
||||
await writeFile(targetPath, "hello\n", "utf8");
|
||||
await symlink(targetPath, symlinkPath);
|
||||
|
||||
const order: string[] = [];
|
||||
await Promise.all([
|
||||
withFileMutationQueue(targetPath, async () => {
|
||||
order.push("target:start");
|
||||
await delay(30);
|
||||
order.push("target:end");
|
||||
}),
|
||||
withFileMutationQueue(symlinkPath, async () => {
|
||||
order.push("alias:start");
|
||||
order.push("alias:end");
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(order).toEqual(["target:start", "target:end", "alias:start", "alias:end"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in edit and write tools", () => {
|
||||
it("preserves both parallel edits on the same file", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "parallel-edit.txt");
|
||||
await writeFile(filePath, "alpha\nbeta\ngamma\n", "utf8");
|
||||
|
||||
const editTool = createEditTool(dir, {
|
||||
operations: {
|
||||
access,
|
||||
readFile: async (path) => {
|
||||
const buffer = await readFile(path);
|
||||
await delay(30);
|
||||
return buffer;
|
||||
},
|
||||
writeFile: async (path, content) => {
|
||||
await delay(30);
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
editTool.execute("call-1", { path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] }),
|
||||
editTool.execute("call-2", { path: filePath, edits: [{ oldText: "beta", newText: "BETA" }] }),
|
||||
]);
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("ALPHA\nBETA\ngamma\n");
|
||||
});
|
||||
|
||||
it("shares the queue between edit and write", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "mixed.txt");
|
||||
await writeFile(filePath, "original\n", "utf8");
|
||||
|
||||
const editTool = createEditTool(dir, {
|
||||
operations: {
|
||||
access,
|
||||
readFile: async (path) => {
|
||||
const buffer = await readFile(path);
|
||||
await delay(30);
|
||||
return buffer;
|
||||
},
|
||||
writeFile: async (path, content) => {
|
||||
await delay(30);
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
const writeTool = createWriteTool(dir, {
|
||||
operations: {
|
||||
mkdir: async () => {},
|
||||
writeFile: async (path, content) => {
|
||||
await delay(10);
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editPromise = editTool.execute("call-1", {
|
||||
path: filePath,
|
||||
edits: [{ oldText: "original", newText: "edited" }],
|
||||
});
|
||||
await delay(5);
|
||||
const writePromise = writeTool.execute("call-2", {
|
||||
path: filePath,
|
||||
content: "replacement\n",
|
||||
});
|
||||
|
||||
await Promise.all([editPromise, writePromise]);
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("replacement\n");
|
||||
});
|
||||
|
||||
it("keeps write queue locked while an aborted write is still in flight", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "abort-write.txt");
|
||||
const firstWriteStarted = createDeferred();
|
||||
const finishFirstWrite = createDeferred();
|
||||
const secondWriteStarted = createDeferred();
|
||||
let firstWriteSettled = false;
|
||||
|
||||
const writeTool = createWriteTool(dir, {
|
||||
operations: {
|
||||
mkdir: async () => {},
|
||||
writeFile: async (path, content) => {
|
||||
if (content === "first\n") {
|
||||
firstWriteStarted.resolve();
|
||||
await finishFirstWrite.promise;
|
||||
await writeFile(path, content, "utf8");
|
||||
firstWriteSettled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (content === "second\n") {
|
||||
expect(firstWriteSettled).toBe(true);
|
||||
secondWriteStarted.resolve();
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const firstWrite = writeTool.execute("call-1", { path: filePath, content: "first\n" }, controller.signal);
|
||||
await firstWriteStarted.promise;
|
||||
controller.abort();
|
||||
|
||||
const secondWrite = writeTool.execute("call-2", { path: filePath, content: "second\n" });
|
||||
expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
|
||||
|
||||
finishFirstWrite.resolve();
|
||||
await expect(firstWrite).rejects.toThrow("Operation aborted");
|
||||
await secondWrite;
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("second\n");
|
||||
});
|
||||
|
||||
it("keeps edit queue locked while an aborted edit write is still in flight", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "abort-edit.txt");
|
||||
await writeFile(filePath, "alpha\nbeta\n", "utf8");
|
||||
const firstWriteStarted = createDeferred();
|
||||
const finishFirstWrite = createDeferred();
|
||||
const secondWriteStarted = createDeferred();
|
||||
let firstWriteSettled = false;
|
||||
|
||||
const editTool = createEditTool(dir, {
|
||||
operations: {
|
||||
access,
|
||||
readFile,
|
||||
writeFile: async (path, content) => {
|
||||
if (content === "ALPHA\nbeta\n") {
|
||||
firstWriteStarted.resolve();
|
||||
await finishFirstWrite.promise;
|
||||
await writeFile(path, content, "utf8");
|
||||
firstWriteSettled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
|
||||
expect(firstWriteSettled).toBe(true);
|
||||
secondWriteStarted.resolve();
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const firstEdit = editTool.execute(
|
||||
"call-1",
|
||||
{ path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
||||
controller.signal,
|
||||
);
|
||||
await firstWriteStarted.promise;
|
||||
controller.abort();
|
||||
|
||||
const secondEdit = editTool.execute("call-2", {
|
||||
path: filePath,
|
||||
edits: [{ oldText: "beta", newText: "BETA" }],
|
||||
});
|
||||
expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
|
||||
|
||||
finishFirstWrite.resolve();
|
||||
await expect(firstEdit).rejects.toThrow("Operation aborted");
|
||||
await secondEdit;
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("ALPHA\nBETA\n");
|
||||
});
|
||||
});
|
||||
39
packages/coding-agent/test/first-time-setup-fork.test.ts
Normal file
39
packages/coding-agent/test/first-time-setup-fork.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/config.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
PACKAGE_NAME: "@example/pi-coding-agent",
|
||||
};
|
||||
});
|
||||
|
||||
import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts";
|
||||
|
||||
describe("shouldRunFirstTimeSetup in forked distributions", () => {
|
||||
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
|
||||
let tempDir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-fork-"));
|
||||
settingsPath = join(tempDir, "settings.json");
|
||||
process.env.PI_EXPERIMENTAL = "1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalPiExperimental === undefined) {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
} else {
|
||||
process.env.PI_EXPERIMENTAL = originalPiExperimental;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for a forked package", () => {
|
||||
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
95
packages/coding-agent/test/first-time-setup.test.ts
Normal file
95
packages/coding-agent/test/first-time-setup.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts";
|
||||
import { ENV_AGENT_DIR } from "../src/config.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
|
||||
describe("shouldRunFirstTimeSetup", () => {
|
||||
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
|
||||
const originalAgentDir = process.env[ENV_AGENT_DIR];
|
||||
let tempDir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-"));
|
||||
settingsPath = join(tempDir, "settings.json");
|
||||
process.env.PI_EXPERIMENTAL = "1";
|
||||
delete process.env[ENV_AGENT_DIR];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalPiExperimental === undefined) {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
} else {
|
||||
process.env.PI_EXPERIMENTAL = originalPiExperimental;
|
||||
}
|
||||
if (originalAgentDir === undefined) {
|
||||
delete process.env[ENV_AGENT_DIR];
|
||||
} else {
|
||||
process.env[ENV_AGENT_DIR] = originalAgentDir;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true when experimental, default agent dir, and no settings.json", () => {
|
||||
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when experimental features are disabled", () => {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
|
||||
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when a custom agent dir is set", () => {
|
||||
process.env[ENV_AGENT_DIR] = tempDir;
|
||||
|
||||
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when settings.json already exists", () => {
|
||||
writeFileSync(settingsPath, "{}", "utf-8");
|
||||
|
||||
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analytics settings", () => {
|
||||
it("defaults to disabled with no tracking identifier", () => {
|
||||
const manager = SettingsManager.inMemory();
|
||||
|
||||
expect(manager.getEnableAnalytics()).toBe(false);
|
||||
expect(manager.getTrackingId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("generates a tracking identifier on opt-in", () => {
|
||||
const manager = SettingsManager.inMemory();
|
||||
|
||||
manager.setEnableAnalytics(true);
|
||||
|
||||
expect(manager.getEnableAnalytics()).toBe(true);
|
||||
expect(manager.getTrackingId()).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it("does not generate a tracking identifier on opt-out", () => {
|
||||
const manager = SettingsManager.inMemory();
|
||||
|
||||
manager.setEnableAnalytics(false);
|
||||
|
||||
expect(manager.getEnableAnalytics()).toBe(false);
|
||||
expect(manager.getTrackingId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the tracking identifier when toggling analytics", () => {
|
||||
const manager = SettingsManager.inMemory();
|
||||
|
||||
manager.setEnableAnalytics(true);
|
||||
const trackingId = manager.getTrackingId();
|
||||
manager.setEnableAnalytics(false);
|
||||
manager.setEnableAnalytics(true);
|
||||
|
||||
expect(manager.getTrackingId()).toBe(trackingId);
|
||||
});
|
||||
});
|
||||
33
packages/coding-agent/test/fixtures/assistant-message-with-thinking-code.json
vendored
Normal file
33
packages/coding-agent/test/fixtures/assistant-message-with-thinking-code.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "So `findModel` just looks up a model by provider + id from the merged models list (built-in + models.json).\n\nFor the SDK, the flow would be:1. `model` provided → use it\n2. Else check settings for default → call `findModel(provider, id)` → check if available (has API key)\n3. Else first from `getAvailableModels()`\n\nThe `findModel` function returns `{ model, error }`. For SDK export, we might want a simpler signature.Options for SDK:\n\n**Option A: Keep current signature**```typescript\nfunction findModel(provider: string, modelId: string, agentDir?: string): { model: Model<any> | null; error: string | null };\n```**Option B: Simpler - just returns model or null**\n```typescript\nfunction findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;```\n\n**Option C: Throws on error**```typescript\nfunction findModel(provider: string, modelId: string, agentDir?: string): Model<any>; // throws if not found\n```\n\nI think **Option B** is fine for SDK. Errors from loading models.json are edge cases - if the file is malformed, we can throw. For \"model not found\", just return null.\n\nActually, for the SDK, maybe we should rethink. The user of the SDK will likely:\n1. Know which model they want and construct it directly2. Or want to pick from available modelsFor case 1, they don't need findModel - they construct the Model object.\nFor case 2, they use `discoverAvailableModels()` and pick.`findModel` is useful for looking up by string ID (like from config or user input). But the signature could just be:```typescriptfunction findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;\n```\n\nReturns the model if found (in built-ins or models.json), null if not found. Throws if models.json is malformed (exceptional case).\n\n\nThe SDK's model discovery functions provide a clean, straightforward way to handle model selection. I'll outline the key methods for finding and selecting models programmatically. The core functions allow discovering all models, filtering to available models with valid API keys, and finding a specific model by provider and ID. This gives developers flexible options for model management in their applications.\n\nIn the session creation process, if no model is explicitly specified, the system will first attempt to use the default provider and model configuration. If that fails, it will then retrieve the first available model from the discovered set. Should no models be found, an error will be raised to prevent silent failures.\n\nThe potential complexity arises from the need to support custom model configurations and ensure robust model selection across different scenarios.",
|
||||
"thinkingSignature": "EqsVCkYIChgCKkBUqXXsLfuqzbiQxI5MbbS31Yw670soGNGQX63Lb/osACS2A5rrS8Gd939xNdXbHUGouQngzqIPs2KFk9HWmKajEgxqYOzDBE7h++0vFUUaDF8r9MacsHyJPwr3XiIwn3spAIQV8IxgAIFoxdYefFrCAeez7pnXqUqaK2QTTG3OjWpCIYzPwvEVs7ObbWVbKpIUy2X7MkKrZOdtlTGRUvmuEij6vCbXjPwj0zH+mjaefERbkL+aT84QCiStHqc7uuM5nZvntl4KZ76Mt1VrFoBXwi3val4fJDP9GhDj7tkD0Id22udIb+yHBuo8yBnyy2fWLMaeRTEn8vN2eUaqiuE7wvgvPF4tf6bn4mKjh/HEwpAzJ+rLsE/hmXA9eG/hub387iF4rnLP/rDJR4olzSQyb7bPpdQ5RLRIymkRJce4wRY0nFxPuZayiYooGwI7gqKPJz2mkTCdWZABn4n6PpqZB+caXCn63A3WvJtZacItZ6z3DAoi2I3jwsOC8BWQmHKBfCXd9wttQ+HuYYmduASJ3j/TNtdO1vZsiItknKneZXTPhmt0nuqphgWiDWnPFv1iOoJw++tLJO+u2hYOtM/3Nx6O+l9QWcQgkgnQjN29SRd7uiI14sTogJkWVrVaKJ6StXx+/mXrro7I++6PSBMnFJevIJ89MFVB8EiYs+x4pOuEJDaNekBU3Tm6+Eg4vL2SguijClR9yv+4bQsIHKtq6QLLABt1SuNRvO9HgUIOx6HDdn0PXeInhqJ/aILA4bRryf6lbRp0qNEcexAVrT8zbrMUkY2SzMX1kEo4IvmprCzmukHXQdal2AoxSdxPp2br12Lcz0njxzhWFd58f0gLRVHKf7gGzTWe6EGVfvve7/yquhVG1IWkDid54PcdqUEpIbeRZE4gklPQhEflfZ9ppnyeRDVmBq4N9Wmv+S19z8/sLRXMXBM2Lv31vVf7QXjZGmJxEWpKfXGPOmuChZsgZuMZSVoXSh9u+gr+M29Se6ArQ/L18/3p8grm8TwT2TKuaMeuIdki7Ja0jQQYPOqoIVHVXahtVto/4YVGcClx6eTbNtXDfKDKnWw7Eu+l+6wjF9nqEjTLQIxjpT6ABWhXw1ersAFIDgDDwRLUZFHZ8i1jQKvg3IxgWsqIyyMXjwm1gfwzeeOrNIkx8KwIGybeheHX1vZRsqaOAhARiziiBsl4PLD8ci6OLJgp1ZBke9QW8DFFwMZY6hNf4yYOb0/6K2g+qx9Z0OuHW7p2MRef97oLiDyx/WCNgv6DUW2FxHy2KjtcB50aeSLfccBCJOXkRlnym08nsBYa7H17REi2O30wkoOPnOYNqytE40EPYwqUPUdRF6WwN6LFEpbGGmQ5atrJ/upzz+MoBoeqeoF0fOrO3AaW27E7dvduDCrK2hF/TZZN5FHipNNHP/JY5NhWPBhCBumxJN9uf+nGqPcQwn3IL0eriz9ki0EUBdAYXY9kCxKYU3DhsbLsBn3YfhXLbLIT1Woy4RUqkWN7BXOC8aWi+uLVm0JUXVt/dr6ndnxdyqJdxc22Wz4EHFZZe+VtntNr1BF/6VsUoQSsSR1c0QvbxPE3iLhZ3R9RPmKduotJsQ6hb3aZrAgsMF5KWlmOKcouGQW1TNEwd8tI8Rxg91FdOuU0o98LddVlUFknfYr9gUn3/NorpUCKjDgZDyY4Oy7QeHWg9E6s6jeH1aYhHsO8mZiPGxQi4n5y0pSU8jFHEoIvlgQ+hN+7bsYRfUNMXfxsYuUZKiUqvCIiInu6W1dkxjS2GOmiQcCjB9XzOxF9gHXEkU2E4xHmSkbpBGrJjR/DHZ8gsosTPDg9VmFY2aYX/WLGYbjguzaKD8zS9LpQ3UZmbC0Jv9bZUGn3TdRRJj+xLY4fqWxEvplWNTJRTAPkHlQbawvgs8ziL9gBmfohPKHg+MA4bFCP2BPaaw/Xmw03TuDhaQ/Nb4e52N7heoN3DMd3NUQl/YFeb4kqzcF24GLhLi/Pbl2Y/JehWVgNyFeIvMkk7laFgydLqCMTWGl8VHiy3koUXOgPG/s/qERzIyYprLd/h5gcGt0aQMgl089UU69wUhT0xXkZjuUSMeCUKHLgjvhbn6gaMoMCrcqe+Ar0eZPGeW7OR9w8jhC/rE5Lh8zMpQ2uKo2Hwi/eFZul6Qq1ZSthx0kcsbqT8wW6Fyr8O42mxUmBVS8TUhvVSOccGVy5tBOXQpxQPgYbXNyUy3obUi9vhPzViEbt6KDIAW5bQwbuDSMHd+tf9nWd8H1nvEO2aWM6/v4+/qLSWqMcTXs3Rea2+GFMQkbRzj1pRN1MLzSjBP5pGLlYPQre5RHK3kImZ7ISMj7oQWfzNYLkswkD2Ay3nzk6v4JpjaFNFAaOhTHjtO0c4qA2elkvQ/5RrtD4g4/wlH+p048wIiuQhw4Iiu3rcFrclXUWny74ON5n56OY5uIXsPsmQQwCGUwtZFBVe5bP3nVgoHCBPI0SyEQXxgbd4q0o+HZyjkH9KdOL6LpxdxbrqbvONS6/EMMheWHxDAmibL5pFJh4z60o+aNejvMoZahKX04M5/KC1k7gwzAn/yIxC+VEPi/IijxKKlU0mEPE+q/HAHTe7S5CdrM5vWzgzNefKk0PjMW3/OnveH9mFoMHmIybWgrCZPlPzLyL3PPBW1Iv6q1g/NOzfxczx/ZbudD3UQOY0u84Acjcb938Y7uvUNHPLfSopleds0hGGgeUGy6aLdidmypcc3b8icF8k3KDozTN0v/3EqgLzb4PY6HML6dIwI6UYpeMvb110GWh1mXgl45v4afFwojhp0Ld92WnOrxEIMKv9/S6NCiUxR6KwAhp7ssPzdPvlTTtlmN01Xn95+Vo4GuZHvgyjcBnF9dIy+WJhwDRcgLrwV+wkZuGR71ACKTdHE3jW3QEuWlf4HuV+63c/OZj3B2rB2s2zadJVGDBn35dX434ZnJZudakoOGcK/0LZ2bhSN8qCkxs/2KJk7TMtBi6wsmQ7VGw74I1+c45iPjRcaO63UO/1rI7dZZZkD5lKje8BgwPBt+V+0DW6OHrfYC6qf+Nc5xrkE8Q6R4fbijYf3MkaDQ1FhJpGUiBIZ90tXPcs06utEq8kNT1kMGLdgoFlHRA3gQzJY7vU2kYVNs0sTp1pQL3JPSHtBdITHnITJLN4tVun0lrv4Ev4L4qkXT+tygwmmYvlR+JlnxkdMDd0DhNcv4lF9ltD1zQaTgfPjSbl3+lW/znRKgXTXqxDzHDIusv5u0lna2X7M7WWXoucp3eyHqTtZiP4DgsDgh9lNiwz4zu0Uqh7DNF5ZTWrSwEItMbQc0vs3Bryd7TirHAOK928rxRwl2Vo9YdDxEbVGV7OmSGdu3NOLDVp/oFvwsSUhFD/BwjgX9xSbLgLab8g3lEsbMGnA9h+aY0M1nSlmjjLavl5XvMvwd6VDRAbEMb+/FdI6Sp/z3/Yx4VmKeh7lB3rPPF6n9RdUijTKbBn6hVRyEZw30n5BMxRwGYxFxb5xhPhOLpmj6UNEQJUkileYUhEGN7o/GMLBBT7eOXujXJhq/ADhex7Wilj5rFy0VtIfuUkrNXpRxVLOdOTA3/eNxYn21B91jFM7UWCZc42BGZhkEvxFuIS+wK8u3TBvMv3RgB"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "So the current flow in main.ts:\n\n**findInitialModelForSession:**\n1. CLI args (`--provider` + `--model`) → use if both provided\n2. Scoped models (`--models` flag) → use first (unless --continue/--resume)\n3. Settings (`defaultProvider` + `defaultModel`) → use if found\n4. First available model with valid API key\n5. Return null if none\n\n**initialThinking:**\n1. Start with `'off'`\n2. If scoped models (and not continuing) → use first scoped model's thinking level\n3. Else check `settings.defaultThinkingLevel`\n4. CLI `--thinking` overrides\n5. Clamp to model capabilities\n\n---\n\nFor SDK, we don't have CLI args or scoped models. Simplifies to:\n\n**model resolution (if omitted):**\n1. Check `settings.defaultProvider` + `settings.defaultModel` → find it, verify has API key\n2. Else first from `discoverAvailableModels()`\n3. Error if none\n\n**thinkingLevel resolution (if omitted):**\n1. Check `settings.defaultThinkingLevel`\n2. Else `'off'`\n3. Clamp to model capabilities\n\n---\n\nFor `findModel`, current signature returns `{ model, error }`. For SDK, simpler:\n\n```typescript\n// Returns model or null. Throws only if models.json is malformed.\nexport function findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;\n```\n\nSo the model-related exports:\n\n```typescript\n// All models (built-in + models.json)\nexport function discoverModels(agentDir?: string): Model<any>[];\n\n// Models with valid API keys\nexport function discoverAvailableModels(agentDir?: string): Promise<Model<any>[]>;\n\n// Lookup by provider/id\nexport function findModel(provider: string, modelId: string, agentDir?: string): Model<any> | null;\n```\n\nDoes this address the wonkiness, or is there something else bothering you about it?"
|
||||
}
|
||||
],
|
||||
"api": "anthropic-messages",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-5",
|
||||
"usage": {
|
||||
"input": 8,
|
||||
"output": 1211,
|
||||
"cacheRead": 89775,
|
||||
"cacheWrite": 611,
|
||||
"totalTokens": 91605,
|
||||
"cost": {
|
||||
"input": 4e-5,
|
||||
"output": 0.030275,
|
||||
"cacheRead": 0.0448875,
|
||||
"cacheWrite": 0.00381875,
|
||||
"total": 0.07902124999999999
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1766357372971
|
||||
}
|
||||
1003
packages/coding-agent/test/fixtures/before-compaction.jsonl
vendored
Normal file
1003
packages/coding-agent/test/fixtures/before-compaction.jsonl
vendored
Normal file
File diff suppressed because one or more lines are too long
0
packages/coding-agent/test/fixtures/empty-agent/.gitkeep
vendored
Normal file
0
packages/coding-agent/test/fixtures/empty-agent/.gitkeep
vendored
Normal file
0
packages/coding-agent/test/fixtures/empty-cwd/.gitkeep
vendored
Normal file
0
packages/coding-agent/test/fixtures/empty-cwd/.gitkeep
vendored
Normal file
25
packages/coding-agent/test/fixtures/fake-external-editor.mjs
vendored
Normal file
25
packages/coding-agent/test/fixtures/fake-external-editor.mjs
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const capturePath = process.argv[2];
|
||||
const filePath = process.argv.at(-1);
|
||||
if (!capturePath || !filePath) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const directory = dirname(filePath);
|
||||
writeFileSync(
|
||||
capturePath,
|
||||
JSON.stringify({
|
||||
filePath,
|
||||
content: readFileSync(filePath, "utf-8"),
|
||||
entries: readdirSync(directory),
|
||||
directoryMode: statSync(directory).mode & 0o777,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
if (process.argv.includes("--fail")) {
|
||||
process.exit(1);
|
||||
}
|
||||
writeFileSync(filePath, process.argv.includes("--empty") ? "" : "edited\n", "utf-8");
|
||||
69
packages/coding-agent/test/fixtures/faux-session-worker.ts
vendored
Normal file
69
packages/coding-agent/test/fixtures/faux-session-worker.ts
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createStaticFacetLoader, defineFacet } from "@earendil-works/chord";
|
||||
import { AgentHarness, BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
|
||||
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { consumeInternalProcessRole } from "../../src/experimental/process.ts";
|
||||
import { runSessionWorkerWithHarness } from "../../src/experimental/session-worker.ts";
|
||||
import { KeyedProbe } from "./keyed-service.ts";
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const role = consumeInternalProcessRole();
|
||||
if (role !== "session-worker") throw new Error("Faux Session worker requires a session-worker invocation");
|
||||
void runSessionWorkerWithHarness(process.argv.slice(2), async (session, options) => {
|
||||
if (options.provider !== "anthropic" || options.model !== "claude-sonnet-4-5") {
|
||||
throw new Error(`Unexpected faux worker model: ${options.provider}/${options.model}`);
|
||||
}
|
||||
const faux = fauxProvider();
|
||||
faux.setResponses([fauxAssistantMessage("deterministic remote answer", { timestamp: 20 })]);
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
const harness = (
|
||||
await AgentHarness.create(
|
||||
{
|
||||
session,
|
||||
models,
|
||||
model: faux.getModel(),
|
||||
tools: [],
|
||||
resources: {},
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
)
|
||||
).harness;
|
||||
const keyedProbeFacet = defineFacet({
|
||||
id: "@test/keyed-probe",
|
||||
setup(env) {
|
||||
const probes = env.provideMany(KeyedProbe);
|
||||
const spawn = (value: string): void => {
|
||||
const state = env.replicatedState({ value });
|
||||
let close = (): void => {};
|
||||
close = probes.spawn("probe", {
|
||||
state,
|
||||
async replace(next) {
|
||||
close();
|
||||
spawn(next);
|
||||
},
|
||||
async wait(context) {
|
||||
const signal = context.abortSignal;
|
||||
if (signal === undefined) throw new Error("Probe wait requires cancellation");
|
||||
if (signal.aborted) throw abortError(signal);
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(abortError(signal)), { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
env.onActivate(() => spawn("first"));
|
||||
},
|
||||
});
|
||||
return { harness, facetLoader: createStaticFacetLoader([keyedProbeFacet]) };
|
||||
}).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
const reason: unknown = signal.reason;
|
||||
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
9
packages/coding-agent/test/fixtures/keyed-service.ts
vendored
Normal file
9
packages/coding-agent/test/fixtures/keyed-service.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
|
||||
|
||||
export interface KeyedProbe {
|
||||
readonly state: ReplicatedState<{ value: string }>;
|
||||
replace(value: string, context: Context): Promise<void>;
|
||||
wait(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export const KeyedProbe = defineService<KeyedProbe>("test.keyed-probe");
|
||||
1019
packages/coding-agent/test/fixtures/large-session.jsonl
vendored
Normal file
1019
packages/coding-agent/test/fixtures/large-session.jsonl
vendored
Normal file
File diff suppressed because one or more lines are too long
8
packages/coding-agent/test/fixtures/skills-collision/first/calendar/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills-collision/first/calendar/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: calendar
|
||||
description: First calendar skill.
|
||||
---
|
||||
|
||||
# Calendar (First)
|
||||
|
||||
This is the first calendar skill.
|
||||
8
packages/coding-agent/test/fixtures/skills-collision/second/calendar/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills-collision/second/calendar/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: calendar
|
||||
description: Second calendar skill.
|
||||
---
|
||||
|
||||
# Calendar (Second)
|
||||
|
||||
This is the second calendar skill.
|
||||
8
packages/coding-agent/test/fixtures/skills/consecutive-hyphens/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/consecutive-hyphens/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: bad--name
|
||||
description: A skill with consecutive hyphens in the name.
|
||||
---
|
||||
|
||||
# Consecutive Hyphens
|
||||
|
||||
This skill has consecutive hyphens in its name.
|
||||
9
packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md
vendored
Normal file
9
packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: disable-model-invocation
|
||||
description: A skill that cannot be invoked by the model.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Manual Only Skill
|
||||
|
||||
This skill can only be invoked via /skill:disable-model-invocation.
|
||||
8
packages/coding-agent/test/fixtures/skills/invalid-name-chars/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/invalid-name-chars/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: Invalid_Name
|
||||
description: A skill with invalid characters in the name.
|
||||
---
|
||||
|
||||
# Invalid Name
|
||||
|
||||
This skill has uppercase and underscore in the name.
|
||||
8
packages/coding-agent/test/fixtures/skills/invalid-yaml/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/invalid-yaml/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: invalid-yaml
|
||||
description: [unclosed bracket
|
||||
---
|
||||
|
||||
# Invalid YAML Skill
|
||||
|
||||
This skill has invalid YAML in the frontmatter.
|
||||
8
packages/coding-agent/test/fixtures/skills/long-name/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/long-name/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: this-is-a-very-long-skill-name-that-exceeds-the-sixty-four-character-limit-set-by-the-standard
|
||||
description: A skill with a name that exceeds 64 characters.
|
||||
---
|
||||
|
||||
# Long Name
|
||||
|
||||
This skill's name is too long.
|
||||
7
packages/coding-agent/test/fixtures/skills/missing-description/SKILL.md
vendored
Normal file
7
packages/coding-agent/test/fixtures/skills/missing-description/SKILL.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: missing-description
|
||||
---
|
||||
|
||||
# Missing Description
|
||||
|
||||
This skill has no description field.
|
||||
11
packages/coding-agent/test/fixtures/skills/multiline-description/SKILL.md
vendored
Normal file
11
packages/coding-agent/test/fixtures/skills/multiline-description/SKILL.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: multiline-description
|
||||
description: |
|
||||
This is a multiline description.
|
||||
It spans multiple lines.
|
||||
And should be normalized.
|
||||
---
|
||||
|
||||
# Multiline Description Skill
|
||||
|
||||
This skill tests that multiline YAML descriptions are normalized to single lines.
|
||||
8
packages/coding-agent/test/fixtures/skills/name-mismatch/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/name-mismatch/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: different-name
|
||||
description: A skill with a name that doesn't match the directory.
|
||||
---
|
||||
|
||||
# Name Mismatch
|
||||
|
||||
This skill's name doesn't match its parent directory.
|
||||
8
packages/coding-agent/test/fixtures/skills/nested/child-skill/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/nested/child-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: child-skill
|
||||
description: A nested skill in a subdirectory.
|
||||
---
|
||||
|
||||
# Child Skill
|
||||
|
||||
This skill is nested in a subdirectory.
|
||||
3
packages/coding-agent/test/fixtures/skills/no-frontmatter/SKILL.md
vendored
Normal file
3
packages/coding-agent/test/fixtures/skills/no-frontmatter/SKILL.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# No Frontmatter
|
||||
|
||||
This skill has no YAML frontmatter at all.
|
||||
3
packages/coding-agent/test/fixtures/skills/root-skill-preferred/SKILL.md
vendored
Normal file
3
packages/coding-agent/test/fixtures/skills/root-skill-preferred/SKILL.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
description: Root skill should win.
|
||||
---
|
||||
3
packages/coding-agent/test/fixtures/skills/root-skill-preferred/nested-child/SKILL.md
vendored
Normal file
3
packages/coding-agent/test/fixtures/skills/root-skill-preferred/nested-child/SKILL.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
description: Nested skill should be ignored.
|
||||
---
|
||||
10
packages/coding-agent/test/fixtures/skills/unknown-field/SKILL.md
vendored
Normal file
10
packages/coding-agent/test/fixtures/skills/unknown-field/SKILL.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: unknown-field
|
||||
description: A skill with an unknown frontmatter field.
|
||||
author: someone
|
||||
version: 1.0
|
||||
---
|
||||
|
||||
# Unknown Field
|
||||
|
||||
This skill has non-standard frontmatter fields.
|
||||
8
packages/coding-agent/test/fixtures/skills/valid-skill/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/valid-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: valid-skill
|
||||
description: A valid skill for testing purposes.
|
||||
---
|
||||
|
||||
# Valid Skill
|
||||
|
||||
This is a valid skill that follows the Agent Skills standard.
|
||||
278
packages/coding-agent/test/footer-data-provider.test.ts
Normal file
278
packages/coding-agent/test/footer-data-provider.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { execFile, spawnSync } from "child_process";
|
||||
import { existsSync, type FSWatcher, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let resolvedBranch = "main";
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execFile: vi.fn(
|
||||
(
|
||||
_command: string,
|
||||
args: readonly string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (args[1] === "symbolic-ref") {
|
||||
setTimeout(
|
||||
() =>
|
||||
callback(
|
||||
resolvedBranch ? null : new Error("detached"),
|
||||
resolvedBranch ? `${resolvedBranch}\n` : "",
|
||||
"",
|
||||
),
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => callback(new Error("unsupported"), "", ""), 0);
|
||||
},
|
||||
),
|
||||
spawnSync: vi.fn((_command: string, args: readonly string[]) => {
|
||||
if (args[1] === "symbolic-ref") {
|
||||
return { status: resolvedBranch ? 0 : 1, stdout: resolvedBranch ? `${resolvedBranch}\n` : "", stderr: "" };
|
||||
}
|
||||
return { status: 1, stdout: "", stderr: "" };
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FooterDataProvider } from "../src/core/footer-data-provider.ts";
|
||||
|
||||
type WorktreeFixture = {
|
||||
worktreeDir: string;
|
||||
reftableDir: string;
|
||||
};
|
||||
|
||||
function createPlainReftableRepo(tempDir: string): string {
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(join(repoDir, ".git", "reftable"), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".git", "HEAD"), "ref: refs/heads/.invalid\n");
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function createPlainRepo(tempDir: string): string {
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(join(repoDir, ".git"), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function createReftableWorktree(tempDir: string): WorktreeFixture {
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const commonGitDir = join(repoDir, ".git");
|
||||
const gitDir = join(commonGitDir, "worktrees", "src");
|
||||
const worktreeDir = join(tempDir, "worktree");
|
||||
const reftableDir = join(commonGitDir, "reftable");
|
||||
|
||||
mkdirSync(gitDir, { recursive: true });
|
||||
mkdirSync(reftableDir, { recursive: true });
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
|
||||
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${gitDir}\n`);
|
||||
writeFileSync(join(gitDir, "HEAD"), "ref: refs/heads/.invalid\n");
|
||||
writeFileSync(join(gitDir, "commondir"), "../..\n");
|
||||
writeFileSync(join(reftableDir, "tables.list"), "0\n");
|
||||
|
||||
return { worktreeDir, reftableDir };
|
||||
}
|
||||
|
||||
function emitReftableChange(provider: FooterDataProvider): void {
|
||||
const { reftableWatcher } = provider as unknown as { reftableWatcher: FSWatcher | null };
|
||||
expect(reftableWatcher).not.toBeNull();
|
||||
reftableWatcher?.emit("change", "change", "tables.list");
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
describe("FooterDataProvider reftable branch detection", () => {
|
||||
let originalCwd: string;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = mkdtempSync(join(tmpdir(), "footer-data-provider-"));
|
||||
resolvedBranch = "main";
|
||||
vi.mocked(spawnSync).mockClear();
|
||||
vi.mocked(execFile).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses HEAD directly in a regular repo from a nested directory", () => {
|
||||
const repoDir = createPlainRepo(tempDir);
|
||||
const nestedDir = join(repoDir, "src", "nested");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
process.chdir(nestedDir);
|
||||
|
||||
const provider = new FooterDataProvider(nestedDir);
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the branch via git when HEAD is .invalid in a reftable repo", () => {
|
||||
const repoDir = createPlainReftableRepo(tempDir);
|
||||
process.chdir(repoDir);
|
||||
|
||||
const provider = new FooterDataProvider(repoDir);
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
expect(vi.mocked(spawnSync)).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringMatching(/repo$/),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the branch via git in a reftable-backed worktree", () => {
|
||||
const { worktreeDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider(worktreeDir);
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unresolved .invalid reftable HEAD as detached", () => {
|
||||
const repoDir = createPlainReftableRepo(tempDir);
|
||||
process.chdir(repoDir);
|
||||
resolvedBranch = "";
|
||||
|
||||
const provider = new FooterDataProvider(repoDir);
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("detached");
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// Drive debounce behavior explicitly; native fs.watch delivery can race watcher startup.
|
||||
it("does not notify listeners when reftable updates keep the same branch", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { worktreeDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider(worktreeDir);
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
vi.mocked(spawnSync).mockClear();
|
||||
const onBranchChange = vi.fn();
|
||||
provider.onBranchChange(onBranchChange);
|
||||
|
||||
emitReftableChange(provider);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
|
||||
expect(vi.mocked(execFile)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(spawnSync)).not.toHaveBeenCalled();
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
expect(onBranchChange).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
provider.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("debounces rapid reftable updates into a single async refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { worktreeDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider(worktreeDir);
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
vi.mocked(execFile).mockClear();
|
||||
|
||||
emitReftableChange(provider);
|
||||
emitReftableChange(provider);
|
||||
emitReftableChange(provider);
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(vi.mocked(execFile)).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
expect(vi.mocked(execFile)).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(650);
|
||||
expect(vi.mocked(execFile)).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
provider.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates the cached branch when the reftable directory changes", async () => {
|
||||
const { worktreeDir, reftableDir } = createReftableWorktree(tempDir);
|
||||
process.chdir(worktreeDir);
|
||||
|
||||
const provider = new FooterDataProvider(worktreeDir);
|
||||
try {
|
||||
expect(provider.getGitBranch()).toBe("main");
|
||||
resolvedBranch = "foo";
|
||||
const onBranchChange = vi.fn();
|
||||
provider.onBranchChange(onBranchChange);
|
||||
|
||||
writeFileSync(join(reftableDir, "tables.list"), "1\n");
|
||||
await waitFor(() => vi.mocked(execFile).mock.calls.length === 1);
|
||||
await waitFor(() => provider.getGitBranch() === "foo");
|
||||
|
||||
expect(vi.mocked(execFile)).toHaveBeenCalledTimes(1);
|
||||
expect(provider.getGitBranch()).toBe("foo");
|
||||
expect(onBranchChange).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries git watchers 5 seconds after an async fs.watch error", async () => {
|
||||
vi.useFakeTimers();
|
||||
const repoDir = createPlainRepo(tempDir);
|
||||
process.chdir(repoDir);
|
||||
|
||||
const provider = new FooterDataProvider(repoDir);
|
||||
try {
|
||||
const providerWithInternals = provider as unknown as {
|
||||
headWatcher: FSWatcher | null;
|
||||
};
|
||||
const originalWatcher = providerWithInternals.headWatcher;
|
||||
expect(originalWatcher).not.toBeNull();
|
||||
expect(originalWatcher?.listenerCount("error")).toBeGreaterThan(0);
|
||||
|
||||
originalWatcher?.emit("error", new Error("simulated EMFILE"));
|
||||
expect(providerWithInternals.headWatcher).toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(providerWithInternals.headWatcher).toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(providerWithInternals.headWatcher).not.toBeNull();
|
||||
expect(providerWithInternals.headWatcher).not.toBe(originalWatcher);
|
||||
} finally {
|
||||
provider.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
252
packages/coding-agent/test/footer-width.test.ts
Normal file
252
packages/coding-agent/test/footer-width.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type { AgentSession } from "../src/core/agent-session.ts";
|
||||
import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts";
|
||||
import { FooterComponent, formatCwdForFooter } from "../src/modes/interactive/components/footer.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../src/utils/ansi.ts";
|
||||
|
||||
type AssistantUsage = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: { total: number };
|
||||
};
|
||||
|
||||
function createSession(options: {
|
||||
sessionName: string;
|
||||
modelId?: string;
|
||||
provider?: string;
|
||||
reasoning?: boolean;
|
||||
thinkingLevel?: string;
|
||||
usage?: AssistantUsage;
|
||||
branchUsage?: AssistantUsage;
|
||||
compactionUsage?: AssistantUsage;
|
||||
toolUsage?: AssistantUsage;
|
||||
usingSubscription?: boolean;
|
||||
}): AgentSession {
|
||||
const usage = options.usage;
|
||||
const entries: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (usage !== undefined) {
|
||||
entries.push({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.branchUsage !== undefined) {
|
||||
entries.push({
|
||||
type: "branch_summary",
|
||||
usage: options.branchUsage,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.compactionUsage !== undefined) {
|
||||
entries.push({
|
||||
type: "compaction",
|
||||
usage: options.compactionUsage,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.toolUsage !== undefined) {
|
||||
entries.push({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
usage: options.toolUsage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const session = {
|
||||
state: {
|
||||
model: {
|
||||
id: options.modelId ?? "test-model",
|
||||
provider: options.provider ?? "test",
|
||||
contextWindow: 200_000,
|
||||
reasoning: options.reasoning ?? false,
|
||||
},
|
||||
thinkingLevel: options.thinkingLevel ?? "off",
|
||||
},
|
||||
sessionManager: {
|
||||
getEntries: () => entries,
|
||||
getSessionName: () => options.sessionName,
|
||||
getCwd: () => "/tmp/project",
|
||||
},
|
||||
getContextUsage: () => ({ contextWindow: 200_000, percent: 12.3 }),
|
||||
modelRuntime: {
|
||||
isUsingSubscription: () => options.usingSubscription ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
return session as unknown as AgentSession;
|
||||
}
|
||||
|
||||
function createFooterData(providerCount: number): ReadonlyFooterDataProvider {
|
||||
const provider = {
|
||||
getGitBranch: () => "main",
|
||||
getExtensionStatuses: () => new Map<string, string>(),
|
||||
getAvailableProviderCount: () => providerCount,
|
||||
onBranchChange: (callback: () => void) => {
|
||||
void callback;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe("formatCwdForFooter", () => {
|
||||
it("does not abbreviate sibling paths that share the home prefix", () => {
|
||||
expect(formatCwdForFooter("/home/user2", "/home/user")).toBe("/home/user2");
|
||||
});
|
||||
|
||||
it("abbreviates the home directory and descendants", () => {
|
||||
expect(formatCwdForFooter("/home/user", "/home/user")).toBe("~");
|
||||
expect(formatCwdForFooter("/home/user/project", "/home/user")).toBe("~/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FooterComponent width handling", () => {
|
||||
beforeAll(() => {
|
||||
initTheme(undefined, false);
|
||||
});
|
||||
|
||||
it("keeps all lines within width for wide session names", () => {
|
||||
const width = 93;
|
||||
const session = createSession({ sessionName: "한글".repeat(30) });
|
||||
const footer = new FooterComponent(session, createFooterData(1));
|
||||
|
||||
const lines = footer.render(width);
|
||||
for (const line of lines) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stats line within width for wide model and provider names", () => {
|
||||
const width = 60;
|
||||
const session = createSession({
|
||||
sessionName: "",
|
||||
modelId: "模".repeat(30),
|
||||
provider: "공급자",
|
||||
reasoning: true,
|
||||
thinkingLevel: "high",
|
||||
usage: {
|
||||
input: 12_345,
|
||||
output: 6_789,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 1.234 },
|
||||
},
|
||||
});
|
||||
const footer = new FooterComponent(session, createFooterData(2));
|
||||
|
||||
const lines = footer.render(width);
|
||||
for (const line of lines) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes summary and tool result usage in the total cost", () => {
|
||||
const session = createSession({
|
||||
sessionName: "",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 0.5 },
|
||||
},
|
||||
branchUsage: {
|
||||
input: 20,
|
||||
output: 5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 0.25 },
|
||||
},
|
||||
compactionUsage: {
|
||||
input: 5,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 0.125 },
|
||||
},
|
||||
toolUsage: {
|
||||
input: 15,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 0.375 },
|
||||
},
|
||||
});
|
||||
const footer = new FooterComponent(session, createFooterData(1));
|
||||
|
||||
const statsLine = stripAnsi(footer.render(120)[1]);
|
||||
expect(statsLine).toContain("$1.250");
|
||||
});
|
||||
|
||||
it("shows the latest cache hit rate when cache usage is present", () => {
|
||||
const session = createSession({
|
||||
sessionName: "",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 10,
|
||||
cacheRead: 50,
|
||||
cacheWrite: 50,
|
||||
cost: { total: 0.001 },
|
||||
},
|
||||
});
|
||||
const footer = new FooterComponent(session, createFooterData(1));
|
||||
|
||||
const statsLine = stripAnsi(footer.render(120)[1]);
|
||||
expect(statsLine).toContain("CH25.0%");
|
||||
});
|
||||
|
||||
it("marks Kimi Coding costs as subscription estimates", () => {
|
||||
const session = createSession({
|
||||
sessionName: "",
|
||||
provider: "kimi-coding",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 1.234 },
|
||||
},
|
||||
});
|
||||
const footer = new FooterComponent(session, createFooterData(1));
|
||||
|
||||
expect(stripAnsi(footer.render(120)[1])).toContain("$1.234 (sub)");
|
||||
});
|
||||
|
||||
it("marks explicitly identified subscription auth", () => {
|
||||
const session = createSession({ sessionName: "", provider: "anthropic", usingSubscription: true });
|
||||
const footer = new FooterComponent(session, createFooterData(1));
|
||||
|
||||
expect(stripAnsi(footer.render(120)[1])).toContain("$0.000 (sub)");
|
||||
});
|
||||
|
||||
it("does not mark generic OAuth sign-in as a subscription", () => {
|
||||
const session = createSession({
|
||||
sessionName: "",
|
||||
provider: "openrouter",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { total: 1.234 },
|
||||
},
|
||||
});
|
||||
const footer = new FooterComponent(session, createFooterData(1));
|
||||
const stats = stripAnsi(footer.render(120)[1]);
|
||||
|
||||
expect(stats).toContain("$1.234");
|
||||
expect(stats).not.toContain("(sub)");
|
||||
});
|
||||
});
|
||||
135
packages/coding-agent/test/format-resume-command.test.ts
Normal file
135
packages/coding-agent/test/format-resume-command.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { APP_NAME } from "../src/config.ts";
|
||||
import type { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { formatResumeCommand } from "../src/modes/interactive/interactive-mode.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStdoutIsTTY) {
|
||||
Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY);
|
||||
} else {
|
||||
Reflect.deleteProperty(process.stdout, "isTTY");
|
||||
}
|
||||
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setStdoutIsTTY(value: boolean): void {
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value });
|
||||
}
|
||||
|
||||
function createTempFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-format-resume-command-"));
|
||||
tempDirs.push(dir);
|
||||
const file = join(dir, "session.jsonl");
|
||||
writeFileSync(file, "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
function createSessionManager(options: {
|
||||
persisted?: boolean;
|
||||
sessionFile?: string;
|
||||
sessionId?: string;
|
||||
sessionDir?: string;
|
||||
usesDefaultSessionDir?: boolean;
|
||||
}): SessionManager {
|
||||
return {
|
||||
isPersisted: () => options.persisted ?? true,
|
||||
getSessionFile: () => options.sessionFile,
|
||||
getSessionId: () => options.sessionId ?? "0197f6e4-4cf9-7f44-a2d8-f8f7f49ee9d3",
|
||||
getSessionDir: () => options.sessionDir ?? "/tmp/pi-sessions",
|
||||
usesDefaultSessionDir: () => options.usesDefaultSessionDir ?? true,
|
||||
} as unknown as SessionManager;
|
||||
}
|
||||
|
||||
describe("formatResumeCommand", () => {
|
||||
it("returns a session resume command for default session dirs", () => {
|
||||
setStdoutIsTTY(true);
|
||||
const sessionFile = createTempFile();
|
||||
const sessionManager = createSessionManager({ sessionFile, sessionId: "test-session" });
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBe(`${APP_NAME} --session test-session`);
|
||||
});
|
||||
|
||||
it("includes unquoted safe session dirs for non-default session dirs", () => {
|
||||
setStdoutIsTTY(true);
|
||||
const sessionFile = createTempFile();
|
||||
const sessionManager = createSessionManager({
|
||||
sessionFile,
|
||||
sessionId: "test-session",
|
||||
sessionDir: "/tmp/custom-pi-sessions",
|
||||
usesDefaultSessionDir: false,
|
||||
});
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBe(
|
||||
`${APP_NAME} --session-dir /tmp/custom-pi-sessions --session test-session`,
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes session dirs containing spaces", () => {
|
||||
setStdoutIsTTY(true);
|
||||
const sessionFile = createTempFile();
|
||||
const sessionManager = createSessionManager({
|
||||
sessionFile,
|
||||
sessionId: "test-session",
|
||||
sessionDir: "/tmp/custom pi sessions",
|
||||
usesDefaultSessionDir: false,
|
||||
});
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBe(
|
||||
`${APP_NAME} --session-dir '/tmp/custom pi sessions' --session test-session`,
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes session dirs containing single quotes", () => {
|
||||
setStdoutIsTTY(true);
|
||||
const sessionFile = createTempFile();
|
||||
const sessionManager = createSessionManager({
|
||||
sessionFile,
|
||||
sessionId: "test-session",
|
||||
sessionDir: "/tmp/custom pi's sessions",
|
||||
usesDefaultSessionDir: false,
|
||||
});
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBe(
|
||||
`${APP_NAME} --session-dir '/tmp/custom pi'\\''s sessions' --session test-session`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when stdout is not a TTY", () => {
|
||||
setStdoutIsTTY(false);
|
||||
const sessionFile = createTempFile();
|
||||
const sessionManager = createSessionManager({ sessionFile });
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for in-memory sessions", () => {
|
||||
setStdoutIsTTY(true);
|
||||
const sessionFile = createTempFile();
|
||||
const sessionManager = createSessionManager({ persisted: false, sessionFile });
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the session file is missing", () => {
|
||||
setStdoutIsTTY(true);
|
||||
const sessionManager = createSessionManager({ sessionFile: "/tmp/pi-missing-session.jsonl" });
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the session file is not set", () => {
|
||||
setStdoutIsTTY(true);
|
||||
const sessionManager = createSessionManager({ sessionFile: undefined });
|
||||
|
||||
expect(formatResumeCommand(sessionManager)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
60
packages/coding-agent/test/frontmatter.test.ts
Normal file
60
packages/coding-agent/test/frontmatter.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFrontmatter, stripFrontmatter } from "../src/utils/frontmatter.ts";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
it("parses keys, strips quotes, and returns body", () => {
|
||||
const input = "---\nname: \"skill-name\"\ndescription: 'A desc'\nfoo-bar: value\n---\n\nBody text";
|
||||
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(input);
|
||||
expect(frontmatter.name).toBe("skill-name");
|
||||
expect(frontmatter.description).toBe("A desc");
|
||||
expect(frontmatter["foo-bar"]).toBe("value");
|
||||
expect(body).toBe("Body text");
|
||||
});
|
||||
|
||||
it("normalizes newlines and handles CRLF", () => {
|
||||
const input = "---\r\nname: test\r\n---\r\nLine one\r\nLine two";
|
||||
const { body } = parseFrontmatter<Record<string, string>>(input);
|
||||
expect(body).toBe("Line one\nLine two");
|
||||
});
|
||||
|
||||
it("throws on invalid YAML frontmatter", () => {
|
||||
const input = "---\nfoo: [bar\n---\nBody";
|
||||
expect(() => parseFrontmatter<Record<string, string>>(input)).toThrow(/at line 1, column 10/);
|
||||
});
|
||||
|
||||
it("parses | multiline yaml syntax", () => {
|
||||
const input = "---\ndescription: |\n Line one\n Line two\n---\n\nBody";
|
||||
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(input);
|
||||
expect(frontmatter.description).toBe("Line one\nLine two\n");
|
||||
expect(body).toBe("Body");
|
||||
});
|
||||
|
||||
it("returns original content when frontmatter is missing or unterminated", () => {
|
||||
const noFrontmatter = "Just text\nsecond line";
|
||||
const missingEnd = "---\nname: test\nBody without terminator";
|
||||
const resultNoFrontmatter = parseFrontmatter<Record<string, string>>(noFrontmatter);
|
||||
const resultMissingEnd = parseFrontmatter<Record<string, string>>(missingEnd);
|
||||
expect(resultNoFrontmatter.body).toBe("Just text\nsecond line");
|
||||
expect(resultMissingEnd.body).toBe(
|
||||
"---\nname: test\nBody without terminator".replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty object for empty or comment-only frontmatter", () => {
|
||||
const input = "---\n# just a comment\n---\nBody";
|
||||
const { frontmatter } = parseFrontmatter(input);
|
||||
expect(frontmatter).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripFrontmatter", () => {
|
||||
it("removes frontmatter and trims body", () => {
|
||||
const input = "---\nkey: value\n---\n\nBody\n";
|
||||
expect(stripFrontmatter(input)).toBe("Body");
|
||||
});
|
||||
|
||||
it("returns body when no frontmatter present", () => {
|
||||
const input = "\n No frontmatter body \n";
|
||||
expect(stripFrontmatter(input)).toBe("\n No frontmatter body \n");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user