Agents crud (#9084)
This commit is contained in:
@@ -1,78 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { exportPersona, importPersonas, refreshPersonas } from "../agents";
|
||||
import {
|
||||
createPersona,
|
||||
deletePersona,
|
||||
exportPersona,
|
||||
importPersonas,
|
||||
listPersonas,
|
||||
refreshPersonas,
|
||||
updatePersona,
|
||||
} from "../agents";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
const mockGooseSourcesCreate = vi.fn();
|
||||
const mockGooseSourcesDelete = vi.fn();
|
||||
const mockGooseSourcesExport = vi.fn();
|
||||
const mockGooseSourcesImport = vi.fn();
|
||||
const mockGooseSourcesList = vi.fn();
|
||||
const mockGooseSourcesUpdate = vi.fn();
|
||||
|
||||
vi.mock("@/shared/api/acpConnection", () => ({
|
||||
getClient: async () => ({
|
||||
goose: {
|
||||
GooseSourcesCreate: (...args: unknown[]) =>
|
||||
mockGooseSourcesCreate(...args),
|
||||
GooseSourcesDelete: (...args: unknown[]) =>
|
||||
mockGooseSourcesDelete(...args),
|
||||
GooseSourcesExport: (...args: unknown[]) =>
|
||||
mockGooseSourcesExport(...args),
|
||||
GooseSourcesImport: (...args: unknown[]) =>
|
||||
mockGooseSourcesImport(...args),
|
||||
GooseSourcesList: (...args: unknown[]) => mockGooseSourcesList(...args),
|
||||
GooseSourcesUpdate: (...args: unknown[]) =>
|
||||
mockGooseSourcesUpdate(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
const source = {
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
path: "/Users/test/.agents/agents/scout.md",
|
||||
global: true,
|
||||
writable: true,
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: "file:///Users/test/.goose/avatars/agents/scout.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("agents API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── exportPersona ────────────────────────────────────────────────────
|
||||
it("listPersonas maps agent sources to personas", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
|
||||
it("exportPersona invokes correct Tauri command with ID", async () => {
|
||||
const mockResult = {
|
||||
json: '{"displayName":"Test"}',
|
||||
suggestedFilename: "test.json",
|
||||
};
|
||||
mockedInvoke.mockResolvedValue(mockResult);
|
||||
const result = await listPersonas();
|
||||
|
||||
const result = await exportPersona("persona-123");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("export_persona", {
|
||||
id: "persona-123",
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: source.path,
|
||||
displayName: "Scout",
|
||||
avatar: { type: "url", value: source.properties.avatar },
|
||||
systemPrompt: "Research carefully.",
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
isBuiltin: false,
|
||||
isFromDisk: true,
|
||||
writable: true,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// ── importPersonas ───────────────────────────────────────────────────
|
||||
it("createPersona creates a global agent source", async () => {
|
||||
mockGooseSourcesCreate.mockResolvedValue({ source });
|
||||
|
||||
it("importPersonas invokes correct Tauri command with bytes and filename", async () => {
|
||||
const mockPersonas = [
|
||||
{
|
||||
id: "imported-1",
|
||||
displayName: "Imported",
|
||||
systemPrompt: "Hello",
|
||||
isBuiltin: false,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
mockedInvoke.mockResolvedValue(mockPersonas);
|
||||
|
||||
const fileBytes = [0x7b, 0x7d]; // "{}"
|
||||
const result = await importPersonas(fileBytes, "personas.json");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("import_personas", {
|
||||
fileBytes,
|
||||
fileName: "personas.json",
|
||||
const result = await createPersona({
|
||||
displayName: "Scout",
|
||||
avatar: { type: "url", value: source.properties.avatar },
|
||||
systemPrompt: "Research carefully.",
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
});
|
||||
expect(result).toEqual(mockPersonas);
|
||||
|
||||
expect(mockGooseSourcesCreate).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: source.properties.avatar,
|
||||
},
|
||||
global: true,
|
||||
});
|
||||
expect(result.displayName).toBe("Scout");
|
||||
});
|
||||
|
||||
// ── refreshPersonas ──────────────────────────────────────────────────
|
||||
it("updatePersona loads existing agent source and updates by path", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
mockGooseSourcesUpdate.mockResolvedValue({
|
||||
source: { ...source, name: "Scout 2" },
|
||||
});
|
||||
|
||||
it("refreshPersonas invokes correct Tauri command", async () => {
|
||||
const mockPersonas = [
|
||||
{
|
||||
id: "p1",
|
||||
displayName: "Refreshed",
|
||||
systemPrompt: "Prompt",
|
||||
isBuiltin: false,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
const result = await updatePersona(source.path, {
|
||||
displayName: "Scout 2",
|
||||
});
|
||||
|
||||
expect(mockGooseSourcesUpdate).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
name: "Scout 2",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: source.properties.avatar,
|
||||
},
|
||||
];
|
||||
mockedInvoke.mockResolvedValue(mockPersonas);
|
||||
});
|
||||
expect(result.displayName).toBe("Scout 2");
|
||||
});
|
||||
|
||||
it("deletePersona deletes an agent source by path", async () => {
|
||||
mockGooseSourcesDelete.mockResolvedValue(undefined);
|
||||
|
||||
await deletePersona(source.path);
|
||||
|
||||
expect(mockGooseSourcesDelete).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
});
|
||||
});
|
||||
|
||||
it("exportPersona exports an agent source", async () => {
|
||||
mockGooseSourcesExport.mockResolvedValue({
|
||||
json: '{"type":"agent"}',
|
||||
filename: "scout.agent.json",
|
||||
});
|
||||
|
||||
const result = await exportPersona(source.path);
|
||||
|
||||
expect(mockGooseSourcesExport).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
json: '{"type":"agent"}',
|
||||
suggestedFilename: "scout.agent.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("importPersonas imports agent source JSON", async () => {
|
||||
mockGooseSourcesImport.mockResolvedValue({ sources: [source] });
|
||||
const data = JSON.stringify({
|
||||
version: 1,
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
});
|
||||
const fileBytes = Array.from(new TextEncoder().encode(data));
|
||||
|
||||
const result = await importPersonas(fileBytes, "scout.agent.json");
|
||||
|
||||
expect(mockGooseSourcesImport).toHaveBeenCalledWith({
|
||||
data,
|
||||
global: true,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refreshPersonas lists personas", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
|
||||
const result = await refreshPersonas();
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("refresh_personas");
|
||||
expect(result).toEqual(mockPersonas);
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,155 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SourceEntry } from "@aaif/goose-sdk";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
import type {
|
||||
Persona,
|
||||
CreatePersonaRequest,
|
||||
UpdatePersonaRequest,
|
||||
Avatar,
|
||||
} from "@/shared/types/agents";
|
||||
|
||||
const AGENT_SOURCE_TYPE = "agent" as const;
|
||||
const AGENT_DESCRIPTION = "Agent";
|
||||
|
||||
type AgentSourceProperties = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type AgentSourceEntry = SourceEntry & {
|
||||
type: typeof AGENT_SOURCE_TYPE;
|
||||
properties: AgentSourceProperties;
|
||||
};
|
||||
|
||||
function isAgentSource(source: SourceEntry): source is AgentSourceEntry {
|
||||
return source.type === AGENT_SOURCE_TYPE;
|
||||
}
|
||||
|
||||
function avatarToProperty(
|
||||
avatar: Avatar | null | undefined,
|
||||
): string | undefined {
|
||||
if (!avatar) return undefined;
|
||||
return avatar.value;
|
||||
}
|
||||
|
||||
function propertyToAvatar(value: string | undefined): Avatar | null {
|
||||
if (!value) return null;
|
||||
return { type: "url", value };
|
||||
}
|
||||
|
||||
function personaProperties(
|
||||
request: CreatePersonaRequest | UpdatePersonaRequest,
|
||||
): AgentSourceProperties | undefined {
|
||||
const properties: AgentSourceProperties = {};
|
||||
if (request.provider) properties.provider = request.provider;
|
||||
if (request.model) properties.model = request.model;
|
||||
const avatar = avatarToProperty(request.avatar);
|
||||
if (avatar) properties.avatar = avatar;
|
||||
return properties;
|
||||
}
|
||||
|
||||
function toPersona(source: AgentSourceEntry): Persona {
|
||||
const writable = source.writable !== false;
|
||||
return {
|
||||
id: source.path,
|
||||
displayName: source.name,
|
||||
avatar: propertyToAvatar(source.properties?.avatar),
|
||||
systemPrompt: source.content,
|
||||
provider: source.properties?.provider,
|
||||
model: source.properties?.model,
|
||||
isBuiltin: !writable,
|
||||
isFromDisk: writable,
|
||||
writable,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function listAgentSources(): Promise<AgentSourceEntry[]> {
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesList({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
});
|
||||
return response.sources.filter(isAgentSource);
|
||||
}
|
||||
|
||||
async function getAgentSource(id: string): Promise<AgentSourceEntry> {
|
||||
const source = (await listAgentSources()).find(
|
||||
(source) => source.path === id,
|
||||
);
|
||||
if (!source) {
|
||||
throw new Error(`Agent '${id}' not found`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function listPersonas(): Promise<Persona[]> {
|
||||
return invoke("list_personas");
|
||||
return (await listAgentSources()).map(toPersona);
|
||||
}
|
||||
|
||||
export async function createPersona(
|
||||
request: CreatePersonaRequest,
|
||||
): Promise<Persona> {
|
||||
return invoke("create_persona", { request });
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesCreate({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
name: request.displayName,
|
||||
description: AGENT_DESCRIPTION,
|
||||
content: request.systemPrompt,
|
||||
properties: personaProperties(request),
|
||||
global: true,
|
||||
});
|
||||
|
||||
if (!isAgentSource(response.source)) {
|
||||
throw new Error(`Unexpected source type returned: ${response.source.type}`);
|
||||
}
|
||||
|
||||
return toPersona(response.source);
|
||||
}
|
||||
|
||||
export async function updatePersona(
|
||||
id: string,
|
||||
request: UpdatePersonaRequest,
|
||||
): Promise<Persona> {
|
||||
return invoke("update_persona", { id, request });
|
||||
const existing = await getAgentSource(id);
|
||||
const client = await getClient();
|
||||
const merged: CreatePersonaRequest = {
|
||||
displayName: request.displayName ?? existing.name,
|
||||
avatar:
|
||||
request.avatar === undefined
|
||||
? propertyToAvatar(existing.properties?.avatar)
|
||||
: request.avatar,
|
||||
systemPrompt: request.systemPrompt ?? existing.content,
|
||||
provider: request.provider ?? existing.properties?.provider,
|
||||
model: request.model ?? existing.properties?.model,
|
||||
};
|
||||
const response = await client.goose.GooseSourcesUpdate({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
name: merged.displayName,
|
||||
description: existing.description || AGENT_DESCRIPTION,
|
||||
content: merged.systemPrompt,
|
||||
properties: personaProperties(merged),
|
||||
});
|
||||
|
||||
if (!isAgentSource(response.source)) {
|
||||
throw new Error(`Unexpected source type returned: ${response.source.type}`);
|
||||
}
|
||||
|
||||
return toPersona(response.source);
|
||||
}
|
||||
|
||||
export async function deletePersona(id: string): Promise<void> {
|
||||
return invoke("delete_persona", { id });
|
||||
const client = await getClient();
|
||||
await client.goose.GooseSourcesDelete({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshPersonas(): Promise<Persona[]> {
|
||||
return invoke("refresh_personas");
|
||||
return listPersonas();
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -36,14 +158,61 @@ export interface ExportResult {
|
||||
}
|
||||
|
||||
export async function exportPersona(id: string): Promise<ExportResult> {
|
||||
return invoke("export_persona", { id });
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesExport({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
});
|
||||
return { json: response.json, suggestedFilename: response.filename };
|
||||
}
|
||||
|
||||
export async function importPersonas(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
): Promise<Persona[]> {
|
||||
return invoke("import_personas", { fileBytes, fileName });
|
||||
if (
|
||||
!fileName.endsWith(".agent.json") &&
|
||||
!fileName.endsWith(".persona.json") &&
|
||||
!fileName.endsWith(".json")
|
||||
) {
|
||||
throw new Error(
|
||||
"File must have a .agent.json, .persona.json, or .json extension",
|
||||
);
|
||||
}
|
||||
|
||||
const raw = new TextDecoder().decode(new Uint8Array(fileBytes));
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const data =
|
||||
parsed.type === AGENT_SOURCE_TYPE
|
||||
? raw
|
||||
: JSON.stringify({
|
||||
version: parsed.version ?? 1,
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
name: parsed.displayName ?? parsed.name,
|
||||
description: AGENT_DESCRIPTION,
|
||||
content:
|
||||
parsed.systemPrompt ?? parsed.content ?? parsed.instructions ?? "",
|
||||
properties: {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
avatar:
|
||||
typeof parsed.avatar === "string"
|
||||
? parsed.avatar
|
||||
: typeof parsed.avatar === "object" &&
|
||||
parsed.avatar !== null &&
|
||||
"value" in parsed.avatar
|
||||
? (parsed.avatar as { value?: unknown }).value
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesImport({
|
||||
data,
|
||||
global: true,
|
||||
});
|
||||
|
||||
return response.sources.filter(isAgentSource).map(toPersona);
|
||||
}
|
||||
|
||||
export interface ImportFileReadResult {
|
||||
@@ -56,22 +225,3 @@ export async function readImportPersonaFile(
|
||||
): Promise<ImportFileReadResult> {
|
||||
return invoke("read_import_persona_file", { sourcePath });
|
||||
}
|
||||
|
||||
export async function savePersonaAvatar(
|
||||
personaId: string,
|
||||
sourcePath: string,
|
||||
): Promise<string> {
|
||||
return invoke("save_persona_avatar", { personaId, sourcePath });
|
||||
}
|
||||
|
||||
export async function savePersonaAvatarBytes(
|
||||
personaId: string,
|
||||
bytes: number[],
|
||||
extension: string,
|
||||
): Promise<string> {
|
||||
return invoke("save_persona_avatar_bytes", { personaId, bytes, extension });
|
||||
}
|
||||
|
||||
export async function getAvatarsDir(): Promise<string> {
|
||||
return invoke("get_avatars_dir");
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { getAvatarsDir } from "@/shared/api/agents";
|
||||
import type { Avatar } from "@/shared/types/agents";
|
||||
|
||||
let cachedAvatarsDir: string | null = null;
|
||||
|
||||
async function ensureAvatarsDir(): Promise<string> {
|
||||
if (!cachedAvatarsDir) {
|
||||
cachedAvatarsDir = await getAvatarsDir();
|
||||
function resolveFileUrl(value: string): string {
|
||||
try {
|
||||
return convertFileSrc(decodeURIComponent(new URL(value).pathname));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return cachedAvatarsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Avatar to a displayable image URL.
|
||||
* Lazily fetches the avatars directory on first call for a local avatar.
|
||||
*/
|
||||
export async function resolveAvatarSrc(
|
||||
avatar: Avatar | null | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!avatar) return undefined;
|
||||
if (avatar.type === "url") return avatar.value;
|
||||
if (avatar.type === "local") {
|
||||
const dir = await ensureAvatarsDir();
|
||||
return convertFileSrc(`${dir}/${avatar.value}`);
|
||||
if (avatar.type === "url") {
|
||||
return avatar.value.startsWith("file://")
|
||||
? resolveFileUrl(avatar.value)
|
||||
: avatar.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
// a narrow union.
|
||||
export type ProviderType = string;
|
||||
|
||||
// Avatar type — either a remote URL or a local file in ~/.goose/avatars/
|
||||
export type Avatar =
|
||||
| { type: "url"; value: string }
|
||||
| { type: "local"; value: string };
|
||||
export interface ProviderConfig {
|
||||
type: ProviderType;
|
||||
name: string;
|
||||
description?: string;
|
||||
models: ModelInfo[];
|
||||
requiresApiKey: boolean;
|
||||
apiKeyEnvVar?: string;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
contextWindow: number;
|
||||
supportsTools: boolean;
|
||||
supportsVision: boolean;
|
||||
supportsThinking: boolean;
|
||||
}
|
||||
|
||||
// Avatar type — remote, data, and file URLs are stored directly in source properties.
|
||||
export type Avatar = { type: "url"; value: string };
|
||||
|
||||
// Persona types (from sprout)
|
||||
export interface Persona {
|
||||
@@ -18,6 +34,7 @@ export interface Persona {
|
||||
model?: string;
|
||||
isBuiltin: boolean;
|
||||
isFromDisk?: boolean;
|
||||
writable?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user