Files
kandev/apps/web/e2e/tests/office/agent-launch-context.spec.ts
T
2026-05-16 20:07:57 +01:00

181 lines
7.2 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { test, expect } from "../../fixtures/office-fixture";
/**
* What an office agent actually receives at launch.
*
* This is the regression net for the "agents stop getting <X>"
* class of bug. The asserts cover the three legs an agent depends
* on:
*
* 1. Bundled system SKILL.md files materialised under the
* worktree's per-agent skill dir (`.agents/skills/kandev-*`).
* 2. `KANDEV_CLI` env contract — the binary exists at the path
* the runtime injects, and `agentctl --help` succeeds.
* 3. Run detail's `runtime.skills` snapshot records the same
* slugs that landed on disk (proves the dispatcher actually
* took the snapshot, not just that the file deployer ran).
*
* If any of those break silently, the CEO gets garbage prompts /
* a broken CLI / missing skills and the user only notices when
* the org grinds to a halt.
*/
const BACKEND_DIR = path.resolve(__dirname, "../../../../../apps/backend");
const AGENTCTL_BIN = path.join(BACKEND_DIR, "bin", "agentctl");
test.describe("Office agent launch context", () => {
test("bundled system skills materialise on the agent's worktree at launch", async ({
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// 1. Prime the bundled system-skill sync for the seedData workspace
// (the lazy sync runs on the first /skills list). The kanban
// seed workspace shares the same lazy-sync path as office.
const primingRes = await apiClient.rawRequest(
"GET",
`/api/v1/office/workspaces/${seedData.workspaceId}/skills`,
);
expect(primingRes.ok).toBe(true);
const primed = (await primingRes.json()) as { skills?: Array<{ slug: string }> };
const slugs = (primed.skills ?? []).map((s) => s.slug);
expect(slugs).toContain("kandev-protocol");
// 2. Attach the bundled slug to the seed agent's desired_skills.
// The runtime materializer resolves slugs against the
// workspace skill registry at session start.
await apiClient.setProfileDesiredSkills(seedData.agentProfileId, ["kandev-protocol"]);
try {
// 3. Launch a real session.
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Launch context — bundled skills",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// 4. Wait for the task's worktree path to settle.
let worktreePath = "";
await expect
.poll(
async () => {
const env = await apiClient.getTaskEnvironment(task.id);
worktreePath = env?.workspace_path ?? env?.worktree_path ?? "";
return worktreePath;
},
{ timeout: 30_000, message: "task environment workspace_path never appeared" },
)
.not.toBe("");
// 5. The bundled SKILL.md landed at the agent-type-specific
// skill dir. mock-agent doesn't declare a custom
// ProjectSkillDir, so the default ".agents/skills" applies.
const skillFile = path.join(
worktreePath,
".agents",
"skills",
"kandev-kandev-protocol",
"SKILL.md",
);
await expect
.poll(() => fs.existsSync(skillFile), {
timeout: 15_000,
message: skillFile,
})
.toBe(true);
// Frontmatter from internal/office/configloader/skills/kandev-protocol
// — pin that the bundled body actually reached the worktree, not
// just an empty file.
const content = fs.readFileSync(skillFile, "utf8");
expect(content).toMatch(/kandev/i);
} finally {
// Tidy up so the worker's next test doesn't inherit the attach.
await apiClient.setProfileDesiredSkills(seedData.agentProfileId, []);
}
});
test("agentctl binary referenced by KANDEV_CLI is invocable", async () => {
// The runtime injects `KANDEV_CLI` pointing at the host's
// agentctl binary. Confirm the binary at the expected path
// exists and responds to a no-arg invocation — failure here
// means a CI run won't have a working CLI surface at all.
expect(fs.existsSync(AGENTCTL_BIN), `${AGENTCTL_BIN} must exist`).toBe(true);
const probe = spawnSync(AGENTCTL_BIN, ["kandev"], { encoding: "utf8", timeout: 5_000 });
// `agentctl kandev` with no subcommand prints a usage banner and
// exits 1. That's enough proof the binary loaded.
expect(probe.status).toBe(1);
expect(probe.stderr).toMatch(/Usage: agentctl kandev/);
expect(probe.stderr).toMatch(/agents/);
expect(probe.stderr).toMatch(/tasks/);
expect(probe.stderr).toMatch(/routines/);
expect(probe.stderr).toMatch(/approvals/);
});
test("office workspace exposes a populated runtime skill snapshot on a seeded run", async ({
apiClient,
officeSeed,
}) => {
// Listing skills in the office workspace triggers the lazy
// per-workspace system-skill sync so the bundled set is
// present before we seed the run. The same path runs on the
// first UI list, so this just mirrors what the user sees.
const list = (await apiClient.rawRequest(
"GET",
`/api/v1/office/workspaces/${officeSeed.workspaceId}/skills`,
)) as Response;
expect(list.ok).toBe(true);
const listBody = (await list.json()) as { skills?: Array<Record<string, unknown>> };
const slugs = (listBody.skills ?? []).map((s) => s.slug as string);
expect(slugs).toContain("kandev-protocol");
// Find the bundled `kandev-protocol` skill id for the snapshot.
const protocolSkill = (listBody.skills ?? []).find((s) => s.slug === "kandev-protocol") as
| { id: string; content_hash?: string }
| undefined;
expect(protocolSkill?.id).toBeTruthy();
// Seed a run, then seed a run_skills snapshot for it referencing
// the bundled kandev-protocol row. Real launches do this via the
// dispatcher; we replicate the row directly so we can assert the
// UI reads it back the way the agent stored it.
const run = await apiClient.seedRun({
agentProfileId: officeSeed.agentId,
reason: "routine_dispatch",
status: "finished",
});
await apiClient.seedRunSkillSnapshot({
runId: run.run_id,
skillId: protocolSkill!.id,
version: "v1",
contentHash: protocolSkill!.content_hash ?? "test-hash",
materializedPath: "/tmp/run-skills/kandev-protocol/SKILL.md",
});
// Hit the office run detail endpoint and confirm the snapshot is
// serialised back in the `runtime.skills` payload — that's what
// the UI's Runtime panel renders.
const detailRes = await apiClient.rawRequest(
"GET",
`/api/v1/office/agents/${officeSeed.agentId}/runs/${run.run_id}`,
);
expect(detailRes.ok).toBe(true);
const detail = (await detailRes.json()) as {
runtime?: { skills?: Array<{ skill_id: string; version?: string }> };
};
const recordedIds = (detail.runtime?.skills ?? []).map((s) => s.skill_id);
expect(recordedIds).toContain(protocolSkill!.id);
});
});