Skip to content

Add zcode agent preset for checkpoint attribution - #2159

Open
antik-x wants to merge 7 commits into
git-ai-project:mainfrom
antik-x:agent/zcode-support
Open

Add zcode agent preset for checkpoint attribution#2159
antik-x wants to merge 7 commits into
git-ai-project:mainfrom
antik-x:agent/zcode-support

Conversation

@antik-x

@antik-x antik-x commented Aug 18, 2026

Copy link
Copy Markdown

Part of #2150 (closing keyword lives in the stacked PR #2161, which completes the feature).

Summary

Adds a ZcodePreset so ZCode edits are attributed to AI sessions, wired through the same git-ai checkpoint zcode --hook-input stdin flow as other agents.

ZCode emits Claude Code-compatible PreToolUse/PostToolUse hook payloads, with two differences that shape this PR:

  • The payload's transcript_path is a temp snapshot under the OS tmpdir, so the preset derives the live rollout (~/.zcode/cli/rollout/model-io-sess_<session_id>.jsonl) from the session id instead (constant-time path join, with path-traversal guards on the session id).
  • Rollouts use a model-io JSONL format, so the model is extracted from the newest record's model.modelId via a new bounded tail-scan extract_model_from_zcode_rollout (falls back to the opening record when no parsable record fits the window; missing files fall back to "unknown").

Design notes:

  • PostToolUseFailure, PermissionRequest, SessionStart, UserPromptSubmit, and Stop produce no events (a failed edit never landed on disk; PreToolUse already captures pre-state).
  • ApplyPatch is classified as a file edit to match zcode's matcher aliases (Write/EditApplyPatch).
  • Claude Code payloads carrying a persistent ~/.claude transcript are rejected with a preset error, mirroring the guards in the claude preset.
  • No StreamSource in v1: zcode's transcript format is not one of the streamed formats; session records (with model) are still created from the checkpoint's AgentId (verified by the e2e tests).

Changes

  • src/commands/checkpoint_agent/presets/zcode.rs — new preset + unit tests
  • src/commands/checkpoint_agent/presets/mod.rs — registry entry
  • src/commands/checkpoint_agent/bash_tool.rsAgent::Zcode classification
  • src/streams/model_extraction.rsextract_model_from_zcode_rollout + tests
  • src/commands/git_ai_handlers.rs — help text
  • tests/fixtures/zcode-rollout-simple.jsonl — sanitized model-io fixture
  • tests/integration/zcode.rs — parse tests + file-edit and bash e2e cycles
  • Test framework: zcode added to is_known_checkpoint_preset (otherwise git_ai() rewrites checkpoint args as pathspec files) and AI_AUTHOR_NAMES

Testing

  • cargo test zcode — unit + integration tests (incl. auto-generated worktree variants)
  • cargo test preset / cargo test model_extraction regressions green
  • cargo clippy --all-targets -- -D warnings clean, cargo fmt applied

A follow-up PR adds the install-hooks installer for ~/.zcode/cli/config.json and README support.

ZCode emits Claude Code-compatible PreToolUse/PostToolUse hook payloads,
but its transcript_path is a temp snapshot rather than a persistent
transcript. The preset derives the live rollout
(~/.zcode/cli/rollout/model-io-sess_<session_id>.jsonl) from the session
id and extracts the model from its first model-io record (bounded read,
missing/unparsable files fall back to "unknown"). Failed tool calls
(PostToolUseFailure) and non-tool events are ignored since nothing landed
on disk; ApplyPatch is classified as a file edit to match zcode's matcher
aliases.
@CLAassistant

CLAassistant commented Aug 18, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@antik-x

antik-x commented Aug 18, 2026

Copy link
Copy Markdown
Author

The workflow runs (Test / Lint & Format / End-to-End Tests) are pending approval since this is the first PR from this fork. Could a maintainer approve the workflow runs so CI can execute? Happy to iterate on any failures once they run.

