Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions desktop/src/features/agents/ui/BatchImportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { buildBatchImportPersonaInput } from "./batchImportPersonaInput";

type BatchImportDialogProps = {
fileName: string;
Expand Down Expand Up @@ -91,13 +92,7 @@ export function BatchImportDialog({
});

try {
await createPersona({
displayName: persona.displayName,
avatarUrl: persona.avatarDataUrl ?? undefined,
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,
});
await createPersona(buildBatchImportPersonaInput(persona));
completed += 1;
setImportedCount(completed);
setItemStatuses((prev) => {
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/features/agents/ui/batchImportPersonaInput.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";

import { buildBatchImportPersonaInput } from "./batchImportPersonaInput.ts";

function persona(overrides = {}) {
return {
displayName: "Imported Agent",
avatarDataUrl: null,
avatarRef: null,
systemPrompt: "Use the imported provider.",
runtime: "goose",
model: "claude-sonnet-4",
provider: "anthropic",
namePool: [],
sourceFile: "agent.persona.md",
...overrides,
};
}

test("buildBatchImportPersonaInput preserves provider from parsed personas", () => {
assert.deepEqual(buildBatchImportPersonaInput(persona()), {
displayName: "Imported Agent",
avatarUrl: undefined,
systemPrompt: "Use the imported provider.",
runtime: "goose",
model: "claude-sonnet-4",
provider: "anthropic",
namePool: undefined,
});
});

test("buildBatchImportPersonaInput carries imported name pools", () => {
assert.deepEqual(
buildBatchImportPersonaInput(persona({ namePool: ["fizz", "buzz"] })),
{
displayName: "Imported Agent",
avatarUrl: undefined,
systemPrompt: "Use the imported provider.",
runtime: "goose",
model: "claude-sonnet-4",
provider: "anthropic",
namePool: ["fizz", "buzz"],
},
);
});
16 changes: 16 additions & 0 deletions desktop/src/features/agents/ui/batchImportPersonaInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { CreatePersonaInput } from "@/shared/api/types";

export function buildBatchImportPersonaInput(
persona: ParsedPersonaPreview,
): CreatePersonaInput {
return {
displayName: persona.displayName,
avatarUrl: persona.avatarDataUrl ?? undefined,
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,
provider: persona.provider ?? undefined,
namePool: persona.namePool.length > 0 ? persona.namePool : undefined,
};
}
27 changes: 25 additions & 2 deletions desktop/src/features/agents/ui/personaImportPlan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function createPersona(overrides = {}) {
systemPrompt: "Be helpful.",
runtime: null,
model: null,
provider: null,
namePool: [],
isBuiltIn: false,
isActive: true,
Expand All @@ -28,8 +29,10 @@ function createPreview(overrides = {}) {
displayName: "Alice",
systemPrompt: "Be helpful.",
avatarDataUrl: null,
avatarRef: null,
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "alice.persona.json",
...overrides,
Expand Down Expand Up @@ -106,6 +109,19 @@ test("buildPersonaImportPlan detects model change", () => {
assert.equal(plan.fields[0]?.label, "Preferred model");
});

test("buildPersonaImportPlan detects provider change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ provider: "anthropic" }),
preview: createPreview({ provider: "databricks" }),
});

assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "provider");
assert.equal(plan.fields[0]?.label, "LLM provider");
assert.equal(plan.fields[0]?.existingValue, "anthropic");
assert.equal(plan.fields[0]?.importedValue, "databricks");
});

test("buildPersonaImportPlan detects name pool change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ namePool: ["Birch", "Compass"] }),
Expand All @@ -123,17 +139,24 @@ test("buildPersonaImportPlan detects multiple field changes", () => {
displayName: "Alice",
systemPrompt: "Old prompt",
model: "gpt-4o",
provider: "openai",
}),
preview: createPreview({
displayName: "Alicia",
systemPrompt: "New prompt",
model: "claude-sonnet-4-20250514",
provider: "anthropic",
}),
});

assert.equal(plan.fields.length, 3);
assert.equal(plan.fields.length, 4);
const fieldNames = plan.fields.map((f) => f.field);
assert.deepEqual(fieldNames, ["displayName", "systemPrompt", "model"]);
assert.deepEqual(fieldNames, [
"displayName",
"systemPrompt",
"model",
"provider",
]);
});

