165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
/**
|
|
* Contracts: AdvisorTranscriptRecorder persists the advisor agent's turns to a
|
|
* subagent-style JSONL (`<session>/__advisor.jsonl`) so the advisor model's usage
|
|
* is attributed in stats and its transcript shows in the Agent Hub.
|
|
*
|
|
* - Assistant turns land as `{type:"message", message:{role:"assistant", usage}}`
|
|
* entries — exactly the shape the stats parser reads for usage.
|
|
* - User deltas are persisted but flagged `synthetic`/agent-attributed so stats'
|
|
* user-message metrics skip them.
|
|
* - Non-conversational message kinds are not persisted.
|
|
* - The target follows the session file: a switch routes later turns to the new
|
|
* session's `__advisor.jsonl`, leaving the prior file intact.
|
|
*/
|
|
import { describe, expect, it } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
import {
|
|
ADVISOR_TRANSCRIPT_FILENAME,
|
|
AdvisorTranscriptRecorder,
|
|
} from "@oh-my-pi/pi-coding-agent/advisor/transcript-recorder";
|
|
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
|
|
|
interface AdvisorEntry {
|
|
type?: string;
|
|
id?: unknown;
|
|
message?: {
|
|
role?: string;
|
|
model?: string;
|
|
usage?: { input?: number };
|
|
synthetic?: boolean;
|
|
attribution?: string;
|
|
};
|
|
}
|
|
|
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "advisor-recorder-"));
|
|
try {
|
|
return await fn(dir);
|
|
} finally {
|
|
await removeWithRetries(dir);
|
|
}
|
|
}
|
|
|
|
/** Parse the message entries (skipping the session header) from an advisor JSONL. */
|
|
async function readMessageEntries(file: string): Promise<AdvisorEntry[]> {
|
|
const text = await Bun.file(file).text();
|
|
// JSON.parse returns `any`; assigning to the typed array narrows reads below.
|
|
const entries: AdvisorEntry[] = text
|
|
.trim()
|
|
.split("\n")
|
|
.map(line => JSON.parse(line));
|
|
return entries.filter(entry => entry.type === "message");
|
|
}
|
|
|
|
function assistantMessage(text: string, inputTokens: number): AgentMessage {
|
|
const message = {
|
|
role: "assistant" as const,
|
|
content: [{ type: "text" as const, text }],
|
|
api: "anthropic-messages",
|
|
provider: "anthropic",
|
|
model: "test-advisor-model",
|
|
usage: {
|
|
input: inputTokens,
|
|
output: 3,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: inputTokens + 3,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason: "stop" as const,
|
|
timestamp: 1,
|
|
};
|
|
return message as unknown as AgentMessage;
|
|
}
|
|
|
|
function userMessage(text: string): AgentMessage {
|
|
const message = { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: 1 };
|
|
return message as unknown as AgentMessage;
|
|
}
|
|
|
|
function developerMessage(text: string): AgentMessage {
|
|
const message = { role: "developer" as const, content: [{ type: "text" as const, text }], timestamp: 1 };
|
|
return message as unknown as AgentMessage;
|
|
}
|
|
|
|
describe("AdvisorTranscriptRecorder", () => {
|
|
it("persists assistant turns with usage to <session>/__advisor.jsonl", async () => {
|
|
await withTempDir(async dir => {
|
|
const sessionFile = path.join(dir, "sess.jsonl");
|
|
const recorder = new AdvisorTranscriptRecorder(
|
|
() => sessionFile,
|
|
() => dir,
|
|
);
|
|
recorder.record(assistantMessage("reviewing", 42));
|
|
await recorder.close();
|
|
|
|
const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME));
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].message?.role).toBe("assistant");
|
|
expect(messages[0].message?.model).toBe("test-advisor-model");
|
|
expect(messages[0].message?.usage?.input).toBe(42);
|
|
// Stats keys on a non-empty entry id; SessionManager must assign one.
|
|
expect(typeof messages[0].id).toBe("string");
|
|
expect(String(messages[0].id).length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it("marks advisor user deltas synthetic and agent-attributed", async () => {
|
|
await withTempDir(async dir => {
|
|
const sessionFile = path.join(dir, "sess.jsonl");
|
|
const recorder = new AdvisorTranscriptRecorder(
|
|
() => sessionFile,
|
|
() => dir,
|
|
);
|
|
recorder.record(userMessage("### Session update"));
|
|
await recorder.close();
|
|
|
|
const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME));
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].message?.role).toBe("user");
|
|
expect(messages[0].message?.synthetic).toBe(true);
|
|
expect(messages[0].message?.attribution).toBe("agent");
|
|
});
|
|
});
|
|
|
|
it("skips non-conversational message kinds", async () => {
|
|
await withTempDir(async dir => {
|
|
const sessionFile = path.join(dir, "sess.jsonl");
|
|
const recorder = new AdvisorTranscriptRecorder(
|
|
() => sessionFile,
|
|
() => dir,
|
|
);
|
|
recorder.record(developerMessage("noise"));
|
|
recorder.record(assistantMessage("kept", 1));
|
|
await recorder.close();
|
|
|
|
const messages = await readMessageEntries(path.join(dir, "sess", ADVISOR_TRANSCRIPT_FILENAME));
|
|
expect(messages.map(m => m.message?.role)).toEqual(["assistant"]);
|
|
});
|
|
});
|
|
|
|
it("routes later turns to the new session file after a switch", async () => {
|
|
await withTempDir(async dir => {
|
|
let sessionFile = path.join(dir, "first.jsonl");
|
|
const recorder = new AdvisorTranscriptRecorder(
|
|
() => sessionFile,
|
|
() => dir,
|
|
);
|
|
recorder.record(assistantMessage("before switch", 1));
|
|
sessionFile = path.join(dir, "second.jsonl");
|
|
recorder.record(assistantMessage("after switch", 2));
|
|
await recorder.close();
|
|
|
|
const first = await readMessageEntries(path.join(dir, "first", ADVISOR_TRANSCRIPT_FILENAME));
|
|
const second = await readMessageEntries(path.join(dir, "second", ADVISOR_TRANSCRIPT_FILENAME));
|
|
expect(first).toHaveLength(1);
|
|
expect(first[0].message?.usage?.input).toBe(1);
|
|
expect(second).toHaveLength(1);
|
|
expect(second[0].message?.usage?.input).toBe(2);
|
|
});
|
|
});
|
|
});
|