Model-io records are append-only per request, so reading only the first
record mislabels every edit after a mid-session model switch. Extraction
now scans the tail window's complete records newest-first (bounded at 4MB
from EOF, since each record embeds the whole conversation) and falls back
to the opening record when no parsable record fits the window.
@antik-x
antik-x force-pushed the agent/zcode-support branch from c153c83 to 8c164c5 Compare August 18, 2026 05:04
devin-ai-integration[bot]

This comment was marked as resolved.

Route the common cases through extract_model_from_jsonl_tail_with and
extract_model_from_jsonl_head_with like the other agents, keeping the
wider last-lines window and bounded first-line read only as fallbacks for
model-io records that embed the whole conversation and exceed the shared
scan windows.
@antik-x

antik-x commented Aug 18, 2026

Copy link
Copy Markdown
Author

Addressed the follow-up finding: extract_model_from_zcode_rollout now routes the common cases through the shared extract_model_from_jsonl_tail_with / extract_model_from_jsonl_head_with scanners like the other agents (4988553). The wider last-complete-lines window and the bounded first-line read remain only as fallbacks for model-io records that embed the whole conversation and exceed the shared windows (50KB tail window / head per-line cap) — all 8 extraction tests still pass unchanged.

devin-ai-integration[bot]

This comment was marked as resolved.

The previous chain consulted the head scanner (oldest records) before the
wide last-lines window, so a session whose early records fit the shared
per-line cap but whose grown records did not was labelled with its
starting model even after a mid-session switch. Reorder to tail scan,
wide last-lines window, head scan, first line, and add a regression test
for the switch-after-growth case.
@antik-x

antik-x commented Aug 18, 2026

Copy link
Copy Markdown
Author

Good catch — the fallback chain did consult the head scanner (oldest records) before the wide last-lines window, so sessions whose records outgrow the shared per-line cap were labelled with their starting model. Reordered to newest-first probes (tail scan, wide last-lines window) ahead of the oldest fallbacks (head scan, first line) and added a regression test for the switch-after-growth case (7486bba). All 9 extraction tests green.

devin-ai-integration[bot]

This comment was marked as resolved.

Patch-style ApplyPatch payloads carry the edited paths inside the patch
text rather than a file_path key, so those checkpoints recorded no files
and the edits lost AI attribution. file_paths_for_event now falls back to
parsing the patch text (shared collect_apply_patch_paths_from_text) and,
for PostToolUse, the tool_response file_path, mirroring the droid preset.

read_last_complete_lines_bounded also stopped discarding the bytes before
the window's first newline when the window covers the whole file
(window_start == 0): that prefix is a complete first line, not a truncated
prior line, so single-record rollouts between the first-line cap and the
wide window size no longer lose their model.
@antik-x

antik-x commented Aug 18, 2026

Copy link
Copy Markdown
Author

Addressed the new findings (9284f83):

  1. ApplyPatch edits losing attribution — patch-style payloads carry the edited paths inside the patch text rather than a file_path key, so those checkpoints recorded no files. file_paths_for_event now falls back to the shared patch-text extractor and, for PostToolUse, the tool_response path, mirroring the droid preset. Covered by pre/post ApplyPatch extraction tests plus the tool_response fallback.

  2. Whole-file window trimming the first recordread_last_complete_lines_bounded only discards the pre-newline prefix when the window starts mid-file; a window covering the whole file keeps its (complete) first line, so single-record rollouts between the first-line cap and the wide window no longer report model "unknown". Covered by the single-large-record test.

Changes were reviewed internally before push; all 29 zcode unit tests and clippy are green.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread src/streams/model_extraction.rs Outdated
Comment on lines +132 to +145
for line in read_last_complete_lines_bounded(path, MAX_ZCODE_ROLLOUT_LAST_LINE_BYTES)
.iter()
.rev()
{
if let Some(model) = extract_model_from_zcode_rollout_line(line) {
return Ok(Some(model));
}
}

