Read dedup does not account for PostToolUse updatedToolOutput: repeated Reads bypass substituting hooks, and readFileState records disk content the model never received
Summary
Claude Code dedups repeated Read calls: a Read of the same path with the same normalized offset/limit while the file's mtime is unchanged is answered from the session's readFileState cache as a "file_unchanged" result, rendered to the model as "Wasted call - file unchanged since your last Read. Refer to that earlier tool_result instead." (the CLI renders the dash as U+2014; shown here in ASCII).
The dedup does not account for hook-substituted results. readFileState is written inside the Read tool's own call, before PostToolUse hooks run, and records the raw disk content; when a PostToolUse hook replaces the result via updatedToolOutput, the substitution is invisible to the cache. On the next identical Read, the harness answers "already read" on the basis of bytes the model never received, and the hook is handed a content-free {type:"file_unchanged"} payload instead of a result it can transform. A hook that substitutes Read results is thereby cut out of the loop on every repeat while the file's mtime is unchanged.
Version verified
Claude Code 2.1.237 (native build, Linux x86_64), verified 2026-08-19 with live sessions plus a read of the installed CLI bundle. The dedup implementation is structurally identical (identifier-normalized comparison) in every retained bundle back to 2.1.232, and it shipped in 2.1.86 per the changelog entry "Read tool now uses compact line-number format and deduplicates unchanged re-reads, reducing token usage".
Minimal repro
A scratch project with three files. The hook substitutes every successful Read result, the way a caching, redaction, or context-management hook does, and logs what it receives.
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/hook.js" }
]
}
]
}
}
hook.js:
const fs = require("node:fs");
const input = JSON.parse(fs.readFileSync(0, "utf8"));
fs.appendFileSync(__dirname + "/hook.log", JSON.stringify(input.tool_response) + "\n");
if (input.tool_response?.type === "text") {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PostToolUse",
updatedToolOutput: {
type: "text",
file: {
filePath: input.tool_input.file_path,
content: "HOOK-SUBSTITUTED: the model received this line instead of the disk content.",
numLines: 1,
startLine: 1,
totalLines: 1,
},
},
},
}));
}
testfile.txt: any few lines of distinctive text, for example ten lines of "DISK-CONTENT line N".
Run from the project directory (any model; observed with claude-sonnet-5):
claude --print --output-format stream-json --verbose --permission-mode acceptEdits --setting-sources project -- "Read testfile.txt in full with the Read tool, then read the exact same path again with identical parameters, then state verbatim the first line of each tool result."
Observed (2.1.237)
The first tool_result delivers the substitution: "1\tHOOK-SUBSTITUTED: the model received this line instead of the disk content." (the harness applies its own line numbering to the hook-supplied content).
The second tool_result delivers zero file bytes: "Wasted call - file unchanged since your last Read. Refer to that earlier tool_result instead."
hook.log shows what the hook received: first a well-formed {"type":"text","file":{...}} carrying the disk content, then {"type":"file_unchanged","file":{"filePath":"..."}} carrying no content at all. PreToolUse and PostToolUse both fire on the deduped call, but a transforming hook has nothing to transform; the only way it can still act is to reconstruct a complete Read result from its own out-of-band cache and emit that as updatedToolOutput (a schema-valid substitution IS applied on the dedup path; a payload that fails the Read output schema, for example a bare string, is dropped with a "PostToolUse hook returned updatedToolOutput that does not match Read's output shape; using original output." hook error and the model still gets the wasted-call text).
Repro variance note: the dedup is gated by a remote flag (tengu_read_dedup_killswitch; there is no local override, since the environment-override reader returns before its CLAUDE_INTERNAL_FC_OVERRIDES parse and the config-override reader is a stub). In testing on 2026-08-19, 7 of 11 fresh sessions issuing the identical read sequence deduped and 4 did not, so the flag currently appears to be evaluated per session. If the second read executes normally, the dedup was remotely disabled for that session; retry in a new session.
Expected
Either the repeated Read executes and flows through hooks again, or the dedup accounts for the substitution. At minimum, the session's file-state tracking should reflect what the model actually received rather than disk bytes that a hook replaced before delivery.
Mechanism (from the installed 2.1.237 bundle)
The Read tool's call writes readFileState before it returns: the entry records the raw disk content, Math.floor(stat.mtimeMs) as the timestamp, and the normalized offset/limit (telemetry: tengu_session_file_read). PostToolUse hooks run later, in the tool executor; updatedToolOutput replaces only the outgoing result. Nothing re-syncs the cache entry for Read: the only post-hook readFileState re-sync is scoped to Edit and Write (the formatter-hook case, and it re-reads the disk).
The dedup runs at the top of the next Read call: same normalized path, h.offset === t and h.limit === r (offset defaults to 1, limit to undefined), Math.floor(stat.mtimeMs) equal to the recorded timestamp, entry not a partial view. On a match it returns {type:"file_unchanged", file:{filePath}} without touching the disk (telemetry: tengu_file_read_dedup), which the result mapper renders as the wasted-call string. There is no consecutiveness, size, or turn condition; any later identical Read in the session dedups.
The executor applies updatedToolOutput with no branch on whether the result came from a real read or the dedup: the payload is validated against the Read output schema (the text arm requires filePath, content, numLines, startLine, totalLines; truncatedByTokenCap is optional) and a valid payload replaces the file_unchanged result. That is what makes the bypass structural rather than a validation quirk: the harness would honor a substitution on the repeat, but the substituting hook receives no content to build one from unless it maintains its own copy.
Resume makes the cache wrong in the opposite direction. On --resume, readFileState is rebuilt from the transcript (the code logs under the name extractReadFilesFromMessages): each prior Read tool_result's rendered text, which is the substituted text when a hook substituted, is stripped of line numbering and recorded as the file's content, with the timestamp taken from the message's wall-clock time rather than the file mtime. The hook's output is thereafter treated as what the session read from disk, and content-comparison consumers of the cache (for example Edit's stale-read recovery) reason over it. Because the seeded timestamp is a message time, the mtime-equality dedup does not re-fire across resume for these entries (verified live); the inconsistency is in the recorded content itself.
Impact
Any PostToolUse hook that substitutes Read results is affected: caching layers, redaction shims, context-management plugins. A layer that returns a projection on the first read and intends to serve fuller content on a later read cannot; identical repeats never reach it with a substitutable result while the file's mtime is unchanged. A redaction shim is not re-consulted on repeats. And the harness's file-state view diverges from the conversation in both directions: in-session the cache holds disk bytes the model never received, and after resume it holds hook output as if it were disk bytes.
Suggested direction
After PostToolUse hooks run for a Read, re-sync the readFileState entry from the final (possibly substituted) result, the way Edit and Write already re-sync; or record that the result was substituted and have the dedup either skip such entries or hand hooks a well-formed replay of the prior result they can transform. Any of these keeps the optimization while restoring the hook contract; no preference on internals.
---
The investigation and writeup for this issue were done by Claude Fable 5.