Skip to content
Open
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
20 changes: 19 additions & 1 deletion packages/client/src/__tests__/agent-briefing.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readdirSync, statSync } from "node:fs";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -536,6 +536,24 @@ describe("buildAgentBriefing — Context Tree", () => {
});

describe("buildAgentBriefing — Skills", () => {
it("renders the canonical welcome routing description once in each family map", () => {
const testFileDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(testFileDir, "..", "..", "..", "..");
const skillMarkdown = readFileSync(join(repoRoot, "skills", "first-tree-welcome", "SKILL.md"), "utf8");
const skillDescription = skillMarkdown.match(/^description:\s*(.*)$/mu)?.[1];

expect(skillDescription).toBeTruthy();
for (const contextTreePath of [null, "/tree"] as const) {
const skillMap = topLevelSection(
buildAgentBriefing(makeOpts({ contextTreePath })),
"# Skills (First Tree Managed)",
);
const welcomeRows = skillMap.split("\n").filter((line) => line.startsWith("| `first-tree-welcome` |"));

expect(welcomeRows).toEqual([`| \`first-tree-welcome\` | ${skillDescription} |`]);
}
});

it("lists only shipped First Tree family skills", () => {
const testFileDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(testFileDir, "..", "..", "..", "..");
Expand Down
12 changes: 10 additions & 2 deletions packages/client/src/runtime/agent-briefing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,8 @@ function firstTreeFamilyMap(contextTreePath: string | null): string {
// agent to load a payload that isn't there; omitting an installed one leaves
// it with no First Tree routing surface. Tests lock this against the
// installer constants and the repo's `skills/` directory.
const welcomeRow = `| \`first-tree-welcome\` | ${FIRST_TREE_WELCOME_LOAD_WHEN} |`;

if (contextTreePath === null) {
// Tree-less: only the core skills are on disk — `first-tree-welcome` plus
// the from-zero build pair (`first-tree-seed` and its `first-tree-write`
Expand All @@ -824,7 +826,7 @@ to build the team's Context Tree from the connected code, load

| Skill | Load when |
|---|---|
| \`first-tree-welcome\` | the onboarding first chat — a natural welcome / "help me get started" message from the user; value-first intro, not a repo scan or tree setup chat |
${welcomeRow}
| \`first-tree-seed\` | set up the team's Context Tree from the connected sources when it has no domain structure yet — creates + binds the repo if none exists, else fills a bound-but-empty tree; refuses once the tree has domain structure |
| \`first-tree-write\` | pulled in by \`first-tree-seed\` as its authoring dependency (source-driven tree writes) |
| \`first-tree-file-bug\` | the user hit a bug in First Tree itself (CLI, runtime, chat, web, GitHub, or tree tooling) and wants it reported — gathers repro + version + chat/user IDs and opens an issue on the first-tree repo via the user's \`gh\` CLI |`;
Expand All @@ -840,13 +842,19 @@ harness skills (\`tdoc\`, \`review\`, \`simplify\`, \`update-config\`,

| Skill | Load when |
|---|---|
| \`first-tree-welcome\` | the onboarding first chat — a natural welcome / "help me get started" message from the user; value-first intro, not a repo scan or tree setup chat |
${welcomeRow}
| \`first-tree-write\` | unconditional (see \`# Required Reading\`) — concept model, source-system boundary, and source-driven tree writes |
| \`first-tree-read\` | read relevant Context Tree files before acting from task / path / feature signals |
| \`first-tree-seed\` | no domain structure yet — bootstrap the team's Context Tree from its sources (create + bind if none exists, else fill a bound-but-empty tree); refuses once the tree has domain structure |
| \`first-tree-file-bug\` | you hit a bug in First Tree itself (CLI, runtime, chat, web, GitHub, or tree tooling) and want it reported — gathers repro + version + chat/user IDs and opens an issue on the first-tree repo via the user's \`gh\` CLI |`;
}

// Keep the welcome routing contract identical in the mutually exclusive
// tree-less and tree-bound briefing variants. The client test binds this text
// to the shipped SKILL.md frontmatter so the routing surfaces cannot drift.
const FIRST_TREE_WELCOME_LOAD_WHEN =
'Use only when the opening message matches a First Tree kickoff shape — "welcome aboard" together with either "Please help me get started with First Tree" or "Please help me get settled into this team on First Tree" — or explicitly asks to "fix the launch blockers found by my production readiness scan". Do not use for dedicated tree setup chats, ordinary chats, PR reviews, repo scans, tree writes, or maintenance.';

/**
* Names of the First Tree skill payloads listed in the Skill Map. Exported
* so the unit test can cross-check against the on-disk `skills/` directory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,50 @@ describe("first-tree-welcome floor invariants", () => {

expect(periodicCases).toHaveLength(10);
expect(periodicCases.every((evalCase) => evalCase.status === "implemented")).toBe(true);
expect(
periodicCases.every((evalCase) => (evalCase.expected as { activation?: unknown }).activation === "preloaded"),
).toBe(true);
expect(periodicCases.some((evalCase) => hasTag(evalCase, "catch-all"))).toBe(false);
});

it("runs exact positive and negative routing shapes as model-backed gate cases", () => {
const liveGateCases = cases.filter(
(evalCase) => evalCase.tier === "gate" && evalCase.status === "implemented",
) as Array<{
expected: { activation?: string };
id: string;
prompt?: string;
}>;
const byId = new Map(liveGateCases.map((evalCase) => [evalCase.id, evalCase]));

const admin = byId.get("first-tree-welcome-admin-trigger");
expect(admin?.expected.activation).toBe("auto-trigger");
expect(admin?.prompt).toContain("welcome aboard");
expect(admin?.prompt).toContain("Please help me get started with First Tree");

const invitee = byId.get("first-tree-welcome-invitee-trigger");
expect(invitee?.expected.activation).toBe("auto-trigger");
expect(invitee?.prompt).toContain("welcome aboard");
expect(invitee?.prompt).toContain("Please help me get settled into this team on First Tree");

const scanFix = byId.get("first-tree-welcome-scan-fix-direct-trigger");
expect(scanFix?.expected.activation).toBe("auto-trigger");
expect(scanFix?.prompt).toContain("fix the launch blockers found by my production readiness scan");
expect(scanFix?.prompt).not.toContain("welcome aboard");

const ordinary = byId.get("first-tree-welcome-ordinary-chat-no-trigger");
expect(ordinary?.expected.activation).toBe("auto-ignore");
expect(ordinary?.prompt).not.toContain("welcome aboard");
expect(ordinary?.prompt).not.toContain("Please help me get started with First Tree");
expect(ordinary?.prompt).not.toContain("Please help me get settled into this team on First Tree");
expect(ordinary?.prompt).not.toContain("fix the launch blockers found by my production readiness scan");

const treeSetup = byId.get("first-tree-welcome-tree-setup-no-trigger");
expect(treeSetup?.expected.activation).toBe("auto-ignore");
expect(treeSetup?.prompt).not.toContain("welcome aboard");
expect(treeSetup?.prompt).not.toContain("fix the launch blockers found by my production readiness scan");
});

it("flags an implemented row whose action has no casePassed branch (orphan)", () => {
// Deliberately break one implemented row's action; `expected` is the schema's
// generic `unknown`, so a plain override is type-safe here.
Expand All @@ -47,6 +88,24 @@ describe("first-tree-welcome floor invariants", () => {
expect(validateFloor(broken).some((error) => error.includes("orphan"))).toBe(true);
});

it("rejects an auto-trigger routing case without an exact kickoff opener", () => {
const broken = cases.map((evalCase) =>
evalCase.id === "first-tree-welcome-admin-trigger" ? { ...evalCase, prompt: "Welcome the user." } : evalCase,
);

expect(validateFloor(broken).some((error) => error.includes("does not match an exact welcome kickoff"))).toBe(true);
});

it("rejects a periodic matrix row that claims automatic activation", () => {
const broken = cases.map((evalCase) =>
evalCase.tier === "periodic"
? { ...evalCase, expected: { ...(evalCase.expected as Record<string, unknown>), activation: "auto-trigger" } }
: evalCase,
);

expect(validateFloor(broken).some((error) => error.includes("periodic matrix rows must use preloaded"))).toBe(true);
});

it("flags an implemented periodic row whose action has no casePassed branch (orphan)", () => {
const broken = cases.map(
(evalCase): SkillEvalCase =>
Expand Down Expand Up @@ -133,6 +192,12 @@ describe("first-tree-welcome floor invariants", () => {
expect(skillMarkdown, `skill should reference the real kickoff opener: "${opener}"`).toContain(opener);
expect(bootstrapProse, `bootstrap-prose.ts should still ship the kickoff opener: "${opener}"`).toContain(opener);
}

const description = skillMarkdown.match(/^description:\s*(.*)$/m)?.[1] ?? "";
expect(description).toContain('"welcome aboard" together with either');
expect(description).toContain('or explicitly asks to "fix the launch blockers');
expect(description).not.toContain("natural opening messages");
expect(skillMarkdown).toContain("Do not infer onboarding from the chat being new or from a generic greeting.");
});

it("keeps the OpenAI/Codex routing metadata description in sync with SKILL.md", () => {
Expand All @@ -153,18 +218,20 @@ describe("first-tree-welcome floor invariants", () => {
expect(yamlDescription).toContain("repo scans");
});

it("hardens both agent-briefing welcome skill-map rows with the scan / tree-setup exclusion", () => {
// agent-briefing.ts ships TWO `first-tree-welcome` "Load when" rows (the
// tree-less and tree-bound briefing variants) — routing hints the agent
// reads. If either omits the scan / tree-setup exclusion it can misroute a
// scan-first chat into the welcome launcher. Bind both so neither drifts back
// to an un-hardened hint.
it("keeps one shared agent-briefing welcome definition on exact kickoff shapes", () => {
// The tree-less and tree-bound maps render one shared definition. Keep the
// source-level floor focused on the exact trigger; the client rendering test
// binds both generated rows to SKILL.md and checks each appears once.
const briefing = readFileSync(join(process.cwd(), "../client/src/runtime/agent-briefing.ts"), "utf8");
const hardenedRows = briefing.match(/first-tree-welcome.*not a repo scan or tree setup chat/g) ?? [];
expect(hardenedRows.length, "both welcome skill-map rows must carry the scan/tree-setup exclusion").toBe(2);
const skillDescription = skillMarkdown.match(/^description:\s*(.*)$/m)?.[1] ?? "";
const sharedDefinitionCount = briefing.split(skillDescription).length - 1;

expect(skillDescription, "SKILL.md must declare a description").not.toBe("");
expect(sharedDefinitionCount, "the briefing source must define the canonical welcome description once").toBe(1);
// The retired un-hardened hints must be gone.
expect(briefing).not.toContain("onboarding welcome / intro / value-first first chat");
expect(briefing).not.toContain("onboarding system messages ask for welcome");
expect(briefing).not.toContain('a natural welcome / "help me get started"');
});

it("keeps production-scan fix fan-out aligned with the scan's 3-5 blocker contract", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function baseMetrics(overrides: Partial<EvalMetrics>): EvalMetrics {
forbiddenClaimHits: [],
forbiddenSideEffectHits: [],
fixtureValidationOk: true,
repoAccessCheckedObserved: false,
repoEvidenceReadObserved: false,
runnerExitCode: 0,
skillFileReadObserved: true,
Expand Down Expand Up @@ -105,6 +106,47 @@ function repoEvidenceReadEvent(): unknown {
}

describe("first-tree-welcome grader", () => {
it("passes an ordinary chat only when welcome stays untriggered", () => {
const evalCase = findCase("first-tree-welcome-ordinary-chat-no-trigger");
const metrics = baseMetrics({
finalResponse: "你好,有什么想聊的?",
skillFileReadObserved: false,
});

expect(casePassed(evalCase, metrics)).toBe(true);
expect(buildGrading(evalCase, metrics, true).scores).toEqual({
outcome_pass: true,
process_pass: true,
risk_pass: true,
routing_pass: true,
});
});

it("fails an ordinary chat when welcome is read or welcome side effects begin", () => {
const evalCase = findCase("first-tree-welcome-ordinary-chat-no-trigger");
const metrics = baseMetrics({
chatAskCount: 1,
repoEvidenceReadObserved: true,
skillFileReadObserved: true,
taskOptionsObserved: true,
});

expect(casePassed(evalCase, metrics)).toBe(false);
expect(buildGrading(evalCase, metrics, false).scores.routing_pass).toBe(false);
});

it("passes the greeting-free scan-fix opener when welcome handles the exact handoff", () => {
expect(
casePassed(
findCase("first-tree-welcome-scan-fix-direct-trigger"),
baseMetrics({
finalResponse: "Repository access works. Please share the report or re-run the scan.",
repoAccessCheckedObserved: true,
}),
),
).toBe(true);
});

it("passes row 1 when the model routes tree kickoff to the tree setup lane", () => {
expect(
casePassed(
Expand Down
Loading