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

@@ -5,14 +5,37 @@
### Breaking Changes ### Breaking Changes
- Changed `ToolDefinition.renderCall` and `renderResult` semantics. Fallback rendering now happens only when a renderer is not defined for that slot. If `renderCall` or `renderResult` is defined, it must return a `Component`. - Changed `ToolDefinition.renderCall` and `renderResult` semantics. Fallback rendering now happens only when a renderer is not defined for that slot. If `renderCall` or `renderResult` is defined, it must return a `Component`.
- Changed slash command provenance to use `sourceInfo` consistently. RPC `get_commands`, `RpcSlashCommand`, and SDK `SlashCommandInfo` no longer expose `location` or `path`. Use `sourceInfo` instead ([#1734](https://github.com/badlogic/pi-mono/issues/1734))
- Removed legacy `source` fields from `Skill` and `PromptTemplate`. Use `sourceInfo.source` for provenance instead ([#1734](https://github.com/badlogic/pi-mono/issues/1734))
- Removed `ResourceLoader.getPathMetadata()`. Resource provenance is now attached directly to loaded resources via `sourceInfo` ([#1734](https://github.com/badlogic/pi-mono/issues/1734))
- Removed `extensionPath` from `RegisteredCommand` and `RegisteredTool`. Use `sourceInfo.path` for provenance instead ([#1734](https://github.com/badlogic/pi-mono/issues/1734))
#### Migration Notes
Resource, command, and tool provenance now use `sourceInfo` consistently.
Common updates:
- RPC `get_commands`: replace `path` and `location` with `sourceInfo.path`, `sourceInfo.scope`, and `sourceInfo.source`
- `SlashCommandInfo`: replace `command.path` and `command.location` with `command.sourceInfo`
- `Skill` and `PromptTemplate`: replace `.source` with `.sourceInfo.source`
- `RegisteredCommand` and `RegisteredTool`: replace `.extensionPath` with `.sourceInfo.path`
- Custom `ResourceLoader` implementations: remove `getPathMetadata()` and read provenance from loaded resources directly
Examples:
- `command.path` -> `command.sourceInfo.path`
- `command.location === "user"` -> `command.sourceInfo.scope === "user"`
- `skill.source` -> `skill.sourceInfo.source`
- `tool.extensionPath` -> `tool.sourceInfo.path`
### Changed ### Changed
- Built-in tools now work like custom tools in extensions. To get built-in tool definitions, import `readToolDefinition` / `createReadToolDefinition()` and the equivalent `bash`, `edit`, `write`, `grep`, `find`, and `ls` exports from `@mariozechner/pi-coding-agent`. - Built-in tools now work like custom tools in extensions. To get built-in tool definitions, import `readToolDefinition` / `createReadToolDefinition()` and the equivalent `bash`, `edit`, `write`, `grep`, `find`, and `ls` exports from `@mariozechner/pi-coding-agent`.
- Cleaned up `buildSystemPrompt()` so built-in tool snippets and tool-local guidelines come from built-in `ToolDefinition` metadata, while cross-tool and global prompt rules stay in system prompt construction. - Cleaned up `buildSystemPrompt()` so built-in tool snippets and tool-local guidelines come from built-in `ToolDefinition` metadata, while cross-tool and global prompt rules stay in system prompt construction.
- Added structured `sourceInfo` to `pi.getAllTools()` results for built-in, SDK, and extension tools ([#1734](https://github.com/badlogic/pi-mono/issues/1734))
### Fixed ### Fixed
- Fixed slash command source attribution for extension commands, prompt templates, and skills in autocomplete and command discovery ([#1734](https://github.com/badlogic/pi-mono/issues/1734))
- Fixed auto-resized image handling to enforce the inline image size limit on the final base64 payload, return text-only fallbacks when resizing cannot produce a safe image, and avoid falling back to the original image in `read` and `@file` auto-resize paths ([#2055](https://github.com/badlogic/pi-mono/issues/2055)) - Fixed auto-resized image handling to enforce the inline image size limit on the final base64 payload, return text-only fallbacks when resizing cannot produce a safe image, and avoid falling back to the original image in `read` and `@file` auto-resize paths ([#2055](https://github.com/badlogic/pi-mono/issues/2055))
- Fixed `pi update` for git packages to skip destructive reset, clean, and reinstall steps when the fetched target already matches the local checkout ([#2503](https://github.com/badlogic/pi-mono/issues/2503)) - Fixed `pi update` for git packages to skip destructive reset, clean, and reinstall steps when the fetched target already matches the local checkout ([#2503](https://github.com/badlogic/pi-mono/issues/2503))
- Fixed print and JSON mode to take over stdout during non-interactive startup, keeping package-manager and other incidental chatter off protocol/output stdout ([#2482](https://github.com/badlogic/pi-mono/issues/2482)) - Fixed print and JSON mode to take over stdout during non-interactive startup, keeping package-manager and other incidental chatter off protocol/output stdout ([#2482](https://github.com/badlogic/pi-mono/issues/2482))

View File

@@ -1126,6 +1126,7 @@ The list matches the RPC `get_commands` ordering: extensions first, then templat
```typescript ```typescript
const commands = pi.getCommands(); const commands = pi.getCommands();
const bySource = commands.filter((command) => command.source === "extension"); const bySource = commands.filter((command) => command.source === "extension");
const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");
``` ```
Each entry has this shape: Each entry has this shape:
@@ -1135,11 +1136,18 @@ Each entry has this shape:
name: string; // Command name without the leading slash name: string; // Command name without the leading slash
description?: string; description?: string;
source: "extension" | "prompt" | "skill"; source: "extension" | "prompt" | "skill";
location?: "user" | "project" | "path"; // For templates and skills sourceInfo: {
path?: string; // Files backing templates, skills, and extensions path: string;
source: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
baseDir?: string;
};
} }
``` ```
Use `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing.
Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive
mode and would not execute if sent via `prompt`. mode and would not execute if sent via `prompt`.
@@ -1191,12 +1199,27 @@ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
Manage active tools. This works for both built-in tools and dynamically registered tools. Manage active tools. This works for both built-in tools and dynamically registered tools.
```typescript ```typescript
const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"] const active = pi.getActiveTools();
const all = pi.getAllTools(); // [{ name: "read", description: "Read file contents..." }, ...] const all = pi.getAllTools();
const names = all.map(t => t.name); // Just names if needed // [{
// name: "read",
// description: "Read file contents...",
// parameters: ...,
// sourceInfo: { path: "<builtin:read>", source: "builtin", scope: "temporary", origin: "top-level" }
// }, ...]
const names = all.map(t => t.name);
const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin");
const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk");
pi.setActiveTools(["read", "bash"]); // Switch to read-only pi.setActiveTools(["read", "bash"]); // Switch to read-only
``` ```
`pi.getAllTools()` returns `name`, `description`, `parameters`, and `sourceInfo`.
Typical `sourceInfo.source` values:
- `builtin` for built-in tools
- `sdk` for tools passed via `createAgentSession({ customTools })`
- extension source metadata for tools registered by extensions
### pi.setModel(model) ### pi.setModel(model)
Set the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models. Set the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models.

View File

@@ -60,10 +60,10 @@ export default function commandsExtension(pi: ExtensionAPI) {
if (selected && !selected.startsWith("---")) { if (selected && !selected.startsWith("---")) {
const cmdName = selected.split(" - ")[0].slice(1); // Remove leading / const cmdName = selected.split(" - ")[0].slice(1); // Remove leading /
const cmd = commands.find((c) => c.name === cmdName); const cmd = commands.find((c) => c.name === cmdName);
if (cmd?.path) { if (cmd?.sourceInfo.path) {
const showPath = await ctx.ui.confirm(cmd.name, `View source path?\n${cmd.path}`); const showPath = await ctx.ui.confirm(cmd.name, `View source path?\n${cmd.sourceInfo.path}`);
if (showPath) { if (showPath) {
ctx.ui.notify(cmd.path, "info"); ctx.ui.notify(cmd.sourceInfo.path, "info");
} }
} }
} }

View File

@@ -5,7 +5,13 @@
* Discover, filter, merge, or replace them. * Discover, filter, merge, or replace them.
*/ */
import { createAgentSession, DefaultResourceLoader, SessionManager, type Skill } from "@mariozechner/pi-coding-agent"; import {
createAgentSession,
createSyntheticSourceInfo,
DefaultResourceLoader,
SessionManager,
type Skill,
} from "@mariozechner/pi-coding-agent";
// Or define custom skills inline // Or define custom skills inline
const customSkill: Skill = { const customSkill: Skill = {
@@ -13,7 +19,7 @@ const customSkill: Skill = {
description: "Custom project instructions", description: "Custom project instructions",
filePath: "/virtual/SKILL.md", filePath: "/virtual/SKILL.md",
baseDir: "/virtual", baseDir: "/virtual",
source: "path", sourceInfo: createSyntheticSourceInfo("/virtual/SKILL.md", { source: "sdk" }),
disableModelInvocation: false, disableModelInvocation: false,
}; };

View File

@@ -6,6 +6,7 @@
import { import {
createAgentSession, createAgentSession,
createSyntheticSourceInfo,
DefaultResourceLoader, DefaultResourceLoader,
type PromptTemplate, type PromptTemplate,
SessionManager, SessionManager,
@@ -15,8 +16,8 @@ import {
const deployTemplate: PromptTemplate = { const deployTemplate: PromptTemplate = {
name: "deploy", name: "deploy",
description: "Deploy the application", description: "Deploy the application",
source: "path",
filePath: "/virtual/prompts/deploy.md", filePath: "/virtual/prompts/deploy.md",
sourceInfo: createSyntheticSourceInfo("/virtual/prompts/deploy.md", { source: "sdk" }),
content: `# Deploy Instructions content: `# Deploy Instructions
1. Build: npm run build 1. Build: npm run build

View File

@@ -75,7 +75,8 @@ import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.j
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js"; import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js";
import type { SettingsManager } from "./settings-manager.js"; import type { SettingsManager } from "./settings-manager.js";
import type { SlashCommandInfo, SlashCommandLocation } from "./slash-commands.js"; import type { SlashCommandInfo } from "./slash-commands.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { buildSystemPrompt } from "./system-prompt.js"; import { buildSystemPrompt } from "./system-prompt.js";
import type { BashOperations } from "./tools/bash.js"; import type { BashOperations } from "./tools/bash.js";
import { createAllToolDefinitions } from "./tools/index.js"; import { createAllToolDefinitions } from "./tools/index.js";
@@ -201,6 +202,11 @@ export interface SessionStats {
cost: number; cost: number;
} }
interface ToolDefinitionEntry {
definition: ToolDefinition;
sourceInfo: SourceInfo;
}
// ============================================================================ // ============================================================================
// Constants // Constants
// ============================================================================ // ============================================================================
@@ -274,7 +280,7 @@ export class AgentSession {
// Tool registry for extension getTools/setTools // Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool> = new Map(); private _toolRegistry: Map<string, AgentTool> = new Map();
private _toolDefinitions: Map<string, ToolDefinition> = new Map(); private _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();
private _toolPromptSnippets: Map<string, string> = new Map(); private _toolPromptSnippets: Map<string, string> = new Map();
private _toolPromptGuidelines: Map<string, string[]> = new Map(); private _toolPromptGuidelines: Map<string, string[]> = new Map();
@@ -710,18 +716,19 @@ export class AgentSession {
} }
/** /**
* Get all configured tools with name, description, and parameter schema. * Get all configured tools with name, description, parameter schema, and source metadata.
*/ */
getAllTools(): ToolInfo[] { getAllTools(): ToolInfo[] {
return Array.from(this._toolDefinitions.values()).map((t) => ({ return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({
name: t.name, name: definition.name,
description: t.description, description: definition.description,
parameters: t.parameters, parameters: definition.parameters,
sourceInfo,
})); }));
} }
getToolDefinition(name: string): ToolDefinition | undefined { getToolDefinition(name: string): ToolDefinition | undefined {
return this._toolDefinitions.get(name); return this._toolDefinitions.get(name)?.definition;
} }
/** /**
@@ -2084,20 +2091,12 @@ export class AgentSession {
} }
private _bindExtensionCore(runner: ExtensionRunner): void { private _bindExtensionCore(runner: ExtensionRunner): void {
const normalizeLocation = (source: string): SlashCommandLocation | undefined => {
if (source === "user" || source === "project" || source === "path") {
return source;
}
return undefined;
};
const getCommands = (): SlashCommandInfo[] => { const getCommands = (): SlashCommandInfo[] => {
const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({ const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({
name: command.name, name: command.name,
description: command.description, description: command.description,
source: "extension", source: "extension",
sourceInfo: command.sourceInfo, sourceInfo: command.sourceInfo,
path: command.extensionPath,
})); }));
const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({ const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({
@@ -2105,8 +2104,6 @@ export class AgentSession {
description: template.description, description: template.description,
source: "prompt", source: "prompt",
sourceInfo: template.sourceInfo, sourceInfo: template.sourceInfo,
location: normalizeLocation(template.source),
path: template.filePath,
})); }));
const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({ const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({
@@ -2114,8 +2111,6 @@ export class AgentSession {
description: skill.description, description: skill.description,
source: "skill", source: "skill",
sourceInfo: skill.sourceInfo, sourceInfo: skill.sourceInfo,
location: normalizeLocation(skill.source),
path: skill.filePath,
})); }));
return [...extensionCommands, ...templates, ...skills]; return [...extensionCommands, ...templates, ...skills];
@@ -2209,16 +2204,30 @@ export class AgentSession {
const registeredTools = this._extensionRunner?.getAllRegisteredTools() ?? []; const registeredTools = this._extensionRunner?.getAllRegisteredTools() ?? [];
const allCustomTools = [ const allCustomTools = [
...registeredTools, ...registeredTools,
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })), ...this._customTools.map((definition) => ({
definition,
sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
})),
]; ];
const definitionRegistry = new Map(this._baseToolDefinitions); const definitionRegistry = new Map<string, ToolDefinitionEntry>(
for (const { definition } of allCustomTools) { Array.from(this._baseToolDefinitions.entries()).map(([name, definition]) => [
definitionRegistry.set(definition.name, definition); name,
{
definition,
sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),
},
]),
);
for (const tool of allCustomTools) {
definitionRegistry.set(tool.definition.name, {
definition: tool.definition,
sourceInfo: tool.sourceInfo,
});
} }
this._toolDefinitions = definitionRegistry; this._toolDefinitions = definitionRegistry;
this._toolPromptSnippets = new Map( this._toolPromptSnippets = new Map(
Array.from(definitionRegistry.values()) Array.from(definitionRegistry.values())
.map((definition) => { .map(({ definition }) => {
const snippet = this._normalizePromptSnippet(definition.promptSnippet); const snippet = this._normalizePromptSnippet(definition.promptSnippet);
return snippet ? ([definition.name, snippet] as const) : undefined; return snippet ? ([definition.name, snippet] as const) : undefined;
}) })
@@ -2226,7 +2235,7 @@ export class AgentSession {
); );
this._toolPromptGuidelines = new Map( this._toolPromptGuidelines = new Map(
Array.from(definitionRegistry.values()) Array.from(definitionRegistry.values())
.map((definition) => { .map(({ definition }) => {
const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines); const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);
return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined; return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined;
}) })

View File

@@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path"; import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.js"; import { APP_NAME, getExportTemplateDir } from "../../config.js";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js"; import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
import type { ToolInfo } from "../extensions/types.js"; import type { ToolDefinition } from "../extensions/types.js";
import type { SessionEntry } from "../session-manager.js"; import type { SessionEntry } from "../session-manager.js";
import { SessionManager } from "../session-manager.js"; import { SessionManager } from "../session-manager.js";
@@ -131,7 +131,7 @@ interface SessionData {
entries: ReturnType<SessionManager["getEntries"]>; entries: ReturnType<SessionManager["getEntries"]>;
leafId: string | null; leafId: string | null;
systemPrompt?: string; systemPrompt?: string;
tools?: ToolInfo[]; tools?: Array<Pick<ToolDefinition, "name" | "description" | "parameters">>;
/** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */ /** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */
renderedTools?: Record<string, RenderedToolHtml>; renderedTools?: Record<string, RenderedToolHtml>;
} }

View File

@@ -2,7 +2,7 @@
* Extension system for lifecycle events and custom tools. * Extension system for lifecycle events and custom tools.
*/ */
export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands.js"; export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.js";
export type { SourceInfo } from "../source-info.js"; export type { SourceInfo } from "../source-info.js";
export { export {
createExtensionRuntime, createExtensionRuntime,

View File

@@ -26,6 +26,7 @@ import * as _bundledPiCodingAgent from "../../index.js";
import { createEventBus, type EventBus } from "../event-bus.js"; import { createEventBus, type EventBus } from "../event-bus.js";
import type { ExecOptions } from "../exec.js"; import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js"; import { execCommand } from "../exec.js";
import { createSyntheticSourceInfo } from "../source-info.js";
import type { import type {
Extension, Extension,
ExtensionAPI, ExtensionAPI,
@@ -174,15 +175,14 @@ function createExtensionAPI(
registerTool(tool: ToolDefinition): void { registerTool(tool: ToolDefinition): void {
extension.tools.set(tool.name, { extension.tools.set(tool.name, {
definition: tool, definition: tool,
extensionPath: extension.path, sourceInfo: extension.sourceInfo,
}); });
runtime.refreshTools(); runtime.refreshTools();
}, },
registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "extensionPath">): void { registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "sourceInfo">): void {
extension.commands.set(name, { extension.commands.set(name, {
name, name,
extensionPath: extension.path,
sourceInfo: extension.sourceInfo, sourceInfo: extension.sourceInfo,
...options, ...options,
}); });
@@ -307,9 +307,16 @@ async function loadExtensionModule(extensionPath: string) {
* Create an Extension object with empty collections. * Create an Extension object with empty collections.
*/ */
function createExtension(extensionPath: string, resolvedPath: string): Extension { function createExtension(extensionPath: string, resolvedPath: string): Extension {
const source =
extensionPath.startsWith("<") && extensionPath.endsWith(">")
? extensionPath.slice(1, -1).split(":")[0] || "temporary"
: "local";
const baseDir = extensionPath.startsWith("<") ? undefined : path.dirname(resolvedPath);
return { return {
path: extensionPath, path: extensionPath,
resolvedPath, resolvedPath,
sourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }),
handlers: new Map(), handlers: new Map(),
tools: new Map(), tools: new Map(),
messageRenderers: new Map(), messageRenderers: new Map(),

View File

@@ -961,8 +961,7 @@ export type MessageRenderer<T = unknown> = (
export interface RegisteredCommand { export interface RegisteredCommand {
name: string; name: string;
extensionPath: string; sourceInfo: SourceInfo;
sourceInfo?: SourceInfo;
description?: string; description?: string;
getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null; getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null;
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>; handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
@@ -1038,7 +1037,7 @@ export interface ExtensionAPI {
// ========================================================================= // =========================================================================
/** Register a custom command. */ /** Register a custom command. */
registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "extensionPath">): void; registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "sourceInfo">): void;
/** Register a keyboard shortcut. */ /** Register a keyboard shortcut. */
registerShortcut( registerShortcut(
@@ -1110,7 +1109,7 @@ export interface ExtensionAPI {
/** Get the list of currently active tool names. */ /** Get the list of currently active tool names. */
getActiveTools(): string[]; getActiveTools(): string[];
/** Get all configured tools with name and description. */ /** Get all configured tools with parameter schema and source metadata. */
getAllTools(): ToolInfo[]; getAllTools(): ToolInfo[];
/** Set the active tools by name. */ /** Set the active tools by name. */
@@ -1277,7 +1276,7 @@ export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
export interface RegisteredTool { export interface RegisteredTool {
definition: ToolDefinition; definition: ToolDefinition;
extensionPath: string; sourceInfo: SourceInfo;
} }
export interface ExtensionFlag { export interface ExtensionFlag {
@@ -1315,8 +1314,10 @@ export type GetSessionNameHandler = () => string | undefined;
export type GetActiveToolsHandler = () => string[]; export type GetActiveToolsHandler = () => string[];
/** Tool info with name, description, and parameter schema */ /** Tool info with name, description, parameter schema, and source metadata */
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters">; export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters"> & {
sourceInfo: SourceInfo;
};
export type GetAllToolsHandler = () => ToolInfo[]; export type GetAllToolsHandler = () => ToolInfo[];
@@ -1417,7 +1418,7 @@ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionAction
export interface Extension { export interface Extension {
path: string; path: string;
resolvedPath: string; resolvedPath: string;
sourceInfo?: SourceInfo; sourceInfo: SourceInfo;
handlers: Map<string, HandlerFn[]>; handlers: Map<string, HandlerFn[]>;
tools: Map<string, RegisteredTool>; tools: Map<string, RegisteredTool>;
messageRenderers: Map<string, MessageRenderer>; messageRenderers: Map<string, MessageRenderer>;

View File

@@ -14,7 +14,6 @@ export {
export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor.js"; export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js"; export type { CompactionResult } from "./compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js"; export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
// Extensions system // Extensions system
export { export {
type AgentEndEvent, type AgentEndEvent,
@@ -59,3 +58,4 @@ export {
type TurnEndEvent, type TurnEndEvent,
type TurnStartEvent, type TurnStartEvent,
} from "./extensions/index.js"; } from "./extensions/index.js";
export { createSyntheticSourceInfo } from "./source-info.js";

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import { basename, isAbsolute, join, resolve, sep } from "path"; import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js"; import { parseFrontmatter } from "../utils/frontmatter.js";
import type { SourceInfo } from "./source-info.js"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
/** /**
* Represents a prompt template loaded from a markdown file * Represents a prompt template loaded from a markdown file
@@ -12,8 +12,7 @@ export interface PromptTemplate {
name: string; name: string;
description: string; description: string;
content: string; content: string;
source: string; // "user", "project", or "path" sourceInfo: SourceInfo;
sourceInfo?: SourceInfo;
filePath: string; // Absolute path to the template file filePath: string; // Absolute path to the template file
} }
@@ -101,7 +100,7 @@ export function substituteArgs(content: string, args: string[]): string {
return result; return result;
} }
function loadTemplateFromFile(filePath: string, source: string): PromptTemplate | null { function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null {
try { try {
const rawContent = readFileSync(filePath, "utf-8"); const rawContent = readFileSync(filePath, "utf-8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent); const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
@@ -123,7 +122,7 @@ function loadTemplateFromFile(filePath: string, source: string): PromptTemplate
name, name,
description, description,
content: body, content: body,
source, sourceInfo,
filePath, filePath,
}; };
} catch { } catch {
@@ -134,7 +133,7 @@ function loadTemplateFromFile(filePath: string, source: string): PromptTemplate
/** /**
* Scan a directory for .md files (non-recursive) and load them as prompt templates. * Scan a directory for .md files (non-recursive) and load them as prompt templates.
*/ */
function loadTemplatesFromDir(dir: string, source: string): PromptTemplate[] { function loadTemplatesFromDir(dir: string, getSourceInfo: (filePath: string) => SourceInfo): PromptTemplate[] {
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];
if (!existsSync(dir)) { if (!existsSync(dir)) {
@@ -160,7 +159,7 @@ function loadTemplatesFromDir(dir: string, source: string): PromptTemplate[] {
} }
if (isFile && entry.name.endsWith(".md")) { if (isFile && entry.name.endsWith(".md")) {
const template = loadTemplateFromFile(fullPath, source); const template = loadTemplateFromFile(fullPath, getSourceInfo(fullPath));
if (template) { if (template) {
templates.push(template); templates.push(template);
} }
@@ -211,18 +210,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];
if (includeDefaults) { const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
// 1. Load global templates from agentDir/prompts/
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
templates.push(...loadTemplatesFromDir(globalPromptsDir, "user"));
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
templates.push(...loadTemplatesFromDir(projectPromptsDir, "project"));
}
const userPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
const isUnderPath = (target: string, root: string): boolean => { const isUnderPath = (target: string, root: string): boolean => {
@@ -234,18 +222,32 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
return target.startsWith(prefix); return target.startsWith(prefix);
}; };
const getSource = (resolvedPath: string): string => { const getSourceInfo = (resolvedPath: string): SourceInfo => {
if (!includeDefaults) { if (isUnderPath(resolvedPath, globalPromptsDir)) {
if (isUnderPath(resolvedPath, userPromptsDir)) { return createSyntheticSourceInfo(resolvedPath, {
return "user"; source: "local",
} scope: "user",
if (isUnderPath(resolvedPath, projectPromptsDir)) { baseDir: globalPromptsDir,
return "project"; });
}
} }
return "path"; if (isUnderPath(resolvedPath, projectPromptsDir)) {
return createSyntheticSourceInfo(resolvedPath, {
source: "local",
scope: "project",
baseDir: projectPromptsDir,
});
}
return createSyntheticSourceInfo(resolvedPath, {
source: "local",
baseDir: statSync(resolvedPath).isDirectory() ? resolvedPath : dirname(resolvedPath),
});
}; };
if (includeDefaults) {
templates.push(...loadTemplatesFromDir(globalPromptsDir, getSourceInfo));
templates.push(...loadTemplatesFromDir(projectPromptsDir, getSourceInfo));
}
// 3. Load explicit prompt paths // 3. Load explicit prompt paths
for (const rawPath of promptPaths) { for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd); const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
@@ -255,11 +257,10 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
try { try {
const stats = statSync(resolvedPath); const stats = statSync(resolvedPath);
const source = getSource(resolvedPath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
templates.push(...loadTemplatesFromDir(resolvedPath, source)); templates.push(...loadTemplatesFromDir(resolvedPath, getSourceInfo));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) { } else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const template = loadTemplateFromFile(resolvedPath, source); const template = loadTemplateFromFile(resolvedPath, getSourceInfo(resolvedPath));
if (template) { if (template) {
templates.push(template); templates.push(template);
} }

View File

@@ -470,6 +470,7 @@ export class DefaultResourceLoader implements ResourceLoader {
...skill, ...skill,
sourceInfo: sourceInfo:
this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ?? this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ??
skill.sourceInfo ??
this.getDefaultSourceInfoForPath(skill.filePath), this.getDefaultSourceInfoForPath(skill.filePath),
})); }));
this.skillDiagnostics = resolvedSkills.diagnostics; this.skillDiagnostics = resolvedSkills.diagnostics;
@@ -493,6 +494,7 @@ export class DefaultResourceLoader implements ResourceLoader {
...prompt, ...prompt,
sourceInfo: sourceInfo:
this.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ?? this.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ??
prompt.sourceInfo ??
this.getDefaultSourceInfoForPath(prompt.filePath), this.getDefaultSourceInfoForPath(prompt.filePath),
})); }));
this.promptDiagnostics = resolvedPrompts.diagnostics; this.promptDiagnostics = resolvedPrompts.diagnostics;
@@ -512,8 +514,9 @@ export class DefaultResourceLoader implements ResourceLoader {
const sourcePath = theme.sourcePath; const sourcePath = theme.sourcePath;
theme.sourceInfo = sourcePath theme.sourceInfo = sourcePath
? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ?? ? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ??
theme.sourceInfo ??
this.getDefaultSourceInfoForPath(sourcePath)) this.getDefaultSourceInfoForPath(sourcePath))
: undefined; : theme.sourceInfo;
return theme; return theme;
}); });
this.themeDiagnostics = resolvedThemes.diagnostics; this.themeDiagnostics = resolvedThemes.diagnostics;
@@ -527,6 +530,9 @@ export class DefaultResourceLoader implements ResourceLoader {
for (const command of extension.commands.values()) { for (const command of extension.commands.values()) {
command.sourceInfo = extension.sourceInfo; command.sourceInfo = extension.sourceInfo;
} }
for (const tool of extension.tools.values()) {
tool.sourceInfo = extension.sourceInfo;
}
} }
} }
@@ -576,11 +582,7 @@ export class DefaultResourceLoader implements ResourceLoader {
return undefined; return undefined;
} }
private getDefaultSourceInfoForPath(filePath: string): SourceInfo | undefined { private getDefaultSourceInfoForPath(filePath: string): SourceInfo {
if (!filePath) {
return undefined;
}
if (filePath.startsWith("<") && filePath.endsWith(">")) { if (filePath.startsWith("<") && filePath.endsWith(">")) {
return { return {
path: filePath, path: filePath,
@@ -606,17 +608,23 @@ export class DefaultResourceLoader implements ResourceLoader {
for (const root of agentRoots) { for (const root of agentRoots) {
if (this.isUnderPath(normalizedPath, root)) { if (this.isUnderPath(normalizedPath, root)) {
return { path: filePath, source: "local", scope: "user", origin: "top-level" }; return { path: filePath, source: "local", scope: "user", origin: "top-level", baseDir: root };
} }
} }
for (const root of projectRoots) { for (const root of projectRoots) {
if (this.isUnderPath(normalizedPath, root)) { if (this.isUnderPath(normalizedPath, root)) {
return { path: filePath, source: "local", scope: "project", origin: "top-level" }; return { path: filePath, source: "local", scope: "project", origin: "top-level", baseDir: root };
} }
} }
return undefined; return {
path: filePath,
source: "local",
scope: "temporary",
origin: "top-level",
baseDir: statSync(normalizedPath).isDirectory() ? normalizedPath : resolve(normalizedPath, ".."),
};
} }
private mergePaths(primary: string[], additional: string[]): string[] { private mergePaths(primary: string[], additional: string[]): string[] {

View File

@@ -90,7 +90,6 @@ export type {
ExtensionContext, ExtensionContext,
ExtensionFactory, ExtensionFactory,
SlashCommandInfo, SlashCommandInfo,
SlashCommandLocation,
SlashCommandSource, SlashCommandSource,
ToolDefinition, ToolDefinition,
} from "./extensions/index.js"; } from "./extensions/index.js";

View File

@@ -5,7 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "pat
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js"; import { parseFrontmatter } from "../utils/frontmatter.js";
import type { ResourceDiagnostic } from "./diagnostics.js"; import type { ResourceDiagnostic } from "./diagnostics.js";
import type { SourceInfo } from "./source-info.js"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
/** Max name length per spec */ /** Max name length per spec */
const MAX_NAME_LENGTH = 64; const MAX_NAME_LENGTH = 64;
@@ -76,8 +76,7 @@ export interface Skill {
description: string; description: string;
filePath: string; filePath: string;
baseDir: string; baseDir: string;
source: string; sourceInfo: SourceInfo;
sourceInfo?: SourceInfo;
disableModelInvocation: boolean; disableModelInvocation: boolean;
} }
@@ -138,6 +137,30 @@ export interface LoadSkillsFromDirOptions {
source: string; source: string;
} }
function createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {
switch (source) {
case "user":
return createSyntheticSourceInfo(filePath, {
source: "local",
scope: "user",
baseDir,
});
case "project":
return createSyntheticSourceInfo(filePath, {
source: "local",
scope: "project",
baseDir,
});
case "path":
return createSyntheticSourceInfo(filePath, {
source: "local",
baseDir,
});
default:
return createSyntheticSourceInfo(filePath, { source, baseDir });
}
}
/** /**
* Load skills from a directory. * Load skills from a directory.
* *
@@ -293,7 +316,7 @@ function loadSkillFromFile(
description: frontmatter.description, description: frontmatter.description,
filePath, filePath,
baseDir: skillDir, baseDir: skillDir,
source, sourceInfo: createSkillSourceInfo(filePath, skillDir, source),
disableModelInvocation: frontmatter["disable-model-invocation"] === true, disableModelInvocation: frontmatter["disable-model-invocation"] === true,
}, },
diagnostics, diagnostics,

View File

@@ -2,15 +2,11 @@ import type { SourceInfo } from "./source-info.js";
export type SlashCommandSource = "extension" | "prompt" | "skill"; export type SlashCommandSource = "extension" | "prompt" | "skill";
export type SlashCommandLocation = "user" | "project" | "path";
export interface SlashCommandInfo { export interface SlashCommandInfo {
name: string; name: string;
description?: string; description?: string;
source: SlashCommandSource; source: SlashCommandSource;
sourceInfo?: SourceInfo; sourceInfo: SourceInfo;
location?: SlashCommandLocation;
path?: string;
} }
export interface BuiltinSlashCommand { export interface BuiltinSlashCommand {

View File

@@ -1,14 +1,17 @@
import type { PathMetadata } from "./package-manager.js"; import type { PathMetadata } from "./package-manager.js";
export type SourceScope = "user" | "project" | "temporary";
export type SourceOrigin = "package" | "top-level";
export interface SourceInfo { export interface SourceInfo {
path?: string; path: string;
source: string; source: string;
scope: "user" | "project" | "temporary"; scope: SourceScope;
origin: "package" | "top-level"; origin: SourceOrigin;
baseDir?: string; baseDir?: string;
} }
export function createSourceInfo(path: string | undefined, metadata: PathMetadata): SourceInfo { export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo {
return { return {
path, path,
source: metadata.source, source: metadata.source,
@@ -17,3 +20,21 @@ export function createSourceInfo(path: string | undefined, metadata: PathMetadat
baseDir: metadata.baseDir, baseDir: metadata.baseDir,
}; };
} }
export function createSyntheticSourceInfo(
path: string,
options: {
source: string;
scope?: SourceScope;
origin?: SourceOrigin;
baseDir?: string;
},
): SourceInfo {
return {
path,
source: options.source,
scope: options.scope ?? "temporary",
origin: options.origin ?? "top-level",
baseDir: options.baseDir,
};
}

View File

@@ -108,7 +108,6 @@ export type {
SessionSwitchEvent, SessionSwitchEvent,
SessionTreeEvent, SessionTreeEvent,
SlashCommandInfo, SlashCommandInfo,
SlashCommandLocation,
SlashCommandSource, SlashCommandSource,
SourceInfo, SourceInfo,
TerminalInputHandler, TerminalInputHandler,
@@ -215,6 +214,7 @@ export {
type Skill, type Skill,
type SkillFrontmatter, type SkillFrontmatter,
} from "./core/skills.js"; } from "./core/skills.js";
export { createSyntheticSourceInfo } from "./core/source-info.js";
// Tools // Tools
export { export {
type BashOperations, type BashOperations,

View File

@@ -333,7 +333,7 @@ export class InteractiveMode {
.map((command) => ({ .map((command) => ({
type: "warning" as const, type: "warning" as const,
message: `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`, message: `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`,
path: command.extensionPath, path: command.sourceInfo.path,
})); }));
} }

View File

@@ -544,35 +544,30 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
case "get_commands": { case "get_commands": {
const commands: RpcSlashCommand[] = []; const commands: RpcSlashCommand[] = [];
// Extension commands
for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) { for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) {
commands.push({ commands.push({
name: command.name, name: command.name,
description: command.description, description: command.description,
source: "extension", source: "extension",
path: command.extensionPath, sourceInfo: command.sourceInfo,
}); });
} }
// Prompt templates (source is always "user" | "project" | "path" in coding-agent)
for (const template of session.promptTemplates) { for (const template of session.promptTemplates) {
commands.push({ commands.push({
name: template.name, name: template.name,
description: template.description, description: template.description,
source: "prompt", source: "prompt",
location: template.source as RpcSlashCommand["location"], sourceInfo: template.sourceInfo,
path: template.filePath,
}); });
} }
// Skills (source is always "user" | "project" | "path" in coding-agent)
for (const skill of session.resourceLoader.getSkills().skills) { for (const skill of session.resourceLoader.getSkills().skills) {
commands.push({ commands.push({
name: `skill:${skill.name}`, name: `skill:${skill.name}`,
description: skill.description, description: skill.description,
source: "skill", source: "skill",
location: skill.source as RpcSlashCommand["location"], sourceInfo: skill.sourceInfo,
path: skill.filePath,
}); });
} }

View File

@@ -10,6 +10,7 @@ import type { ImageContent, Model } from "@mariozechner/pi-ai";
import type { SessionStats } from "../../core/agent-session.js"; import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js"; import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js"; import type { CompactionResult } from "../../core/compaction/index.js";
import type { SourceInfo } from "../../core/source-info.js";
// ============================================================================ // ============================================================================
// RPC Commands (stdin) // RPC Commands (stdin)
@@ -78,10 +79,8 @@ export interface RpcSlashCommand {
description?: string; description?: string;
/** What kind of command this is */ /** What kind of command this is */
source: "extension" | "prompt" | "skill"; source: "extension" | "prompt" | "skill";
/** Where the command was loaded from (undefined for extensions) */ /** Source metadata for the owning resource */
location?: "user" | "project" | "path"; sourceInfo: SourceInfo;
/** File path to the command source */
path?: string;
} }
// ============================================================================ // ============================================================================

View File

@@ -67,7 +67,23 @@ describe("AgentSession dynamic tool registration", () => {
await session.bindExtensions({}); 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.getActiveToolNames()).toContain("dynamic_tool");
expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); 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."); 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(); 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 () => { it("keeps custom tools active but omits them from available tools when promptSnippet is not provided", async () => {
const settingsManager = SettingsManager.create(tempDir, agentDir); const settingsManager = SettingsManager.create(tempDir, agentDir);
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();

View File

@@ -20,6 +20,7 @@ import {
import { ModelRegistry } from "../src/core/model-registry.js"; import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-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 { codingTools } from "../src/core/tools/index.js";
import { createTestResourceLoader } from "./utilities.js"; import { createTestResourceLoader } from "./utilities.js";
@@ -74,6 +75,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
return { return {
path: "test-extension", path: "test-extension",
resolvedPath: "/test/test-extension.ts", resolvedPath: "/test/test-extension.ts",
sourceInfo: createSyntheticSourceInfo("<test:test-extension>", { source: "test" }),
handlers, handlers,
tools: new Map(), tools: new Map(),
messageRenderers: new Map(), messageRenderers: new Map(),
@@ -228,6 +230,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const throwingExtension: Extension = { const throwingExtension: Extension = {
path: "throwing-extension", path: "throwing-extension",
resolvedPath: "/test/throwing-extension.ts", resolvedPath: "/test/throwing-extension.ts",
sourceInfo: createSyntheticSourceInfo("<test:throwing-extension>", { source: "test" }),
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([ handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[ [
"session_before_compact", "session_before_compact",
@@ -276,6 +279,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const extension1: Extension = { const extension1: Extension = {
path: "extension1", path: "extension1",
resolvedPath: "/test/extension1.ts", resolvedPath: "/test/extension1.ts",
sourceInfo: createSyntheticSourceInfo("<test:extension1>", { source: "test" }),
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([ handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[ [
"session_before_compact", "session_before_compact",
@@ -306,6 +310,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const extension2: Extension = { const extension2: Extension = {
path: "extension2", path: "extension2",
resolvedPath: "/test/extension2.ts", resolvedPath: "/test/extension2.ts",
sourceInfo: createSyntheticSourceInfo("<test:extension2>", { source: "test" }),
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([ handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[ [
"session_before_compact", "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 { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import type { Skill } from "../src/core/skills.js"; import type { Skill } from "../src/core/skills.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
describe("DefaultResourceLoader", () => { describe("DefaultResourceLoader", () => {
let tempDir: string; let tempDir: string;
@@ -411,7 +412,7 @@ Content`,
description: "Injected skill", description: "Injected skill",
filePath: "/fake/path", filePath: "/fake/path",
baseDir: "/fake", baseDir: "/fake",
source: "custom", sourceInfo: createSyntheticSourceInfo("/fake/path", { source: "custom" }),
disableModelInvocation: false, disableModelInvocation: false,
}; };
const loader = new DefaultResourceLoader({ 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 type { ResourceLoader } from "../src/core/resource-loader.js";
import { createAgentSession } from "../src/core/sdk.js"; import { createAgentSession } from "../src/core/sdk.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { createSyntheticSourceInfo } from "../src/core/source-info.js";
describe("createAgentSession skills option", () => { describe("createAgentSession skills option", () => {
let tempDir: string; let tempDir: string;
@@ -79,7 +80,7 @@ This is a test skill.
description: "A custom skill", description: "A custom skill",
filePath: "/fake/path/SKILL.md", filePath: "/fake/path/SKILL.md",
baseDir: "/fake/path", baseDir: "/fake/path",
source: "custom" as const, sourceInfo: createSyntheticSourceInfo("/fake/path/SKILL.md", { source: "sdk" }),
disableModelInvocation: false, disableModelInvocation: false,
}; };

View File

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