From f600fadc28d2c7cdc36544bf760c7ae9f556b10d Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 14 Jul 2026 18:19:33 -0700 Subject: [PATCH] feat(mcp): compact program edit context --- crates/daemon/src/session.rs | 13 +- crates/daemon/src/storage.rs | 66 +++++++- crates/mcp/src/tools.rs | 159 +++++++++++++----- crates/protocol/src/lib.rs | 1 + specs/0048-program-shimmer-agent-contract.md | 4 +- .../0053-program-shimmer-block-addressing.md | 8 +- specs/0057-program-shimmer-tooltip.md | 4 +- 7 files changed, 194 insertions(+), 61 deletions(-) diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 9a57777a..6548f6ef 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -543,8 +543,8 @@ fn program_pty_submit_bytes(prompt: &str) -> Vec { fn program_run_instructions() -> Vec { vec![ "Execute this construct program as an autonomous run.".to_string(), - "Shimmer semantics: a shimmering block means 'work on this block is still pending in this run — queued, in progress, or not yet done; outcome unknown'. No shimmer means 'settled — done, skipped, or no work needed'. Pending is about the state of the work, not how it runs: it applies the same whether you do the block yourself, delegate it, or drive it some other way, and a block counts as pending the moment you decide to act on it. A Run starts every executed block shimmering. Shimmer is a declared per-block state addressed by a stable block ref: read each block's id from the program get tool (or from the `blocks` returned by an edit/update), then declare a block pending or settled with the construct_program_edit `shimmer` list, e.g. `shimmer: [{id, shimmer: true, tooltip: \"Building PR\"}]`. When you set a block shimmer: true you MUST include a `tooltip`: a concise (≤10-word) description of that block's run status, shown when a viewer hovers the shimmering block; it is ignored when settling. The list is partial — it may target any block, not only the ones your edits change — and declaring a block settled clears it WITHOUT changing its text. Editing a block's semantic text advances its content epoch and gives it a new ref, so to keep an edited block shimmering — e.g. when you move a still-in-flight task into an In progress section or append a @{session} clip — set keep_pending: true on that edit; it re-adds the resulting block's new ref in the same call, so you need not know it and the block never goes dark. Never leave a settled block shimmering, and never drop shimmer from a block whose work is still in flight.".to_string(), - "Planning pass — this MUST be your first program action, before doing or delegating any work: read the program with the get tool to obtain every block's id, then make one construct_program_edit whose `shimmer` list declares each still-pending block's id shimmer: true (each with a concise ≤10-word `tooltip` of its run status) and each settled block's id shimmer: false. This needs no change to the blocks' text — declaring an id settled clears it. Doing this first makes the program reflect your plan within seconds of the Run instead of only after the first task completes; skipping it strands settled blocks shimmering and risks dropping shimmer from blocks still in flight.".to_string(), + "Shimmer semantics: a shimmering block means 'work on this block is still pending in this run — queued, in progress, or not yet done; outcome unknown'. No shimmer means 'settled — done, skipped, or no work needed'. Pending is about the state of the work, not how it runs. A Run starts every executed block shimmering. Declare status with construct_program_edit: `pending` maps each stable block ref to a concise (≤10-word) hover status, while `settled` is an array of refs to clear. Editing semantic text advances a block's ref, so set `keep_pending: true` when changing unfinished work; the compact edit response returns any new refs. Never leave settled work shimmering or drop shimmer from work still in flight.".to_string(), + "Planning pass — this MUST be your first program action, before doing or delegating work: read the program to obtain block ids, then make one status-only construct_program_edit with a `pending` map for every still-pending block and `settle_others: true`. No text edit is needed. This makes skipped/no-work blocks settle immediately and keeps planned work shimmering.".to_string(), "Treat program_run.markdown as free-form instructions and state for this turn, not as a request for a one-shot status report or as a fixed task-management schema.".to_string(), "Infer the user's intended objective from the document structure and prose, then keep taking useful next actions while there is actionable work you can do.".to_string(), "Do not ask the user to run the program again; if the document still implies useful work you can perform, continue in this turn.".to_string(), @@ -559,7 +559,7 @@ fn program_run_instructions() -> Vec { } fn program_execution_prompt() -> String { - "Run the current construct program autonomously. Before doing work, call agentd_context (or construct_context if you are using MCP) and read the program_run field for the latest program content, smart clip reference, and run instructions. If program_run is unavailable, read the current program with the program get tool before acting. Then, before starting or delegating any task, your first program action must be a single planning-pass construct_program_edit whose shimmer list declares every still-pending block's stable id shimmer: true and every settled block's stable id shimmer: false (read the block ids from the program get tool), so settled blocks stop shimmering immediately." + "Run the current construct program autonomously. Before doing work, call agentd_context (or construct_context if you are using MCP) and read the program_run field for the latest program content, smart clip reference, and run instructions. If program_run is unavailable, read the current program with the program get tool before acting. Then, before starting or delegating any task, your first program action must be one status-only construct_program_edit whose pending map assigns every still-pending block's stable id a concise status and whose settle_others flag is true, so omitted blocks settle immediately." .to_string() } @@ -5064,6 +5064,8 @@ mod tests { assert!(prompt.contains("construct_context")); assert!(prompt.contains("program_run")); assert!(prompt.contains("latest program content")); + assert!(prompt.contains("pending map")); + assert!(prompt.contains("settle_others")); assert!(!prompt.contains("Compare options and summarize findings.")); } @@ -5111,6 +5113,11 @@ mod tests { .instructions .iter() .any(|s| s.contains("Do not ask the user to run the program again"))); + assert!(context + .instructions + .iter() + .any(|s| s.contains("status-only construct_program_edit") + && s.contains("settle_others"))); assert!(context .instructions .iter() diff --git a/crates/daemon/src/storage.rs b/crates/daemon/src/storage.rs index f4a91aff..6f559ee6 100644 --- a/crates/daemon/src/storage.rs +++ b/crates/daemon/src/storage.rs @@ -513,8 +513,8 @@ impl Storage { /// there is no `base_version` gate: edits are anchored to text, so /// concurrent changes to *other* regions merge cleanly. Returns an error /// — and writes nothing — when any edit's anchor is missing or ambiguous, - /// so the caller can re-read and retry. A no-op edit set leaves the version - /// untouched. + /// so the caller can re-read and retry. An empty or no-op edit set leaves + /// the document and version untouched, allowing status-only program edits. pub fn edit_program( &self, id: &str, @@ -523,7 +523,7 @@ impl Storage { note: Option, ) -> Result { if edits.is_empty() { - anyhow::bail!("program edit: no edits provided"); + return self.read_program(id); } let current = self.read_program(id)?; let markdown = apply_program_edits(¤t.markdown, edits)?; @@ -701,7 +701,7 @@ impl Storage { } fn next_program_block_id(meta: &mut ProgramMeta) -> String { - let id = format!("pb_{:016x}", meta.next_block_ordinal); + let id = format!("b{:x}", meta.next_block_ordinal); meta.next_block_ordinal = meta.next_block_ordinal.saturating_add(1); id } @@ -723,11 +723,22 @@ impl Storage { /// prior record an edited block's continuity is attributed to. A leftover /// span beyond the leftover records' count is a brand-new block. fn reconcile_program_block_identities(&self, meta: &mut ProgramMeta, markdown: &str) -> bool { + // Block ids are program-scoped ordinals. Migrate the old fixed-width + // spelling on access so ids stay compact in subsequent payloads. + let mut migrated = false; + for block in &mut meta.blocks { + if let Some(hex) = block.block_id.strip_prefix("pb_") { + if let Ok(ordinal) = u64::from_str_radix(hex, 16) { + block.block_id = format!("b{ordinal:x}"); + migrated = true; + } + } + } let spans = construct_protocol::program_block_spans(markdown); let old = meta.blocks.clone(); let mut used = vec![false; old.len()]; let mut next: Vec> = vec![None; spans.len()]; - let mut changed = old.len() != spans.len(); + let mut changed = migrated || old.len() != spans.len(); for (i, span) in spans.iter().enumerate() { if let Some(j) = old @@ -2033,6 +2044,51 @@ mod program_tests { assert_eq!(out, "# Todo\n- ship it @{harness:claude}\n# Done\n"); } + #[test] + fn program_block_refs_use_compact_ordinals_and_migrate_padded_ids() { + let tmp = tempfile::tempdir().unwrap(); + let storage = Storage::new(tmp.path().join("data")).unwrap(); + storage + .update_program( + "s1", + "# Todo\n".into(), + ProgramUpdateActor::Human, + None, + None, + None, + ) + .unwrap(); + let (_, blocks) = storage.read_program_with_blocks("s1").unwrap(); + assert_eq!(blocks[0].id, "b0:0"); + + let mut meta = storage.read_program_meta("s1").unwrap(); + meta.blocks[0].block_id = "pb_0000000000000000".into(); + storage.save_program_meta_struct("s1", &meta).unwrap(); + let (_, migrated) = storage.read_program_with_blocks("s1").unwrap(); + assert_eq!(migrated[0].id, "b0:0"); + } + + #[test] + fn status_only_program_edit_does_not_create_revision() { + let tmp = tempfile::tempdir().unwrap(); + let storage = Storage::new(tmp.path().join("data")).unwrap(); + let initial = storage + .update_program( + "s1", + "# Todo\n".into(), + ProgramUpdateActor::Human, + None, + None, + None, + ) + .unwrap(); + let result = storage + .edit_program("s1", &[], ProgramUpdateActor::Agent, None) + .unwrap(); + assert_eq!(result.version, initial.version); + assert!(storage.read_program_revisions("s1").unwrap().is_empty()); + } + #[test] fn apply_program_edits_appends_on_empty_old_string() { // Empty doc: append sets the content. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 302e28ae..2666035f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -6,7 +6,7 @@ use construct_protocol::{agent_context, CreateSessionParams, PtySize}; use anyhow::{anyhow, Result}; use base64::Engine; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; mod browser; @@ -107,14 +107,13 @@ pub fn catalog() -> Vec { // ----- Write ----- tool( "construct_program_edit", - "PREFERRED for changing a session's program: apply one or more anchored find/replace edits (like the code Edit tool). Each edit replaces `old_string` with `new_string`; set `replace_all` to replace every occurrence, or include enough surrounding context to make `old_string` unique. An empty `old_string` appends `new_string` to the document. Edits apply to the LATEST program content, so a human editing a different region at the same time merges cleanly — no version to pass and no conflict. The call fails (writing nothing) only if an `old_string` is missing or ambiguous, which means that exact text changed underneath you: re-read with construct_program_get and retry. Agent edits need no user confirmation. For moving or reclassifying text between headings, use ONE construct_program_edit call with multiple `edits` entries — one entry removes the text from its old location and another inserts it at the new location. Do not make a separate remove call followed by a separate add call; the Program view can otherwise show a transient missing block. Shimmer (the program-run progress animation) is declared per block by stable ref: pass a `shimmer` array of `{id, shimmer}` entries to mark blocks pending (true) or settled (false). The list is PARTIAL and may target ANY block — not only the ones these edits change — so a planning pass can settle no-work blocks without touching their text; blocks you omit keep their current shimmer. Get block ids from construct_program_get or the `blocks` echoed by this call. Editing a block's semantic text advances its content epoch and gives it a new ref, so to keep an edited block shimmering set `keep_pending: true` on that edit (preferred — it re-adds the resulting block's new ref atomically, so you need not know it and the block never goes dark; use this when moving an in-flight task into an In progress section or appending a @{session} clip). Ids that no longer match a block are ignored (that block changed underneath you). Every entry that sets `shimmer: true` MUST include a `tooltip`: a concise (≤10-word) description of that block's run status (e.g. \"Building PR\", \"Waiting on CI\"); it is shown when a user hovers the shimmering block. Tooltips are ignored for `shimmer: false`.", + "Edit Program text and/or run status against the latest document. Anchored replacements are atomic; put both halves of a move in ONE call. Set `keep_pending` when an edit changes unfinished work. `pending` maps stable block refs to concise hover statuses; `settled` clears refs. On the first planning pass, set `settle_others` to clear every block omitted from `pending`. Stale refs fail closed. The compact response returns only refs created or changed by this call.", json!({ "type": "object", "properties": { "session_id": { "type": "string" }, "edits": { "type": "array", - "minItems": 1, "description": "All targeted replacements to apply atomically in order. For moves, put the removal and insertion edits in this same array so the block never disappears between tool calls.", "items": { "type": "object", @@ -127,22 +126,19 @@ pub fn catalog() -> Vec { "required": ["old_string", "new_string"] } }, - "shimmer": { + "pending": { + "type": "object", + "description": "Pending block refs mapped to concise (≤10-word) hover statuses.", + "additionalProperties": { "type": "string" } + }, + "settled": { "type": "array", - "description": "Partial per-block shimmer declaration applied after the edits. Each entry sets one block's pending state by its stable `id`/block ref (from construct_program_get or a prior call's `blocks`). Omitted blocks keep their current shimmer.", - "items": { - "type": "object", - "properties": { - "id": { "type": "string", "description": "Stable block ref from a block projection" }, - "shimmer": { "type": "boolean", "description": "true = pending (keep shimmering); false = settled (clear)" }, - "tooltip": { "type": "string", "description": "Required when shimmer is true: a concise (≤10-word) description of this block's run status, shown on hover (e.g. \"Building PR\", \"Waiting on CI\"). Ignored when shimmer is false." } - }, - "required": ["id", "shimmer"] - } + "description": "Block refs whose work has settled.", + "items": { "type": "string" } }, + "settle_others": { "type": "boolean", "description": "Planning pass: settle every current block omitted from `pending`." }, "note": { "type": "string" } - }, - "required": ["edits"] + } }), ), tool( @@ -620,46 +616,114 @@ pub async fn call(client: &Arc, session_id: Option<&str>, params: Value) } "construct_program_edit" => { let sid = optional_session_arg(&args, session_id)?; - let edits: Vec = serde_json::from_value( - args.get("edits") - .cloned() - .ok_or_else(|| anyhow!("missing or non-array `edits`"))?, - ) - .map_err(|e| anyhow!("invalid `edits`: {e}"))?; - if edits.is_empty() { - return Err(anyhow!("`edits` must contain at least one edit")); - } - let shimmer: Vec = match args.get("shimmer") { + let before = client.program_get(&sid).await?; + let edits: Vec = match args.get("edits") { + Some(v) => serde_json::from_value(v.clone()) + .map_err(|e| anyhow!("invalid `edits`: {e}"))?, + None => Vec::new(), + }; + let pending: HashMap = match args.get("pending") { Some(v) => serde_json::from_value(v.clone()).map_err(|e| { - anyhow!("invalid `shimmer` (expected an array of {{id, shimmer}}): {e}") + anyhow!("invalid `pending` (expected an object of block-ref: status): {e}") })?, + None => HashMap::new(), + }; + let settled: Vec = match args.get("settled") { + Some(v) => serde_json::from_value(v.clone()) + .map_err(|e| anyhow!("invalid `settled` (expected block-ref array): {e}"))?, None => Vec::new(), }; - // A block declared pending must carry a concise run-status tooltip - // (spec 0057); settling needs none. - for decl in &shimmer { - if decl.shimmer - && decl - .tooltip - .as_deref() - .map(str::trim) - .unwrap_or("") - .is_empty() - { + let settle_others = args + .get("settle_others") + .and_then(Value::as_bool) + .unwrap_or(false); + if edits.is_empty() && pending.is_empty() && settled.is_empty() && !settle_others { + return Err(anyhow!( + "program edit needs at least one edit or run-status change" + )); + } + for (id, status) in &pending { + if status.trim().is_empty() { return Err(anyhow!( - "shimmer entry for block {} is shimmer:true but has no tooltip — a pending block needs a concise (≤10-word) run-status tooltip", - decl.id + "pending block {id} needs a concise run-status tooltip" )); } + if settled.contains(id) { + return Err(anyhow!("block {id} cannot be both pending and settled")); + } } + let mut shimmer: Vec = pending + .iter() + .map(|(id, tooltip)| construct_protocol::ProgramShimmerDecl { + id: id.clone(), + shimmer: true, + tooltip: Some(tooltip.clone()), + }) + .collect(); + let mut settled_refs: HashSet = settled.into_iter().collect(); + if settle_others { + settled_refs.extend( + before + .blocks + .iter() + .filter(|block| !pending.contains_key(&block.id)) + .map(|block| block.id.clone()), + ); + } + shimmer.extend(settled_refs.into_iter().map(|id| { + construct_protocol::ProgramShimmerDecl { + id, + shimmer: false, + tooltip: None, + } + })); let params = construct_protocol::ProgramEditParams { - session_id: sid, + session_id: sid.clone(), edits, actor: construct_protocol::ProgramUpdateActor::Agent, note: arg_str(&args, "note").ok(), shimmer, }; - serde_json::to_value(client.program_edit(params).await?)? + let result = client.program_edit(params).await?; + let before_refs: HashSet<&str> = + before.blocks.iter().map(|block| block.id.as_str()).collect(); + let changed_blocks: Vec = result + .blocks + .iter() + .filter(|block| !before_refs.contains(block.id.as_str())) + .map(|block| { + let mut value = json!({ + "id": block.id, + "text": block.text, + "pending": block.shimmer, + }); + if let Some(tooltip) = &block.tooltip { + value["tooltip"] = json!(tooltip); + } + value + }) + .collect(); + let mut response = serde_json::Map::new(); + response.insert("ok".into(), json!(true)); + response.insert("version".into(), json!(result.program.version)); + if result.program.version != before.program.version { + response.insert("changed".into(), json!(true)); + } + if !changed_blocks.is_empty() { + response.insert("blocks".into(), json!(changed_blocks)); + } + if let Some(run) = result.active_run { + response.insert( + "run".into(), + json!({ + "stage": run.stage, + "pending": run.pending_block_count(), + "settled": run.settled_block_count, + "total": run.total_block_count, + }), + ); + } + Value::Object(response) } "construct_program_execute" => { let sid = optional_session_arg(&args, session_id)?; @@ -1161,14 +1225,17 @@ mod tests { .and_then(|description| description.as_str()) .expect("edits description"); - assert!( - description.contains("ONE construct_program_edit call with multiple `edits` entries"), - "tool description should tell agents to move blocks in one call" - ); + assert!(description.contains("ONE call")); assert!( edits_description.contains("For moves"), "edits schema description should reinforce atomic move edits" ); + let schema = tool.get("inputSchema").expect("input schema"); + assert!(schema.pointer("/properties/pending").is_some()); + assert!(schema.pointer("/properties/settled").is_some()); + assert!(schema.pointer("/properties/settle_others").is_some()); + assert!(schema.pointer("/properties/shimmer").is_none()); + assert!(schema.get("required").is_none(), "status-only calls need no edits"); } #[test] diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 652ff3dc..84d4d932 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1594,6 +1594,7 @@ pub struct ProgramEdit { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgramEditParams { pub session_id: String, + #[serde(default)] pub edits: Vec, #[serde(default)] pub actor: ProgramUpdateActor, diff --git a/specs/0048-program-shimmer-agent-contract.md b/specs/0048-program-shimmer-agent-contract.md index 8f216248..1fa392fe 100644 --- a/specs/0048-program-shimmer-agent-contract.md +++ b/specs/0048-program-shimmer-agent-contract.md @@ -13,7 +13,7 @@ Shimmer on a program block has exactly one meaning to the agent: **the block's w From that single meaning, the agent's obligations follow: -- **Planning pass first.** The agent's first program action on a Run — before doing or delegating any task — is one edit that declares the run's pending set: every still-pending block's id marked `shimmer: true` and every settled block declared settled. Because shimmer is a declared per-block state (see `0053-program-shimmer-block-addressing`), this needs no textual change to the settled blocks — declaring their ids settled clears them. This is the only way no-work and skipped blocks stop shimmering promptly instead of riding the optimistic full-program shimmer until the turn ends. +- **Planning pass first.** The agent's first program action on a Run — before doing or delegating any task — is one status-only edit whose `pending` map declares every still-pending block and whose `settle_others` flag clears every omitted block. Because shimmer is a declared per-block state (see `0053-program-shimmer-block-addressing`), this needs no textual edit. This is the only way no-work and skipped blocks stop shimmering promptly instead of riding the optimistic full-program shimmer until the turn ends. - **A pending block is kept pending on every edit that changes it, until it settles.** Changing a block's text changes its id, and its prior shimmer does not carry to the new content. The atomic way to keep an edited block pending is to flag that edit `keep_pending` — it re-adds the resulting block's new id in the same call. This is preferred for the common case of moving a still-in-flight task into an In progress section or appending a hand-off clip, because it needs no knowledge of the not-yet-known new id and the pending set never transiently empties. (Equivalently, a follow-up declaration of the new id revives it: the run survives a transient empty, per `0042`.) An edit that changes a still-pending block and does neither will wrongly settle a block whose work is still in flight. - **A settled block is never declared pending.** Once work on a block is done, skipped, or unnecessary, it is declared settled (or simply omitted from a partial declaration once already settled) so it clears. @@ -38,6 +38,6 @@ This does not change the start/narrow/stop lifecycle, the optimistic client-side The execution mechanisms below (run-it-yourself, delegate-to-a-subagent) are illustrative, not exhaustive; the contract treats any way of running a block identically. -- A program has three blocks. The agent's first action is a planning-pass edit that declares the pending set: blocks A (the agent will run it itself) and B (delegated, not started yet) are declared `shimmer: true`; block C (already done, no work) is declared settled and clears immediately — no change to C's text is required. A and B keep shimmering; C is calm within seconds of Run. +- A program has three blocks. The agent's first action sends `pending: {A: \"Implementing\", B: \"Preparing delegation\"}` with `settle_others: true`. Block C is omitted and clears immediately without a text edit. A and B keep shimmering; C is calm within seconds of Run. - The agent hands block B off and edits it to note the hand-off. The edit changes B's text (new id) and declares that new id `shimmer: true`, so B keeps shimmering while the work is in flight. When the result lands and the agent writes it back, that final edit declares B settled and B settles. - The agent finishes block A itself and writes the result without re-declaring A pending. A settles. Block B is still pending and keeps shimmering until its result arrives or the turn ends. diff --git a/specs/0053-program-shimmer-block-addressing.md b/specs/0053-program-shimmer-block-addressing.md index 1be90bb7..f9a98c3d 100644 --- a/specs/0053-program-shimmer-block-addressing.md +++ b/specs/0053-program-shimmer-block-addressing.md @@ -12,7 +12,7 @@ Program-block shimmer is a declared per-block state addressed by a daemon-owned Definitions: - A **block** is a run of non-blank Markdown lines split at heading and list-item boundaries: each heading line, each list item (with its wrapped continuation lines), and each plain paragraph is its own block. -- A **block id** identifies one block instance across moves and concurrent edits. +- A **block id** identifies one block instance across moves and concurrent edits. It is a compact program-scoped hexadecimal ordinal (`b0`, `b1`, …), without fixed-width padding. - A **content epoch** increments when that block instance's semantic content changes. - A **block ref** is `block_id:content_epoch`. It is the authoritative shimmer address returned as each block's `id`/`block_ref` in program projections. - A **content id** is the legacy normalized-content hash. It ignores smart-clip `clip_id` metadata and remains available as `content_id` for compatibility, but it is ambiguous for duplicate text and must not be preferred when block refs are available. @@ -22,13 +22,13 @@ The daemon stores block identity in program metadata and reconciles it whenever Shimmer is carried across the program surfaces as follows: - **Read** returns an ordered projection of the document: each block with its stable ref (`id`/`block_ref`), legacy `content_id`, text or source line range, and current `shimmer` boolean. -- **Edit** accepts an optional and partial list of `{id, shimmer}` declarations. The id may be a stable ref or a legacy content id. Declarations resolve against the document the call produces; a declaration whose id matches no block is dropped. +- **Edit** accepts partial pending and settled declarations. The MCP surface encodes pending declarations as a `{block_ref: tooltip}` map and settled declarations as a block-ref array. Its `settle_others` planning-pass flag settles every current block omitted from the pending map. Declarations resolve against the document the call produces; a declaration whose id matches no block is dropped. - **Update** accepts a complete shimmer declaration over the blocks of the new Markdown. The daemon maps that ordered declaration to the new stable refs. - **Execute** lights the executed region optimistically immediately. The daemon then publishes stable refs for the active run so clients can replace the optimistic projection as soon as possible. For a selection Run, the client should tell the daemon which real document blocks its selection overlaps — computed by checking containment of the selection's range against each block's line range, not by re-parsing the raw selected text into its own standalone document and hash-matching the result. The daemon trusts that client-supplied identity when given. Hash-matching a re-parsed selection is a legacy fallback for callers that omit it; it only identifies the right block when the selection spans one or more whole blocks exactly, because a strict substring of a single block's text hashes differently from that block's real (full) content. Because shimmer is keyed by block ref, a block's prior shimmer does not carry across a semantic edit by default. Agents that edit a still-in-flight block must set `keep_pending: true` on that edit, or explicitly declare the resulting block's new ref pending in the same call. `keep_pending` is preferred because it re-adds the produced ref atomically, before any intermediate empty pending set can make the UI go dark. -Every write returns the fresh per-block projection. Agents should read once, then use the echoed blocks from each write. +Daemon IPC writes return the fresh per-block projection for interactive clients. The MCP edit surface instead returns a compact acknowledgement and only blocks whose refs were created or changed by the call. Agents should read once, retain unchanged refs, and use the changed-block echo for follow-up edits. ## Reason @@ -41,6 +41,8 @@ The model remains plain-Markdown friendly: identity lives in program metadata, n ## Consequences - Program get, edit, update, execute, and state payloads publish the per-block projection so clients do not independently invent block identity. +- Program-scoped ordinal ids are compact because they occur repeatedly in model-visible requests and responses; fixed-width or globally unique spellings add context cost without adding safety. +- An MCP status-only edit may omit text edits entirely. This must not create a document revision or require a no-op text replacement. - Clients should use stable refs whenever the local buffer matches the saved daemon document. They may fall back to content ids only for legacy payloads or dirty optimistic buffers. - A stale stable-ref declaration fails closed: it matches no block if the target's semantic content changed and the caller did not use `keep_pending`. - Identical-content blocks are distinct when addressed by stable ref. Legacy content-id declarations may still affect all matching duplicates and should be treated as compatibility behavior. diff --git a/specs/0057-program-shimmer-tooltip.md b/specs/0057-program-shimmer-tooltip.md index 82b26e72..6f46f163 100644 --- a/specs/0057-program-shimmer-tooltip.md +++ b/specs/0057-program-shimmer-tooltip.md @@ -9,9 +9,9 @@ Scope: Program-block shimmer carries a required, concise agent status tooltip, w A program block's shimmer state carries an agent-authored **tooltip**: a concise, ≤10-word string describing why that block is still running (e.g. "Building PR", "Waiting on CI"). -- **Setting shimmer requires a tooltip.** When an agent declares a block `shimmer: true`, it MUST also supply a `tooltip`. The requirement is enforced at the agent-facing call boundary (the program-edit and program-update MCP tools): a pending declaration with a missing or empty tooltip is rejected with a clear error. A tooltip is **not** needed for `shimmer: false` (settling) and is ignored there. +- **Setting shimmer requires a tooltip.** In the program-edit MCP tool, every `pending` map value is the block's required tooltip; empty values are rejected. The program-update MCP tool continues to require a parallel tooltip for each pending boolean. Settling needs no tooltip. - **The tooltip travels with the shimmer state across the program surfaces** (building on `0053-program-shimmer-block-addressing`): - - The per-block shimmer declaration unit gains a `tooltip` (the edit surface's `{id, shimmer}` entries; the update surface's complete declaration carries a parallel per-block tooltip array in document order). + - The per-block shimmer declaration unit carries a tooltip (the MCP edit surface encodes it as the value in its `{block_ref: tooltip}` pending map; the update surface's complete declaration carries a parallel per-block tooltip array in document order). - The daemon stores each pending block's tooltip alongside its id in the active run, keyed by stable block ref when available and by legacy content id only for fallback. Settling a block, or dropping it from the pending set, drops its tooltip. - The per-block projection returned by program get/edit/update (the `blocks` array) reports each block's tooltip, so any renderer reads it without re-deriving it. - **The ≤10-word guidance is enforced by graceful normalization, not rejection.** A stored tooltip is trimmed, internal whitespace collapsed, and truncated to at most ten words (with an ellipsis when truncated). An over-long tooltip is never a hard error.