test("hasAnyPersonaImportChanges returns false for empty plan", () => {
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/features/agents/ui/personaImportPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,18 @@ export function buildPersonaImportPlan({
});
}

const existingProvider = normalizeOptionalText(persona.provider);
const importedProvider = normalizeOptionalText(preview.provider);
if (existingProvider !== importedProvider) {
fields.push({
field: "provider",
label: "LLM provider",
existingValue: existingProvider,
importedValue: importedProvider,
...singleLineChanges(existingProvider, importedProvider),
});
}

const existingNamePool = namePoolToString(persona.namePool);
const importedNamePool = namePoolToString(preview.namePool);
if (existingNamePool !== importedNamePool) {
Expand Down
60 changes: 60 additions & 0 deletions desktop/src/features/agents/ui/personaImportUpdateInput.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";

import { buildPersonaImportUpdateInput } from "./personaImportUpdateInput.ts";

function createPersona(overrides = {}) {
return {
id: "persona-1",
displayName: "Alice",
avatarUrl: null,
systemPrompt: "Be helpful.",
runtime: "goose",
model: "claude-sonnet-4",
provider: "anthropic",
namePool: [],
isBuiltIn: false,
isActive: true,
envVars: {},
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}

function createPreview(overrides = {}) {
return {
displayName: "Alice",
systemPrompt: "Be helpful.",
avatarDataUrl: null,
avatarRef: null,
runtime: "goose",
model: "gpt-5",
provider: "databricks",
namePool: [],
sourceFile: "alice.md",
...overrides,
};
}

test("buildPersonaImportUpdateInput applies selected model and provider updates", () => {
const input = buildPersonaImportUpdateInput({
existing: createPersona(),
preview: createPreview(),
selectedFields: ["model", "provider"],
});

assert.equal(input.model, "gpt-5");
assert.equal(input.provider, "databricks");
});

test("buildPersonaImportUpdateInput preserves provider when provider is not selected", () => {
const input = buildPersonaImportUpdateInput({
existing: createPersona(),
preview: createPreview(),
selectedFields: ["model"],
});

assert.equal(input.model, "gpt-5");
assert.equal(input.provider, "anthropic");
});
45 changes: 45 additions & 0 deletions desktop/src/features/agents/ui/personaImportUpdateInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";

type BuildPersonaImportUpdateInputArgs = {
existing: AgentPersona;
preview: ParsedPersonaPreview;
selectedFields: Iterable<string>;
};

export function buildPersonaImportUpdateInput({
existing,
preview,
selectedFields,
}: BuildPersonaImportUpdateInputArgs): UpdatePersonaInput {
const selectedFieldSet = new Set(selectedFields);

return {
id: existing.id,
displayName: selectedFieldSet.has("displayName")
? preview.displayName
: existing.displayName,
systemPrompt: selectedFieldSet.has("systemPrompt")
? preview.systemPrompt
: existing.systemPrompt,
avatarUrl: selectedFieldSet.has("avatarUrl")
? (preview.avatarDataUrl ?? undefined)
: (existing.avatarUrl ?? undefined),
runtime: selectedFieldSet.has("runtime")
? (preview.runtime ?? undefined)
: (existing.runtime ?? undefined),
model: selectedFieldSet.has("model")
? (preview.model ?? undefined)
: (existing.model ?? undefined),
provider: selectedFieldSet.has("provider")
? (preview.provider ?? undefined)
: (existing.provider ?? undefined),
namePool: selectedFieldSet.has("namePool")
? preview.namePool.length > 0
? preview.namePool
: undefined
: existing.namePool.length > 0
? [...existing.namePool]
: undefined,
};
}
5 changes: 4 additions & 1 deletion desktop/src/features/agents/ui/usePersonaActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ export function usePersonaActions() {
clearFeedback("library");
try {
const result = await parsePersonaFiles(fileBytes, fileName);
if (isSingleItemFile(fileBytes) && result.personas.length === 1) {
if (
isSingleItemFile(fileBytes, fileName) &&
result.personas.length === 1
) {
setShouldLoadAcpRuntimes(true);
setPersonaDialogState(importPersonaDialogState(result.personas[0]));
} else if (result.personas.length > 0) {
Expand Down
36 changes: 8 additions & 28 deletions desktop/src/features/agents/ui/usePersonaImportActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
updatePersona as updatePersonaApi,
type ParsedPersonaPreview,
} from "@/shared/api/tauriPersonas";
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
import type { AgentPersona } from "@/shared/api/types";
import { buildPersonaImportPlan } from "./personaImportPlan";
import { buildPersonaImportUpdateInput } from "./personaImportUpdateInput";
import {
editPersonaDialogState,
type PersonaDialogState,
Expand Down Expand Up @@ -105,41 +106,20 @@ export function usePersonaImportActions(
preview: personaImportTargetPreview.preview,
});

const selectedFieldSet = new Set(selectedFields);
const preview = personaImportTargetPreview.preview;
const existing = personaImportTarget;

try {
const updateInput: UpdatePersonaInput = {
id: existing.id,
displayName: selectedFieldSet.has("displayName")
? preview.displayName
: existing.displayName,
systemPrompt: selectedFieldSet.has("systemPrompt")
? preview.systemPrompt
: existing.systemPrompt,
avatarUrl: selectedFieldSet.has("avatarUrl")
? (preview.avatarDataUrl ?? undefined)
: (existing.avatarUrl ?? undefined),
runtime: selectedFieldSet.has("runtime")
? (preview.runtime ?? undefined)
: (existing.runtime ?? undefined),
model: selectedFieldSet.has("model")
? (preview.model ?? undefined)
: (existing.model ?? undefined),
namePool: selectedFieldSet.has("namePool")
? preview.namePool.length > 0
? preview.namePool
: undefined
: existing.namePool.length > 0
? [...existing.namePool]
: undefined,
};
const updateInput = buildPersonaImportUpdateInput({
existing,
preview,
selectedFields,
});

await updatePersonaApi(updateInput);

const updatedFieldCount = plan.fields.filter((field) =>
selectedFieldSet.has(field.field),
selectedFields.includes(field.field),
).length;

feedback.setPersonaNoticeMessage(
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/agents/ui/useTeamActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export function useTeamActions(
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
setTeamDialogState(null);
} catch (err) {
actions.setActionErrorMessage(
err instanceof Error
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/shared/lib/fileMagic.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";

import { isSingleItemFile } from "./fileMagic.ts";

test("isSingleItemFile treats markdown persona files as single imports", () => {
assert.equal(isSingleItemFile([45, 45, 45], "fizz.md"), true);
});

test("isSingleItemFile treats legacy persona markdown files as single imports", () => {
assert.equal(isSingleItemFile([45, 45, 45], "fizz.persona.md"), true);
});

test("isSingleItemFile does not classify arbitrary text files as single imports", () => {
assert.equal(isSingleItemFile([45, 45, 45], "notes.txt"), false);
});
14 changes: 11 additions & 3 deletions desktop/src/shared/lib/fileMagic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ function matchesMagic(
return magic.every((b, i) => bytes[i] === b);
}

/** Return true when `bytes` looks like a single-item file (PNG or JSON). */
export function isSingleItemFile(bytes: number[] | readonly number[]): boolean {
function isMarkdownFileName(fileName: string | undefined): boolean {
return fileName?.toLowerCase().endsWith(".md") ?? false;
}

/** Return true when the file format carries one persona instead of a bundle. */
export function isSingleItemFile(
bytes: number[] | readonly number[],
fileName?: string,
): boolean {
return (
matchesMagic(bytes, PNG_MAGIC) ||
(bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE)
(bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE) ||
isMarkdownFileName(fileName)
);
}
Loading