fix(coding-agent): unify source provenance, closes #1734

This commit is contained in:
Mario Zechner
2026-03-23 02:02:42 +01:00
parent 883862a354
commit 4e5af01d73
26 changed files with 340 additions and 157 deletions

View File

@@ -67,7 +67,23 @@ describe("AgentSession dynamic tool registration", () => {
await session.bindExtensions({});
expect(session.getAllTools().map((tool) => tool.name)).toContain("dynamic_tool");
const allTools = session.getAllTools();
const dynamicTool = allTools.find((tool) => tool.name === "dynamic_tool");
const readTool = allTools.find((tool) => tool.name === "read");
expect(allTools.map((tool) => tool.name)).toContain("dynamic_tool");
expect(dynamicTool?.sourceInfo).toMatchObject({
path: "<inline:1>",
source: "inline",
scope: "temporary",
origin: "top-level",
});
expect(readTool?.sourceInfo).toMatchObject({
path: "<builtin:read>",
source: "builtin",
scope: "temporary",
origin: "top-level",
});
expect(session.getActiveToolNames()).toContain("dynamic_tool");
expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior");
expect(session.systemPrompt).toContain("- Use dynamic_tool when the user asks for dynamic behavior tests.");
@@ -75,6 +91,49 @@ describe("AgentSession dynamic tool registration", () => {
session.dispose();
});
it("returns source metadata for SDK custom tools", async () => {
const settingsManager = SettingsManager.create(tempDir, agentDir);
const sessionManager = SessionManager.inMemory();
const resourceLoader = new DefaultResourceLoader({
cwd: tempDir,
agentDir,
settingsManager,
});
await resourceLoader.reload();
const { session } = await createAgentSession({
cwd: tempDir,
agentDir,
model: getModel("anthropic", "claude-sonnet-4-5")!,
settingsManager,
sessionManager,
resourceLoader,
customTools: [
{
name: "sdk_tool",
label: "SDK Tool",
description: "Tool registered through createAgentSession",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
},
],
});
const sdkTool = session.getAllTools().find((tool) => tool.name === "sdk_tool");
expect(sdkTool?.sourceInfo).toMatchObject({
path: "<sdk:sdk_tool>",
source: "sdk",
scope: "temporary",
origin: "top-level",
});
expect(session.getActiveToolNames()).toContain("sdk_tool");
session.dispose();
});
it("keeps custom tools active but omits them from available tools when promptSnippet is not provided", async () => {
const settingsManager = SettingsManager.create(tempDir, agentDir);
const sessionManager = SessionManager.inMemory();

View File

@@ -20,6 +20,7 @@ import {
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
import { codingTools } from "../src/core/tools/index.js";
import { createTestResourceLoader } from "./utilities.js";
@@ -74,6 +75,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
return {
path: "test-extension",
resolvedPath: "/test/test-extension.ts",
sourceInfo: createSyntheticSourceInfo("<test:test-extension>", { source: "test" }),
handlers,
tools: new Map(),
messageRenderers: new Map(),
@@ -228,6 +230,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const throwingExtension: Extension = {
path: "throwing-extension",
resolvedPath: "/test/throwing-extension.ts",
sourceInfo: createSyntheticSourceInfo("<test:throwing-extension>", { source: "test" }),
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[
"session_before_compact",
@@ -276,6 +279,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const extension1: Extension = {
path: "extension1",
resolvedPath: "/test/extension1.ts",
sourceInfo: createSyntheticSourceInfo("<test:extension1>", { source: "test" }),
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[
"session_before_compact",
@@ -306,6 +310,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const extension2: Extension = {
path: "extension2",
resolvedPath: "/test/extension2.ts",
sourceInfo: createSyntheticSourceInfo("<test:extension2>", { source: "test" }),
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[
"session_before_compact",

View File

@@ -9,6 +9,7 @@ import { DefaultResourceLoader } from "../src/core/resource-loader.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import type { Skill } from "../src/core/skills.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
describe("DefaultResourceLoader", () => {
let tempDir: string;
@@ -411,7 +412,7 @@ Content`,
description: "Injected skill",
filePath: "/fake/path",
baseDir: "/fake",
source: "custom",
sourceInfo: createSyntheticSourceInfo("/fake/path", { source: "custom" }),
disableModelInvocation: false,
};
const loader = new DefaultResourceLoader({

View File

@@ -6,6 +6,7 @@ import { createExtensionRuntime } from "../src/core/extensions/loader.js";
import type { ResourceLoader } from "../src/core/resource-loader.js";
import { createAgentSession } from "../src/core/sdk.js";
import { SessionManager } from "../src/core/session-manager.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
describe("createAgentSession skills option", () => {
let tempDir: string;
@@ -79,7 +80,7 @@ This is a test skill.
description: "A custom skill",
filePath: "/fake/path/SKILL.md",
baseDir: "/fake/path",
source: "custom" as const,
sourceInfo: createSyntheticSourceInfo("/fake/path/SKILL.md", { source: "sdk" }),
disableModelInvocation: false,
};

View File

@@ -3,10 +3,29 @@ import { join, resolve } from "path";
import { describe, expect, it } from "vitest";
import type { ResourceDiagnostic } from "../src/core/diagnostics.js";
import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
const fixturesDir = resolve(__dirname, "fixtures/skills");
const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision");
function createTestSkill(options: {
name: string;
description: string;
filePath: string;
baseDir: string;
disableModelInvocation?: boolean;
source?: string;
}): Skill {
return {
name: options.name,
description: options.description,
filePath: options.filePath,
baseDir: options.baseDir,
sourceInfo: createSyntheticSourceInfo(options.filePath, { source: options.source ?? "test" }),
disableModelInvocation: options.disableModelInvocation ?? false,
};
}
describe("skills", () => {
describe("loadSkillsFromDir", () => {
it("should load a valid skill", () => {
@@ -18,7 +37,7 @@ describe("skills", () => {
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("valid-skill");
expect(skills[0].description).toBe("A valid skill for testing purposes.");
expect(skills[0].source).toBe("test");
expect(skills[0].sourceInfo.source).toBe("test");
expect(diagnostics).toHaveLength(0);
});
@@ -210,14 +229,12 @@ describe("skills", () => {
it("should format skills as XML", () => {
const skills: Skill[] = [
{
createTestSkill({
name: "test-skill",
description: "A test skill.",
filePath: "/path/to/skill/SKILL.md",
baseDir: "/path/to/skill",
source: "test",
disableModelInvocation: false,
},
}),
];
const result = formatSkillsForPrompt(skills);
@@ -232,14 +249,12 @@ describe("skills", () => {
it("should include intro text before XML", () => {
const skills: Skill[] = [
{
createTestSkill({
name: "test-skill",
description: "A test skill.",
filePath: "/path/to/skill/SKILL.md",
baseDir: "/path/to/skill",
source: "test",
disableModelInvocation: false,
},
}),
];
const result = formatSkillsForPrompt(skills);
@@ -252,14 +267,12 @@ describe("skills", () => {
it("should escape XML special characters", () => {
const skills: Skill[] = [
{
createTestSkill({
name: "test-skill",
description: 'A skill with <special> & "characters".',
filePath: "/path/to/skill/SKILL.md",
baseDir: "/path/to/skill",
source: "test",
disableModelInvocation: false,
},
}),
];
const result = formatSkillsForPrompt(skills);
@@ -271,22 +284,18 @@ describe("skills", () => {
it("should format multiple skills", () => {
const skills: Skill[] = [
{
createTestSkill({
name: "skill-one",
description: "First skill.",
filePath: "/path/one/SKILL.md",
baseDir: "/path/one",
source: "test",
disableModelInvocation: false,
},
{
}),
createTestSkill({
name: "skill-two",
description: "Second skill.",
filePath: "/path/two/SKILL.md",
baseDir: "/path/two",
source: "test",
disableModelInvocation: false,
},
}),
];
const result = formatSkillsForPrompt(skills);
@@ -298,22 +307,19 @@ describe("skills", () => {
it("should exclude skills with disableModelInvocation from prompt", () => {
const skills: Skill[] = [
{
createTestSkill({
name: "visible-skill",
description: "A visible skill.",
filePath: "/path/visible/SKILL.md",
baseDir: "/path/visible",
source: "test",
disableModelInvocation: false,
},
{
}),
createTestSkill({
name: "hidden-skill",
description: "A hidden skill.",
filePath: "/path/hidden/SKILL.md",
baseDir: "/path/hidden",
source: "test",
disableModelInvocation: true,
},
}),
];
const result = formatSkillsForPrompt(skills);
@@ -325,14 +331,13 @@ describe("skills", () => {
it("should return empty string when all skills have disableModelInvocation", () => {
const skills: Skill[] = [
{
createTestSkill({
name: "hidden-skill",
description: "A hidden skill.",
filePath: "/path/hidden/SKILL.md",
baseDir: "/path/hidden",
source: "test",
disableModelInvocation: true,
},
}),
];
const result = formatSkillsForPrompt(skills);
@@ -351,7 +356,7 @@ describe("skills", () => {
skillPaths: [join(fixturesDir, "valid-skill")],
});
expect(skills).toHaveLength(1);
expect(skills[0].source).toBe("path");
expect(skills[0].sourceInfo.scope).toBe("temporary");
expect(diagnostics).toHaveLength(0);
});
@@ -415,7 +420,7 @@ describe("skills", () => {
}
expect(skillMap.size).toBe(1);
expect(skillMap.get("calendar")?.source).toBe("first");
expect(skillMap.get("calendar")?.sourceInfo.source).toBe("first");
expect(collisionWarnings).toHaveLength(1);
expect(collisionWarnings[0].message).toContain("name collision");
});