Skip to content

fix(personas): persist pack-backed persona UI edits across reboot#1392

Merged
wpfleger96 merged 7 commits into
mainfrom
duncan/persona-edit-writeback
Jun 30, 2026
Merged

fix(personas): persist pack-backed persona UI edits across reboot#1392
wpfleger96 merged 7 commits into
mainfrom
duncan/persona-edit-writeback

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Problem

Pack-backed personas (source_team set) have their PersonaRecord fields overwritten on every app launch by sync_team_from_dir (teams.rs:502-509), which reads the source .persona.md and does an unconditional pack-wins overwrite. A UI edit saved via update_persona writes only to personas.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_persona saves to personas.json, if the persona has source_team set, write the edited fields back to the source .persona.md so launch sync becomes a no-op. The source file is resolved the same way launch sync resolves it — load_pack(source_dir) plus LoadedPersona.source_path — rather than by filename convention, so it works for both agents/ and personas/ pack layouts. The owning team is matched by team.id OR team_persona_key(team), covering legacy/backfilled teams whose TeamRecord.id is a UUID while source_team holds the manifest/directory id.

Six fields are written back:

  • Frontmatter: display_name, runtime, avatar, and model (joined as provider:model per the pack format, bare when provider is absent — the inverse of split_model).
  • Body: the persona prompt.

Prompt decomposition

PersonaRecord.system_prompt is the composed prompt — the raw body plus pack instructions appended by compose_prompt (resolve.rs:248-255):

{body}\n\n---\n# Team Instructions\n{pack_instructions}

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:

  1. If system_prompt == compose_prompt(current_body, instructions) → prompt unedited; body byte-preserved.
  2. Else, with no pack instructions → body = system_prompt verbatim.
  3. Else strip the exact \n\n---\n# Team Instructions\n{instructions} suffix to recover the new body.
  4. Safety guard: if instructions are present but the exact suffix is absent (an edit shape that can't be cleanly decomposed), the body is preserved and a skip is logged — never a corrupted file or double-append.

Byte fidelity is preserved at both normalization boundaries so the decomposition's exact-suffix match holds: update_persona no longer trims system_prompt (instruction files commonly end in a trailing newline that the trim would strip), and PersonaDialog submits systemPrompt verbatim. The five other UI fields are still trimmed. create_persona storage is unchanged.

Implementation notes

  • Frontmatter rewrite: split_frontmatter + serde_yaml::Value round-trip (same approach as rewrite_legacy_persona_md_runtime in migration.rs).
  • Non-fatal: missing source_dir (JSON-only team), unmatched team, unreadable/missing file, unresolvable pack/persona, parse or write error → logged and swallowed. personas.json is already persisted, so the in-app edit lands regardless and update_persona never starts returning Err.
  • In-app personas (source_team == None) → early return, no file I/O.

Tests

cargo test --lib (desktop): 845 passed, 0 failed. Coverage includes the joined provider:model key, bare model, runtime/avatar clear, 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-newline instructions.md), edited-without-instructions, unedited round-trip, the safety guard, and a multi-save round-trip proving the == short-circuit holds across consecutive saves.

@wpfleger96 wpfleger96 changed the title fix(personas): write pack-backed persona edits back to source .persona.md fix(personas): write all pack-backed persona edits back to source .persona.md Jun 30, 2026
@wpfleger96 wpfleger96 marked this pull request as ready for review June 30, 2026 15:19
@wpfleger96 wpfleger96 changed the title fix(personas): write all pack-backed persona edits back to source .persona.md fix(personas): persist pack-backed persona UI edits across reboot Jun 30, 2026
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 7 commits June 30, 2026 11:31
…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>
@wpfleger96 wpfleger96 force-pushed the duncan/persona-edit-writeback branch from a7ef002 to 7f48919 Compare June 30, 2026 15:31
@wpfleger96 wpfleger96 merged commit a7e1202 into main Jun 30, 2026
21 of 25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/persona-edit-writeback branch June 30, 2026 15:44
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)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant