Skip to content

fix(convo): relativize file change keys against path.base#124

Open
akesling wants to merge 7 commits into
mainfrom
fix/convo-relative-change-keys
Open

fix(convo): relativize file change keys against path.base#124
akesling wants to merge 7 commits into
mainfrom
fix/convo-relative-change-keys

Conversation

@akesling

@akesling akesling commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Root cause

The RFC keys change artifacts as URLs, with bare paths relative to path.base — but derive_path inserted provider paths verbatim. Claude tool inputs are absolute (/Users/x/proj/src/main.rs); Codex/Cursor paths are workspace-relative. Derived documents therefore mixed absolute and relative keys depending on harness, breaking the RFC contract and making cross-harness merge/diff/resume ambiguous.

Fix

One canonical rule at the shared choke point, so all six providers are fixed at once:

  • Derive (toolpath-convo::derive_path): when the resolved base.uri is file://<root> and a file path is absolute and under <root> (component-boundary match — /a/b does not swallow /a/bc/x), the change key is stored base-relative. Applies to both file.write key branches and meta.extra["files_changed"]. Paths outside the base, non-file bases, and the provider://id conversation self-key pass through verbatim.
  • Extract (extract_conversation): a relative key (no leading /, no ://) resolves against view.base.working_dir, so FileMutation.path and files_changed come back absolute for projectors. The IR convention on FileMutation.path is now documented: absolute when the workspace root is known.
  • Round-trip derive→extract→derive is stable in both directions (covered by a dedicated test with an under-base and an outside-base key).

Provider-crate test assertions (codex ×2, cursor, opencode) updated to the new canonical relative keys — each keeps its original invariant (raw/operation/tool_id assertions untouched).

Tests

TDD: helper unit tests red (E0425) before implementation, green after; new round-trip integration test; full workspace suite (1644 tests) + clippy green. toolpath-convo bumped 0.11.1 → 0.11.2 (0.11.1 was already taken by the step-id dedup fix on main).

Notes

  • Part of a six-PR consistency series from a cross-harness audit; CHANGELOG/version lines may conflict with sibling fix PRs — resolve by keeping both entries.
  • No path-cli/toolpath-cli bump here; one release bump lands after the series merges.
  • Reviewer note worth keeping visible: a degenerate file:/// (filesystem-root) base conservatively never relativizes — safe and round-trip-stable.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🔍 Preview deployed: https://b0a28189.toolpath.pages.dev

akesling added 6 commits July 9, 2026 12:38
…relativized change keys

#124 relativized file-change artifact keys against path.base but only had
one integration test and no property coverage. Adds: a 9-case
derive->JSON->extract->re-derive identity/idempotency table (nested,
exact-root, outside-base, trailing-slash base, deep nesting, unicode,
no-base absolute/relative, prefix-not-boundary); relativize_key/resolve_key
edge-case units (exact root, trailing-slash base, empty root, case
sensitivity, URI passthrough); a files_changed-consistency test; and a
backward-compat characterization pinning that documents shared before
relativization (absolute keys) still extract unchanged.
…onversation providers

Extends each provider's real-fixture test with a ground-truth check for
#124's key relativization: for every derived file.write change, the key
is relative iff its original path genuinely sat under path.base at a
path-component boundary (independently recomputed via std::path, not by
calling derive.rs's own relativize_key) -- catching both an
absolute-under-base leak and a wrongly-relativized outside-base key --
plus extract -> re-derive idempotency of the file.write key set.

claude and cursor fixtures carry real absolute file mutations under a
recorded base, so stripping is exercised directly. gemini and pi's
fixtures record already-relative tool-call paths, so a synthetic
absolute mutation/tool-call is injected to genuinely exercise stripping
alongside the fixture's own untouched relative paths. codex already had
a relativize-and-assert-membership test in fidelity.rs; this adds the
leak/idempotency half next to it. opencode's plain to_view() (no
snapshot resolver) still runs the tool-input fallback unconditionally,
so its real fixture's absolute paths exercise stripping directly. cursor
gets a new real_fixture_roundtrip.rs (it previously only had a
live-DB-gated sanity test and a cross-harness projection test, neither
of which exercised this fixture's key relativization).
…uments

path p validate keeps accepting a pre-#124 document whose file.write keys
are verbatim absolute paths -- relativization is a derive_path write-side
behavior, not a schema requirement, so old documents stay valid forever.

path p merge concatenates paths verbatim with no cross-document key
unification (see cmd_merge.rs::merge_into_graph), so two documents that
touch the "same" file under different key conventions -- one absolute,
one base-relative -- merge with both keys surviving as distinct
artifacts. Pins this as the intended (if unglamorous) cross-version
behavior rather than a bug to fix.

Neither test needs the $TOOLPATH_CONFIG_DIR/TEST_ENV_LOCK sandbox the
resume/share/import suites use: validate and merge operate purely on the
file paths given to them and never touch $HOME or the cache.
Review follow-up on the #124 provider key-stability tests.

Finding 1: codex, opencode, and cursor had the derive+assert block inlined
in the #[test] body while claude/gemini/pi factored it into a local
assert_file_write_keys_match_base helper. Extract each into a matching
per-crate local helper (no shared test-util crate — the cross-crate
sameness is accepted). codex now sources ground truth from the view's
FileMutation paths (Codex populates these from patch_apply_end) and uses
BTreeSet like the others instead of HashSet.

Finding 2: the no-leak assertion could silently no-op — a fixture with no
absolute-under-base path never enters the `if under_base` branch, so the
test would pass without ever exercising the exact case #124 relativizes.
All six helpers now track a saw_under_base bool and assert it fired at
least once. Verified per provider: claude, codex, opencode, and cursor
real fixtures already carry absolute paths under their recorded base, so
the branch fires on real data with no injection; gemini and pi record
provider-relative tool paths (and derive no usable base), so they keep
the synthetic absolute-under-base FileMutation/tool-call injection that
makes the branch genuinely run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant