Files
PaperClipAI/server/src/__tests__/company-skill-test-runs-service.test.ts
T
b13eb5b2b5 Skill Studio: three-pane skill IDE with sandboxed test runs (#9241)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Skills Manager gives operators a reusable skill layer, but
iteration still required manual edits, ad hoc prompts, and indirect run
inspection.
> - Skill authors need a focused workflow for editing skill files,
saving representative test inputs, and running those inputs through an
agent without exposing harness tasks as normal company work.
> - The backend therefore needs durable test inputs, reusable run
templates, hidden harness issues, scoped run execution, retention
metadata, and read-containment rules around hidden work.
> - The frontend needs a three-pane Studio that keeps skill files, saved
inputs/templates, and run output/history visible together while
preserving the existing design system and token rules.
> - This pull request ships that Skill Studio surface end to end:
database migrations, shared contracts, server APIs/services, hidden
harness execution behavior, UI routes/components, and focused tests.
> - The benefit is faster and safer skill iteration, with inspectable
outputs and fewer ways for internal harness work to leak into normal
task lists, costs, or adjacent read APIs.

## Linked Issues or Issue Description

No public GitHub issue exists for this feature. Feature request summary:

- Problem: Skill authors need to edit and test company skills in one
place instead of switching between the skill detail page, task creation,
run output, and manual prompt history.
- Proposed solution: Add a Skill Studio workbench with saved inputs,
reusable templates, hidden sandboxed test runs, live run status, output
inspection, run history, rerun/delete controls, and frontmatter-aware
editing.
- Expected users: Paperclip operators and agent-company maintainers who
create, fork, import, and tune skills.
- Related public PRs: Supersedes #9205, which was replaced so the public
PR branch name follows contributor policy.
- Duplicate search: searched public GitHub issues and PRs for "Skill
Studio"; no other active public issue or PR directly covers this
feature.

## What Changed

- Added database migrations for Skill Studio test inputs, test runs,
test run retention, and reusable run templates.
- Added shared Skill Studio types, validators, route helpers,
frontmatter utilities, and status handling.
- Added server services and routes for saved inputs, test runs,
templates, reruns, terminal-run deletion, hidden harness issue
execution, and run-detail hydration.
- Strengthened hidden-issue read containment across issue-adjacent
routes and cost rollups used by skill test harness work.
- Added the Skill Studio UI with skill file editing, frontmatter
editing, saved inputs, templates, run creation/cancel/rerun/delete
flows, output rendering, history, route support, and responsive pane
behavior.
- Added focused backend, shared, and UI tests for the new APIs, routing
logic, editor/run behavior, hidden-issue containment, and migration
safety.
- Rebased onto current `master`, removed the generated lockfile diff
from the PR, and verified no workflow files are changed.

## Verification

- [x] `pnpm --filter @paperclipai/db check:migrations`
- [x] `pnpm check:token-gates`
- [x] `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/company-skill-test-runs-service.test.ts
ui/src/lib/skill-studio.test.ts ui/src/pages/SkillStudio.test.tsx` — 5
files, 132 tests passed
- [x] Greptile review on the latest PR head
- [x] GitHub PR checks on the latest PR head

## Risks

- Medium risk because this is a broad feature touching database schema,
server orchestration, issue visibility, and a large UI surface.
- Hidden harness issue containment is security-sensitive; this PR
includes regression coverage for adjacent read paths and cost rollups.
- The new migrations are additive and use idempotent guards where
applicable, but deployed databases that previously tested draft
migration numbers should still be checked carefully.
- The UI depends on a new resizable panels package in `ui/package.json`;
the lockfile is intentionally left to repository automation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent with shell, git, and GitHub CLI tool
use. Earlier feature commits include assistance from other Paperclip
coding agents; this PR preparation, rebase, cleanup commit, and PR body
were completed by OpenAI Codex in a Paperclip worktree.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:08:56 -05:00

754 lines
27 KiB
TypeScript

import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
assets,
companies,
companySkillTestRunTemplates,
companySkillTestRuns,
companySkills,
createDb,
documents,
issueAttachments,
issueDocuments,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { companySkillService } from "../services/company-skills.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company skill test run tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("companySkillService skill test runs", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof companySkillService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const cleanupDirs = new Set<string>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skill-test-runs-");
db = createDb(tempDb.connectionString);
svc = companySkillService(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueAttachments);
await db.delete(issueWorkProducts);
await db.delete(assets);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(companySkillTestRuns);
await db.delete(companySkillTestRunTemplates);
await db.delete(issues);
await db.delete(agents);
await db.delete(companySkills);
await db.delete(companies);
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedSkillAndAgent() {
const companyId = randomUUID();
const skillId = randomUUID();
const agentId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-test-run-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Review Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Tester",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/review`,
slug: "review",
name: "Review Skill",
description: null,
markdown: "# Review Skill\n",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { sourceKind: "managed_local" },
});
return { companyId, skillId, agentId };
}
const runDeps = (companyId: string) => ({
createHarnessIssue: async (issue: Parameters<Parameters<typeof svc.createTestRun>[4]["createHarnessIssue"]>[0]) => {
await db.insert(issues).values({ ...issue, companyId, priority: "medium" });
return { id: issue.id };
},
wakeHarnessIssue: async () => null,
retentionDays: 7,
});
it("cleans up the harness issue if persisting the test run fails", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const cleanedIssueIds: string[] = [];
let createdIssueId: string | null = null;
let wakeCalls = 0;
await expect(
svc.createTestRun(
companyId,
skillId,
{ content: "test this skill", agentId },
{ type: "user", userId: "local-board" },
{
createHarnessIssue: async (issue) => {
createdIssueId = issue.id;
await db.insert(issues).values({ ...issue, companyId, priority: "medium" });
return { id: issue.id };
},
wakeHarnessIssue: async () => {
wakeCalls += 1;
},
cleanupHarnessIssue: async (issueId) => {
cleanedIssueIds.push(issueId);
await db
.update(issues)
.set({ status: "cancelled", hiddenAt: new Date() })
.where(eq(issues.id, issueId));
},
retentionDays: Number.NaN,
},
),
).rejects.toThrow();
expect(createdIssueId).toBeTruthy();
expect(cleanedIssueIds).toEqual([createdIssueId]);
expect(wakeCalls).toBe(0);
const issue = await db
.select({ status: issues.status, hiddenAt: issues.hiddenAt })
.from(issues)
.where(eq(issues.id, createdIssueId!))
.then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("cancelled");
expect(issue?.hiddenAt).toBeInstanceOf(Date);
});
it("appends the built-in default template while keeping the input snapshot clean", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const run = await svc.createTestRun(
companyId,
skillId,
{ content: "test this skill", agentId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
expect(run.inputSnapshot).toBe("test this skill");
expect(run.templateId).toBe("built-in:default-test-template");
expect(run.templateName).toBe("Default test template");
expect(run.templateBody).toContain("{{skillName}}");
expect(run.renderedTemplateBody).toContain("Skills Studio test for `Review Skill`");
expect(run.renderedTemplateBody).toContain(`company/${companyId}/review`);
expect(run.renderedTemplateBody).toContain("issue document `output`");
expect(run.harnessIssueDescription).toBe(`test this skill\n\n---\n\n${run.renderedTemplateBody}`);
const issue = await db
.select({ description: issues.description })
.from(issues)
.where(eq(issues.id, run.issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.description).toBe(run.harnessIssueDescription);
});
it("honors No template without weakening the clean run snapshot", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const run = await svc.createTestRun(
companyId,
skillId,
{ content: "test this skill", agentId, templateId: null },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
expect(run.inputSnapshot).toBe("test this skill");
expect(run.templateId).toBeNull();
expect(run.templateName).toBeNull();
expect(run.templateBody).toBeNull();
expect(run.renderedTemplateBody).toBeNull();
expect(run.harnessIssueDescription).toBe("test this skill");
const issue = await db
.select({ description: issues.description })
.from(issues)
.where(eq(issues.id, run.issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.description).toBe("test this skill");
});
it("manages custom templates and renders only explicit placeholders", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const template = await svc.createTestRunTemplate(companyId, {
name: "Focused smoke",
description: "Short run",
body: "Run {{skillName}} v{{skillVersion}} for {{runId}} on {{issueId}} into {{outputDocumentKey}}.",
}, { type: "user", userId: "local-board" });
const listed = await svc.listTestRunTemplates(companyId);
expect(listed.map((entry) => entry.id)).toEqual(["built-in:default-test-template", template.id]);
expect(listed[0]?.builtIn).toBe(true);
const run = await svc.createTestRun(
companyId,
skillId,
{ content: "custom template run", agentId, templateId: template.id },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
expect(run.templateId).toBe(template.id);
expect(run.templateName).toBe("Focused smoke");
expect(run.renderedTemplateBody).toContain("Run Review Skill v1");
expect(run.renderedTemplateBody).toContain(run.id);
expect(run.renderedTemplateBody).toContain(run.issueId);
expect(run.renderedTemplateBody).toContain("into output");
await expect(
svc.createTestRunTemplate(companyId, {
name: "Bad template",
body: "Use {{unknownPlaceholder}}.",
}, { type: "user", userId: "local-board" }),
).rejects.toThrow(/unknown template placeholder/i);
const updated = await svc.updateTestRunTemplate(companyId, template.id, {
name: "Focused smoke v2",
body: "Use {{skillKey}}.",
}, { type: "user", userId: "local-board" });
expect(updated?.name).toBe("Focused smoke v2");
expect(updated?.body).toBe("Use {{skillKey}}.");
await expect(
svc.updateTestRunTemplate(companyId, "built-in:default-test-template", { name: "Changed" }),
).rejects.toThrow(/read-only/i);
const deleted = await svc.deleteTestRunTemplate(companyId, template.id);
expect(deleted?.deletedAt).toBeInstanceOf(Date);
expect((await svc.listTestRunTemplates(companyId)).map((entry) => entry.id)).toEqual([
"built-in:default-test-template",
]);
});
it("rejects unknown or cross-company template ids", async () => {
const first = await seedSkillAndAgent();
const second = await seedSkillAndAgent();
const otherTemplate = await svc.createTestRunTemplate(second.companyId, {
name: "Other company",
body: "Other {{skillName}}.",
}, { type: "user", userId: "local-board" });
await expect(
svc.createTestRun(
first.companyId,
first.skillId,
{ content: "test", agentId: first.agentId, templateId: randomUUID() },
{ type: "user", userId: "local-board" },
runDeps(first.companyId),
),
).rejects.toThrow(/test run template not found/i);
await expect(
svc.createTestRun(
first.companyId,
first.skillId,
{ content: "test", agentId: first.agentId, templateId: otherTemplate.id },
{ type: "user", userId: "local-board" },
runDeps(first.companyId),
),
).rejects.toThrow(/test run template not found/i);
});
it("re-run can use the viewed template body snapshot after source template edits", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const template = await svc.createTestRunTemplate(companyId, {
name: "Snapshot me",
body: "Original {{skillName}}.",
}, { type: "user", userId: "local-board" });
const first = await svc.createTestRun(
companyId,
skillId,
{ content: "repeatable", agentId, templateId: template.id },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
await svc.updateTestRunTemplate(companyId, template.id, {
body: "Edited {{skillName}}.",
}, { type: "user", userId: "local-board" });
const reRun = await svc.createTestRun(
companyId,
skillId,
{
content: first.inputSnapshot,
agentId: first.agentId,
skillVersionId: first.skillVersionId,
templateSnapshot: {
templateId: first.templateId,
templateName: first.templateName,
templateBody: first.templateBody,
},
},
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
expect(reRun.skillVersionId).toBe(first.skillVersionId);
expect(reRun.templateId).toBe(template.id);
expect(reRun.templateBody).toBe("Original {{skillName}}.");
expect(reRun.renderedTemplateBody).toBe("Original Review Skill.");
expect(reRun.harnessIssueDescription).toContain("Original Review Skill.");
expect(reRun.harnessIssueDescription).not.toContain("Edited Review Skill.");
});
it("only deletes terminal runs and soft-deletes them out of history", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const run = await svc.createTestRun(
companyId,
skillId,
{ content: "test this skill", agentId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
// In-flight run must be cancelled first.
await expect(
svc.deleteTestRun(companyId, skillId, run.id, { hideHarnessIssue: async () => null }),
).rejects.toThrow(/cancel the run/i);
await svc.completeTestRunForIssue({ companyId, issueId: run.issueId, outcome: "succeeded" });
const hidden: string[] = [];
const deleted = await svc.deleteTestRun(companyId, skillId, run.id, {
hideHarnessIssue: async (issueId) => {
hidden.push(issueId);
},
});
expect(deleted?.id).toBe(run.id);
expect(deleted?.harnessIssueDeletedAt).toBeInstanceOf(Date);
expect(deleted?.taskExpired).toBe(true);
expect(hidden).toEqual([run.issueId]);
// Gone from listings and detail.
expect(await svc.listTestRuns(companyId, skillId)).toHaveLength(0);
expect(await svc.getTestRunDetail(companyId, skillId, run.id)).toBeNull();
// Deleting again is a no-op 404 (returns null).
expect(
await svc.deleteTestRun(companyId, skillId, run.id, { hideHarnessIssue: async () => null }),
).toBeNull();
});
it("re-run pins an explicit skill version instead of the live head", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const first = await svc.createTestRun(
companyId,
skillId,
{ content: "first run", agentId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
const pinnedVersionId = first.skillVersionId;
const reRun = await svc.createTestRun(
companyId,
skillId,
{ content: "first run", agentId, skillVersionId: pinnedVersionId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
expect(reRun.skillVersionId).toBe(pinnedVersionId);
// A bogus version id is rejected rather than silently falling back to head.
await expect(
svc.createTestRun(
companyId,
skillId,
{ content: "first run", agentId, skillVersionId: randomUUID() },
{ type: "user", userId: "local-board" },
runDeps(companyId),
),
).rejects.toThrow(/skill version not found/i);
});
it("ignores superseded test harness issue transitions", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const first = await svc.createTestRun(
companyId,
skillId,
{ content: "first run", agentId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
const replacement = await svc.createTestRun(
companyId,
skillId,
{ content: "replacement run", agentId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
const listed = await svc.listTestRuns(companyId, skillId);
expect(listed.map((run) => run.id)).toEqual([replacement.id, first.id]);
expect(listed.find((run) => run.id === first.id)?.supersededAt).toBeInstanceOf(Date);
expect(await svc.getTestRunDetail(companyId, skillId, first.id)).not.toBeNull();
expect(await svc.markTestRunRunning(companyId, first.issueId)).toBeNull();
expect(await svc.completeTestRunForIssue({
companyId,
issueId: first.issueId,
outcome: "succeeded",
})).toBeNull();
const cancelledIssueIds: string[] = [];
expect(await svc.cancelTestRun(companyId, skillId, first.id, {
cancelHarnessIssue: async (issueId) => {
cancelledIssueIds.push(issueId);
},
})).toBeNull();
expect(cancelledIssueIds).toEqual([]);
const firstRow = await db
.select({
status: companySkillTestRuns.status,
error: companySkillTestRuns.error,
supersededAt: companySkillTestRuns.supersededAt,
outputSnapshot: companySkillTestRuns.outputSnapshot,
})
.from(companySkillTestRuns)
.where(eq(companySkillTestRuns.id, first.id))
.then((rows) => rows[0] ?? null);
expect(firstRow?.status).toBe("cancelled");
expect(firstRow?.error).toBe("Superseded by newer run");
expect(firstRow?.supersededAt).toBeInstanceOf(Date);
expect(firstRow?.outputSnapshot).toBe("");
const hiddenIssueIds: string[] = [];
const deletedFirst = await svc.deleteTestRun(companyId, skillId, first.id, {
hideHarnessIssue: async (issueId) => {
hiddenIssueIds.push(issueId);
},
});
expect(deletedFirst?.id).toBe(first.id);
expect(hiddenIssueIds).toEqual([first.issueId]);
expect((await svc.listTestRuns(companyId, skillId)).map((run) => run.id)).toEqual([replacement.id]);
const runningReplacement = await svc.markTestRunRunning(companyId, replacement.issueId);
expect(runningReplacement?.status).toBe("running");
});
it("snapshots output and keeps run history after harness issue retention", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const agentId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-test-run-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Review Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Tester",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {
model: "gpt-5.4",
paperclipSkillSync: { desiredSkills: [`company/${companyId}/review`] },
instructionsFilePath: "/tmp/AGENTS.md",
},
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/review`,
slug: "review",
name: "Review Skill",
description: null,
markdown: "# Review Skill\n",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { sourceKind: "managed_local" },
});
const input = await svc.createTestInput(companyId, skillId, {
name: "cases/simple",
content: "Try the review skill",
}, { type: "user", userId: "local-board" });
const run = await svc.createTestRun(companyId, skillId, {
inputId: input.id,
agentId,
}, { type: "user", userId: "local-board" }, {
createHarnessIssue: async (issue) => {
await db.insert(issues).values({
...issue,
companyId,
priority: "medium",
});
return { id: issue.id };
},
wakeHarnessIssue: async () => null,
retentionDays: 0,
});
expect(run.skillVersionId).toMatch(/[0-9a-f-]{36}/);
expect(run.inputSnapshot).toBe("Try the review skill");
expect(run.agentConfigSnapshot).toEqual(expect.objectContaining({
adapterType: "codex_local",
model: "gpt-5.4",
instructionsRef: "/tmp/AGENTS.md",
}));
const documentId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId,
title: "Output",
format: "markdown",
latestBody: "## Result\n\nThe skill responded.",
createdByAgentId: agentId,
updatedByAgentId: agentId,
});
await db.insert(issueDocuments).values({
companyId,
issueId: run.issueId,
documentId,
key: "output",
});
const completed = await svc.completeTestRunForIssue({
companyId,
issueId: run.issueId,
outcome: "succeeded",
});
expect(completed?.status).toBe("succeeded");
expect(completed?.outputSnapshot).toBe("## Result\n\nThe skill responded.");
await db
.update(companySkillTestRuns)
.set({ harnessIssueExpiresAt: new Date(Date.now() - 60_000) })
.where(eq(companySkillTestRuns.id, run.id));
const pruned = await svc.pruneExpiredTestHarnessIssues(companyId);
expect(pruned.pruned).toBe(1);
const detail = await svc.getTestRunDetail(companyId, skillId, run.id);
expect(detail?.taskExpired).toBe(true);
expect(detail?.harnessIssue).toBeNull();
expect(detail?.outputSnapshot).toBe("## Result\n\nThe skill responded.");
expect(detail?.outputBody).toBe("## Result\n\nThe skill responded.");
expect(detail?.harnessContent).toEqual({
available: false,
unavailableReason: "expired",
documents: [],
attachments: [],
workProducts: [],
});
});
it("hydrates rich documents, attachments, and work products scoped to the run's harness issue", async () => {
const { companyId, skillId, agentId } = await seedSkillAndAgent();
const run = await svc.createTestRun(
companyId,
skillId,
{ content: "produce rich output", agentId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
// Sibling run in the same company whose content must not leak into `run`'s detail.
const otherRun = await svc.createTestRun(
companyId,
skillId,
{ content: "unrelated run", agentId },
{ type: "user", userId: "local-board" },
runDeps(companyId),
);
async function seedIssueContent(issueId: string, marker: string) {
const documentId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId,
title: `Output ${marker}`,
format: "markdown",
latestBody: `## Result ${marker}`,
createdByAgentId: agentId,
updatedByAgentId: agentId,
});
await db.insert(issueDocuments).values({ companyId, issueId, documentId, key: "output" });
const assetId = randomUUID();
await db.insert(assets).values({
id: assetId,
companyId,
provider: "local",
objectKey: `skill-tests/${marker}.png`,
contentType: "image/png",
byteSize: 2048,
sha256: marker.repeat(8).slice(0, 64).padEnd(64, "0"),
originalFilename: `${marker}.png`,
createdByAgentId: agentId,
});
const attachmentId = randomUUID();
await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId, assetId });
const workProductId = randomUUID();
await db.insert(issueWorkProducts).values({
id: workProductId,
companyId,
issueId,
type: "artifact",
provider: "paperclip",
title: `Artifact ${marker}`,
status: "active",
summary: `Generated ${marker}`,
metadata: {
attachmentId,
contentType: "image/png",
byteSize: 2048,
contentPath: `/api/attachments/${attachmentId}/content`,
originalFilename: `${marker}.png`,
},
});
return { documentId, attachmentId, workProductId };
}
const mine = await seedIssueContent(run.issueId, "mine");
await seedIssueContent(otherRun.issueId, "other");
const detail = await svc.getTestRunDetail(companyId, skillId, run.id);
expect(detail).not.toBeNull();
expect(detail?.harnessContent.available).toBe(true);
expect(detail?.harnessContent.unavailableReason).toBeNull();
expect(detail?.harnessContent.documents).toHaveLength(1);
const doc = detail!.harnessContent.documents[0]!;
expect(doc).toEqual(expect.objectContaining({
id: mine.documentId,
companyId,
issueId: run.issueId,
key: "output",
title: "Output mine",
format: "markdown",
body: "## Result mine",
createdByAgentId: agentId,
}));
expect(typeof doc.latestRevisionNumber).toBe("number");
expect(doc.createdAt).toBeInstanceOf(Date);
expect(doc.updatedAt).toBeInstanceOf(Date);
expect("sourceTrust" in doc).toBe(true);
expect(detail?.harnessContent.attachments).toHaveLength(1);
const attachment = detail!.harnessContent.attachments[0]!;
expect(attachment).toEqual(expect.objectContaining({
id: mine.attachmentId,
companyId,
issueId: run.issueId,
contentType: "image/png",
byteSize: 2048,
originalFilename: "mine.png",
contentPath: `/api/attachments/${mine.attachmentId}/content`,
openPath: `/api/attachments/${mine.attachmentId}/content`,
downloadPath: `/api/attachments/${mine.attachmentId}/content?download=1`,
}));
expect(detail?.harnessContent.workProducts).toHaveLength(1);
const workProduct = detail!.harnessContent.workProducts[0]!;
expect(workProduct).toEqual(expect.objectContaining({
id: mine.workProductId,
companyId,
issueId: run.issueId,
type: "artifact",
provider: "paperclip",
title: "Artifact mine",
summary: "Generated mine",
}));
expect(workProduct.metadata).toEqual(expect.objectContaining({
attachmentId: mine.attachmentId,
contentType: "image/png",
byteSize: 2048,
originalFilename: "mine.png",
}));
// Compatibility summaries stay in sync with the rich collections.
expect(detail?.documents).toEqual([
expect.objectContaining({ key: "output", title: "Output mine", body: "## Result mine" }),
]);
expect(detail?.artifacts).toEqual(expect.arrayContaining([
expect.objectContaining({ id: mine.attachmentId, kind: "attachment", title: "mine.png" }),
expect.objectContaining({ id: mine.workProductId, kind: "work_product", title: "Artifact mine" }),
]));
expect(detail?.artifacts).toHaveLength(2);
});
it("keeps hydration company-scoped and reports a deleted harness issue", async () => {
const first = await seedSkillAndAgent();
const second = await seedSkillAndAgent();
const run = await svc.createTestRun(
first.companyId,
first.skillId,
{ content: "scoped run", agentId: first.agentId },
{ type: "user", userId: "local-board" },
runDeps(first.companyId),
);
// Cross-company access never resolves another company's run.
expect(await svc.getTestRunDetail(second.companyId, first.skillId, run.id)).toBeNull();
expect(await svc.getTestRunDetail(second.companyId, second.skillId, run.id)).toBeNull();
// Harness issue marked deleted outside the retention path -> clear "deleted"
// state, while stored run snapshots stay usable.
await db
.update(companySkillTestRuns)
.set({ harnessIssueDeletedAt: new Date() })
.where(eq(companySkillTestRuns.id, run.id));
const detail = await svc.getTestRunDetail(first.companyId, first.skillId, run.id);
expect(detail).not.toBeNull();
expect(detail?.harnessIssue).toBeNull();
expect(detail?.inputSnapshot).toBe("scoped run");
expect(detail?.harnessContent).toEqual({
available: false,
unavailableReason: "deleted",
documents: [],
attachments: [],
workProducts: [],
});
});
});