mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
fix(pack): preserve canonical citation coordinates and conversation identity (#493)
Publish cass.pack.v2: keep search's one-based line_number unchanged as
citation.message_index and emit message_index_base=1. Version 1 subtracted one,
allowing a copied citation to select the preceding real archived message. Keep
physical spans separately verified; include schema identity in JSONL and explicit
canonical selectors in Markdown without bypassing privacy redaction.
Count sessions by source/path/conversation, scope physical-span deduplication to
source identity, and bind evidence/candidate IDs to length-prefixed canonical
identity. Preserve intentional content deduplication and deterministic ordering.
Add five regressions, including real pack-to-view/expand round trips across five
output projections, shared paths, a missing source file and a plausible wrong
preceding message. Preserve all 24 existing tests and their concurrent expanded
serializer check. Extend the permanent runner with pack aliases, error goldens
and the existing real archive handoff journey.
Exact-source preparation, pinned rustfmt and diff checks passed; downloaded
candidate hashes match reviewed bytes. Native pack run 35680926109 is still
executing. Previous canonical/physical bound suites passed, but current strict
Clippy and full runtime qualification are not claimed. RCH/UBS unavailable
locally. A combined candidate refused concurrent changes; none were overwritten.
Restore candidate publishing to read-only. Reparent the reviewed changes onto
631ceadd after a safe non-fast-forward refusal, preserving its MCP preparation.
Direct main via GitHub, no PR or force. Related: coding_agent_session_search-2l1b0.20.
This commit is contained in:
@@ -1,104 +1,13 @@
|
||||
name: GH493 canonical anchor candidate
|
||||
|
||||
# The reviewed candidate is now production code. Keep this manual entry point
|
||||
# as a read-only alias for the permanent regression suite, not a blob publisher.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/gh493-coordinate-candidate.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@881ba7bf39a41cda34ac9e123fb41b44ed08232f
|
||||
with:
|
||||
toolchain: nightly-2026-08-31
|
||||
components: rustfmt
|
||||
- name: Preserve current tests and admit exact reviewed pack sources
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import base64, hashlib, json, os, pathlib, subprocess, urllib.request
|
||||
inputs = {
|
||||
'src/search/pack_planner.rs': '8da40fafe3a838dba5854817ed6aacf50cb5d8ea',
|
||||
'tests/cli_robot.rs': '2bbae550983d5b2d6f11fb53ad78721ced05d2bb',
|
||||
'tests/e2e_cli_flows.rs': '7e345504670c80ca957f5e8cb07085b81c060b0c',
|
||||
'docs/planning/ANSWER_PACKS_CONTRACT.md': 'da7daf701813c13e2bd1cb495083db40ed975e0f',
|
||||
'tests/gh493_message_coordinates.rs': '7b3e31d6cc53d144a6cd796c5b7900b08d3c6e08',
|
||||
}
|
||||
outputs = {
|
||||
'src/search/pack_planner.rs': '9ece232289ce82ee37dff4a36dea741de7ad1dba',
|
||||
'tests/cli_robot.rs': '68c78f33143487669204ee67671e2513dcd09cdf',
|
||||
'tests/e2e_cli_flows.rs': '996ef269861be8b890b16825e7adf6e1b1512f7c',
|
||||
'docs/planning/ANSWER_PACKS_CONTRACT.md': '04834c3939523115d53a9988578d52f50554ffeb',
|
||||
}
|
||||
for path, sha in inputs.items():
|
||||
actual = subprocess.check_output(['git', 'hash-object', path], text=True).strip()
|
||||
assert actual == sha, f'{path}: {actual} != {sha}'
|
||||
def blob(sha):
|
||||
request = urllib.request.Request(f'https://api.github.com/repos/{os.environ["GITHUB_REPOSITORY"]}/git/blobs/{sha}', headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'User-Agent': 'cass-candidate-review'})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
result = json.load(response)
|
||||
assert result['encoding'] == 'base64'
|
||||
data = base64.b64decode(result['content'])
|
||||
assert hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() == sha
|
||||
return data
|
||||
pathlib.Path('candidate').mkdir()
|
||||
original = pathlib.Path('tests/gh493_message_coordinates.rs').read_bytes()
|
||||
pathlib.Path('candidate/original-tests.rs').write_bytes(original)
|
||||
for path, sha in outputs.items():
|
||||
pathlib.Path(path).write_bytes(blob(sha))
|
||||
with open('tests/gh493_message_coordinates.rs', 'ab') as output:
|
||||
output.write(blob('e8709bbdcc4a6462685a25fed1c0dfc7cce74de3'))
|
||||
assert pathlib.Path('tests/gh493_message_coordinates.rs').read_bytes().startswith(original)
|
||||
pathlib.Path('candidate/input-blobs.json').write_text(json.dumps(inputs, indent=2))
|
||||
PY
|
||||
- name: Format the appended tests and verify the complete patch
|
||||
run: |
|
||||
rustfmt --edition 2024 --config skip_children=true tests/gh493_message_coordinates.rs
|
||||
rustfmt --edition 2024 --config skip_children=true --check src/search/pack_planner.rs tests/gh493_message_coordinates.rs tests/cli_robot.rs tests/e2e_cli_flows.rs
|
||||
git diff --check
|
||||
- name: Publish reviewed source blobs only
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib, shutil, subprocess, urllib.request
|
||||
inputs = json.loads(pathlib.Path('candidate/input-blobs.json').read_text())
|
||||
assert set(subprocess.check_output(['git', 'diff', '--name-only'], text=True).splitlines()) == set(inputs)
|
||||
manifest = {'base_commit': os.environ['GITHUB_SHA'], 'base_blobs': inputs, 'candidate_blobs': {}}
|
||||
for path in inputs:
|
||||
request = urllib.request.Request(f'https://api.github.com/repos/{os.environ["GITHUB_REPOSITORY"]}/git/blobs', data=json.dumps({'content': pathlib.Path(path).read_text(), 'encoding': 'utf-8'}).encode(), headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'User-Agent': 'cass-candidate-review'}, method='POST')
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
sha = json.load(response)['sha']
|
||||
assert sha == subprocess.check_output(['git', 'hash-object', path], text=True).strip()
|
||||
manifest['candidate_blobs'][path] = sha
|
||||
destination = pathlib.Path('candidate') / path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(path, destination)
|
||||
pathlib.Path('candidate/manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
pathlib.Path('candidate/review.patch').write_bytes(subprocess.check_output(['git', 'diff']))
|
||||
print(json.dumps(manifest, indent=2))
|
||||
PY
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: pack-current-main-candidate
|
||||
path: candidate/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
verify:
|
||||
uses: ./.github/workflows/message-coordinate-regression.yml
|
||||
|
||||
@@ -9,11 +9,15 @@ on:
|
||||
- src/followup_coordinates.rs
|
||||
- src/followup_coordinates/**
|
||||
- src/search/query.rs
|
||||
- src/search/pack_planner.rs
|
||||
- src/indexer/lexical_reconcile/canary.rs
|
||||
- tests/lexical_reconcile_canary.rs
|
||||
- tests/gh493_message_coordinates.rs
|
||||
- tests/gh493_reported_coordinates.rs
|
||||
- tests/gh493_windowed_followup.rs
|
||||
- tests/e2e_cli_flows.rs
|
||||
- tests/golden_robot_pack.rs
|
||||
- docs/planning/ANSWER_PACKS_CONTRACT.md
|
||||
- docs/reference/QUICK_REFERENCE.md
|
||||
- tests/cli_robot.rs
|
||||
- tests/cli_view_budget.rs
|
||||
@@ -107,8 +111,8 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db
|
||||
with:
|
||||
cache-on-failure: true
|
||||
- name: Run reconciliation, coordinate, field-mask and deadline regressions
|
||||
run: cargo test --locked --no-fail-fast --test lexical_reconcile_canary --test gh493_message_coordinates --test gh493_reported_coordinates --test gh493_windowed_followup --test cli_view_budget --test spec_field_mask_contract -- --nocapture
|
||||
- name: Run reconciliation, coordinate, pack, field-mask and deadline regressions
|
||||
run: cargo test --locked --no-fail-fast --test lexical_reconcile_canary --test gh493_message_coordinates --test gh493_reported_coordinates --test gh493_windowed_followup --test cli_view_budget --test spec_field_mask_contract --test golden_robot_pack -- --nocapture
|
||||
# Keep every gate failing on errors, but do not let one failure hide the
|
||||
# remaining independent contracts or the production compiler diagnostics.
|
||||
- name: Check existing dispatch and error envelopes
|
||||
@@ -122,9 +126,19 @@ jobs:
|
||||
cargo test --locked --test cli_robot view_
|
||||
cargo test --locked --test cli_robot fields_
|
||||
cargo test --locked --test cli_robot capabilities_matches_golden_contract
|
||||
- name: Check pack aliases, field masks and timeout contracts
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo test --locked --test cli_robot pack -- --nocapture
|
||||
- name: Check the real archive-to-pack journey
|
||||
if: ${{ !cancelled() }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo test --locked --test e2e_cli_flows pack_handoff_journey_uses_real_archive_and_preserves_sources -- --exact --nocapture | tee "$RUNNER_TEMP/pack-journey.log"
|
||||
grep -F 'test result: ok. 1 passed;' "$RUNNER_TEMP/pack-journey.log"
|
||||
- name: Lint the regression targets and production dependency
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo clippy --locked --test gh493_message_coordinates --test gh493_reported_coordinates --test gh493_windowed_followup --test lexical_reconcile_canary -- -D warnings
|
||||
- name: Check coordinate implementation formatting
|
||||
if: ${{ !cancelled() }}
|
||||
run: rustfmt --edition 2024 --config skip_children=true --check src/lib.rs src/search/query.rs src/followup_coordinates.rs src/followup_coordinates/window.rs tests/gh493_message_coordinates.rs tests/gh493_reported_coordinates.rs tests/gh493_windowed_followup.rs tests/cli_robot.rs tests/cli_dispatch_coverage.rs tests/util/timeout.rs tests/spec_field_mask_contract.rs
|
||||
run: rustfmt --edition 2024 --config skip_children=true --check src/lib.rs src/search/query.rs src/search/pack_planner.rs src/followup_coordinates.rs src/followup_coordinates/window.rs tests/gh493_message_coordinates.rs tests/gh493_reported_coordinates.rs tests/gh493_windowed_followup.rs tests/cli_robot.rs tests/e2e_cli_flows.rs tests/cli_dispatch_coverage.rs tests/util/timeout.rs tests/spec_field_mask_contract.rs
|
||||
|
||||
@@ -141,9 +141,23 @@ exit code.
|
||||
|
||||
All object keys are stable and snake_case.
|
||||
|
||||
The current response is **`cass.pack.v2`**. Version 1's zero-based
|
||||
`citation.message_index` must not be passed directly to `view`/`expand`.
|
||||
Version 2 preserves search's one-based `line_number` unchanged as
|
||||
`citation.message_index` and includes `message_index_base: 1` on every citation.
|
||||
Use it with `--message-index`, never `--line`, preserving `source_path`,
|
||||
`source_id`, `conversation_id`, and the archive used for search. This is the
|
||||
stored `messages.idx + 1`, not a dense vector position or a physical file line.
|
||||
Unknown indices remain null. Explicit field masks may omit required identity
|
||||
fields; consumers must not invent the missing components.
|
||||
|
||||
Session limits, diversity and source summaries distinguish conversations by
|
||||
source, source path and conversation ID, including multiple sessions in a single
|
||||
provider database. Without a conversation ID, only path-level identity is known.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "cass.pack.v1",
|
||||
"schema_version": "cass.pack.v2",
|
||||
"query": {
|
||||
"text": "storage open recovery",
|
||||
"normalized": "storage open recovery",
|
||||
@@ -232,7 +246,12 @@ Evidence IDs encode all 32 bytes of the BLAKE3 digest using the RFC 4648
|
||||
base32 alphabet (`A-Z`, `2-7`), without `=` padding. Each ID is `ev_` followed
|
||||
by 52 characters; the final character's unused bits are zero. Published IDs
|
||||
bind to the citation core after source verification, including unverified
|
||||
fallbacks.
|
||||
fallbacks. Version 2 hashes a domain-separated, length-prefixed encoding of
|
||||
source identity and path, optional conversation/message identity, physical span,
|
||||
and span hash. Missing and zero coordinates are distinct; embedded delimiters
|
||||
cannot alias neighboring fields. Evidence and candidate IDs change with this
|
||||
version. Content-based duplicate suppression within a pack is unchanged, while
|
||||
physical-span overlap suppression never crosses source identity.
|
||||
|
||||
## Pack Object Schema
|
||||
|
||||
@@ -287,7 +306,8 @@ Citation fields:
|
||||
| `agent` | string | Agent slug. |
|
||||
| `line_start` | integer or null | One-indexed first source line for the span. |
|
||||
| `line_end` | integer or null | One-indexed last source line for the span. |
|
||||
| `message_index` | integer or null | Zero-indexed message position when known. |
|
||||
| `message_index` | integer or null | One-based stored message index (`messages.idx + 1`), unchanged from search's `line_number`; use with `--message-index`. |
|
||||
| `message_index_base` | integer | Always `1`, including when the index is unknown/null. |
|
||||
| `conversation_id` | integer or null | Internal DB conversation id when available. |
|
||||
| `content_hash` | string | Hex hash of normalized content. |
|
||||
| `span_hash` | string | Hex hash of the exact selected span before redaction. |
|
||||
@@ -527,7 +547,7 @@ Empty search results are success by default:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "cass.pack.v1",
|
||||
"schema_version": "cass.pack.v2",
|
||||
"evidence": [],
|
||||
"omitted": {"count": 0, "items": []},
|
||||
"warnings": ["no_evidence_found"]
|
||||
@@ -541,7 +561,7 @@ same fields. TOON encodes the same payload with the existing `toon` crate path.
|
||||
|
||||
JSONL emits one object per line:
|
||||
|
||||
1. `{"_meta": ...}`
|
||||
1. `{"schema_version": "cass.pack.v2", "_meta": ..., "budget": ...}`
|
||||
2. `{"pack": ...}`
|
||||
3. One line per `evidence` item.
|
||||
4. One line with `{"omitted": ...}`
|
||||
@@ -558,7 +578,7 @@ Markdown output must include citations inline:
|
||||
|
||||
## Evidence
|
||||
|
||||
[ev_abc123] codex local /path/session.jsonl:42-47
|
||||
[ev_abc123] codex local /path/session.jsonl:42-47 conversation_id=7 message_index=8 (1-based)
|
||||
```
|
||||
|
||||
Markdown is not a replacement for robot JSON; implementation must add JSON
|
||||
|
||||
+100
-44
@@ -225,6 +225,8 @@ pub struct PackCandidate {
|
||||
pub line_start: Option<usize>,
|
||||
pub line_end: Option<usize>,
|
||||
pub conversation_id: Option<i64>,
|
||||
/// One-based canonical `messages.idx + 1`, as accepted by --message-index.
|
||||
/// Physical source-file coordinates belong only in line_start/line_end.
|
||||
pub message_index: Option<usize>,
|
||||
pub citation_verified: bool,
|
||||
pub content_hash: String,
|
||||
@@ -253,7 +255,7 @@ impl PackCandidate {
|
||||
) -> Self {
|
||||
// Search navigation uses normalized message ordinals. Physical source
|
||||
// lines are assigned only after reading and matching the source record.
|
||||
let message_index = hit.line_number.and_then(|line| line.checked_sub(1));
|
||||
let message_index = hit.line_number.filter(|&line| line != 0);
|
||||
let source_id = if hit.source_id.trim().is_empty() {
|
||||
"local".to_string()
|
||||
} else {
|
||||
@@ -265,14 +267,8 @@ impl PackCandidate {
|
||||
hit.origin_kind.trim().to_string()
|
||||
};
|
||||
let content_hash = format!("{:016x}", hit.content_hash);
|
||||
let candidate_id = format!(
|
||||
"{}:{}:{}",
|
||||
source_id,
|
||||
hit.source_path,
|
||||
hit.line_number.unwrap_or_default()
|
||||
);
|
||||
Self {
|
||||
candidate_id,
|
||||
let mut candidate = Self {
|
||||
candidate_id: String::new(),
|
||||
source_path: hit.source_path.clone(),
|
||||
source_id,
|
||||
origin_kind,
|
||||
@@ -305,11 +301,13 @@ impl PackCandidate {
|
||||
query_phrase_count,
|
||||
source_readiness: PackSourceReadiness::Healthy,
|
||||
source_explicitly_requested: false,
|
||||
}
|
||||
};
|
||||
candidate.candidate_id = evidence_id(&candidate);
|
||||
candidate
|
||||
}
|
||||
|
||||
fn session_key(&self) -> (&str, &str) {
|
||||
(&self.source_id, &self.source_path)
|
||||
fn session_key(&self) -> (&str, &str, Option<i64>) {
|
||||
(&self.source_id, &self.source_path, self.conversation_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,6 +737,7 @@ struct RenderedCitation {
|
||||
line_start: Option<usize>,
|
||||
line_end: Option<usize>,
|
||||
message_index: Option<usize>,
|
||||
message_index_base: u8,
|
||||
conversation_id: Option<i64>,
|
||||
content_hash: String,
|
||||
span_hash: String,
|
||||
@@ -799,7 +798,7 @@ struct RenderedPrivacy {
|
||||
#[derive(Debug, Default)]
|
||||
struct SourceAccumulator {
|
||||
origin_kind: String,
|
||||
sessions: BTreeSet<String>,
|
||||
sessions: BTreeSet<(String, Option<i64>)>,
|
||||
evidence_count: usize,
|
||||
newest_evidence_at_ms: Option<i64>,
|
||||
healthy: bool,
|
||||
@@ -880,10 +879,10 @@ struct ScoredCandidate {
|
||||
#[derive(Debug, Default)]
|
||||
struct SelectedState {
|
||||
source_ids: HashSet<String>,
|
||||
sessions: HashSet<(String, String)>,
|
||||
sessions: HashSet<(String, String, Option<i64>)>,
|
||||
span_hashes: HashSet<String>,
|
||||
content_hashes: HashSet<String>,
|
||||
ranges: Vec<(String, Option<usize>, Option<usize>)>,
|
||||
ranges: Vec<(String, String, Option<usize>, Option<usize>)>,
|
||||
}
|
||||
|
||||
pub fn plan_answer_pack(
|
||||
@@ -1045,10 +1044,11 @@ pub fn plan_answer_pack(
|
||||
}
|
||||
|
||||
let session_key = candidate.session_key();
|
||||
if !selected_state
|
||||
.sessions
|
||||
.contains(&(session_key.0.to_string(), session_key.1.to_string()))
|
||||
&& selected_state.sessions.len() >= request.limits.max_sessions
|
||||
if !selected_state.sessions.contains(&(
|
||||
session_key.0.to_string(),
|
||||
session_key.1.to_string(),
|
||||
session_key.2,
|
||||
)) && selected_state.sessions.len() >= request.limits.max_sessions
|
||||
{
|
||||
omitted.push(omitted_candidate(
|
||||
candidate,
|
||||
@@ -1062,9 +1062,11 @@ pub fn plan_answer_pack(
|
||||
selected_state
|
||||
.source_ids
|
||||
.insert(candidate.source_id.clone());
|
||||
selected_state
|
||||
.sessions
|
||||
.insert((candidate.source_id.clone(), candidate.source_path.clone()));
|
||||
selected_state.sessions.insert((
|
||||
candidate.source_id.clone(),
|
||||
candidate.source_path.clone(),
|
||||
candidate.conversation_id,
|
||||
));
|
||||
selected_state
|
||||
.span_hashes
|
||||
.insert(candidate.span_hash.clone());
|
||||
@@ -1072,6 +1074,7 @@ pub fn plan_answer_pack(
|
||||
.content_hashes
|
||||
.insert(candidate.content_hash.clone());
|
||||
selected_state.ranges.push((
|
||||
candidate.source_id.clone(),
|
||||
candidate.source_path.clone(),
|
||||
candidate.line_start,
|
||||
candidate.line_end,
|
||||
@@ -1271,9 +1274,10 @@ fn hard_omission_reason(
|
||||
|| selected_state
|
||||
.ranges
|
||||
.iter()
|
||||
.any(|(source_path, start, end)| {
|
||||
.any(|(source_id, source_path, start, end)| {
|
||||
// ubs:ignore — compares public citation paths, never secret material.
|
||||
source_path == &candidate.source_path
|
||||
source_id == &candidate.source_id
|
||||
&& source_path == &candidate.source_path
|
||||
&& line_ranges_overlap(*start, *end, candidate.line_start, candidate.line_end)
|
||||
})
|
||||
{
|
||||
@@ -1445,7 +1449,11 @@ fn freshness_score(candidate: &PackCandidate, request: &PackPlanRequest) -> f64
|
||||
}
|
||||
|
||||
fn source_diversity_score(candidate: &PackCandidate, selected_state: &SelectedState) -> f64 {
|
||||
let session_key = (candidate.source_id.clone(), candidate.source_path.clone());
|
||||
let session_key = (
|
||||
candidate.source_id.clone(),
|
||||
candidate.source_path.clone(),
|
||||
candidate.conversation_id,
|
||||
);
|
||||
if selected_state.sessions.contains(&session_key) {
|
||||
0.0
|
||||
} else if selected_state.source_ids.contains(&candidate.source_id) {
|
||||
@@ -1508,9 +1516,10 @@ fn duplicate_penalty(candidate: &PackCandidate, selected_state: &SelectedState)
|
||||
if selected_state
|
||||
.ranges
|
||||
.iter()
|
||||
.any(|(source_path, start, end)| {
|
||||
.any(|(source_id, source_path, start, end)| {
|
||||
// ubs:ignore — compares public citation paths, never secret material.
|
||||
source_path == &candidate.source_path
|
||||
source_id == &candidate.source_id
|
||||
&& source_path == &candidate.source_path
|
||||
&& line_ranges_overlap(*start, *end, candidate.line_start, candidate.line_end)
|
||||
})
|
||||
{
|
||||
@@ -1560,6 +1569,17 @@ fn candidate_ordering(
|
||||
})
|
||||
.then_with(|| left_candidate.source_id.cmp(&right_candidate.source_id))
|
||||
.then_with(|| left_candidate.source_path.cmp(&right_candidate.source_path))
|
||||
.then_with(|| {
|
||||
left_candidate
|
||||
.conversation_id
|
||||
.cmp(&right_candidate.conversation_id)
|
||||
})
|
||||
.then_with(|| {
|
||||
compare_optional_usize_low_first(
|
||||
left_candidate.message_index,
|
||||
right_candidate.message_index,
|
||||
)
|
||||
})
|
||||
.then_with(|| {
|
||||
compare_optional_usize_low_first(left_candidate.line_start, right_candidate.line_start)
|
||||
})
|
||||
@@ -1621,17 +1641,42 @@ fn estimated_tokens(text: &str) -> usize {
|
||||
}
|
||||
|
||||
fn evidence_id(candidate: &PackCandidate) -> String {
|
||||
let mut hasher_input = String::new();
|
||||
hasher_input.push_str(&candidate.source_id);
|
||||
hasher_input.push('\n');
|
||||
hasher_input.push_str(&candidate.source_path);
|
||||
hasher_input.push('\n');
|
||||
hasher_input.push_str(&candidate.line_start.unwrap_or_default().to_string());
|
||||
hasher_input.push('\n');
|
||||
hasher_input.push_str(&candidate.line_end.unwrap_or_default().to_string());
|
||||
hasher_input.push('\n');
|
||||
hasher_input.push_str(&candidate.span_hash);
|
||||
let hash = blake3::hash(hasher_input.as_bytes());
|
||||
// Pathnames alone are not session identities: provider databases contain
|
||||
// many conversations at one path. Length-prefix strings so embedded
|
||||
// newlines/colons cannot move data into a neighboring identity component.
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"cass.pack.evidence.v2\0");
|
||||
for text in [&candidate.source_id, &candidate.source_path] {
|
||||
hasher.update(&(text.len() as u64).to_le_bytes());
|
||||
hasher.update(text.as_bytes());
|
||||
}
|
||||
match candidate.conversation_id {
|
||||
Some(id) => {
|
||||
hasher.update(&[1]);
|
||||
hasher.update(&id.to_le_bytes());
|
||||
}
|
||||
None => {
|
||||
hasher.update(&[0]);
|
||||
}
|
||||
}
|
||||
for coordinate in [
|
||||
candidate.message_index,
|
||||
candidate.line_start,
|
||||
candidate.line_end,
|
||||
] {
|
||||
match coordinate {
|
||||
Some(number) => {
|
||||
hasher.update(&[1]);
|
||||
hasher.update(&(number as u64).to_le_bytes());
|
||||
}
|
||||
None => {
|
||||
hasher.update(&[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
hasher.update(&(candidate.span_hash.len() as u64).to_le_bytes());
|
||||
hasher.update(candidate.span_hash.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
encoded_evidence_id(hash.as_bytes())
|
||||
}
|
||||
|
||||
@@ -1819,7 +1864,7 @@ fn rendered_answer_pack_with_correlation(
|
||||
}
|
||||
|
||||
RenderedAnswerPack {
|
||||
schema_version: "cass.pack.v1",
|
||||
schema_version: "cass.pack.v2",
|
||||
query: RenderedQuery {
|
||||
text: query_text,
|
||||
normalized: normalized_query,
|
||||
@@ -2284,6 +2329,7 @@ fn rendered_evidence(
|
||||
line_start: candidate.line_start,
|
||||
line_end: candidate.line_end,
|
||||
message_index: candidate.message_index,
|
||||
message_index_base: 1,
|
||||
conversation_id: candidate.conversation_id,
|
||||
content_hash: candidate.content_hash.clone(),
|
||||
span_hash: candidate.span_hash.clone(),
|
||||
@@ -2398,7 +2444,10 @@ fn rendered_source_summary(evidence: &[RenderedEvidence]) -> Vec<RenderedSourceS
|
||||
healthy: true,
|
||||
..SourceAccumulator::default()
|
||||
});
|
||||
entry.sessions.insert(item.citation.source_path.clone());
|
||||
entry.sessions.insert((
|
||||
item.citation.source_path.clone(),
|
||||
item.citation.conversation_id,
|
||||
));
|
||||
entry.evidence_count += 1;
|
||||
entry.newest_evidence_at_ms =
|
||||
newer_timestamp(entry.newest_evidence_at_ms, item.citation.created_at_ms);
|
||||
@@ -2576,6 +2625,7 @@ fn render_answer_pack_jsonl(
|
||||
let mut lines = Vec::with_capacity(envelope.evidence.len() + 4);
|
||||
lines.push(json_line(
|
||||
serde_json::json!({
|
||||
"schema_version": envelope.schema_version,
|
||||
"_meta": &envelope.meta,
|
||||
"budget": &envelope.budget,
|
||||
}),
|
||||
@@ -2658,6 +2708,12 @@ fn render_answer_pack_markdown(envelope: &RenderedAnswerPack) -> String {
|
||||
out.push_str(&line_end.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(conversation_id) = item.citation.conversation_id {
|
||||
out.push_str(&format!(" conversation_id={conversation_id}"));
|
||||
}
|
||||
if let Some(message_index) = item.citation.message_index {
|
||||
out.push_str(&format!(" message_index={message_index} (1-based)"));
|
||||
}
|
||||
out.push_str("\n\n");
|
||||
// Preserve the complete prepared excerpt, including blank lines.
|
||||
// A longer fence keeps source fences, links and HTML literal.
|
||||
@@ -3081,7 +3137,7 @@ mod tests {
|
||||
let candidate = PackCandidate::from_search_hit(&hit, 1, 0);
|
||||
|
||||
assert_eq!(candidate.match_type, "implicit_wildcard");
|
||||
assert_eq!(candidate.message_index, Some(11));
|
||||
assert_eq!(candidate.message_index, Some(12));
|
||||
assert_eq!(candidate.line_start, None);
|
||||
assert_eq!(candidate.line_end, None);
|
||||
assert!(!candidate.citation_verified);
|
||||
@@ -3104,7 +3160,7 @@ mod tests {
|
||||
let mut item = candidate("verify", "local", path.to_str().unwrap(), 10.0);
|
||||
item.created_at_ms = None;
|
||||
item.excerpt = "verifiedneedle".to_string();
|
||||
item.message_index = Some(0);
|
||||
item.message_index = Some(1);
|
||||
let base = plan_answer_pack(request(vec![item])).unwrap();
|
||||
|
||||
for (contents, expected_line) in [
|
||||
@@ -3126,7 +3182,7 @@ mod tests {
|
||||
assert_eq!(citation.line_start, expected_line);
|
||||
assert_eq!(citation.line_end, expected_line);
|
||||
assert_eq!(citation.citation_verified, expected_line.is_some());
|
||||
assert_eq!(citation.message_index, Some(0));
|
||||
assert_eq!(citation.message_index, Some(1));
|
||||
if expected_line.is_some() {
|
||||
assert_eq!(
|
||||
citation.span_hash,
|
||||
@@ -3425,7 +3481,7 @@ mod tests {
|
||||
|
||||
assert!(!rendered.contains('\n'));
|
||||
assert_eq!(value, render_answer_pack_value(&plan, &req).unwrap());
|
||||
assert_eq!(value["schema_version"], "cass.pack.v1");
|
||||
assert_eq!(value["schema_version"], "cass.pack.v2");
|
||||
assert_eq!(value["_meta"]["format"], "compact");
|
||||
assert_eq!(value["_meta"]["partial"], false);
|
||||
assert_eq!(
|
||||
|
||||
+3
-3
@@ -1271,7 +1271,7 @@ fn assert_pack_command_returns_evidence(command: &str, extra_args: &[&str]) {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json: Value = serde_json::from_str(stdout.trim()).expect("valid pack JSON");
|
||||
|
||||
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v1"));
|
||||
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v2"));
|
||||
assert_eq!(json["query"]["text"].as_str(), Some("auth"));
|
||||
assert_eq!(json["limits"]["max_evidence"].as_u64(), Some(1));
|
||||
assert_eq!(json["limits"]["max_sessions"].as_u64(), Some(1));
|
||||
@@ -5909,7 +5909,7 @@ fn implicit_robot_pack_query_uses_pack_when_pack_only_flags_present() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json: Value = serde_json::from_str(stdout.trim()).expect("valid pack JSON");
|
||||
|
||||
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v1"));
|
||||
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v2"));
|
||||
assert_eq!(json["query"]["text"].as_str(), Some("auth failed"));
|
||||
assert_eq!(json["limits"]["max_evidence"].as_u64(), Some(1));
|
||||
assert_eq!(json["limits"]["max_sessions"].as_u64(), Some(1));
|
||||
@@ -6362,7 +6362,7 @@ fn explicit_search_pack_only_flags_run_pack_in_robot_mode() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json: Value = serde_json::from_str(stdout.trim()).expect("valid pack JSON");
|
||||
|
||||
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v1"));
|
||||
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v2"));
|
||||
assert_eq!(json["query"]["text"].as_str(), Some("auth failed"));
|
||||
assert_eq!(json["limits"]["max_evidence"].as_u64(), Some(1));
|
||||
assert_eq!(json["limits"]["max_sessions"].as_u64(), Some(1));
|
||||
|
||||
@@ -459,7 +459,7 @@ fn pack_handoff_journey_uses_real_archive_and_preserves_sources() {
|
||||
)
|
||||
.expect("write scrubbed pack artifact");
|
||||
|
||||
assert_eq!(json["schema_version"], "cass.pack.v1");
|
||||
assert_eq!(json["schema_version"], "cass.pack.v2");
|
||||
assert_eq!(json["query"]["text"], "checkout failure");
|
||||
assert_eq!(json["limits"]["max_tokens"], 4000);
|
||||
assert!(
|
||||
|
||||
@@ -1056,3 +1056,405 @@ fn corrected_robot_failures_are_one_error_envelope_and_success_still_teaches() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_candidate(
|
||||
source: &str,
|
||||
path: &str,
|
||||
conversation_id: Option<i64>,
|
||||
number: Option<usize>,
|
||||
hash: u64,
|
||||
) -> coding_agent_search::search::pack_planner::PackCandidate {
|
||||
use coding_agent_search::search::pack_planner::PackCandidate;
|
||||
use coding_agent_search::search::query::{MatchType, SearchHit};
|
||||
PackCandidate::from_search_hit(
|
||||
&SearchHit {
|
||||
title: "pack identity".into(),
|
||||
snippet: "pack evidence".into(),
|
||||
content: format!("distinct pack evidence {hash}"),
|
||||
content_hash: hash,
|
||||
conversation_id,
|
||||
score: 1.0,
|
||||
source_path: path.into(),
|
||||
agent: "claude_code".into(),
|
||||
workspace: "/work".into(),
|
||||
workspace_original: None,
|
||||
created_at: Some(1_733_000_000_000),
|
||||
line_number: number,
|
||||
match_type: MatchType::Exact,
|
||||
source_id: source.into(),
|
||||
origin_kind: "local".into(),
|
||||
origin_host: None,
|
||||
},
|
||||
1,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_indices_and_evidence_ids_bind_the_complete_canonical_identity() {
|
||||
use coding_agent_search::search::pack_planner::{PackPlanRequest, plan_answer_pack};
|
||||
for number in [None, Some(0), Some(1), Some(8), Some(129), Some(541)] {
|
||||
let candidate = pack_candidate("local", "/shared/provider.db", Some(42), number, 1);
|
||||
assert_eq!(candidate.message_index, number.filter(|&value| value != 0));
|
||||
assert!(candidate.line_start.is_none());
|
||||
assert!(!candidate.citation_verified);
|
||||
}
|
||||
// Identical content and source spans in separate single-item packs must
|
||||
// still identify the right conversation and message. Content deduplication
|
||||
// WITHIN a pack is intentionally unchanged.
|
||||
let candidates = vec![
|
||||
pack_candidate("local", "/shared/provider.db", Some(42), Some(8), 1),
|
||||
pack_candidate("local", "/shared/provider.db", Some(43), Some(8), 1),
|
||||
pack_candidate("local", "/shared/provider.db", Some(42), Some(9), 1),
|
||||
pack_candidate("remote-host", "/shared/provider.db", Some(42), Some(8), 1),
|
||||
pack_candidate("local", "/shared/provider.db", None, Some(8), 1),
|
||||
pack_candidate("local", "/shared/provider.db", Some(42), None, 1),
|
||||
// These collided with newline-delimited citation hashing.
|
||||
pack_candidate("local\n/shared", "provider.db", Some(42), Some(8), 1),
|
||||
pack_candidate("local", "/shared\nprovider.db", Some(42), Some(8), 1),
|
||||
];
|
||||
let mut candidate_ids = std::collections::HashSet::new();
|
||||
let mut evidence_ids = std::collections::HashSet::new();
|
||||
for candidate in candidates {
|
||||
assert!(candidate_ids.insert(candidate.candidate_id.clone()));
|
||||
let request = PackPlanRequest {
|
||||
candidates: vec![candidate],
|
||||
..Default::default()
|
||||
};
|
||||
let plan = plan_answer_pack(request.clone()).unwrap();
|
||||
let repeat = plan_answer_pack(request).unwrap();
|
||||
assert_eq!(plan.evidence.len(), 1);
|
||||
assert_eq!(plan.evidence[0].id, repeat.evidence[0].id);
|
||||
assert!(evidence_ids.insert(plan.evidence[0].id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_session_caps_and_source_summaries_count_shared_path_conversations() {
|
||||
use coding_agent_search::search::pack_planner::{
|
||||
PackOmittedReason, PackPlanRequest, PackRenderRequest, plan_answer_pack,
|
||||
render_answer_pack_value_without_trust_correlation,
|
||||
};
|
||||
let candidates = vec![
|
||||
pack_candidate("local", "/shared/provider.db", Some(11), Some(1), 11),
|
||||
pack_candidate("local", "/shared/provider.db", Some(22), Some(1), 22),
|
||||
pack_candidate("local", "/shared/provider.db", Some(11), Some(2), 12),
|
||||
];
|
||||
for cap in [1, 2] {
|
||||
let mut request = PackPlanRequest {
|
||||
candidates: candidates.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
request.limits.max_sessions = cap;
|
||||
let plan = plan_answer_pack(request.clone()).unwrap();
|
||||
assert_eq!(plan.selected_session_count, cap);
|
||||
assert_eq!(plan.selected_evidence_count, if cap == 1 { 2 } else { 3 });
|
||||
if cap == 1 {
|
||||
assert_eq!(plan.omitted.len(), 1);
|
||||
assert_eq!(
|
||||
plan.omitted[0].reason,
|
||||
PackOmittedReason::MaxSessionsReached
|
||||
);
|
||||
}
|
||||
request.candidates.reverse();
|
||||
let reversed = plan_answer_pack(request).unwrap();
|
||||
assert_eq!(
|
||||
plan.evidence
|
||||
.iter()
|
||||
.map(|item| &item.id)
|
||||
.collect::<Vec<_>>(),
|
||||
reversed
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|item| &item.id)
|
||||
.collect::<Vec<_>>(),
|
||||
"equal-ranked shared paths must not make selection depend on input order",
|
||||
);
|
||||
let value = render_answer_pack_value_without_trust_correlation(
|
||||
&plan,
|
||||
&PackRenderRequest::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(value["realized"]["selected_session_count"], cap);
|
||||
assert_eq!(value["pack"]["source_summary"][0]["session_count"], cap);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_physical_overlap_deduplication_is_scoped_to_the_source() {
|
||||
use coding_agent_search::search::pack_planner::{PackPlanRequest, plan_answer_pack};
|
||||
let mut left = pack_candidate("local", "/shared/session.jsonl", Some(11), Some(8), 11);
|
||||
left.line_start = Some(4);
|
||||
left.line_end = Some(7);
|
||||
left.citation_verified = true;
|
||||
let mut right = pack_candidate(
|
||||
"work-laptop",
|
||||
"/shared/session.jsonl",
|
||||
Some(22),
|
||||
Some(8),
|
||||
22,
|
||||
);
|
||||
right.line_start = left.line_start;
|
||||
right.line_end = left.line_end;
|
||||
right.citation_verified = true;
|
||||
let plan = plan_answer_pack(PackPlanRequest {
|
||||
candidates: vec![left.clone(), right.clone()],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.selected_evidence_count, 2,
|
||||
"same pathname on different hosts is not one file"
|
||||
);
|
||||
right.source_id = "local".into();
|
||||
let plan = plan_answer_pack(PackPlanRequest {
|
||||
candidates: vec![left, right],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.selected_evidence_count, 1,
|
||||
"actual same-source physical overlap is still deduplicated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_renderer_coordinates_are_self_describing_without_bypassing_privacy() {
|
||||
use coding_agent_search::search::pack_planner::{
|
||||
PackPlanRequest, PackRenderFormat, PackRenderRequest, plan_answer_pack,
|
||||
render_answer_pack_without_trust_correlation,
|
||||
};
|
||||
let mut candidate = pack_candidate(
|
||||
"alice.internal",
|
||||
"/home/alice/history.jsonl",
|
||||
Some(42),
|
||||
Some(8),
|
||||
1,
|
||||
);
|
||||
candidate.origin_kind = "ssh".into();
|
||||
candidate.origin_host = Some("alice.internal".into());
|
||||
let plan = plan_answer_pack(PackPlanRequest {
|
||||
candidates: vec![candidate],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
for format in [
|
||||
PackRenderFormat::Json,
|
||||
PackRenderFormat::CompactJson,
|
||||
PackRenderFormat::Jsonl,
|
||||
PackRenderFormat::Markdown,
|
||||
] {
|
||||
let request = PackRenderRequest {
|
||||
format,
|
||||
..Default::default()
|
||||
};
|
||||
let output = render_answer_pack_without_trust_correlation(&plan, &request).unwrap();
|
||||
assert!(!output.contains("alice.internal"));
|
||||
assert!(!output.contains("/home/alice"));
|
||||
if format == PackRenderFormat::Markdown {
|
||||
assert!(output.contains("conversation_id=42 message_index=8 (1-based)"));
|
||||
} else if format == PackRenderFormat::Jsonl {
|
||||
let lines: Vec<Value> = output
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(lines[0]["schema_version"], "cass.pack.v2");
|
||||
let citation = &lines
|
||||
.iter()
|
||||
.find(|line| line.get("evidence").is_some())
|
||||
.unwrap()["evidence"]["citation"];
|
||||
assert_eq!(citation["message_index_base"], 1);
|
||||
assert_eq!(citation["message_index"], 8);
|
||||
} else {
|
||||
let payload: Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(payload["schema_version"], "cass.pack.v2");
|
||||
let citation = &payload["evidence"][0]["citation"];
|
||||
assert_eq!(citation["message_index_base"], 1);
|
||||
assert_eq!(citation["message_index"], 8);
|
||||
assert_eq!(citation["conversation_id"], 42);
|
||||
assert!(citation["line_start"].is_null());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_pack_citations_round_trip_unchanged_with_shared_paths_and_missing_sources() {
|
||||
let fixture = Fixture::new(&[0, 1, 7]);
|
||||
let storage = FrankenStorage::open(&fixture.db).unwrap();
|
||||
let other = seed(&storage, &fixture.path, "codex", &[0, 1, 7]);
|
||||
let targets = [
|
||||
(
|
||||
fixture.conversation_id,
|
||||
"PACK493TARGET first conversation evidence",
|
||||
),
|
||||
(other, "PACK493TARGET second conversation evidence"),
|
||||
];
|
||||
for (conversation_id, content) in targets {
|
||||
storage
|
||||
.raw()
|
||||
.execute_compat(
|
||||
"UPDATE messages SET content = ?1 WHERE conversation_id = ?2 AND idx = 1",
|
||||
coding_agent_search::franken_sync::params![content, conversation_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(storage);
|
||||
for (conversation_id, _) in targets {
|
||||
let indexed = fixture
|
||||
.command("index")
|
||||
.arg("--data-dir")
|
||||
.arg(&fixture.data)
|
||||
.arg("--reconcile-conversation")
|
||||
.arg(conversation_id.to_string())
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
indexed.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&indexed.stderr)
|
||||
);
|
||||
}
|
||||
// Both the previous canonical message and physical file line 2 exist and
|
||||
// are plausible WRONG targets. A mistaken offset must not pass this test.
|
||||
let wrong = decode(fixture.follow(
|
||||
"view",
|
||||
1,
|
||||
&["--conversation-id", &fixture.conversation_id.to_string()],
|
||||
));
|
||||
assert_eq!(wrong["lines"][0]["content"], "canonical neighbour");
|
||||
let source = std::fs::read(&fixture.path).unwrap();
|
||||
let retained_source = fixture.path.with_extension("retained");
|
||||
std::fs::rename(&fixture.path, &retained_source).unwrap();
|
||||
let before = std::fs::read(&fixture.db).unwrap();
|
||||
for (format, args) in [
|
||||
("json", vec!["--json"]),
|
||||
("compact", vec!["--robot-format", "compact"]),
|
||||
("jsonl", vec!["--robot-format", "jsonl"]),
|
||||
("minimal", vec!["--json", "--fields", "minimal"]),
|
||||
(
|
||||
"custom",
|
||||
vec![
|
||||
"--json",
|
||||
"--fields",
|
||||
"schema_version,evidence[].citation,evidence[].excerpt",
|
||||
],
|
||||
),
|
||||
] {
|
||||
let output = fixture
|
||||
.command("pack")
|
||||
.args([
|
||||
"PACK493TARGET",
|
||||
"--mode",
|
||||
"lexical",
|
||||
"--freshness-policy",
|
||||
"allow-stale",
|
||||
"--max-sessions",
|
||||
"2",
|
||||
"--max-tokens",
|
||||
"20000",
|
||||
"--timeout",
|
||||
"30000",
|
||||
"--require-evidence",
|
||||
])
|
||||
.arg("--data-dir")
|
||||
.arg(&fixture.data)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{format}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let items: Vec<Value> = if format == "jsonl" {
|
||||
let records: Vec<Value> = String::from_utf8(output.stdout)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(records[0]["schema_version"], "cass.pack.v2");
|
||||
records
|
||||
.into_iter()
|
||||
.filter_map(|mut row| row.get_mut("evidence").map(Value::take))
|
||||
.collect()
|
||||
} else {
|
||||
let value: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(value["schema_version"], "cass.pack.v2");
|
||||
if format == "json" || format == "compact" {
|
||||
assert_eq!(value["realized"]["selected_session_count"], 2);
|
||||
assert_eq!(value["pack"]["source_summary"][0]["session_count"], 2);
|
||||
}
|
||||
value["evidence"].as_array().unwrap().clone()
|
||||
};
|
||||
assert_eq!(
|
||||
items.len(),
|
||||
2,
|
||||
"{format}: distinct conversations cannot be collapsed"
|
||||
);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for item in items {
|
||||
let citation = &item["citation"];
|
||||
assert_eq!(citation["message_index_base"], 1);
|
||||
assert_eq!(citation["message_index"], 2);
|
||||
assert!(citation["line_start"].is_null());
|
||||
let cid = citation["conversation_id"].as_i64().unwrap();
|
||||
assert!(seen.insert(cid));
|
||||
let expected = targets.iter().find(|(id, _)| *id == cid).unwrap().1;
|
||||
assert_eq!(item["excerpt"], expected);
|
||||
for command in ["view", "expand"] {
|
||||
let value = decode(
|
||||
fixture
|
||||
.command(command)
|
||||
.arg(citation["source_path"].as_str().unwrap())
|
||||
.args(["--source", citation["source_id"].as_str().unwrap()])
|
||||
.arg("--conversation-id")
|
||||
.arg(cid.to_string())
|
||||
.arg("--message-index")
|
||||
.arg(citation["message_index"].as_u64().unwrap().to_string())
|
||||
.args(["-C", "0", "--json"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
let rows = if command == "view" {
|
||||
&value["lines"]
|
||||
} else {
|
||||
&value
|
||||
};
|
||||
assert_eq!(rows.as_array().unwrap().len(), 1);
|
||||
assert_eq!(rows[0]["content"], expected);
|
||||
assert_eq!(rows[0]["conversation_id"], cid);
|
||||
assert_eq!(rows[0]["message_index"], 2);
|
||||
assert_eq!(rows[0]["is_target"], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let capped = decode(
|
||||
fixture
|
||||
.command("pack")
|
||||
.args([
|
||||
"PACK493TARGET",
|
||||
"--mode",
|
||||
"lexical",
|
||||
"--freshness-policy",
|
||||
"allow-stale",
|
||||
"--json",
|
||||
"--max-sessions",
|
||||
"1",
|
||||
"--max-tokens",
|
||||
"20000",
|
||||
"--timeout",
|
||||
"30000",
|
||||
"--require-evidence",
|
||||
])
|
||||
.arg("--data-dir")
|
||||
.arg(&fixture.data)
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(capped["realized"]["selected_session_count"], 1);
|
||||
assert_eq!(capped["evidence"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(std::fs::read(&fixture.db).unwrap(), before);
|
||||
assert_eq!(std::fs::read(&retained_source).unwrap(), source);
|
||||
assert!(!fixture.path.exists());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user