diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx index 5e0ba0f6c..62c3b94a5 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -18,6 +18,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { buildBatchImportPersonaInput } from "./batchImportPersonaInput"; type BatchImportDialogProps = { fileName: string; @@ -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) => { diff --git a/desktop/src/features/agents/ui/batchImportPersonaInput.test.mjs b/desktop/src/features/agents/ui/batchImportPersonaInput.test.mjs new file mode 100644 index 000000000..3ddd1679e --- /dev/null +++ b/desktop/src/features/agents/ui/batchImportPersonaInput.test.mjs @@ -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"], + }, + ); +}); diff --git a/desktop/src/features/agents/ui/batchImportPersonaInput.ts b/desktop/src/features/agents/ui/batchImportPersonaInput.ts new file mode 100644 index 000000000..69baeaf95 --- /dev/null +++ b/desktop/src/features/agents/ui/batchImportPersonaInput.ts @@ -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, + }; +} diff --git a/desktop/src/features/agents/ui/personaImportPlan.test.mjs b/desktop/src/features/agents/ui/personaImportPlan.test.mjs index 98556ccda..eb065b919 100644 --- a/desktop/src/features/agents/ui/personaImportPlan.test.mjs +++ b/desktop/src/features/agents/ui/personaImportPlan.test.mjs @@ -14,6 +14,7 @@ function createPersona(overrides = {}) { systemPrompt: "Be helpful.", runtime: null, model: null, + provider: null, namePool: [], isBuiltIn: false, isActive: true, @@ -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, @@ -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"] }), @@ -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", () => { diff --git a/desktop/src/features/agents/ui/personaImportPlan.ts b/desktop/src/features/agents/ui/personaImportPlan.ts index 999e9ed65..506602cc1 100644 --- a/desktop/src/features/agents/ui/personaImportPlan.ts +++ b/desktop/src/features/agents/ui/personaImportPlan.ts @@ -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) { diff --git a/desktop/src/features/agents/ui/personaImportUpdateInput.test.mjs b/desktop/src/features/agents/ui/personaImportUpdateInput.test.mjs new file mode 100644 index 000000000..bb1f5a2b5 --- /dev/null +++ b/desktop/src/features/agents/ui/personaImportUpdateInput.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/agents/ui/personaImportUpdateInput.ts b/desktop/src/features/agents/ui/personaImportUpdateInput.ts new file mode 100644 index 000000000..27869b670 --- /dev/null +++ b/desktop/src/features/agents/ui/personaImportUpdateInput.ts @@ -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; +}; + +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, + }; +} diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index e8f1930e1..a1b2faded 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -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) { diff --git a/desktop/src/features/agents/ui/usePersonaImportActions.ts b/desktop/src/features/agents/ui/usePersonaImportActions.ts index 34d8ea430..0f8e7c67a 100644 --- a/desktop/src/features/agents/ui/usePersonaImportActions.ts +++ b/desktop/src/features/agents/ui/usePersonaImportActions.ts @@ -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, @@ -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( diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts index 996cd0c24..ffc4024ba 100644 --- a/desktop/src/features/agents/ui/useTeamActions.ts +++ b/desktop/src/features/agents/ui/useTeamActions.ts @@ -226,6 +226,7 @@ export function useTeamActions( queryClient.invalidateQueries({ queryKey: personasQueryKey }), queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), ]); + setTeamDialogState(null); } catch (err) { actions.setActionErrorMessage( err instanceof Error diff --git a/desktop/src/shared/lib/fileMagic.test.mjs b/desktop/src/shared/lib/fileMagic.test.mjs new file mode 100644 index 000000000..21ebde2a7 --- /dev/null +++ b/desktop/src/shared/lib/fileMagic.test.mjs @@ -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); +}); diff --git a/desktop/src/shared/lib/fileMagic.ts b/desktop/src/shared/lib/fileMagic.ts index edbc1ad14..402961bad 100644 --- a/desktop/src/shared/lib/fileMagic.ts +++ b/desktop/src/shared/lib/fileMagic.ts @@ -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) ); }