Agents crud (#9084)

This commit is contained in:
Jack Amadeo
2026-05-11 13:33:32 -04:00
committed by GitHub
parent 40d4b118bb
commit a38922d95e
43 changed files with 1463 additions and 1458 deletions
+163 -53
View File
@@ -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);
});
});
+176 -26
View File
@@ -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");
}
+9 -15
View File
@@ -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;
}
+21 -4
View File
@@ -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;
}