fix(personas): persist pack-backed persona UI edits across reboot#1392
Merged
Conversation
…a.md On every app launch, sync_team_from_dir re-reads each .persona.md and unconditionally overwrites the PersonaRecord fields (model, provider, display_name, runtime, avatar). A UI edit to personas.json was therefore clobbered on the next boot, making it appear to not persist. Extend update_persona: after saving to personas.json, if the persona has source_team set, locate the team's source_dir, derive the slug path (<source_dir>/agents/<slug>.persona.md), and rewrite its YAML frontmatter with the four editable fields: display_name, runtime, avatar, and model (joined as provider:model per the pack format). The markdown body is preserved byte-for-byte because PersonaRecord.system_prompt is the composed prompt (body + pack_instructions) and writing it back would cause double-appended instructions on the next launch sync. Failure is non-fatal: a missing source_dir (JSON-only team), unreadable file, or write error is logged and swallowed. The personas.json write already succeeded so the in-app edit always lands regardless. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ntion write_back_persona_md previously derived the .persona.md path as <source_dir>/agents/<slug>.persona.md. The launch sync does not use that convention — it calls load_pack(source_dir) and iterates LoadedPersona.source_path, which is the manifest-resolved absolute path. For any pack using a personas/ layout, a nested path, or where name: != basename the old code would target the wrong file, and launch sync would still reset the record from the real source file. Fix: derive the path the same way the sync does — load_pack(source_dir), find LoadedPersona where persona.name == slug, use source_path. Non-fatal contract unchanged: any error (pack load, no matching name, I/O) logs via eprintln and returns Ok so update_persona never fails for write-back reasons. Tests added: temp-pack with personas/ layout and temp-pack where the file basename differs from name: — both assert the manifest-resolved file is rewritten, not a convention-based agents/ path. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
write_back_persona_md previously found the team with: .find(|t| t.id == source_team_id) But sync_team_from_dir uses team_persona_key(team), which returns source_dir.file_name() when available, falling back to team.id. For legacy/backfilled teams (team.id is a UUID, personas store the manifest directory name in source_team), the id-only predicate never matched, so write-back silently fell through and launch sync still reset the edit. Fix: introduce find_team_for_persona_source(teams, source_team) which checks both team.id == source_team and team_persona_key(t) == source_team, covering both modern and legacy team shapes. Promote team_persona_key to pub and re-export it so commands/ can call it directly. Tests added: legacy UUID team matched by source_dir.file_name(); modern team matched by id; JSON-only team falls back to id; no-match returns None. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
test_find_team_uuid_alone_does_not_match_manifest_id implied 'not found' but the lookup intentionally matches via team.id. Rename to test_find_team_manifest_dir_name_matches_regardless_of_uuid_id, which states what the test actually asserts: that a manifest-dir-name lookup finds a legacy team regardless of its UUID id. Zero logic change. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
System prompt edits to pack-backed personas were non-sticky: the UI
saved system_prompt to personas.json, but launch sync overwrote it
from the source .persona.md body (via compose_prompt). This extends
write_back_persona_md to also write the body when the user edits it.
The recovery strategy reverses compose_prompt:
compose_prompt(body, instructions) =
body + "\n\n---\n# Team Instructions\n{instructions}"
To recover the new raw body from system_prompt:
- No pack_instructions: body = system_prompt verbatim.
- pack_instructions present: strip the exact suffix. If the suffix is
absent (user edited inside the instructions block, or instructions
drifted), preserve the existing file body and log a skip — better
a non-sticky edit than a corrupted file or double-append on next sync.
- system_prompt == compose_prompt(current_raw_body, instructions): user
did not edit the prompt; existing body is preserved byte-for-byte.
rewrite_persona_md_frontmatter is renamed rewrite_persona_md and gains
current_raw_body and pack_instructions parameters. All frontmatter
fields (display_name, runtime, avatar, model) continue to be written.
Tests added: prompt edited with instructions, prompt edited without
instructions, unedited body preserved, safety guard, round-trip (no
double-append on next launch sync after write-back).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
write_back_persona_md decomposes the composed system_prompt by suffix-stripping the Team Instructions block. That suffix is built from pack_instructions read verbatim from disk (pack.rs:173-185), so normal instructions.md files with a trailing newline produce a suffix that ends in newline. The previous system_prompt.trim() in update_persona stripped that trailing newline, causing strip_suffix to fail and the safety guard to silently discard the body write-back for ordinary instruction files. Fix: store system_prompt verbatim (no trim) in update_persona so the stored value is byte-identical to what compose_prompt produces, keeping the decomposition inverse exactly aligned. create_persona is unchanged — its trim is unrelated to write-back. Regression test added: pack_instructions with trailing newline, user edits prompt → body is rewritten (suffix-strip succeeds, safety guard does not fire). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ion test PersonaDialog submitted systemPrompt.trim() on save. This destroyed the byte-fidelity established by the backend no-trim fix: on the second save the stored (trimmed) system_prompt no longer matched compose_prompt(body, instructions\n), so the == short-circuit missed, suffix-strip failed on the trimmed string, and the safety guard preserved the old body — making prompt edits non-sticky on every save after the first. Drop the .trim() from PersonaDialog.tsx baseInput. The create_persona backend still trims at line 171 (unchanged), so create-path storage is unaffected. Whitespace-only prompts on the update path store verbatim — no panic, no spurious safety-guard: compose_prompt != whitespace, branch falls to the no-instructions write-verbatim path. Add test_multi_save_round_trip_short_circuit_holds_both_times: two consecutive no-edit saves with trailing-newline instructions must both hit the == short-circuit, produce identical file content, and never fire the safety guard. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
a7ef002 to
7f48919
Compare
wpfleger96
pushed a commit
that referenced
this pull request
Jun 30, 2026
…work * origin/main: (25 commits) fix(thread): stop mid-scroll content jump in live threads (#1397) fix(ci): restore main to green — tauri fmt, personas.rs file-size split, Windows path test (#1399) fix(desktop): enable buzz-dev-mcp MCP server for Codex agents (#1394) fix(ci): restore E2E flakiness fixes for pgschema, docker-pull, and spec timing (#1396) fix(personas): persist pack-backed persona UI edits across reboot (#1392) fix(buzz-acp): clear steer_rx on all run_prompt_task exit paths (#1391) Restore channel date divider rule (#1395) Speed up profile wave action (#1379) Restore visible links for rich previews (#1378) Mobile channel list polish (#1367) style(desktop): unify corner radii to rounded-2xl (16px) (#1393) fix(desktop): skip keychain write when blob contents are unchanged (#1377) fix(desktop): stop clipping the agent-activity row under the composer (#1371) Constrain macOS overscroll to conversations (#1317) Mobile appearance foundation (#1366) chore(release): release Buzz Desktop version 0.3.38 (#1375) feat(desktop): provider-agnostic model selection + databricks discovery (#1307) release(helm): buzz chart 0.1.1 (#1374) Harden relay attack surfaces (#1369) ci(helm): publish chart to GHCR on chart-v* tags (#1372) ...
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Pack-backed personas (
source_teamset) have theirPersonaRecordfields overwritten on every app launch bysync_team_from_dir(teams.rs:502-509), which reads the source.persona.mdand does an unconditional pack-wins overwrite. A UI edit saved viaupdate_personawrites only topersonas.json, so launch sync clobbers it on the next boot — the reported symptom was an edited model resetting to its old value after restart.Fix
After
update_personasaves topersonas.json, if the persona hassource_teamset, write the edited fields back to the source.persona.mdso launch sync becomes a no-op. The source file is resolved the same way launch sync resolves it —load_pack(source_dir)plusLoadedPersona.source_path— rather than by filename convention, so it works for bothagents/andpersonas/pack layouts. The owning team is matched byteam.idORteam_persona_key(team), covering legacy/backfilled teams whoseTeamRecord.idis a UUID whilesource_teamholds the manifest/directory id.Six fields are written back:
display_name,runtime,avatar, andmodel(joined asprovider:modelper the pack format, bare when provider is absent — the inverse ofsplit_model).Prompt decomposition
PersonaRecord.system_promptis the composed prompt — the raw body plus pack instructions appended bycompose_prompt(resolve.rs:248-255):Writing the composed string back to the file body would double-append the Team Instructions block on every launch. So write-back recovers the raw body by reversing
compose_prompt:system_prompt == compose_prompt(current_body, instructions)→ prompt unedited; body byte-preserved.system_promptverbatim.\n\n---\n# Team Instructions\n{instructions}suffix to recover the new body.Byte fidelity is preserved at both normalization boundaries so the decomposition's exact-suffix match holds:
update_personano longer trimssystem_prompt(instruction files commonly end in a trailing newline that the trim would strip), andPersonaDialogsubmitssystemPromptverbatim. The five other UI fields are still trimmed.create_personastorage is unchanged.Implementation notes
split_frontmatter+serde_yaml::Valueround-trip (same approach asrewrite_legacy_persona_md_runtimeinmigration.rs).source_dir(JSON-only team), unmatched team, unreadable/missing file, unresolvable pack/persona, parse or write error → logged and swallowed.personas.jsonis already persisted, so the in-app edit lands regardless andupdate_personanever starts returningErr.source_team == None) → early return, no file I/O.Tests
cargo test --lib(desktop): 845 passed, 0 failed. Coverage includes the joinedprovider:modelkey, bare model,runtime/avatarclear, unrelated-key preservation, body-not-replaced-by-composed-prompt, both pack layouts, legacy/modern team-key matching, and the prompt-decomposition paths — edited-with-instructions (including a trailing-newlineinstructions.md), edited-without-instructions, unedited round-trip, the safety guard, and a multi-save round-trip proving the==short-circuit holds across consecutive saves.