a3s 0.10.3

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
#[test]
fn section_draft_citations_are_nonempty_scoped_and_source_backed() {
    let limits = InquiryLimits::default();
    let mut state = drafting_state();
    let unchanged = state.clone();

    let empty = InquiryEvent::SectionDrafted {
        section_id: "section:first".to_string(),
        content: "A supported first-section draft.".to_string(),
        citation_ids: Vec::new(),
    };
    assert_eq!(
        state.apply(&empty, &limits),
        Err(InquiryError::EmptyBatch {
            resource: "section citation ids"
        })
    );
    assert_eq!(state, unchanged);

    let claim_only = InquiryEvent::SectionDrafted {
        section_id: "section:first".to_string(),
        content: "A claim-only first-section draft.".to_string(),
        citation_ids: vec!["claim:first".to_string()],
    };
    assert_eq!(
        state.apply(&claim_only, &limits),
        Err(InquiryError::MissingSourceCitation {
            section_id: "section:first".to_string()
        })
    );
    assert_eq!(state, unchanged);

    let cross_section = InquiryEvent::SectionDrafted {
        section_id: "section:first".to_string(),
        content: "A cross-section citation draft.".to_string(),
        citation_ids: vec!["source:second".to_string()],
    };
    assert_eq!(
        state.apply(&cross_section, &limits),
        Err(InquiryError::UnknownId {
            resource: "outline section citation",
            id: "source:second".to_string()
        })
    );
    assert_eq!(state, unchanged);

    let valid = InquiryEvent::SectionDrafted {
        section_id: "section:first".to_string(),
        content: "A supported first-section draft.".to_string(),
        citation_ids: vec!["claim:first".to_string(), "source:first".to_string()],
    };
    state
        .apply(&valid, &limits)
        .expect("a section-local source citation should be accepted");
    assert_eq!(
        state.drafts["section:first"].citation_ids,
        ["claim:first", "source:first"]
    );
}

#[test]
fn section_revision_rounds_are_explicit_and_replayable() {
    let limits = InquiryLimits::default();
    let mut events = vec![InquiryEvent::SectionRevisionStarted {
        round: 1,
        section_ids: vec!["section:first".to_string()],
        input_digest: "digest:first".to_string(),
    }];
    let mut state = drafting_state();
    for event in &events {
        state.apply(event, &limits).unwrap();
    }
    assert_eq!(state.active_section_revision().unwrap().round, 1);

    events.extend([
        InquiryEvent::SectionDrafted {
            section_id: "section:first".to_string(),
            content: "A revised, source-backed first section.".to_string(),
            citation_ids: vec!["claim:first".to_string(), "source:first".to_string()],
        },
        InquiryEvent::SectionRevisionCommitted {
            round: 1,
            input_digest: "digest:first".to_string(),
        },
    ]);
    for event in &events[1..] {
        state.apply(event, &limits).unwrap();
    }
    assert!(state.active_section_revision().is_none());
    assert_eq!(state.section_revisions.len(), 1);
    assert!(state.section_revisions[0].committed);
    assert_eq!(
        state.section_revisions[0].drafted_section_ids,
        ["section:first"]
    );

    let mut full_history = vec![
        InquiryEvent::StrategySelected {
            method: ResearchMethod::Focused,
        },
        InquiryEvent::QuestionsQueued {
            questions: vec![Question::queued(
                "question:root",
                None,
                "What evidence answers the inquiry?",
            )],
        },
        InquiryEvent::EvidenceAccepted {
            evidence: EvidenceRef::new(
                "evidence:first",
                vec!["claim:first".to_string()],
                vec!["source:first".to_string()],
            ),
        },
        InquiryEvent::EvidenceAccepted {
            evidence: EvidenceRef::new(
                "evidence:second",
                vec!["claim:second".to_string()],
                vec!["source:second".to_string()],
            ),
        },
        InquiryEvent::QuestionAnswered {
            question_id: "question:root".to_string(),
            answer: "The accepted evidence supports the finding.".to_string(),
            evidence_ids: vec!["evidence:first".to_string(), "evidence:second".to_string()],
        },
        InquiryEvent::OutlineCommitted {
            outline: ResearchOutline {
                sections: vec![
                    outline_section("section:first", "claim:first", "source:first"),
                    outline_section("section:second", "claim:second", "source:second"),
                ],
            },
        },
    ];
    full_history.extend(events);
    assert_eq!(replay(&full_history, &limits).unwrap(), state);
}

