aion-core 0.31.0

Pure domain model and shared vocabulary for Aion durable workflows.
Documentation
//! Structured edits to an assistant session's shared document.
//!
//! The assistant's `assistant_document_edit` tool submits edits as
//! **replace-exactly-once** operations — an `old_string` that must occur
//! exactly once in the document as it now stands, and the `new_string` that
//! replaces it. This is the one edit idiom a language model reliably produces:
//! it names the bytes it read, so a stale read fails loudly instead of landing
//! somewhere else, and it needs no offset arithmetic that drifts the moment the
//! operator types.
//!
//! # One applier, two callers
//!
//! [`apply_document_edits`] is the ONLY definition of what a batch of edits
//! does to a text. The server's append path calls it to VALIDATE a batch before
//! recording it, and the transcript projection calls it again to fold a
//! recorded batch into the shared document. Because both read the same
//! function, an edit the append path accepted is an edit the projection applies
//! identically — there is no second matcher to disagree with the first.
//!
//! # Batches are atomic
//!
//! Edits in one batch apply in order, each against the text the previous one
//! produced. The first edit that cannot apply fails the WHOLE batch — nothing
//! before it is kept — so a recorded batch always applied cleanly in full, and
//! a refused batch changed nothing at all.

use serde::{Deserialize, Serialize};

/// One replace-exactly-once edit.
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantDocumentEditOp {
    /// The exact bytes to replace. Must occur exactly once in the document as
    /// it stands when this edit applies — zero matches means the edit was
    /// written against text that is not there; two or more means it does not
    /// say WHICH occurrence it meant.
    pub old_string: String,
    /// What replaces them.
    pub new_string: String,
}

/// Why a batch of edits could not be applied.
///
/// Phrased around the ordinal (one-based, the position in the submitted batch)
/// because the caller is a model reading its own batch back: "edit 2" is how it
/// finds the operation it wrote.
#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
pub enum AssistantDocumentEditError {
    /// The edit's `old_string` is empty, which matches nothing and everything.
    #[error(
        "edit {ordinal}: `old_string` is empty — an empty match names no place in the document. \
         Quote the exact bytes to replace."
    )]
    EmptyOldString {
        /// One-based position of the edit in its batch.
        ordinal: usize,
    },
    /// The edit's `old_string` does not occur in the document.
    #[error(
        "edit {ordinal}: `old_string` was not found in the document (searched for {preview:?}). \
         The document may have changed since it was read — read it again and re-quote the exact \
         bytes."
    )]
    Absent {
        /// One-based position of the edit in its batch.
        ordinal: usize,
        /// The start of the string that was searched for, for the message.
        preview: String,
    },
    /// The edit's `old_string` occurs more than once, so it does not say which
    /// occurrence it means.
    #[error(
        "edit {ordinal}: `old_string` occurs {matches} times (searched for {preview:?}), so it \
         does not say which occurrence to replace. Quote more surrounding text to make it unique."
    )]
    Ambiguous {
        /// One-based position of the edit in its batch.
        ordinal: usize,
        /// How many times it occurred.
        matches: usize,
        /// The start of the string that was searched for, for the message.
        preview: String,
    },
}

/// How much of a failed `old_string` the error message quotes back.
const PREVIEW_CHARACTERS: usize = 80;

/// Apply `edits` to `text`, in order, each replacing exactly one occurrence.
///
/// # Errors
///
/// [`AssistantDocumentEditError`] naming the first edit that could not apply.
/// The batch is atomic: on error, nothing was applied.
pub fn apply_document_edits(
    text: &str,
    edits: &[AssistantDocumentEditOp],
) -> Result<String, AssistantDocumentEditError> {
    let mut current = text.to_owned();
    for (index, edit) in edits.iter().enumerate() {
        let ordinal = index.saturating_add(1);
        if edit.old_string.is_empty() {
            return Err(AssistantDocumentEditError::EmptyOldString { ordinal });
        }
        match current.matches(edit.old_string.as_str()).count() {
            0 => {
                return Err(AssistantDocumentEditError::Absent {
                    ordinal,
                    preview: preview_of(&edit.old_string),
                });
            }
            1 => {
                current = current.replacen(edit.old_string.as_str(), &edit.new_string, 1);
            }
            matches => {
                return Err(AssistantDocumentEditError::Ambiguous {
                    ordinal,
                    matches,
                    preview: preview_of(&edit.old_string),
                });
            }
        }
    }
    Ok(current)
}

