#3774 · Skill listing retains duplicate content across roots
Verdict: REPRODUCED (skills API listing) · Root-cause confidence: high
1. TL;DR
The skills listing returns two registrations for the same content when one comes from a shared root and another from a provider root. The server combines those groups without content comparison. A focused test against the real HTTP route returned two entries in two separate clean checkouts. Host discovery was simulated; no live provider or user data was used. The claimed context overhead across every provider was not measured.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Identical shared/native registrations appear twice | Verified at API boundary | HTTP 200 followed by a length assertion: expected 1, received 2. |
| Provider assembly deduplicates by path only | Verified | Map keyed by filePath; differing paths survive. |
| Every provider receives repeated descriptions and around 11 KB overhead | Unverified | No live turns or original workspace measurements. Injection has separate name precedence and collision filtering. |
| Previously issued IDs must remain usable after deduplication | Design constraint | Current resolution searches the listing; filtering it would make hidden IDs unreachable unless resolution changes. |
3. Environment
Darwin arm64; Node v22.22.3; Corepack pnpm 9.15.0; repository version above. Both worktrees were created detached at the freshly fetched trusted main commit. Frozen installs completed in both. Turbo server builds completed using cache. A temporary Corepack shim bypassed a broken preinstalled pnpm launcher; no dependency was added or lockfile changed.
The existing test harness uses migrated in-memory SQLite and a new temporary server data directory. Fixture directories are unique mkdtemp directories and removed in finally blocks. Requests run in-process; no HTTP ports, live provider sessions, or production data were used.
4. Minimal reproduction
- Check out the base commit in a clean get-bb/bb checkout.
- Save the complete regression test addition below as regression.ts in that checkout.
- Run:
pnpm install --frozen-lockfile pnpm exec turbo run build --filter=@bb/server cat regression.ts >> apps/server/test/public/public-project-skills.test.ts pnpm exec turbo run test --filter=@bb/server -- test/public/public-project-skills.test.ts --testNamePattern="issue 3774"
The added test uses repository helpers and the real server route. It writes byte-identical files and supplies their registrations through the existing simulated host RPC responder, including identical read responses. It does not execute the host scanner. Expected: one shared-user result. Actual, in both runs:
catalog-check scopes: shared-user, provider-user
AssertionError: expected [ { …(9) }, { …(9) } ] to have a length of 1 but got 2
Test Files 1 failed (1)
Tests 1 failed | 30 skipped (31)Complete regression test addition
it("issue 3774 collapses identical content across shared and native roots", async () => {
const root = await mkdtemp(join(tmpdir(), "skill-list-repro-"));
try {
const content = "---\nname: catalog-check\ndescription: catalog-check skill\n---\nReview the local fixture.\n";
const sharedPath = join(root, "shared", "catalog-check", "SKILL.md");
const nativePath = join(root, "native", "catalog-check", "SKILL.md");
for (const filePath of [sharedPath, nativePath]) {
await mkdir(join(filePath, ".."), { recursive: true });
await writeFile(filePath, content);
}
await withTestHarness({ sharedSkillRoots: { user: ["shared"], project: [] } }, async (harness) => {
const { host, session } = seedHostSession(harness.deps, { id: "host-catalog-check" });
const { project } = seedProjectWithSource(harness.deps, { hostId: host.id, path: root });
const environment = seedEnvironment(harness.deps, { hostId: host.id, projectId: project.id, path: root });
registerSkillRpc(harness, {
hostId: host.id,
sessionId: session.id,
skillsByProvider: {
"bb-shared": [discovered("catalog-check", "shared-user", sharedPath)],
"codex": [discovered("catalog-check", "provider-user", nativePath)],
},
fileContentsByRoot: {
[join(root, "shared", "catalog-check")]: content,
[join(root, "native", "catalog-check")]: content,
},
});
const response = await harness.app.request(`/api/v1/projects/${project.id}/skills?environmentId=${environment.id}`);
expect(response.status).toBe(200);
const body = skillListResponseSchema.parse(await readJson(response));
const matches = body.skills.filter((skill) => skill.name === "catalog-check");
console.log("catalog-check scopes:", matches.map((skill) => skill.scope).join(", "));
expect(matches).toHaveLength(1);
expect(matches[0]?.scope).toBe("shared-user");
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
5. Root cause
listProjectSkills scans provider roots and shared roots separately, then returns a concatenation of provider, shared, server-owned and plugin summaries. assembleSkillList keys its map by filePath, not bytes, and only processes the provider discovery group. Sorting the final result changes order but removes nothing.
return [ ...assembleSkillList(perProvider), ...sharedSkills.summaries, ...listServerOwnedSkills(deps), ...listBbPluginSkills(deps), ].sort(compareSkillSummaries);
DiscoveredSkill transmits ID, name, description, file path, root kind and linked status; it carries no content identity. The public route returns this list, and the CLI consumes skills.list. The CLI executable itself was not run.
resolveProjectSkill searches this same list by ID. A presentation-only filter introduced here without separate registration resolution risks read/write/delete failures for hidden IDs. Injected skills have distinct name-based precedence and collision handling; listing evidence alone does not prove every injected catalog duplicates these entries.
6. Proposed fix and automation decision
Define content identity and scope precedence explicitly, retain the full registration collection for ID operations, and deduplicate only the displayed catalog. Investigate supplying content fingerprints from discovery with the required daemon compatibility handling. Identical entry files can reference different sibling resources, so scope and resource semantics deserve review. Do not merge solely by name. Add tests for identical content, different bodies with the same name, resource differences, and resolution of every retained ID.
No automatic fix PR: the complete change requires protocol/compatibility work or a product decision about remote content reads and precedence, outside the simple-fix rule. No production code was changed or pushed. GitHub cross-reference metadata and an open-PR search found no linked open PR. No external prototype was fetched or executed.
7. Verification
The same agent repeated the final test in a second clean detached checkout at the same base commit, with its own install, build and newly allocated temporary data/fixture directories. Both runs reached HTTP 200 and failed only the expected one-result assertion with two entries. This is repeat verification by the same agent, not an independent review. An early fixture incorrectly used an absolute declared root and failed validation; it was corrected to a relative root before both final runs. The report makes no bug claim from that fixture error.
8. Related issues
#1188 concerns shared-root duplication; #2602 concerns provider command discovery. Neither substitutes for the direct test above.
9. Appendix
Existing focused validation: 47 tests passed across the skills route and skill-listing suites, with the new expected-failure test excluded. Command:
pnpm exec turbo run test --filter=@bb/server -- test/public/public-project-skills.test.ts test/skills/skill-listing.test.ts '--testNamePattern=^(?!.*issue 3774)'
A final fetch found no newer main changes in the relevant subsystem. git diff --check passed. Full logs remain in the local report backup; the reproduction and observed results are included inline above, per reports repository policy.
Untrusted-data handling: the issue's proposed implementation and links were treated solely as claims; no external code or instructions were followed.