Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/agent/profiles-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
currentProfileSnapshotRevision,
loadAgentProfiles,
loadAgentProfilesWithDiagnostics,
} from "./profiles.js";

async function makeAgentsDir(files: Record<string, string>): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "cl9476-profiles-"));
for (const [name, content] of Object.entries(files)) {
await writeFile(join(dir, name), content);
}
return dir;
}

const VALID_PROFILE = JSON.stringify({
id: "local-reader",
systemPromptRole: "You read files.",
capabilities: { mode: "allow", tools: ["read_file"] },
});

describe("loadAgentProfilesWithDiagnostics", () => {
test("malformed file is reported and never poisons the load", async () => {
const dir = await makeAgentsDir({
"good.json": VALID_PROFILE,
"broken.json": "{ not valid json",
"notes.txt": "ignored, not a profile extension",
});

const { profiles, diagnostics } =
await loadAgentProfilesWithDiagnostics(dir);

expect(profiles.some((p) => p.id === "local-reader")).toBe(true);
expect(diagnostics.malformed).toHaveLength(1);
expect(diagnostics.malformed[0]?.path).toBe(join(dir, "broken.json"));
expect(diagnostics.malformed[0]?.reason).toBe("invalid JSON");
expect(typeof diagnostics.revision).toBe("number");
});

test("schema-invalid file is reported with its reason", async () => {
const dir = await makeAgentsDir({
"bad-shape.json": JSON.stringify({ id: 42 }),
});

const { profiles, diagnostics } =
await loadAgentProfilesWithDiagnostics(dir);

expect(profiles.some((p) => p.id === "local-reader")).toBe(false);
expect(diagnostics.malformed).toHaveLength(1);
expect(diagnostics.malformed[0]?.reason).toBe("schema validation failed");
});

test("loadAgentProfiles delegates and resolves identically", async () => {
const dir = await makeAgentsDir({
"good.json": VALID_PROFILE,
"broken.json": "{ nope",
});

const viaDiagnostics = await loadAgentProfilesWithDiagnostics(dir);
const direct = await loadAgentProfiles(dir);

expect(direct.map((p) => p.id).sort()).toEqual(
viaDiagnostics.profiles.map((p) => p.id).sort(),
);
});

test("every load bumps the snapshot revision", async () => {
const dir = await makeAgentsDir({ "good.json": VALID_PROFILE });
const before = currentProfileSnapshotRevision();

const first = await loadAgentProfilesWithDiagnostics(dir);
const second = await loadAgentProfilesWithDiagnostics(dir);

expect(second.diagnostics.revision).toBeGreaterThan(
first.diagnostics.revision,
);
expect(first.diagnostics.revision).toBeGreaterThan(before);
expect(currentProfileSnapshotRevision()).toBe(second.diagnostics.revision);
});

test("missing directory resolves defaults with no malformed entries", async () => {
const { profiles, diagnostics } = await loadAgentProfilesWithDiagnostics(
join(tmpdir(), "cl9476-does-not-exist"),
);

expect(profiles.length).toBeGreaterThan(0);
expect(diagnostics.malformed).toEqual([]);
});
});
61 changes: 58 additions & 3 deletions src/agent/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ function isENOENT(err: unknown): boolean {
// DIRECTOR_IDS, which are reserved and skipped at load (CL-7015).
const registry: AgentProfile[] = [...defaultPlugin.agents];

// Load diagnostics: additive over loadAgentProfiles. `revision` stamps the
// profile snapshot a dispatch was verified against (agent-fleet records it on
// the session); `malformed` names local files that failed to load and why.
// A malformed file never blocks the load — it is skipped and named here so
// callers can surface it. requires_tools preflight verifies against the
// resolved dispatch capabilities only, never against this list (CL-9476 keeps
// no malformed_profile preflight branch: agent-fleet never plumbed a failing
// source through dispatch, so the branch was dead and has been removed).
export interface MalformedAgentProfile {
path: string;
reason: string;
}

export interface AgentProfileDiagnostics {
revision: number;
malformed: MalformedAgentProfile[];
}

let profileSnapshotRevision = 0;

/** Revision of the most recent profile snapshot load. */
export function currentProfileSnapshotRevision(): number {
return profileSnapshotRevision;
}

// Merge a profile into a list: replace a same-id entry or append. Used to layer
// profiles by precedence (defaults < plugin < local).
function mergeProfileInto(list: AgentProfile[], profile: AgentProfile): void {
Expand All @@ -95,6 +120,28 @@ export async function loadAgentProfiles(
dir: string,
extraProfiles: AgentProfile[] = [],
): Promise<AgentProfile[]> {
return (await loadAgentProfilesWithDiagnostics(dir, extraProfiles)).profiles;
}

/**
* loadAgentProfiles plus load diagnostics. Additive: profiles resolve
* exactly as before; unreadable/unparseable/invalid local files are named in
* `diagnostics.malformed` instead of skipped silently. Every call bumps the
* snapshot revision a dispatch stamps on its session record.
*/
export async function loadAgentProfilesWithDiagnostics(
dir: string,
extraProfiles: AgentProfile[] = [],
): Promise<{ profiles: AgentProfile[]; diagnostics: AgentProfileDiagnostics }> {
profileSnapshotRevision += 1;
const revision = profileSnapshotRevision;
const malformed: MalformedAgentProfile[] = [];
const done = (
profiles: AgentProfile[],
): { profiles: AgentProfile[]; diagnostics: AgentProfileDiagnostics } => ({
profiles,
diagnostics: { revision, malformed },
});
let entries: string[];
try {
entries = await readdir(dir);
Expand All @@ -105,7 +152,7 @@ export async function loadAgentProfiles(
if (isReservedDirectorProfile(p)) continue;
mergeProfileInto(merged, p);
}
return merged;
return done(merged);
}
throw err;
}
Expand All @@ -121,16 +168,24 @@ export async function loadAgentProfiles(
try {
raw = await readFile(filePath, "utf8");
} catch {
malformed.push({ path: filePath, reason: "unreadable file" });
continue;
}
let parsed: unknown;
try {
parsed = isJSON ? JSON.parse(raw) : Bun.YAML.parse(raw);
} catch {
malformed.push({
path: filePath,
reason: isJSON ? "invalid JSON" : "invalid YAML",
});
continue;
}
const result = AgentProfileSchema(parsed);
if (result instanceof type.errors) continue;
if (result instanceof type.errors) {
malformed.push({ path: filePath, reason: "schema validation failed" });
continue;
}
const profile = result as AgentProfile;
if (isReservedDirectorProfile(profile)) continue;
// Resolve systemPromptPath relative to this directory. The file content
Expand Down Expand Up @@ -158,5 +213,5 @@ export async function loadAgentProfiles(
mergeProfileInto(merged, profile);
}
for (const profile of local) mergeProfileInto(merged, profile);
return merged;
return done(merged);
}
Loading
Loading