#[test]
fn section_revision_replay_rejects_conflicts_and_incomplete_commits() {
    let limits = InquiryLimits::default();
    let mut state = drafting_state();
    state
        .apply(
            &InquiryEvent::SectionRevisionStarted {
                round: 1,
                section_ids: vec!["section:first".to_string()],
                input_digest: "digest:first".to_string(),
            },
            &limits,
        )
        .unwrap();

    let duplicate = state
        .apply(
            &InquiryEvent::SectionRevisionStarted {
                round: 1,
                section_ids: vec!["section:first".to_string()],
                input_digest: "digest:first".to_string(),
            },
            &limits,
        )
        .unwrap_err();
    assert!(matches!(
        duplicate,
        InquiryError::InvalidSectionRevision { ref reason }
            if reason.contains("still active")
    ));

    let untargeted = state
        .apply(
            &InquiryEvent::SectionDrafted {
                section_id: "section:second".to_string(),
                content: "An untargeted replacement.".to_string(),
                citation_ids: vec!["claim:second".to_string(), "source:second".to_string()],
            },
            &limits,
        )
        .unwrap_err();
    assert!(matches!(
        untargeted,
        InquiryError::InvalidSectionRevision { ref reason }
            if reason.contains("untargeted section")
    ));

    let incomplete = state
        .apply(
            &InquiryEvent::SectionRevisionCommitted {
                round: 1,
                input_digest: "digest:first".to_string(),
            },
            &limits,
        )
        .unwrap_err();
    assert!(matches!(
        incomplete,
        InquiryError::InvalidSectionRevision { ref reason }
            if reason.contains("no replacement draft")
    ));

    state
        .apply(
            &InquiryEvent::SectionDrafted {
                section_id: "section:first".to_string(),
                content: "A revised, source-backed first section.".to_string(),
                citation_ids: vec!["claim:first".to_string(), "source:first".to_string()],
            },
            &limits,
        )
        .unwrap();
    let mismatched = state
        .apply(
            &InquiryEvent::SectionRevisionCommitted {
                round: 1,
                input_digest: "digest:other".to_string(),
            },
            &limits,
        )
        .unwrap_err();
    assert!(matches!(
        mismatched,
        InquiryError::InvalidSectionRevision { ref reason }
            if reason.contains("does not match")
    ));
}

#[test]
fn section_revision_budget_counts_started_rounds_across_replay() {
    let limits = InquiryLimits::default();
    let mut state = drafting_state();
    for round in 1..=limits.max_section_revision_rounds {
        let digest = format!("digest:{round}");
        state
            .apply(
                &InquiryEvent::SectionRevisionStarted {
                    round,
                    section_ids: vec!["section:first".to_string()],
                    input_digest: digest.clone(),
                },
                &limits,
            )
            .unwrap();
        state
            .apply(
                &InquiryEvent::SectionDrafted {
                    section_id: "section:first".to_string(),
                    content: format!("A source-backed revision from round {round}."),
                    citation_ids: vec!["claim:first".to_string(), "source:first".to_string()],
                },
                &limits,
            )
            .unwrap();
        state
            .apply(
                &InquiryEvent::SectionRevisionCommitted {
                    round,
                    input_digest: digest,
                },
                &limits,
            )
            .unwrap();
    }

    let error = state
        .apply(
            &InquiryEvent::SectionRevisionStarted {
                round: limits.max_section_revision_rounds + 1,
                section_ids: vec!["section:first".to_string()],
                input_digest: "digest:excess".to_string(),
            },
            &limits,
        )
        .unwrap_err();
    assert_eq!(
        error,
        InquiryError::HardLimitExceeded {
            resource: "section revision rounds",
            limit: 2,
            actual: 3,
        }
    );
}