Skip to content

playbook: publish every client edit, and re-converge when one is rejected (#1103, #1104, #1089) - #1115

Closed
edwin-zvs wants to merge 1 commit into
e2e-playbook-paste-regressionsfrom
fix-playbook-paste-sync
Closed

playbook: publish every client edit, and re-converge when one is rejected (#1103, #1104, #1089)#1115
edwin-zvs wants to merge 1 commit into
e2e-playbook-paste-regressionsfrom
fix-playbook-paste-sync

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Fixes #1103, #1104 and #1089, and turns on the regressions from #1114 (which this stacks on — review that one first; GitHub will retarget this to main when it merges).

These are one bug wearing three hats. A Playbook is a document a human and an agent hold at once, so an interactive client owes it two things: publish every local mutation, and re-converge when a publish is rejected. The TUI was doing neither on some paths, and the failure mode was invisible — a correct-looking buffer that had stopped talking to the daemon in both directions.

#1103 — paste never published

on_paste inserted into the buffer and returned. Every keystroke path snapshots the buffer and calls flush_playbook_live_edit; this one didn't, so a pasted block lived only in the client. Worse, the next keystroke's anchored edit was then derived from a base the daemon never received, so it failed too — and so did every one after it.

Paste now goes through insert_playbook_text_synced, which inserts, follows the scroll, publishes the cursor, and flushes — the same tail handle_playbook_key runs.

#1104 — CR line breaks stored verbatim

Terminals routinely deliver the line breaks inside a bracketed paste as \r (it's what a tty expects for Enter, and what tmux's paste-buffer sends without -r). Nothing normalized them. Stored verbatim they aren't line breaks to anything downstream: the renderer paints them as nothing, so the block collapses to one visible line, and block splitting is newline-based, so it collapses to one addressable block — one shimmer target, one selection-Run target, one opaque line for the agent.

Normalized in two places, deliberately:

  • Client, in insert_playbook_text — so the caret lands where the user expects immediately rather than after a round trip, and every ingress path is covered (bracketed paste, clipboard yank, clip insertion).
  • Daemon, in both playbook write paths — so "a stored Playbook contains no lone CR" is an invariant of the store rather than a promise each client has to keep. The web UI and MCP writers get it for free.

#1089 — rejected live edits were swallowed

let Ok(result) = … else { return }; dropped the failure. That left saved_markdown stale, so on_playbook_state stopped adopting remote changes (correctly, to protect what looked like unsaved work) — and the document was severed in both directions while the buffer kept accepting input.

A rejected edit now falls back to save_playbook_popup, the same three-way merge C-x C-s performs: it rebases onto the latest document, keeps both sides, re-arms saved_markdown, and sets its own status so the user sees that a merge happened. No new machinery.

A third instance, found while writing the spec

apply_playbook_template — clicking a template button in the empty state — had exactly the same shape: replace the buffer, return, never publish. So a brand-new Playbook, the very first thing a user does with one, existed only in that client. Fixed the same way.

Tests

The two regressions from #1114 lose their #[ignore] and pass:

test playbook_paste_normalizes_carriage_returns ... ok
test playbook_paste_reaches_the_daemon ... ok
test playbook_recovers_from_a_diverged_buffer ... ok

playbook_recovers_from_a_diverged_buffer is new here, covering #1089. Verified it fails without the fix, for its own reason:

editing never re-converged after a rejected live edit: the daemon is still on "seedXYZ\n"

It opens the divergence with C-x u, which today mutates without publishing (#1088). The assertion is written to hold after that's fixed too — there is then simply no divergence to recover from, and the typed character still lands.

Specs

  • 0171-playbook-client-edits-publish-and-reconverge — the rule these three bugs each broke, and the reason the second half (re-converge) exists: a client that stops publishing doesn't lose one edit, it loses all of them, silently.
  • 0172-playbook-stores-newline-normalized-markdown — why the store owns the newline guarantee rather than each client.

Binaries

crates/cli and crates/daemon both change, and both live in the single construct binary:

/Users/moon/agentd/.claude/worktrees/fix-playbook-paste-sync/target/debug/construct

To try it: open a Playbook, paste a multi-line Markdown block from your terminal, and check construct playbook get <session> — the structure survives and the daemon has it without saving.

…cted

Three paths mutated the Playbook buffer and returned without publishing,
so the change lived only in that client — and because the next keystroke's
anchored edit was then derived from a base the daemon never received, it
failed too, and so did every one after it. The buffer kept accepting input
and looked correct the whole time.

- Paste (#1103): on_paste now runs the same tail handle_playbook_key does —
  insert, follow scroll, publish cursor, flush.
- Template buttons: apply_playbook_template had the same shape, so a
  brand-new Playbook filled from the empty state never left the client.
- Rejected live edits (#1089): the failure was dropped on the floor, which
  left saved_markdown stale — on_playbook_state then stopped adopting
  remote changes to protect the "unsaved" edit, severing the document in
  both directions. It now falls back to the same 3-way merge C-x C-s
  performs, which rebases, keeps both sides, and reports the merge.

Also normalize CR line endings (#1104). Terminals send CR for the line
breaks inside a bracketed paste, and stored verbatim they are not line
breaks to anything downstream: the renderer paints them as nothing and
block splitting is newline-based, so a pasted block collapsed to one
visible line and one addressable block. Normalized on the client so the
caret is right immediately, and in both daemon write paths so the
guarantee belongs to the store rather than to each client.

Drops the #[ignore] from the two regressions added in #1114 and adds a
third covering the rejected-edit recovery.

Specs 0171 (edits publish and re-converge) and 0172 (stored Playbooks are
newline-normalized).
@edwin-zvs
edwin-zvs deleted the branch e2e-playbook-paste-regressions August 1, 2026 19:20
@edwin-zvs edwin-zvs closed this Aug 1, 2026
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Superseded by #1116 — GitHub auto-closed this when its base branch (e2e-playbook-paste-regressions) was deleted on the #1114 merge, and a closed PR's base cannot be retargeted. Same commit, rebased onto main.

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