mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 12:03:49 +00:00
Previously, Agent.emit() was called before state was updated (e.g., appendMessage). This meant event handlers saw stale state - when message_end fired, agent.state.messages didn't include the message yet. Now state is updated first, then events are emitted, so handlers see consistent state that matches the event.
285 lines
8.8 KiB
TypeScript
285 lines
8.8 KiB
TypeScript
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
import { RpcClient } from "../src/modes/rpc/rpc-client.js";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
/**
|
|
* RPC mode tests.
|
|
*/
|
|
describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_TOKEN)("RPC mode", () => {
|
|
let client: RpcClient;
|
|
let sessionDir: string;
|
|
|
|
beforeEach(() => {
|
|
sessionDir = join(tmpdir(), `pi-rpc-test-${Date.now()}`);
|
|
client = new RpcClient({
|
|
cliPath: join(__dirname, "..", "dist", "cli.js"),
|
|
cwd: join(__dirname, ".."),
|
|
env: { PI_CODING_AGENT_DIR: sessionDir },
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4-5",
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await client.stop();
|
|
if (sessionDir && existsSync(sessionDir)) {
|
|
rmSync(sessionDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test("should get state", async () => {
|
|
await client.start();
|
|
const state = await client.getState();
|
|
|
|
expect(state.model).toBeDefined();
|
|
expect(state.model?.provider).toBe("anthropic");
|
|
expect(state.model?.id).toBe("claude-sonnet-4-5");
|
|
expect(state.isStreaming).toBe(false);
|
|
expect(state.messageCount).toBe(0);
|
|
}, 30000);
|
|
|
|
test("should save messages to session file", async () => {
|
|
await client.start();
|
|
|
|
// Send prompt and wait for completion
|
|
const events = await client.promptAndWait("Reply with just the word 'hello'");
|
|
|
|
// Should have message events
|
|
const messageEndEvents = events.filter((e) => e.type === "message_end");
|
|
expect(messageEndEvents.length).toBeGreaterThanOrEqual(2); // user + assistant
|
|
|
|
// Wait for file writes
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
// Verify session file
|
|
const sessionsPath = join(sessionDir, "sessions");
|
|
expect(existsSync(sessionsPath)).toBe(true);
|
|
|
|
const sessionDirs = readdirSync(sessionsPath);
|
|
expect(sessionDirs.length).toBeGreaterThan(0);
|
|
|
|
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
|
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
|
expect(sessionFiles.length).toBe(1);
|
|
|
|
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
|
const entries = sessionContent
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line));
|
|
|
|
// First entry should be session header
|
|
expect(entries[0].type).toBe("session");
|
|
|
|
// Should have user and assistant messages
|
|
const messages = entries.filter((e: { type: string }) => e.type === "message");
|
|
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const roles = messages.map((m: { message: { role: string } }) => m.message.role);
|
|
expect(roles).toContain("user");
|
|
expect(roles).toContain("assistant");
|
|
}, 90000);
|
|
|
|
test("should handle manual compaction", async () => {
|
|
await client.start();
|
|
|
|
// First send a prompt to have messages to compact
|
|
await client.promptAndWait("Say hello");
|
|
|
|
// Compact
|
|
const result = await client.compact();
|
|
expect(result.summary).toBeDefined();
|
|
expect(result.tokensBefore).toBeGreaterThan(0);
|
|
|
|
// Wait for file writes
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
// Verify compaction in session file
|
|
const sessionsPath = join(sessionDir, "sessions");
|
|
const sessionDirs = readdirSync(sessionsPath);
|
|
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
|
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
|
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
|
const entries = sessionContent
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line));
|
|
|
|
const compactionEntries = entries.filter((e: { type: string }) => e.type === "compaction");
|
|
expect(compactionEntries.length).toBe(1);
|
|
expect(compactionEntries[0].summary).toBeDefined();
|
|
}, 120000);
|
|
|
|
test("should execute bash command", async () => {
|
|
await client.start();
|
|
|
|
const result = await client.bash("echo hello");
|
|
expect(result.output.trim()).toBe("hello");
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.cancelled).toBe(false);
|
|
}, 30000);
|
|
|
|
test("should add bash output to context", async () => {
|
|
await client.start();
|
|
|
|
// First send a prompt to initialize session
|
|
await client.promptAndWait("Say hi");
|
|
|
|
// Run bash command
|
|
const uniqueValue = `test-${Date.now()}`;
|
|
await client.bash(`echo ${uniqueValue}`);
|
|
|
|
// Wait for file writes
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
// Verify bash message in session
|
|
const sessionsPath = join(sessionDir, "sessions");
|
|
const sessionDirs = readdirSync(sessionsPath);
|
|
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
|
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
|
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
|
const entries = sessionContent
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line));
|
|
|
|
const bashMessages = entries.filter(
|
|
(e: { type: string; message?: { role: string } }) =>
|
|
e.type === "message" && e.message?.role === "bashExecution",
|
|
);
|
|
expect(bashMessages.length).toBe(1);
|
|
expect(bashMessages[0].message.output).toContain(uniqueValue);
|
|
}, 90000);
|
|
|
|
test("should include bash output in LLM context", async () => {
|
|
await client.start();
|
|
|
|
// Run a bash command with a unique value
|
|
const uniqueValue = `unique-${Date.now()}`;
|
|
await client.bash(`echo ${uniqueValue}`);
|
|
|
|
// Ask the LLM what the output was
|
|
const events = await client.promptAndWait(
|
|
"What was the exact output of the echo command I just ran? Reply with just the value, nothing else.",
|
|
);
|
|
|
|
// Find assistant's response
|
|
const messageEndEvents = events.filter((e) => e.type === "message_end") as AgentEvent[];
|
|
const assistantMessage = messageEndEvents.find(
|
|
(e) => e.type === "message_end" && e.message?.role === "assistant",
|
|
) as any;
|
|
|
|
expect(assistantMessage).toBeDefined();
|
|
|
|
const textContent = assistantMessage.message.content.find((c: any) => c.type === "text");
|
|
expect(textContent?.text).toContain(uniqueValue);
|
|
}, 90000);
|
|
|
|
test("should set and get thinking level", async () => {
|
|
await client.start();
|
|
|
|
// Set thinking level
|
|
await client.setThinkingLevel("high");
|
|
|
|
// Verify via state
|
|
const state = await client.getState();
|
|
expect(state.thinkingLevel).toBe("high");
|
|
}, 30000);
|
|
|
|
test("should cycle thinking level", async () => {
|
|
await client.start();
|
|
|
|
// Get initial level
|
|
const initialState = await client.getState();
|
|
const initialLevel = initialState.thinkingLevel;
|
|
|
|
// Cycle
|
|
const result = await client.cycleThinkingLevel();
|
|
expect(result).toBeDefined();
|
|
expect(result!.level).not.toBe(initialLevel);
|
|
|
|
// Verify via state
|
|
const newState = await client.getState();
|
|
expect(newState.thinkingLevel).toBe(result!.level);
|
|
}, 30000);
|
|
|
|
test("should get available models", async () => {
|
|
await client.start();
|
|
|
|
const models = await client.getAvailableModels();
|
|
expect(models.length).toBeGreaterThan(0);
|
|
|
|
// All models should have required fields
|
|
for (const model of models) {
|
|
expect(model.provider).toBeDefined();
|
|
expect(model.id).toBeDefined();
|
|
expect(model.contextWindow).toBeGreaterThan(0);
|
|
expect(typeof model.reasoning).toBe("boolean");
|
|
}
|
|
}, 30000);
|
|
|
|
test("should get session stats", async () => {
|
|
await client.start();
|
|
|
|
// Send a prompt first
|
|
await client.promptAndWait("Hello");
|
|
|
|
const stats = await client.getSessionStats();
|
|
expect(stats.sessionFile).toBeDefined();
|
|
expect(stats.sessionId).toBeDefined();
|
|
expect(stats.userMessages).toBeGreaterThanOrEqual(1);
|
|
expect(stats.assistantMessages).toBeGreaterThanOrEqual(1);
|
|
}, 90000);
|
|
|
|
test("should reset session", async () => {
|
|
await client.start();
|
|
|
|
// Send a prompt
|
|
await client.promptAndWait("Hello");
|
|
|
|
// Verify messages exist
|
|
let state = await client.getState();
|
|
expect(state.messageCount).toBeGreaterThan(0);
|
|
|
|
// Reset
|
|
await client.reset();
|
|
|
|
// Verify messages cleared
|
|
state = await client.getState();
|
|
expect(state.messageCount).toBe(0);
|
|
}, 90000);
|
|
|
|
test("should export to HTML", async () => {
|
|
await client.start();
|
|
|
|
// Send a prompt first
|
|
await client.promptAndWait("Hello");
|
|
|
|
// Export
|
|
const result = await client.exportHtml();
|
|
expect(result.path).toBeDefined();
|
|
expect(result.path.endsWith(".html")).toBe(true);
|
|
expect(existsSync(result.path)).toBe(true);
|
|
}, 90000);
|
|
|
|
test("should get last assistant text", async () => {
|
|
await client.start();
|
|
|
|
// Initially null
|
|
let text = await client.getLastAssistantText();
|
|
expect(text).toBeNull();
|
|
|
|
// Send prompt
|
|
await client.promptAndWait("Reply with just: test123");
|
|
|
|
// Should have text now
|
|
text = await client.getLastAssistantText();
|
|
expect(text).toContain("test123");
|
|
}, 90000);
|
|
});
|