Files
2026-07-13 12:22:59 +08:00

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);
});
});
});