mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
test(core): fill elision coverage gaps
The original suite covered the tool-result path well but left holes: loadElisionPolicy had no tests at all (now: missing file, full config, partial merge, unparseable JSON, and the all-or-nothing malformed-key behavior — via an injectable config path so tests stay off the real WorkDir), images/note policies had no idempotency or determinism tests (the properties prefix caching depends on), the note floor boundary was unpinned, resolveAgent delegation was unverified, and nothing exercised a multi-turn reference chain or the per-resolve hot config reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4b4e6af2ea
commit
30570a9325
2 changed files with 163 additions and 6 deletions
|
|
@ -1,4 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import type { TurnContext, TurnEvent } from "@x/shared/dist/turns.js";
|
||||
import {
|
||||
|
|
@ -8,6 +11,7 @@ import {
|
|||
elideHistoricImages,
|
||||
elideHistoricMiddlePaneContent,
|
||||
elideHistoricToolResults,
|
||||
loadElisionPolicy,
|
||||
} from "./context-elision.js";
|
||||
import { TurnRepoContextResolver } from "./context-resolver.js";
|
||||
import { InMemoryTurnRepo } from "./in-memory-turn-repo.js";
|
||||
|
|
@ -437,4 +441,154 @@ describe("ElidingContextResolver", () => {
|
|||
if (middlePane?.kind !== "note") throw new Error("expected note pane");
|
||||
expect(middlePane.content).toContain("omitted from history");
|
||||
});
|
||||
|
||||
it("delegates resolveAgent to the inner resolver untouched", async () => {
|
||||
const repo = new InMemoryTurnRepo();
|
||||
const snapshot = {
|
||||
agentId: "copilot",
|
||||
systemPrompt: "SYS",
|
||||
model: { provider: "fake", model: "m" },
|
||||
tools: [],
|
||||
};
|
||||
const agent = await resolver(repo, POLICY_OFF).resolveAgent(snapshot);
|
||||
expect(agent).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("elides across every turn of a multi-turn reference chain", async () => {
|
||||
const repo = new InMemoryTurnRepo();
|
||||
const T2 = "2026-07-02T11-00-00Z-0000002-000";
|
||||
repo.seed(toolTurnLog(T1, [], "a".repeat(200)));
|
||||
repo.seed(toolTurnLog(T2, { previousTurnId: T1 }, "b".repeat(200)));
|
||||
const resolved = await resolver(repo, {
|
||||
...POLICY_OFF,
|
||||
toolResults: true,
|
||||
}).resolve({ previousTurnId: T2 });
|
||||
const tools = resolved.filter((m) => m.role === "tool");
|
||||
expect(tools).toHaveLength(2);
|
||||
for (const tool of tools) {
|
||||
expect(tool.content).toContain("elided");
|
||||
}
|
||||
});
|
||||
|
||||
it("reloads the policy on every resolve (hot config)", async () => {
|
||||
const repo = new InMemoryTurnRepo();
|
||||
const output = "x".repeat(200);
|
||||
repo.seed(toolTurnLog(T1, [], output));
|
||||
let loads = 0;
|
||||
const eliding = new ElidingContextResolver({
|
||||
inner: new TurnRepoContextResolver({ turnRepo: repo }),
|
||||
loadPolicy: () => {
|
||||
loads += 1;
|
||||
return { ...POLICY_OFF, toolResults: loads > 1 };
|
||||
},
|
||||
});
|
||||
const first = await eliding.resolve({ previousTurnId: T1 });
|
||||
const second = await eliding.resolve({ previousTurnId: T1 });
|
||||
expect(loads).toBe(2);
|
||||
expect(first.find((m) => m.role === "tool")?.content).toBe(output);
|
||||
expect(second.find((m) => m.role === "tool")?.content).toContain("elided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("elision policy idempotency and determinism", () => {
|
||||
// Placeholders must never themselves be elided on a second pass, and the
|
||||
// transforms must be pure — both are what keeps replayed prefixes
|
||||
// byte-stable for provider prefix caching.
|
||||
it("elideHistoricImages is idempotent and deterministic", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{ type: "text" as const, text: "watch" },
|
||||
frame("camera"),
|
||||
frame("screen"),
|
||||
],
|
||||
},
|
||||
];
|
||||
const once = elideHistoricImages(messages);
|
||||
expect(elideHistoricImages(once)).toEqual(once);
|
||||
expect(elideHistoricImages(messages)).toEqual(once);
|
||||
});
|
||||
|
||||
it("elideHistoricMiddlePaneContent is idempotent and deterministic", () => {
|
||||
const messages = [noteMessage("n".repeat(600))];
|
||||
const once = elideHistoricMiddlePaneContent(messages);
|
||||
expect(elideHistoricMiddlePaneContent(once)).toEqual(once);
|
||||
expect(elideHistoricMiddlePaneContent(messages)).toEqual(once);
|
||||
});
|
||||
|
||||
it("keeps notes at the floor verbatim and elides just above it", () => {
|
||||
const atFloor = noteMessage("n".repeat(500));
|
||||
expect(elideHistoricMiddlePaneContent([atFloor])).toEqual([atFloor]);
|
||||
const [above] = elideHistoricMiddlePaneContent([
|
||||
noteMessage("n".repeat(501)),
|
||||
]);
|
||||
if (above.role !== "user") throw new Error("expected user message");
|
||||
expect(above.userMessageContext?.middlePane?.kind === "note"
|
||||
? above.userMessageContext.middlePane.content
|
||||
: "").toContain("omitted from history");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadElisionPolicy", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "elision-config-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(content: string): string {
|
||||
const p = path.join(dir, "context.json");
|
||||
fs.writeFileSync(p, content);
|
||||
return p;
|
||||
}
|
||||
|
||||
it("returns defaults when the file is missing", () => {
|
||||
expect(loadElisionPolicy(path.join(dir, "nope.json"))).toEqual(
|
||||
DEFAULT_ELISION_POLICY,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a full config", () => {
|
||||
const p = write(
|
||||
JSON.stringify({
|
||||
elideHistoricToolResults: false,
|
||||
elideHistoricToolResultsThresholdChars: 42,
|
||||
elideHistoricImages: false,
|
||||
elideHistoricMiddlePaneContent: false,
|
||||
}),
|
||||
);
|
||||
expect(loadElisionPolicy(p)).toEqual({
|
||||
toolResults: false,
|
||||
toolResultThresholdChars: 42,
|
||||
images: false,
|
||||
middlePaneContent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges a partial config with defaults", () => {
|
||||
const p = write(JSON.stringify({ elideHistoricToolResultsThresholdChars: 42 }));
|
||||
expect(loadElisionPolicy(p)).toEqual({
|
||||
...DEFAULT_ELISION_POLICY,
|
||||
toolResultThresholdChars: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to defaults on unparseable JSON", () => {
|
||||
expect(loadElisionPolicy(write("{nope"))).toEqual(DEFAULT_ELISION_POLICY);
|
||||
});
|
||||
|
||||
it("discards the whole file when any key is malformed (all-or-nothing)", () => {
|
||||
const p = write(
|
||||
JSON.stringify({
|
||||
elideHistoricToolResults: false,
|
||||
elideHistoricToolResultsThresholdChars: "not-a-number",
|
||||
}),
|
||||
);
|
||||
expect(loadElisionPolicy(p)).toEqual(DEFAULT_ELISION_POLICY);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -71,14 +71,17 @@ const ContextConfig = z.object({
|
|||
const CONTEXT_CONFIG_PATH = path.join(WorkDir, "config", "context.json");
|
||||
|
||||
// Read the elision policy from config/context.json, falling back to defaults
|
||||
// for missing keys or an unreadable file. Read per resolve so a config edit
|
||||
// applies to the next turn without a restart.
|
||||
export function loadElisionPolicy(): ElisionPolicy {
|
||||
// for missing keys or an unreadable file (a single malformed key discards the
|
||||
// whole file — all-or-nothing by design, so a typo can't half-apply). Read
|
||||
// per resolve so a config edit applies to the next turn without a restart.
|
||||
export function loadElisionPolicy(
|
||||
configPath: string = CONTEXT_CONFIG_PATH,
|
||||
): ElisionPolicy {
|
||||
try {
|
||||
if (!fs.existsSync(CONTEXT_CONFIG_PATH)) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return DEFAULT_ELISION_POLICY;
|
||||
}
|
||||
const raw = fs.readFileSync(CONTEXT_CONFIG_PATH, "utf-8");
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = ContextConfig.parse(JSON.parse(raw));
|
||||
return {
|
||||
toolResults:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue