fix(coding-agent): disambiguate duplicate slash commands, fixes #1061
This commit is contained in:
@@ -35,6 +35,7 @@ Examples:
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed extension command name conflicts so extensions with duplicate command names can load together. Conflicting extension commands now get numeric invocation suffixes in load order, for example `/review:1` and `/review:2` ([#1061](https://github.com/badlogic/pi-mono/issues/1061))
|
||||||
- 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 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))
|
||||||
|
|||||||
@@ -1089,6 +1089,8 @@ Labels persist in the session and survive restarts. Use them to mark important p
|
|||||||
|
|
||||||
Register a command.
|
Register a command.
|
||||||
|
|
||||||
|
If multiple extensions register the same command name, pi keeps them all and assigns numeric invocation suffixes in load order, for example `/review:1` and `/review:2`.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
pi.registerCommand("stats", {
|
pi.registerCommand("stats", {
|
||||||
description: "Show session statistics",
|
description: "Show session statistics",
|
||||||
@@ -1133,7 +1135,7 @@ Each entry has this shape:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
name: string; // Command name without the leading slash
|
name: string; // Invokable command name without the leading slash. May be suffixed like "review:1"
|
||||||
description?: string;
|
description?: string;
|
||||||
source: "extension" | "prompt" | "skill";
|
source: "extension" | "prompt" | "skill";
|
||||||
sourceInfo: {
|
sourceInfo: {
|
||||||
|
|||||||
@@ -2093,7 +2093,7 @@ export class AgentSession {
|
|||||||
private _bindExtensionCore(runner: ExtensionRunner): void {
|
private _bindExtensionCore(runner: ExtensionRunner): void {
|
||||||
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.invocationName,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
source: "extension",
|
source: "extension",
|
||||||
sourceInfo: command.sourceInfo,
|
sourceInfo: command.sourceInfo,
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export type {
|
|||||||
// Commands
|
// Commands
|
||||||
RegisteredCommand,
|
RegisteredCommand,
|
||||||
RegisteredTool,
|
RegisteredTool,
|
||||||
|
ResolvedCommand,
|
||||||
// Events - Resources
|
// Events - Resources
|
||||||
ResourcesDiscoverEvent,
|
ResourcesDiscoverEvent,
|
||||||
ResourcesDiscoverResult,
|
ResourcesDiscoverResult,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
RegisteredCommand,
|
RegisteredCommand,
|
||||||
RegisteredTool,
|
RegisteredTool,
|
||||||
|
ResolvedCommand,
|
||||||
ResourcesDiscoverEvent,
|
ResourcesDiscoverEvent,
|
||||||
ResourcesDiscoverResult,
|
ResourcesDiscoverResult,
|
||||||
SessionBeforeCompactResult,
|
SessionBeforeCompactResult,
|
||||||
@@ -467,41 +468,53 @@ export class ExtensionRunner {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectRegisteredCommands(diagnostics?: ResourceDiagnostic[]): RegisteredCommand[] {
|
private resolveRegisteredCommands(): ResolvedCommand[] {
|
||||||
const commands: RegisteredCommand[] = [];
|
const commands: RegisteredCommand[] = [];
|
||||||
const commandOwners = new Map<string, string>();
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
for (const ext of this.extensions) {
|
for (const ext of this.extensions) {
|
||||||
for (const command of ext.commands.values()) {
|
for (const command of ext.commands.values()) {
|
||||||
const existingOwner = commandOwners.get(command.name);
|
|
||||||
if (existingOwner) {
|
|
||||||
const message = `Extension command '${command.name}' from ${ext.path} conflicts with ${existingOwner}. Skipping.`;
|
|
||||||
diagnostics?.push({ type: "warning", message, path: ext.path });
|
|
||||||
if (diagnostics && !this.hasUI()) {
|
|
||||||
console.warn(message);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
commandOwners.set(command.name, ext.path);
|
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
|
counts.set(command.name, (counts.get(command.name) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return commands;
|
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
const takenInvocationNames = new Set<string>();
|
||||||
|
|
||||||
|
return commands.map((command) => {
|
||||||
|
const occurrence = (seen.get(command.name) ?? 0) + 1;
|
||||||
|
seen.set(command.name, occurrence);
|
||||||
|
|
||||||
|
let invocationName = (counts.get(command.name) ?? 0) > 1 ? `${command.name}:${occurrence}` : command.name;
|
||||||
|
|
||||||
|
if (takenInvocationNames.has(invocationName)) {
|
||||||
|
let suffix = occurrence;
|
||||||
|
do {
|
||||||
|
suffix++;
|
||||||
|
invocationName = `${command.name}:${suffix}`;
|
||||||
|
} while (takenInvocationNames.has(invocationName));
|
||||||
|
}
|
||||||
|
|
||||||
|
takenInvocationNames.add(invocationName);
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
invocationName,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getRegisteredCommands(): RegisteredCommand[] {
|
getRegisteredCommands(): ResolvedCommand[] {
|
||||||
const diagnostics: ResourceDiagnostic[] = [];
|
this.commandDiagnostics = [];
|
||||||
const commands = this.collectRegisteredCommands(diagnostics);
|
return this.resolveRegisteredCommands();
|
||||||
this.commandDiagnostics = diagnostics;
|
|
||||||
return commands;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommandDiagnostics(): ResourceDiagnostic[] {
|
getCommandDiagnostics(): ResourceDiagnostic[] {
|
||||||
return this.commandDiagnostics;
|
return this.commandDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommand(name: string): RegisteredCommand | undefined {
|
getCommand(name: string): ResolvedCommand | undefined {
|
||||||
return this.collectRegisteredCommands().find((command) => command.name === name);
|
return this.resolveRegisteredCommands().find((command) => command.invocationName === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -967,6 +967,10 @@ export interface RegisteredCommand {
|
|||||||
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCommand extends RegisteredCommand {
|
||||||
|
invocationName: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Extension API
|
// Extension API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -849,9 +849,8 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||||||
private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {
|
private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {
|
||||||
const conflicts: Array<{ path: string; message: string }> = [];
|
const conflicts: Array<{ path: string; message: string }> = [];
|
||||||
|
|
||||||
// Track which extension registered each tool, command, and flag
|
// Track which extension registered each tool and flag
|
||||||
const toolOwners = new Map<string, string>();
|
const toolOwners = new Map<string, string>();
|
||||||
const commandOwners = new Map<string, string>();
|
|
||||||
const flagOwners = new Map<string, string>();
|
const flagOwners = new Map<string, string>();
|
||||||
|
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
@@ -868,19 +867,6 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check commands
|
|
||||||
for (const commandName of ext.commands.keys()) {
|
|
||||||
const existingOwner = commandOwners.get(commandName);
|
|
||||||
if (existingOwner && existingOwner !== ext.path) {
|
|
||||||
conflicts.push({
|
|
||||||
path: ext.path,
|
|
||||||
message: `Command "/${commandName}" conflicts with ${existingOwner}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
commandOwners.set(commandName, ext.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check flags
|
// Check flags
|
||||||
for (const flagName of ext.flags.keys()) {
|
for (const flagName of ext.flags.keys()) {
|
||||||
const existingOwner = flagOwners.get(flagName);
|
const existingOwner = flagOwners.get(flagName);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export type {
|
|||||||
ReadToolCallEvent,
|
ReadToolCallEvent,
|
||||||
RegisteredCommand,
|
RegisteredCommand,
|
||||||
RegisteredTool,
|
RegisteredTool,
|
||||||
|
ResolvedCommand,
|
||||||
SessionBeforeCompactEvent,
|
SessionBeforeCompactEvent,
|
||||||
SessionBeforeForkEvent,
|
SessionBeforeForkEvent,
|
||||||
SessionBeforeSwitchEvent,
|
SessionBeforeSwitchEvent,
|
||||||
|
|||||||
@@ -332,7 +332,10 @@ export class InteractiveMode {
|
|||||||
.filter((command) => builtinNames.has(command.name))
|
.filter((command) => builtinNames.has(command.name))
|
||||||
.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:
|
||||||
|
command.invocationName === command.name
|
||||||
|
? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`
|
||||||
|
: `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`,
|
||||||
path: command.sourceInfo.path,
|
path: command.sourceInfo.path,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -386,7 +389,7 @@ export class InteractiveMode {
|
|||||||
const extensionCommands: SlashCommand[] = (
|
const extensionCommands: SlashCommand[] = (
|
||||||
this.session.extensionRunner?.getRegisteredCommands().filter((cmd) => !builtinCommandNames.has(cmd.name)) ?? []
|
this.session.extensionRunner?.getRegisteredCommands().filter((cmd) => !builtinCommandNames.has(cmd.name)) ?? []
|
||||||
).map((cmd) => ({
|
).map((cmd) => ({
|
||||||
name: cmd.name,
|
name: cmd.invocationName,
|
||||||
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
|
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
|
||||||
getArgumentCompletions: cmd.getArgumentCompletions,
|
getArgumentCompletions: cmd.getArgumentCompletions,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||||||
|
|
||||||
for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) {
|
for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) {
|
||||||
commands.push({
|
commands.push({
|
||||||
name: command.name,
|
name: command.invocationName,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
source: "extension",
|
source: "extension",
|
||||||
sourceInfo: command.sourceInfo,
|
sourceInfo: command.sourceInfo,
|
||||||
|
|||||||
@@ -345,9 +345,10 @@ describe("ExtensionRunner", () => {
|
|||||||
|
|
||||||
expect(commands.length).toBe(2);
|
expect(commands.length).toBe(2);
|
||||||
expect(commands.map((c) => c.name).sort()).toEqual(["cmd-a", "cmd-b"]);
|
expect(commands.map((c) => c.name).sort()).toEqual(["cmd-a", "cmd-b"]);
|
||||||
|
expect(commands.map((c) => c.invocationName).sort()).toEqual(["cmd-a", "cmd-b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gets command by name", async () => {
|
it("gets command by invocation name", async () => {
|
||||||
const cmdCode = `
|
const cmdCode = `
|
||||||
export default function(pi) {
|
export default function(pi) {
|
||||||
pi.registerCommand("my-cmd", {
|
pi.registerCommand("my-cmd", {
|
||||||
@@ -364,13 +365,14 @@ describe("ExtensionRunner", () => {
|
|||||||
const cmd = runner.getCommand("my-cmd");
|
const cmd = runner.getCommand("my-cmd");
|
||||||
expect(cmd).toBeDefined();
|
expect(cmd).toBeDefined();
|
||||||
expect(cmd?.name).toBe("my-cmd");
|
expect(cmd?.name).toBe("my-cmd");
|
||||||
|
expect(cmd?.invocationName).toBe("my-cmd");
|
||||||
expect(cmd?.description).toBe("My command");
|
expect(cmd?.description).toBe("My command");
|
||||||
|
|
||||||
const missing = runner.getCommand("not-exists");
|
const missing = runner.getCommand("not-exists");
|
||||||
expect(missing).toBeUndefined();
|
expect(missing).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters out duplicate extension commands", async () => {
|
it("suffixes duplicate extension commands in insertion order", async () => {
|
||||||
const cmdCode = (description: string) => `
|
const cmdCode = (description: string) => `
|
||||||
export default function(pi) {
|
export default function(pi) {
|
||||||
pi.registerCommand("shared-cmd", {
|
pi.registerCommand("shared-cmd", {
|
||||||
@@ -382,22 +384,18 @@ describe("ExtensionRunner", () => {
|
|||||||
fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("First command"));
|
fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("First command"));
|
||||||
fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("Second command"));
|
fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("Second command"));
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||||
const commands = runner.getRegisteredCommands();
|
const commands = runner.getRegisteredCommands();
|
||||||
const diagnostics = runner.getCommandDiagnostics();
|
const diagnostics = runner.getCommandDiagnostics();
|
||||||
|
|
||||||
expect(commands).toHaveLength(1);
|
expect(commands).toHaveLength(2);
|
||||||
expect(commands[0]?.name).toBe("shared-cmd");
|
expect(commands.map((command) => command.name)).toEqual(["shared-cmd", "shared-cmd"]);
|
||||||
expect(commands[0]?.description).toBe("First command");
|
expect(commands.map((command) => command.invocationName)).toEqual(["shared-cmd:1", "shared-cmd:2"]);
|
||||||
|
expect(commands.map((command) => command.description)).toEqual(["First command", "Second command"]);
|
||||||
expect(diagnostics).toHaveLength(1);
|
expect(diagnostics).toEqual([]);
|
||||||
expect(diagnostics[0]?.path).toEqual(path.join(extensionsDir, "cmd-b.ts"));
|
expect(runner.getCommand("shared-cmd:1")?.description).toBe("First command");
|
||||||
|
expect(runner.getCommand("shared-cmd:2")?.description).toBe("Second command");
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with"));
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ Project skill`,
|
|||||||
|
|
||||||
const extensionsResult = loader.getExtensions();
|
const extensionsResult = loader.getExtensions();
|
||||||
expect(extensionsResult.extensions).toHaveLength(2);
|
expect(extensionsResult.extensions).toHaveLength(2);
|
||||||
expect(extensionsResult.errors.some((e) => e.error.includes('Command "/deploy" conflicts'))).toBe(true);
|
expect(extensionsResult.errors.some((e) => e.error.includes('Command "/deploy" conflicts'))).toBe(false);
|
||||||
|
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||||
@@ -208,12 +208,18 @@ Project skill`,
|
|||||||
modelRegistry,
|
modelRegistry,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runner.getCommand("deploy")?.description).toBe("project deploy");
|
expect(runner.getCommand("deploy:1")?.description).toBe("project deploy");
|
||||||
|
expect(runner.getCommand("deploy:2")?.description).toBe("user deploy");
|
||||||
expect(runner.getCommand("project-only")?.description).toBe("project only");
|
expect(runner.getCommand("project-only")?.description).toBe("project only");
|
||||||
expect(runner.getCommand("user-only")?.description).toBe("user only");
|
expect(runner.getCommand("user-only")?.description).toBe("user only");
|
||||||
|
|
||||||
const commandNames = runner.getRegisteredCommands().map((c) => c.name);
|
const commands = runner.getRegisteredCommands();
|
||||||
expect(commandNames.filter((name) => name === "deploy")).toHaveLength(1);
|
expect(commands.map((command) => command.invocationName)).toEqual([
|
||||||
|
"deploy:1",
|
||||||
|
"project-only",
|
||||||
|
"deploy:2",
|
||||||
|
"user-only",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should honor overrides for auto-discovered resources", async () => {
|
it("should honor overrides for auto-discovered resources", async () => {
|
||||||
@@ -551,7 +557,8 @@ export default function(pi: ExtensionAPI) {
|
|||||||
modelRegistry,
|
modelRegistry,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runner.getCommand("deploy")?.description).toBe("explicit command");
|
expect(runner.getCommand("deploy:1")?.description).toBe("explicit command");
|
||||||
|
expect(runner.getCommand("deploy:2")?.description).toBe("global command");
|
||||||
expect(runner.getToolDefinition("duplicate-tool")?.description).toBe("explicit tool");
|
expect(runner.getToolDefinition("duplicate-tool")?.description).toBe("explicit tool");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { createHarness, type Harness } from "./test-harness.js";
|
import { createHarness, createHarnessWithExtensions, type Harness } from "./test-harness.js";
|
||||||
|
|
||||||
describe("test harness", () => {
|
describe("test harness", () => {
|
||||||
let harness: Harness;
|
let harness: Harness;
|
||||||
@@ -257,6 +257,58 @@ describe("test harness", () => {
|
|||||||
expect(firstText).toBeLessThan(firstToolcall);
|
expect(firstText).toBeLessThan(firstToolcall);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads inline extension factories and disambiguates duplicate commands", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
harness = await createHarnessWithExtensions({
|
||||||
|
extensionFactories: [
|
||||||
|
{
|
||||||
|
path: "<alpha>",
|
||||||
|
factory: (pi) => {
|
||||||
|
pi.registerCommand("shared-cmd", {
|
||||||
|
description: "Alpha command",
|
||||||
|
handler: async (args) => {
|
||||||
|
calls.push(`alpha:${args}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "<beta>",
|
||||||
|
factory: (pi) => {
|
||||||
|
pi.registerCommand("shared-cmd", {
|
||||||
|
description: "Beta command",
|
||||||
|
handler: async (args) => {
|
||||||
|
calls.push(`beta:${args}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const runner = harness.session.extensionRunner;
|
||||||
|
expect(runner).toBeDefined();
|
||||||
|
|
||||||
|
const commands = runner!.getRegisteredCommands();
|
||||||
|
expect(
|
||||||
|
commands.map((command) => ({
|
||||||
|
name: command.name,
|
||||||
|
invocationName: command.invocationName,
|
||||||
|
description: command.description,
|
||||||
|
path: command.sourceInfo.path,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "shared-cmd", invocationName: "shared-cmd:1", description: "Alpha command", path: "<alpha>" },
|
||||||
|
{ name: "shared-cmd", invocationName: "shared-cmd:2", description: "Beta command", path: "<beta>" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await runner!.getCommand("shared-cmd:1")?.handler("first", runner!.createCommandContext());
|
||||||
|
await runner!.getCommand("shared-cmd:2")?.handler("second", runner!.createCommandContext());
|
||||||
|
|
||||||
|
expect(calls).toEqual(["alpha:first", "beta:second"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("session persistence works", async () => {
|
it("session persistence works", async () => {
|
||||||
harness = createHarness({ responses: ["persisted"] });
|
harness = createHarness({ responses: ["persisted"] });
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ 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 type { Settings } from "../src/core/settings-manager.js";
|
import type { Settings } from "../src/core/settings-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
import { createTestResourceLoader } from "./utilities.js";
|
import type { ExtensionFactory, ResourceLoader } from "../src/index.js";
|
||||||
|
import {
|
||||||
|
type CreateTestExtensionsResultInput,
|
||||||
|
createTestExtensionsResult,
|
||||||
|
createTestResourceLoader,
|
||||||
|
} from "./utilities.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Faux model
|
// Faux model
|
||||||
@@ -327,6 +332,10 @@ export interface HarnessOptions {
|
|||||||
tools?: AgentTool[];
|
tools?: AgentTool[];
|
||||||
/** Base tools override (replaces built-in read/bash/edit/write). */
|
/** Base tools override (replaces built-in read/bash/edit/write). */
|
||||||
baseToolsOverride?: Record<string, AgentTool>;
|
baseToolsOverride?: Record<string, AgentTool>;
|
||||||
|
/** Optional resource loader override. */
|
||||||
|
resourceLoader?: ResourceLoader;
|
||||||
|
/** Inline extensions to load into the session resource loader. */
|
||||||
|
extensionFactories?: Array<ExtensionFactory | CreateTestExtensionsResultInput>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Harness {
|
export interface Harness {
|
||||||
@@ -346,10 +355,17 @@ export interface Harness {
|
|||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHarness(options: HarnessOptions = {}): Harness {
|
function createTempDir(): string {
|
||||||
const tempDir = join(tmpdir(), `pi-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
const tempDir = join(tmpdir(), `pi-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
mkdirSync(tempDir, { recursive: true });
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarnessWithResourceLoader(
|
||||||
|
options: HarnessOptions,
|
||||||
|
resourceLoader: ResourceLoader,
|
||||||
|
tempDir: string,
|
||||||
|
): Harness {
|
||||||
const baseModel = options.model ?? fauxModel;
|
const baseModel = options.model ?? fauxModel;
|
||||||
const model: Model<any> = options.contextWindow ? { ...baseModel, contextWindow: options.contextWindow } : baseModel;
|
const model: Model<any> = options.contextWindow ? { ...baseModel, contextWindow: options.contextWindow } : baseModel;
|
||||||
|
|
||||||
@@ -382,7 +398,7 @@ export function createHarness(options: HarnessOptions = {}): Harness {
|
|||||||
settingsManager,
|
settingsManager,
|
||||||
cwd: tempDir,
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
resourceLoader: createTestResourceLoader(),
|
resourceLoader,
|
||||||
baseToolsOverride: options.baseToolsOverride,
|
baseToolsOverride: options.baseToolsOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -412,3 +428,19 @@ export function createHarness(options: HarnessOptions = {}): Harness {
|
|||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createHarness(options: HarnessOptions = {}): Harness {
|
||||||
|
if (options.extensionFactories?.length) {
|
||||||
|
throw new Error("createHarness does not support extensionFactories. Use createHarnessWithExtensions().");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
return createHarnessWithResourceLoader(options, options.resourceLoader ?? createTestResourceLoader(), tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHarnessWithExtensions(options: HarnessOptions = {}): Promise<Harness> {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const extensionsResult = await createTestExtensionsResult(options.extensionFactories ?? [], tempDir);
|
||||||
|
const resourceLoader = options.resourceLoader ?? createTestResourceLoader({ extensionsResult });
|
||||||
|
return createHarnessWithResourceLoader(options, resourceLoader, tempDir);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { getModel, type OAuthCredentials, type OAuthProvider } from "@mariozechn
|
|||||||
import { getOAuthApiKey } from "@mariozechner/pi-ai/oauth";
|
import { getOAuthApiKey } from "@mariozechner/pi-ai/oauth";
|
||||||
import { AgentSession } from "../src/core/agent-session.js";
|
import { AgentSession } from "../src/core/agent-session.js";
|
||||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||||
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
|
import { createEventBus } from "../src/core/event-bus.js";
|
||||||
|
import type { Extension, ExtensionFactory, LoadExtensionsResult } from "../src/core/extensions/index.js";
|
||||||
|
import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.js";
|
||||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
import type { ResourceLoader } from "../src/core/resource-loader.js";
|
import type { ResourceLoader } from "../src/core/resource-loader.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
@@ -175,9 +177,46 @@ export interface TestSessionContext {
|
|||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTestResourceLoader(): ResourceLoader {
|
export interface CreateTestExtensionsResultInput {
|
||||||
|
factory: ExtensionFactory;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestExtensionsResult(
|
||||||
|
inputs: Array<ExtensionFactory | CreateTestExtensionsResultInput>,
|
||||||
|
cwd = process.cwd(),
|
||||||
|
): Promise<LoadExtensionsResult> {
|
||||||
|
const runtime = createExtensionRuntime();
|
||||||
|
const eventBus = createEventBus();
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
|
||||||
|
for (const [index, input] of inputs.entries()) {
|
||||||
|
const factory = typeof input === "function" ? input : input.factory;
|
||||||
|
const extensionPath =
|
||||||
|
typeof input === "function" ? `<inline:${index + 1}>` : (input.path ?? `<inline:${index + 1}>`);
|
||||||
|
extensions.push(await loadExtensionFromFactory(factory, cwd, eventBus, runtime, extensionPath));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
extensions,
|
||||||
|
errors: [],
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTestResourceLoaderOptions {
|
||||||
|
extensionsResult?: LoadExtensionsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestResourceLoader(options: CreateTestResourceLoaderOptions = {}): ResourceLoader {
|
||||||
|
const extensionsResult = options.extensionsResult ?? {
|
||||||
|
extensions: [],
|
||||||
|
errors: [],
|
||||||
|
runtime: createExtensionRuntime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getExtensions: () => extensionsResult,
|
||||||
getSkills: () => ({ skills: [], diagnostics: [] }),
|
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||||
|
|||||||
Reference in New Issue
Block a user