QUALITY-1264: Structured apply-diff errors — Rust client (warp) - #14382
QUALITY-1264: Structured apply-diff errors — Rust client (warp)#14382warp-dev-github-integration[bot] wants to merge 5 commits into
Conversation
…ure wording) Cross-repo spec (warp-proto-apis, warp, warp-server) to move edit_files failure wording from the client to warp-server via a structured, typed Failure contract, with commit-pinned references and a cloud handoff/orchestration plan. Co-Authored-By: Oz <oz-agent@warp.dev>
…ALITY-1264) Client-side (warp) implementation of structured apply-diff error reporting. Changes: - Add DiffApplicationFailure enum in crates/ai/src/agent/action_result/ mirroring proto Failure variants (UnmatchedDiffs, ChangesAlreadyApplied, MissingFile, ReadFailed, AlreadyExists, MultipleFileCreation, MultipleFileRenames, MutatedDeletedFile, NoDiffsApplicable, RemoteFileOperationsUnsupported, Opaque). - Add render() helper that reproduces the existing error wording and serves as the single rendering path for all consumers. - Add redacted Debug impls for DiffApplicationFailure, DiffMatchFailure, DiffMatchFailures, and RequestFileEditsResult so sensitive content (file paths, search text) never leaks into logs/telemetry. - Enrich DiffMatchFailures with per-search-block detail (Vec<DiffMatchFailure { search, expected_range }>) and MAX_DIFF_MATCH_FAILURE_BYTES cap; drop #[derive(Copy)]. - Change RequestFileEditsResult::DiffApplicationFailed { error: String } to { failures: Vec<DiffApplicationFailure> } and update all consumers: Display impl, markdown render (agent/mod.rs), SDK output (both sites), and TUI consumers. - Map DiffApplicationError -> Vec<DiffApplicationFailure> at all 3 construction sites (diff-match failures, save_failure_result, review- surface-vanished path). - Update API boundary (convert.rs) to populate Error.failures from structured list AND set Error.message for back-compat. - Update read-back (convert_conversation.rs) to reconstruct failures from proto when present, otherwise wrap message in Opaque. - Pin warp_multi_agent_api to local checkout of varoon/structured-apply- diff-errors (will switch to final SHA before merge). - Update existing tests to use the new DiffMatchFailures struct shape. cargo test -p ai: 340/340 passing. Co-Authored-By: Oz <oz-agent@warp.dev>
Switch from local path patch to the final merged rev on varoon/structured-apply-diff-errors: e10758eae44a20a6c681bdda40bab0a35e4e858e Re-comment the local development patch block. Co-Authored-By: Oz <oz-agent@warp.dev>
Per TECH.md 'Testing and validation':
1. Move render()/Debug tests from inline mod to
crates/ai/src/agent/action_result/diff_application_failure_tests.rs
per the repo _tests.rs convention; wire up with
#[cfg(test)] #[path = ...] mod tests;.
2. Add convert_tests.rs coverage (PRODUCT 2/7/8/10-12):
- Every DiffApplicationFailure variant maps to the expected
proto Failure.kind.
- Error.message is always populated alongside Error.failures.
- truncated is set when search text exceeds MAX_DIFF_MATCH_FAILURE_BYTES.
- Unknown expected range encodes as expected_start/end_line = 0.
- UnmatchedDiffs with noop_deltas emits a separate
ChangesAlreadyApplied proto entry.
3. Add read-back tests (PRODUCT 13) in convert_tests.rs:
- proto with only message reconstructs as Opaque.
- proto with failures reconstructs into the matching category.
- Unrecognised kind is silently skipped.
4. Move proto read-back conversion to pub failures_from_proto_error
in crates/ai so it can be tested and re-used; re-export from
ai::agent::action_result; update convert_conversation.rs to call
the canonical function.
5. Remove now-duplicate proto_failure_to_diff_application_failure
from convert_conversation.rs.
Validation:
- cargo test -p ai: 365/365 passing
- ./script/format: clean
- cargo clippy -p ai --all-targets --tests -- -D warnings: clean
Co-Authored-By: Oz <oz-agent@warp.dev>
|
@warp-dev-github-integration[bot] I'm starting a first review of this pull request. You can view the conversation on Warp. I completed the review and no human review was requested for this pull request. Comment Powered by Oz |
There was a problem hiding this comment.
Overview
This PR moves edit_files apply-diff failures from a string-only client result to structured Rust/client types with proto conversion, read-back support, local rendering, tests, and related specs.
Concerns
- The real diff-application path truncates search-block text before proto conversion, so over-cap search blocks lose the
truncatedsignal even though direct conversion tests pass. - Read-back can drop the entire failure message when structured proto failures are present but all entries are unrecognized/empty.
Verdict
Found: 0 critical, 2 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
There was a problem hiding this comment.
Overview
Structured DiffApplicationFailure for edit_files failures in the Rust client, per specs/structured-apply-diff-errors/{PRODUCT,TECH}.md (carried in this PR). Reviewed against the spec, the linked issue, and the sibling proto (#351) / server (#13383) PRs.
The design is sound and several of the spec's hardest requirements are met well:
- Single rendering path confirmed.
Display(action_result/mod.rs:748), the markdown render (app/src/ai/agent/mod.rs:1246), both SDK sites (agent_sdk/driver/output.rs:102,:894), and the protomessagefield all route through the onerender()helper. The TUI (tui_file_edits_view.rs) deliberately keeps a static label and re-implements no wording. Nothing is scattered. - No-regression wording is genuinely locked in.
render_one()reproducesto_conversation_message()frommasterbyte-for-byte, anddiff_application_failure_tests.rsasserts against the old strings rather than against current output. - Sensitive-data hardening is real. Manual redacted
DebugonDiffMatchFailure,DiffMatchFailures,DiffApplicationFailure, andRequestFileEditsResult;#[serde(skip)]onsearch_block_failures; three leak tests that assert the search text and path do not appear in{:?}. PRODUCT 7 is satisfied. - Back-compat both directions.
convert.rspopulates bothError.failuresandError.message;failures_from_proto_errorreconstructsOpaquefrom a message-only proto and the right category from structured failures; unknown kinds are skipped rather than panicking. Proto is pinned toe10758e, and the0sentinel />0guard for unknown line ranges matches the server's> 0checks. - Test placement follows the repo's
${file}_tests.rs+#[path]convention throughout.
However, the branch does not build, and one spec invariant is not actually satisfied on the production code path despite a passing test. Requesting changes.
Concerns
Blocking
1. CI is red — the branch does not compile. Formatting + Clippy fails on Linux, MacOS, and wasm (Linux job). Two distinct errors:
-
app/src/ai/blocklist/diff_storage_tests.rs:108is an unmigrated consumer — it still destructuresDiffApplicationFailed { error }:error[E0026]: variant `RequestFileEditsResult::DiffApplicationFailed` does not have a field named `error` error[E0027]: pattern does not mention field `failures` --> app/src/ai/blocklist/diff_storage_tests.rs:108:13This site is not in TECH.md's consumer list, which is likely why it was missed. It is the only remaining one — I grepped every
DiffApplicationFailedoccurrence on the branch and the other eight are all migrated. -
to_conversation_message(diff_application.rs:97) anderror_for_conversation(:143) are now unreachable, and-D warningsdeniesdead_code:error: associated items `to_conversation_message` and `error_for_conversation` are never usedTECH.md "Follow-ups" defers deleting this path, but it can't be deferred here — it no longer compiles. Since
render()now owns the wording, deleting both (and the now-unusedVec1/itertoolsimports, if they fall out) is the right fix rather than#[allow(dead_code)].This is exactly the gap flagged in the implementation notes:
cargo test -p aipassing does not cover theapp-crate consumer edits. CI does cover them, and it caught both.
2. SearchBlockFailure.truncated is always false in production — PRODUCT invariant 8 is not met. The byte cap is applied twice, and the flag is dropped at the site that can actually observe it:
error_to_failures(diff_application.rs:851-865) truncatesb.searchtoMAX_DIFF_MATCH_FAILURE_BYTES, computestruncated, and then discards it (let _ = truncated;) becauseDiffSearchBlockFailurehas notruncatedfield.convert.rs:359then re-testsb.search.len() > MAX_DIFF_MATCH_FAILURE_BYTES— but the text has already been capped upstream, so this is never true andtruncatedis hardcoded-false in practice.
The two truncation tests pass only because they construct DiffSearchBlockFailure { search: "x".repeat(MAX + 100) } directly, bypassing error_to_failures. The server therefore can never tell the agent a block was shortened.
Suggested fix: apply the cap in exactly one place. Either drop the truncation from error_to_failures and let convert.rs own it (simplest — matches the doc comment on DiffSearchBlockFailure, which says it carries the raw, pre-truncation text), or add a truncated: bool to DiffSearchBlockFailure and have convert.rs pass it through. Please also add a test that drives the real path (error_to_failures → convert) end-to-end, since the current tests would stay green either way.
Major (please address)
3. render() can return an empty string — PRODUCT invariant 9 regression. error_for_conversation(&Vec1<...>) was type-level non-empty; render(&[...]) is not, and the doc comment concedes 0 failures → "". Two reachable paths produce an empty agent-facing message:
- an empty
failuresslice; UnmatchedDiffs { fuzzy_match_failure_count: 0, changes_already_applied_count: 0 }, whichrender_onerenders to""and whichdiff_application_failure_to_protomaps to zero proto entries — yieldingError { message: "", failures: [] }, so the server has nothing to fall back on either.
A cheap guard in render() (fall back to a generic non-empty message when the composed string is empty) closes both, and matches what the spec asks the server-side renderer to do.
4. V4A unmatched diffs ship no per-block detail. fuzzy_match_v4a_diffs (crates/ai/src/diff_validation/mod.rs:477) increments failures.fuzzy_match_failures but never pushes a DiffMatchFailure, unlike the search/replace path at :680. So for V4A diffs the server receives search_block_failures: [] and cannot enumerate the failed blocks (PRODUCT criterion 2). Either populate it from the V4A hunk context or add a comment explaining why V4A is intentionally detail-free.
5. Proto round-trip is not stable for UnmatchedDiffs + no-op. Encoding splits one UnmatchedDiffs { changes_already_applied_count: N } into two proto entries (UnmatchedDiffs + ChangesAlreadyApplied), but proto_failure_to_diff_application_failure reads UnmatchedDiffs back with changes_already_applied_count: 0. A read-back therefore yields two failures, and render() flips from the combined single-line wording to bulleted output:
before: "Could not apply all diffs to f.rs. The changes to f.rs were already made."
after: "* Could not apply all diffs to f.rs.\n* The changes to f.rs were already made."
PRODUCT 13 only requires read-back not to error, so this isn't blocking, but it does change how historical conversations render locally. Worth either fixing (re-merge adjacent entries for the same file on read-back) or documenting as accepted.
Minor
6. u.fuzzy_match_failure_count as u8 (convert.rs:484) silently wraps a proto u32 (e.g. 256 → 0). Prefer u8::try_from(...).unwrap_or(u8::MAX).
7. hint_range_for_failure (diff_validation/mod.rs:565) is snapshotted before the out-of-bounds line_range.filter(...) at :572, so a range the matcher subsequently rejected can still be reported to the agent as the expected location. Probably fine as a hint, but worth a comment.
Cross-repo (FYI only — not defects in this PR)
The 0-sentinel encoding, the >0 guards, and the proto pin all line up with #351 and #13383. The 1-indexed exclusive→inclusive conversion (r.end - 1) is correct given parse_line_numbers returns start..end+1.
Not applicable
This is a headless wire-format + agent-facing-string change with no user-visible UI surface, so visual/computer-use proof is not required and its absence is not held against the PR.
Verdict
Rejected — changes required. The branch does not compile (two -D warnings/type errors caught by CI, one an unmigrated consumer and one a now-dead wording path), and SearchBlockFailure.truncated is inert on the real code path despite green tests, so PRODUCT invariant 8 is not actually met. Items 3-5 are also worth addressing before merge. Everything else — the single render path, byte-for-byte wording preservation, the redaction work, and both back-compat directions — looks good and should survive the fix unchanged.
Review run
https://oz.staging.warp.dev/runs/019fa63c-73fb-7e99-82b6-6eb8613343c3
|
Carry-forward note (cumulative review). Two prior unresolved threads from the implementation pass are still outstanding at head
|
CI fixes:
- Migrate diff_storage_tests.rs DiffApplicationFailed { error } to { failures }
pattern matching with Opaque check; this was the unmigrated consumer (E0026/E0027)
- Remove dead DiffApplicationError::to_conversation_message and
error_for_conversation — these are no longer called from non-test code and
were causing -D warnings failures; remove the tests that covered them (the
same behavior is covered in diff_application_failure_tests.rs via render())
PRODUCT 8 — byte cap owned by one layer:
- error_to_failures now stores the raw (pre-truncation) search text in
DiffSearchBlockFailure.search, per its doc comment
- diff_application_failure_to_proto in convert.rs is the sole owner of
MAX_DIFF_MATCH_FAILURE_BYTES; the second cap in error_to_failures was
causing the convert.rs condition to never fire, so truncated was always false
- Add end-to-end regression test error_to_failures_preserves_raw_search_and_proto_sets_truncated
that drives the real path from DiffMatchFailure → error_to_failures →
RequestFileEditsResult → proto and asserts truncated == true (PRODUCT 8)
PRODUCT 9 — invariant 9 (agent always gets non-empty message):
- failures_from_proto_error: fall back to Opaque when all structured entries
have unrecognised kind (previously returned empty Vec)
- render_one: add fallback message for UnmatchedDiffs {0, 0} (e.g. after
u8::MAX round-trip saturation)
- proto_failure_to_diff_application_failure: clamp u32→u8 with
u8::try_from(...).unwrap_or(u8::MAX) instead of silent truncation
Round-trip symmetry:
- failures_from_proto_error: merge standalone ChangesAlreadyApplied back into
preceding UnmatchedDiffs for same file, restoring combined single-line wording
after a proto round-trip (the proto split them; we now re-join them)
- Add tests: read_back_all_unrecognised_falls_back_to_opaque and
read_back_changes_already_applied_merged_into_unmatched_diffs
V4A per-block detail (PRODUCT criterion 2):
- fuzzy_match_v4a_diffs: push DiffMatchFailure with diff.old as search text
when a hunk fails to match, so the server can enumerate failed blocks
(expected_range is None since V4A hunks don't carry explicit line numbers)
NIT:
- Add clarifying comment for hint_range_for_failure snapshot taken before
out-of-bounds line_range.filter()
Co-Authored-By: Oz <oz-agent@warp.dev>
There was a problem hiding this comment.
Overview
Re-review of the rework (cycle 1) at f5f043f, cumulative against the prior review at 850c86b. I verified each prior finding against the diff rather than the rework summary. All 7 are genuinely fixed, and I found no regressions in the parts of the change that were already sound. The cycle-1 blocker "branch does not compile" is closed: gh pr checks at this head is fully green — Formatting + Clippy on Linux/macOS/Windows/wasm, Run Linux/MacOS/Windows tests, and every release-flag compilation job all SUCCESS. That CI run (fmt + clippy + the full test suites on three platforms at this exact SHA) is the build/test gate for this review; a cold local rebuild of the workspace would be strictly weaker.
Prior findings — confirmed fixed
- [was BLOCKING] Double byte cap (PRODUCT 8). Real fix.
error_to_failuresnow storesb.search.clone()raw (diff_application.rs:790-793) and the cap exists only indiff_application_failure_to_proto(convert.rs:358-367). I scrutinised the new test as asked:error_to_failures_preserves_raw_search_and_proto_sets_truncatedstarts from aDiffApplicationError, runs the realerrors_to_failures, asserts the raw text survives uncapped, then runs the realTryFrom<RequestFileEditsResult>proto conversion and assertstruncated == truewithsearch.len() <= MAX. It exercises both production functions and would have failed before the fix — not a shortcut. (One narrow gap noted below.) - [was BLOCKING/IMPORTANT]
failures_from_proto_errordropping themessagefallback. Fixed atconvert.rs:470-476. It cannot return an emptyVec: the all-unrecognised path returnsOpaque, and the merge loop only removes an entry when a precedingUnmatchedDiffssurvives. Covered byread_back_all_unrecognised_falls_back_to_opaque. - [was MAJOR] Empty
render()forUnmatchedDiffs{0,0}(PRODUCT 9). Fixed —render_onehas a defensive fallback (diff_application_failure.rs:235-237). NoDiffApplicationFailurevariant can now render to"". See the minor note on the empty-slice case. - [was MAJOR] Round-trip asymmetry for
UnmatchedDiffs+ChangesAlreadyApplied. I judged this one specifically, since merging on read-back is a behavioural choice rather than obviously-lossless. I think it is correct here. The mis-merge you would worry about — a genuine standaloneChangesAlreadyAppliedlegitimately following anUnmatchedDiffsfor the same file — is unreachable:DiffApplicationErrorhas noChangesAlreadyAppliedvariant, soerror_to_failurescan never emit that pairing, and the only producer of it isdiff_application_failure_to_proto's own split. The decode path is used solely byconvert_conversation.rs:807to read back client-authored persisted data. So the merge is unambiguous in practice, and PRODUCT 13 ("must not error") holds. Two non-blocking caveats inline. - [was MAJOR] V4A unmatched diffs carrying no per-block detail. Fixed — the
Nonearm offuzzy_match_v4a_diffspushesDiffMatchFailure { search: diff.old.clone(), expected_range: None }. - [was MINOR]
u32 as u8silent truncation. Fixed withu8::try_from(...).unwrap_or(u8::MAX)(convert.rs:527). It composes correctly with #3: clamping toMAXguarantees> 0, so the decode path can never land in the zero-count case; the #3 fallback covers only a genuinely zero-count encode. No interaction bug. - [was NIT] Snapshot comment. Fixed — the comment now explains that
hint_range_for_failureis captured before the out-of-bounds filter and that this is intentional.
No regressions in the previously-sound work
- Single
render()path holds:Display(action_result/mod.rs:751), markdown (agent/mod.rs:1246), SDK text + JSON (output.rs:102/:894) and the protomessageall calldiff_application_failure::render. The TUI shows a static"File edits failed"label with no duplicated wording. - Wording lock-in is byte-identical to
master'sto_conversation_message/error_for_conversation, including the single-entry no-prefix vs."* "-joined multi-entry shape. Verified againstaf6fc40. - Redaction (PRODUCT 7) is thorough: manual
DebugonDiffMatchFailure,DiffMatchFailures,DiffSearchBlockFailure,DiffApplicationFailureandRequestFileEditsResult, plus#[serde(skip)]on the telemetry-serialisedDiffMatchFailures::search_block_failures, with leak tests. Worth calling out that the pre-existingsafe_warn!(safe: ("Failure(s) applying diff: {failures:?}"))sites now route through the redactedDebug— that is exactly the leak invariant 7 targets, and it is closed. - Back-compat both directions (PRODUCT 10-13), the
0-sentinel range encoding (round-trips5..11→(5,10)→5..11), and the proto pin toe10758eae44a20a6c681bdda40bab0a35e4e858eall check out.
Scope
Headless wire-format + agent-facing-string change with no user-visible UI surface, so visual/computer-use proof is not applicable and its absence is not held against this PR.
Concerns
All non-blocking; see the inline comments.
- MINOR
render(&[])still returns"", and that value feedsError.messagefor old servers. Unreachable today, but guarded only by a doc comment. - MINOR The two fixes that are pure defensive code (#3's zero-count fallback and #6's clamp) have no regression tests — the same shape of "fixed but unexercised" code that regressed in cycle 1.
- MINOR The merge loop's doc says "consecutive" but the code scans every earlier index.
- MINOR
error_to_failures' variant mapping is only covered forUnmatchedDiffs; TECH.md's testing plan asked for the fullDiffApplicationError→DiffApplicationFailuretable.
Cross-repo observation (not a defect)
The server keeps richer unmatched-diffs wording (the retry instruction plus a per-block enumeration) rather than matching this client's render(). That is correct and I am not flagging it: PRODUCT item 3 requires that content in the agent-facing message, which the server now owns, and TECH.md's risk section explicitly sanctions the client's render() diverging once the server owns wording. PRODUCT non-goal 3 also puts the client's local display out of scope. I do not think the client's render() should be updated to match — enumerating raw search blocks in the GUI/SDK/TUI transcript would be noise for a human reader, and keeping render() pinned to today's strings is what makes the no-regression lock-in test meaningful. Flagging only so the divergence is a recorded decision rather than drift.
Verdict
ACCEPTED. All seven prior findings are genuinely resolved, the invariant-8 fix is backed by a test that drives the real code path, the round-trip merge is sound for this codebase, and CI is green at f5f043f. The four remaining items are minor and can be picked up here or as follow-ups at the author's discretion — none should block the merge. Reminder that merge order is proto → server → client, and this client PR lands last.
(Posted as a COMMENT event rather than a formal approval: GitHub rejects an approve/request-changes event from the PR author's own app identity.)
Review run
https://oz.staging.warp.dev/runs/019fa699-07f3-7da1-89a7-4f65d0ddf4c2
| pub fn render(failures: &[DiffApplicationFailure]) -> String { | ||
| let messages: Vec<String> = failures.iter().map(render_one).collect(); | ||
| if messages.len() == 1 { | ||
| messages.into_iter().next().unwrap_or_default() |
There was a problem hiding this comment.
MINOR: render(&[]) returns "". The docstring above acknowledges this ("callers should treat this as unexpected"), but this value is also what populates Error.message, which is the only thing an old server shows the agent (PRODUCT 11). An empty failure list would therefore produce an empty agent-facing error — the exact class of thing PRODUCT 9 exists to prevent.
It is unreachable today, and I checked all four construction sites to confirm: request_file_edits.rs:156 maps from a Vec1 so it yields >= 1; diff_storage.rs:178 and code_diff_view.rs:363 each push exactly one Opaque; and failures_from_proto_error can't return empty. So this is latent, not live.
Still, given the whole point of the change is invariant 9, a hard guarantee is cheap and removes the need for the caller contract:
pub fn render(failures: &[DiffApplicationFailure]) -> String {
let messages: Vec<String> = failures.iter().map(render_one).collect();
match messages.len() {
0 => "Could not apply the requested file edits.".to_string(),
1 => messages.into_iter().next().unwrap_or_default(),
_ => messages.iter().format_with("\n", |msg, f| f(&format_args!("* {msg}"))).to_string(),
}
}Non-blocking.
| if let DiffApplicationFailure::ChangesAlreadyApplied { file: ref caa_file } = failures[i] { | ||
| let caa_file = caa_file.clone(); | ||
| // Find the most-recent UnmatchedDiffs for the same file at an earlier index. | ||
| let merge_idx = (0..i).rev().find(|&j| { |
There was a problem hiding this comment.
MINOR (doc/code mismatch): the comment above and the doc on failures_from_proto_error both say consecutive ChangesAlreadyApplied entries are merged, but (0..i).rev().find(...) scans every earlier index. So [UnmatchedDiffs{A}, MissingFile{B}, ChangesAlreadyApplied{A}] merges across the intervening MissingFile{B}, which is not what "consecutive" describes and quietly reorders the failure list that PRODUCT item 1 says is order-preserving.
This is unreachable from the encoder (the split always emits the pair adjacently), so it's a documentation-accuracy issue rather than a live bug. Either tighten the search to i.checked_sub(1) so it genuinely only looks at the immediately-preceding entry, or reword the comments to say "the most recent preceding". I'd slightly prefer the former since it makes the inverse of diff_application_failure_to_proto exact.
Separately, worth a one-line comment: changes_already_applied_count: N > 1 encodes to a single ChangesAlreadyApplied entry and decodes back to 1. That's invisible to render() (which only tests > 0), so it's harmless today — but it is a real fidelity loss that a future consumer of the count could trip over.
| file: u.file.clone(), | ||
| // Clamp to u8::MAX rather than silently truncating (256 → 0 would | ||
| // make render_one emit an empty string — PRODUCT 9 violation). | ||
| fuzzy_match_failure_count: u8::try_from(u.fuzzy_match_failure_count).unwrap_or(u8::MAX), |
There was a problem hiding this comment.
MINOR (test gap): the clamp is the right fix and I verified it composes correctly with the render_one zero-count fallback — unwrap_or(u8::MAX) guarantees > 0, so the decode path can never reach the empty-render case. But there is no test for it, and likewise none for the UnmatchedDiffs { fuzzy_match_failure_count: 0, changes_already_applied_count: 0 } fallback in render_one.
Both are pure defensive branches, which is precisely the shape of code that silently rotted in cycle 1 (the byte cap looked correct and was dead). Two cheap asserts would pin them:
#[test]
fn read_back_oversized_fuzzy_count_clamps_to_u8_max() {
// 300 must not wrap to 44, and must never become 0 (PRODUCT 9).
}
#[test]
fn render_unmatched_with_zero_counts_is_non_empty() {
// PRODUCT 9: no category renders to "".
}Non-blocking.
| }, | ||
| }]; | ||
|
|
||
| let failures = errors_to_failures(&errors); |
There was a problem hiding this comment.
Nit / coverage: this test is the right shape and genuinely drives the production path, so the invariant-8 fix is properly pinned. Two small gaps worth noting for a follow-up:
- It hand-builds
DiffMatchFailures, so it would not catch a cap accidentally reintroduced upstream insidefuzzy_match_file_diffs. I confirmed by reading that function that it storessearch.to_owned()uncapped, so the invariant holds today — but the test does not defend that end of the pipeline. Driving one oversized search block throughapply_editsagainst a temp file would close the loop. errors_to_failuresmapping is only asserted forUnmatchedDiffs. TECH.md's testing plan asks for the fullDiffApplicationError->DiffApplicationFailuretable; the other nine arms are trivial 1:1 maps but currently untested. A single table-driven test would cover them.
Non-blocking.
Description
Client-side (warp) implementation of structured apply-diff error reporting (QUALITY-1264). Moves
edit_filesfailure details from opaque rendered strings to a typedDiffApplicationFailurestructure that the server can render with per-model/per-harness wording.This is the Agent C PR in a 3-way cross-repo change:
What changed
New types in
crates/ai(layering-safe):DiffApplicationFailureenum mirroring all protoFailurevariants, with redactedDebugto prevent sensitive content from leaking into logsDiffSearchBlockFailurecarrying per-block search text + expected line rangerender(&[DiffApplicationFailure]) -> String— the single rendering path for all consumers, reproducing the current agent-facing wording verbatimfailures_from_proto_error()— canonical read-back conversion from proto, re-exported fromai::agent::action_resultDiffMatchFailuresenrichment (crates/ai/src/diff_validation/):search_block_failures: Vec<DiffMatchFailure>with#[serde(skip)]to prevent telemetry leakageMAX_DIFF_MATCH_FAILURE_BYTES = 1024cap applied at proto-conversion time#[derive(Copy)]; add redactedDebugimplfuzzy_match_file_diffswhen a search block fails to matchShape change:
RequestFileEditsResult::DiffApplicationFailed { error: String }→{ failures: Vec<DiffApplicationFailure> }render()instead of interpolating the stringAPI boundary (
convert.rs):Error.failures(structured) andError.message(back-compat fallback)diff_application_failure_to_proto()handles the UnmatchedDiffs → also emits ChangesAlreadyApplied when noop_deltas > 00sentinel for unknown line ranges (matches server renderer's> 0checks)Read-back (
convert_conversation.rs):ai::agent::action_result::failures_from_proto_error()— reconstructs from proto when present, wrapsmessageinOpaquefor old entriesThree construction sites updated:
request_file_edits.rs) →errors_to_failures()diff_storage.rs) →Opaquecode_diff_view.rs) →OpaqueLinked Issue
ready-to-specorready-to-implement.Linear: https://linear.app/warpdotdev/issue/QUALITY-1264/structured-apply-diff-errors-server-controlled-edit-files-failure
Testing
Tests added:
diff_application_failure_tests.rs(new, per repo convention)render()wording lock-in for all variants; redactedDebugfor file paths and search textconvert_tests.rs(extended)DiffApplicationFailurevariant → expected protoFailure.kind;Error.messagealways populated;truncatedset when capped;0sentinel for unknown ranges;ChangesAlreadyAppliedemitted separately when noop; read-back: message-only →Opaque; failures → category; unknown kind skippeddiff_application_tests.rs(updated)DiffMatchFailuresstruct literal updated for newsearch_block_failuresfieldtui_diff_storage_tests.rs(updated)DiffApplicationFailed { failures: _ }match patternChecks that ran in this environment:
cargo test -p ai./script/formatcargo clippy -p ai --all-targets --tests -- -D warnings./script/presubmit(includingcargo checkon the full warp binary)warpcrate is ~1M LOC and exceeds sandbox memory. Reviewer should run./script/presubmitlocally.No UI changes — this is a wire-format + agent-string change. The agent-facing strings are unchanged (
render()reproduces the current wording exactly).Agent Mode
CHANGELOG-NONE
Conversation: https://staging.warp.dev/conversation/f0f183a2-1073-4220-b2e6-4f1fd8e145c1
Run: https://oz.staging.warp.dev/runs/019fa5f3-f6aa-7721-92f8-b272e2dc7eb8
This PR was generated with Oz.