if let Some(model) =
extract_model_from_jsonl_head_with(path, extract_model_from_zcode_rollout_line)
{
return Ok(Some(model));
}

@devin-ai-integration devin-ai-integration Bot Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Long AI sessions can be labelled with the model used at session start instead of the model actually in use

When the newest entry in the transcript is bigger than the read window, the code falls back to the very first entry of the session (extract_model_from_jsonl_head_with at src/streams/model_extraction.rs:141-145) instead of the most recent readable one, so a session that switched models mid-way is reported under the old model.
Impact: Attribution for long sessions can name the wrong AI model, and the longer the session the more likely it is wrong.

Why the newest-record probes fail and the oldest-record fallback wins

Each zcode model-io record embeds the whole conversation, so the last line grows monotonically and, per the comment at src/streams/model_extraction.rs:57-61, "routinely" exceeds the windows.

  1. extract_model_from_jsonl_tail_with reads only the last 50KB (MAX_JSONL_SCAN_BYTES), which is a truncated fragment of the giant last line → no parse.
  2. read_last_complete_lines_bounded(path, 4MB) seeks to size - 4MB. If the last record is larger than 4MB, the entire window lies inside that single line, window.find('\n') returns None, and the function returns an empty vector (src/streams/model_extraction.rs:103-107) — it never reaches the second-to-last record, which is complete and readable just before the giant one.
  3. Control therefore reaches the head scanner, which returns the first record of the session (oldest model), defeating the stated goal that "newest-record probes come first so mid-session model switches are attributed correctly".

A backwards scan for the last newline preceding the final record (or repeatedly widening/stepping the window back past the oversized line) would recover the newest parseable record instead of falling all the way back to the session's opening record.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Model-io records embed the whole conversation, so the previous wide window
full-line read cost several megabytes and multiple copies per hook
invocation, and when the newest record exceeded the window the extraction
fell all the way back to the session's opening record. Extraction now
locates the newest record's start with a bounded backward chunk scan and
reads only that record's head for model.modelId (JSON string contents
escape their quotes, so a raw "modelId":" match only lands on real key
positions). Records past the 16MB backward budget still fall back to the
opening records.
@antik-x

antik-x commented Aug 18, 2026

Copy link
Copy Markdown
Author

Addressed the two extraction findings (443321b):

  1. Newest record beyond the read window — replaced the fixed 4MB whole-line window with a bounded backward chunk scan (256KB chunks, 16MB budget) that locates the newest record's start, so records up to the budget are now reachable regardless of size; past the budget extraction still falls back to the opening records.

  2. Multi-megabyte reads per hook invocation — the wide window also full-parsed whole multi-MB lines (three copies plus a multi-MB serde parse). Extraction now reads only the newest record's head (64KB) and pulls model.modelId by string scan: JSON string contents escape their quotes, so a raw "modelId":" needle only matches real key positions. The 5MB/17MB test cases now finish in ~0.1s.

Both changes were reviewed internally before push; 11 extraction tests green, clippy clean.

@antik-x

antik-x commented Aug 20, 2026

Copy link
Copy Markdown
Author

@svarlamov apologies for the direct ping — could you take a look when you have a moment? Two things would unblock this stack:

  1. CI approval: the workflow runs (Test / Lint & Format / E2E) have been waiting on first-PR-from-fork approval, so the branches have no build signal yet.
  2. Review: Add zcode agent preset for checkpoint attribution #2159 adds the zcode checkpoint preset, and Add zcode install-hooks support and README entry #2161 (stacked on this) adds the install-hooks installer + README entry — together they close Does Zcode have official hooks support for GitAI? #2150, which has interest from other users.

All Devin Review rounds are resolved, local test suites (31 unit + 4 e2e) and clippy are green. Happy to iterate on anything that comes up.

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.

2 participants