/// The first [`PREVIEW_CHARACTERS`] characters, on character boundaries.
fn preview_of(searched: &str) -> String {
    searched.chars().take(PREVIEW_CHARACTERS).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn op(old: &str, new: &str) -> AssistantDocumentEditOp {
        AssistantDocumentEditOp {
            old_string: old.to_owned(),
            new_string: new.to_owned(),
        }
    }

    /// Edits apply in order, each against the text the previous one produced —
    /// so a later edit may match text an earlier edit wrote.
    #[test]
    fn edits_apply_in_order_against_the_running_text() -> Result<(), AssistantDocumentEditError> {
        let applied = apply_document_edits(
            "workflow demo\nstep one\n",
            &[op("step one", "step first"), op("first", "first_renamed")],
        )?;
        assert_eq!(applied, "workflow demo\nstep first_renamed\n");
        Ok(())
    }

    /// Zero matches refuses the batch and names the edit, so a model that read
    /// a stale document is told to read again rather than left guessing.
    #[test]
    fn an_absent_old_string_refuses_the_whole_batch() {
        let refused = apply_document_edits(
            "workflow demo\n",
            &[op("workflow demo", "workflow renamed"), op("gone", "there")],
        );
        assert_eq!(
            refused,
            Err(AssistantDocumentEditError::Absent {
                ordinal: 2,
                preview: "gone".to_owned(),
            }),
            "the batch is atomic: the first edit matched, and none of it lands"
        );
    }

    /// Two matches is a refusal, not a guess: the edit does not say which
    /// occurrence it means, and picking the first would silently edit the
    /// wrong place half the time.
    #[test]
    fn an_ambiguous_old_string_is_refused_with_its_count() {
        let refused = apply_document_edits("a b a", &[op("a", "c")]);
        assert_eq!(
            refused,
            Err(AssistantDocumentEditError::Ambiguous {
                ordinal: 1,
                matches: 2,
                preview: "a".to_owned(),
            })
        );
    }

    /// An empty `old_string` matches everywhere and nowhere; it is refused by
    /// name rather than reported as absent or applied at position zero.
    #[test]
    fn an_empty_old_string_is_refused_by_name() {
        let refused = apply_document_edits("text", &[op("", "inserted")]);
        assert_eq!(
            refused,
            Err(AssistantDocumentEditError::EmptyOldString { ordinal: 1 })
        );
    }

    /// The error's preview is clipped on CHARACTER boundaries, so a long
    /// multi-byte `old_string` cannot split a code point in the message.
    #[test]
    fn a_long_search_string_is_previewed_on_character_boundaries() -> Result<(), String> {
        let long = "é".repeat(200);
        let refused = apply_document_edits("text", &[op(&long, "x")]);
        match refused {
            Err(AssistantDocumentEditError::Absent { preview, .. }) => {
                assert_eq!(preview.chars().count(), 80);
                Ok(())
            }
            other => Err(format!("expected an Absent refusal, got {other:?}")),
        }
    }

    /// Replacement is literal, exactly-once: regex metacharacters in either
    /// string are bytes, not patterns.
    #[test]
    fn strings_are_literal_bytes_not_patterns() -> Result<(), AssistantDocumentEditError> {
        let applied = apply_document_edits("route a|b", &[op("a|b", "a.b")])?;
        assert_eq!(applied, "route a.b");
        Ok(())
    }

    /// Occurrences are counted DISJOINT, exactly as [`str::matches`] counts
    /// them: `"\n\n"` occurs ONCE in `"\n\n\n"`, so the edit applies. This is
    /// the canonical exactly-once semantics; the console's twin applier must
    /// count the same way, or the server records a batch the editor refuses.
    #[test]
    fn overlapping_candidates_count_as_disjoint_matches() -> Result<(), AssistantDocumentEditError>
    {
        let applied = apply_document_edits("a\n\n\nb", &[op("\n\n", "\n")])?;
        assert_eq!(applied, "a\n\nb");
        let applied = apply_document_edits("aaa", &[op("aa", "x")])?;
        assert_eq!(applied, "xa");
        Ok(())
    }
}