Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/codestory-cli/src/app/tests/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub(super) fn sample_retrieval() -> RetrievalStateDto {

pub(super) fn sample_agent_answer_with_graph(graph: GraphArtifactDto) -> AgentAnswerDto {
AgentAnswerDto {
source_coverage: Vec::new(),
answer_id: "context-test".to_string(),
prompt: "capped_bundle".to_string(),
summary: "Bundle summary".to_string(),
Expand Down Expand Up @@ -88,6 +89,7 @@ pub(super) fn sample_task_brief_packet() -> AgentPacketDto {
trace: Vec::new(),
},
answer: AgentAnswerDto {
source_coverage: Vec::new(),
answer_id: "answer-task-brief".to_string(),
prompt: "Add `$env:SECRET $(Get-ChildItem) 'literal' task brief".to_string(),
summary: "Use the packet command path.".to_string(),
Expand Down
3 changes: 3 additions & 0 deletions crates/codestory-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4981,6 +4981,7 @@ mod tests {
#[test]
fn context_markdown_contract_includes_evidence_packet_shape() {
let answer = AgentAnswerDto {
source_coverage: Vec::new(),
answer_id: "answer-1".to_string(),
prompt: "build_packet".to_string(),
summary: "Packet output is assembled from retrieved CLI evidence.".to_string(),
Expand Down Expand Up @@ -5126,6 +5127,7 @@ mod tests {

fn well_grounded_packet_answer() -> AgentAnswerDto {
AgentAnswerDto {
source_coverage: Vec::new(),
answer_id: "answer-telemetry".to_string(),
prompt: "Explain how the installer dispatches commands.".to_string(),
summary: "The installer dispatches install, download, and use commands.".to_string(),
Expand Down Expand Up @@ -5913,6 +5915,7 @@ mod tests {
#[test]
fn context_markdown_surfaces_low_confidence_trace_gaps() {
let answer = AgentAnswerDto {
source_coverage: Vec::new(),
answer_id: "answer-1".to_string(),
prompt: "weak_hit".to_string(),
summary: "Retrieval was incomplete.".to_string(),
Expand Down
69 changes: 69 additions & 0 deletions crates/codestory-cli/src/stdio_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,19 @@ static CONTEXT_PACKET_SCHEMA: SchemaObject = SchemaObject::object(
SchemaProperty::string("retrieval_version", "Retrieval version."),
SchemaProperty::array("graphs", "Graph artifacts.", &GENERIC_OBJECT_SCHEMA),
SchemaProperty::object("retrieval_trace", "Retrieval trace and summary."),
// Both are optional on the wire and both were already being emitted:
// this schema is `additionalProperties: false`, so leaving them
// undeclared published a contract the tool itself violates. `freshness`
// has been emitted undeclared since EV-78.
SchemaProperty::object(
"freshness",
"Index freshness observation, when one was made.",
),
SchemaProperty::array(
"source_coverage",
"Coverage for the files this packet rested on, when any were checked.",
&GENERIC_OBJECT_SCHEMA,
),
],
&[
"packet_id",
Expand Down Expand Up @@ -2479,4 +2492,60 @@ mod tests {
assert_eq!(budget["minimum"], 1_000);
assert_eq!(budget["maximum"], 120_000);
}

/// The context packet must not emit a field its own published schema
/// forbids.
///
/// `CONTEXT_PACKET_SCHEMA` is `additionalProperties: false`, and
/// `context_packet_json` serializes the whole `AgentAnswerDto`, so every
/// optional field added to that DTO silently becomes a schema violation. It
/// happened twice before anyone noticed — `freshness` since EV-78 and
/// `source_coverage` — because nothing compared the two.
#[test]
fn the_context_packet_emits_only_fields_its_schema_declares() {
let declared = CONTEXT_PACKET_SCHEMA.declared_property_names();
let mut answer = codestory_contracts::api::AgentAnswerDto {
answer_id: "packet".to_string(),
prompt: "question".to_string(),
summary: "summary".to_string(),
freshness: None,
source_coverage: Vec::new(),
sections: Vec::new(),
citations: Vec::new(),
subgraph_ids: Vec::new(),
retrieval_version: "test".to_string(),
graphs: Vec::new(),
retrieval_trace: serde_json::from_value(serde_json::json!({
"request_id": "r",
"resolved_profile": "architecture",
"policy_mode": "latency_first",
"total_latency_ms": 0,
"steps": [],
}))
.expect("minimal retrieval trace"),
};
// Populate the optional fields: they are `skip_serializing_if`, so an
// empty fixture would pass while a real packet failed.
answer.source_coverage = vec![codestory_contracts::api::SourceCoverageObservationDto {
path: "data/big.json".to_string(),
status: codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded,
reason: None,
not_established_cause: None,
observed_size: Some(2),
byte_cap: Some(1),
}];

let packet = crate::output::context_packet_json(&answer);
let emitted = packet.as_object().expect("packet object");
let undeclared = emitted
.keys()
.filter(|key| !declared.contains(&key.as_str()))
.cloned()
.collect::<Vec<_>>();
assert!(
undeclared.is_empty(),
"the packet emits {undeclared:?}, which its published output schema \
forbids: declared = {declared:?}"
);
}
}
14 changes: 8 additions & 6 deletions crates/codestory-contracts/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ pub use dto::{
SearchPlanTermsDto, SearchQueryAssessmentDto, SearchRepoTextMode, SearchRequest,
SearchResultsDto, SearchTargetDto, SearchVerificationTargetDto, SemanticFallbackRecordDto,
SemanticModeDto, SetUiLayoutRequest, SnippetContextDto, SnippetScopeDto,
SourceFreshnessTelemetryDto, SourceOccurrenceDto, SourcePolicyExclusionDto,
StartIndexingRequest, StorageStatsDto, StoredSemanticDocsContractDto, SummaryGenerationDto,
SymbolContextDto, SymbolSummaryDto, SystemActionResponse, TrailConfigDto, TrailContextDto,
TrailFilterOptionsDto, TrailStoryDto, TrailStoryStepDto, UpdateBookmarkCategoryRequest,
UpdateBookmarkRequest, WorkspaceMemberIndexDto, WriteFileDataUrlRequest, WriteFileResponse,
WriteFileTextRequest, validate_packet_probe, validate_packet_probe_request,
SourceCoverageNotEstablishedCauseDto, SourceCoverageObservationDto, SourceCoverageStatusDto,
SourceCoverageUnprovableCauseDto, SourceFreshnessTelemetryDto, SourceOccurrenceDto,
SourcePolicyExclusionDto, StartIndexingRequest, StorageStatsDto, StoredSemanticDocsContractDto,
SummaryGenerationDto, SymbolContextDto, SymbolSummaryDto, SystemActionResponse, TrailConfigDto,
TrailContextDto, TrailFilterOptionsDto, TrailStoryDto, TrailStoryStepDto,
UpdateBookmarkCategoryRequest, UpdateBookmarkRequest, WorkspaceMemberIndexDto,
WriteFileDataUrlRequest, WriteFileResponse, WriteFileTextRequest, validate_packet_probe,
validate_packet_probe_request,
};
pub use errors::{
ApiError, ApiErrorDetails, COMMAND_FAILURE_SCHEMA_VERSION, CommandFailureEnvelope,
Expand Down
127 changes: 127 additions & 0 deletions crates/codestory-contracts/src/api/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,126 @@ pub struct IndexFreshnessDto {
pub samples: Vec<IndexFreshnessSampleDto>,
}

/// What the index knows about one source file it was asked about.
///
/// Deliberately inert, like [`IndexFreshnessDto`]: it carries no verdict about whether the file is
/// usable and none about whether evidence over it is provable. Both are computed downstream by
/// consumers reading this same observation.
#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)]
pub struct SourceCoverageObservationDto {
/// The path as the consumer asked about it, so a caller can match its own request.
pub path: String,
pub status: SourceCoverageStatusDto,
/// Set only when `status` is `Incomplete`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<FileCoverageReason>,
/// Set only when `status` is `NotEstablished`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_established_cause: Option<SourceCoverageNotEstablishedCauseDto>,
/// Observed size and the cap that refused it, when the file was policy-excluded.
///
/// Reported so a gap sentence can name numbers instead of a word. No verdict reads these.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub byte_cap: Option<u64>,
}

/// What the index established about one file.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceCoverageStatusDto {
/// The file is in the published core with no recorded coverage defect.
Indexed,
/// A published policy exclusion refused the file before scheduling.
PolicyExcluded,
/// The file was indexed but a coverage reason was recorded against it.
Incomplete,
/// Coverage could not be determined; see `not_established_cause`.
NotEstablished,
}

/// Why a coverage lookup could not reach a verdict.
///
/// The same split [`IndexFreshnessNotCheckedCauseDto`] makes: `PublicationIncomplete` means there
/// is no complete core to ask, which is a deliberate and recoverable state; `LookupUnavailable`
/// means the query itself failed and proves nothing.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceCoverageNotEstablishedCauseDto {
PublicationIncomplete,
LookupUnavailable,
}

/// Why a consumer must treat evidence over one file as unprovable.
///
/// [`SourceCoverageStatusDto`] describes what the *index* did. This describes what a consumer may
/// conclude, and as with freshness those are different judgements — so this carries variants the
/// producer cannot emit.
///
/// One deliberate divergence from [`FreshnessUnknownCauseDto`], and it is the whole asymmetry:
/// that mapping takes an `Option` because *no freshness observation at all* is itself unknown and
/// must cap. Coverage's takes a value, because an empty observation list means no path was
/// checked, which is legitimate and must cap nothing. A failed lookup is carried per path as
/// `NotEstablished` rather than by absence.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceCoverageUnprovableCauseDto {
/// A published policy exclusion refused the file, so the index never read it.
PolicyExcluded,
/// The file carries a recorded coverage defect.
IncompleteIndex,
/// There is no complete publication to ask about this file.
PublicationIncomplete,
/// The coverage lookup failed.
LookupUnavailable,
/// The observation reported `Incomplete` without naming a reason.
ReasonUnreported,
/// The observation reported `NotEstablished` without naming a cause.
CauseUnreported,
}

impl SourceCoverageUnprovableCauseDto {
/// Stable machine-readable identity for gap text and typed output fields.
pub fn id(self) -> &'static str {
match self {
Self::PolicyExcluded => "policy_excluded",
Self::IncompleteIndex => "incomplete_index",
Self::PublicationIncomplete => "publication_incomplete",
Self::LookupUnavailable => "lookup_unavailable",
Self::ReasonUnreported => "reason_unreported",
Self::CauseUnreported => "cause_unreported",
}
}

/// The unprovable cause an observation carries, or `None` when the file is covered.
///
/// `Indexed` is the only verdict that establishes coverage. Everything else — including an
/// `Incomplete` that names no reason — is unprovable, because defaulting an unnamed defect to
/// covered is the exposure this type exists to close.
pub fn for_observation(observation: &SourceCoverageObservationDto) -> Option<Self> {
match observation.status {
SourceCoverageStatusDto::Indexed => None,
SourceCoverageStatusDto::PolicyExcluded => Some(Self::PolicyExcluded),
SourceCoverageStatusDto::Incomplete => Some(match observation.reason {
Some(_) => Self::IncompleteIndex,
None => Self::ReasonUnreported,
}),
SourceCoverageStatusDto::NotEstablished => {
Some(match observation.not_established_cause {
Some(SourceCoverageNotEstablishedCauseDto::PublicationIncomplete) => {
Self::PublicationIncomplete
}
Some(SourceCoverageNotEstablishedCauseDto::LookupUnavailable) => {
Self::LookupUnavailable
}
None => Self::CauseUnreported,
})
}
}
}
}

/// Readiness goal being evaluated.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -2649,6 +2769,13 @@ pub struct AgentAnswerDto {
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freshness: Option<IndexFreshnessDto>,
/// Coverage for the files this answer actually touched.
///
/// Empty means no path was checked, which is legitimate and caps nothing — the opposite of
/// `freshness`, where absence is itself an unknown. A failed lookup arrives here as a
/// `NotEstablished` observation rather than as an absent list.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_coverage: Vec<SourceCoverageObservationDto>,
pub sections: Vec<AgentResponseSectionDto>,
pub citations: Vec<AgentCitationDto>,
pub subgraph_ids: Vec<String>,
Expand Down
1 change: 1 addition & 0 deletions crates/codestory-runtime/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(crate) mod packet_claim_profile_registry;
pub(crate) mod packet_claim_profiles;
pub(crate) mod packet_claims;
pub(crate) mod packet_command_profiles;
pub(crate) mod packet_coverage;
pub(crate) mod packet_degradation;
pub(crate) mod packet_evidence;
pub(crate) mod packet_freshness;
Expand Down
Loading