From 65de8a51be2019297a13fae01fb092b73943d149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 11 Aug 2026 12:13:49 +0200 Subject: [PATCH] fix(coding-agents): drop harness transport wrappers from retained turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3023. Claude Code delivers as an ordinary type:"user" message — string body, no isMeta flag — so nothing filtered it and fact extraction saw the harness's background-task plumbing (task id, tool-use id, status, summary) as something the user said. Measured across 400 local transcripts: 39 such messages, 16,289 chars, every one of them the entire message. Reproduced with the real reader on a real transcript (243 retained turns, 1 of them a task-notification carrying 519 chars of transport), and verified after: 242 turns, 0 noise, every genuine turn preserved. The tag joins MEMORY_TAG_RE, which already covered codex's for exactly this reason. joins it too: today those only ride inside tool_result blocks, which this reader drops entirely, so it is insurance against the harness moving them — the rule is tag-structural, not content-guessing. Note what the issue asked for and this does NOT do. Its two named cases are already handled here: skill bodies arrive with isMeta:true (8/8 in the sample) and are dropped with every other meta line, and is inside a dropped tool_result. Only the third case was live. Stripping removes the BLOCK and keeps surrounding text, so a message that is nothing but a wrapper renders empty and is dropped as a no-content turn, while a real message mentioning one keeps the user's words — the mistake the old plugin's unanchored strip_channel_envelope made (#3124). --- .../coding-agents/src/core/transcript-util.ts | 14 ++++- .../coding-agents/src/core/transcript.test.ts | 62 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/hindsight-integrations/coding-agents/src/core/transcript-util.ts b/hindsight-integrations/coding-agents/src/core/transcript-util.ts index 7bcc56fd68..107e827ea6 100644 --- a/hindsight-integrations/coding-agents/src/core/transcript-util.ts +++ b/hindsight-integrations/coding-agents/src/core/transcript-util.ts @@ -7,10 +7,18 @@ export const TOOL_TEXT_CAP = 2000; /** Injected context — stripped from retained text so a write-back never re-ingests its own * injected memory (a retain→reflect feedback loop). Covers every block the hooks inject, PLUS - * the host's own hook-transport wrappers (codex surfaces hook stdout/errors as `` - * user messages — transport noise, not the user's work). Tag-structural, not content-guessing. */ + * the host's own hook-transport wrappers, which arrive as ordinary USER messages and would + * otherwise be extracted as things the user said (#3023): + * codex surfaces hook stdout/errors this way + * Claude Code reports a background task's outcome — task id, tool-use id, + * status; measured at 39 of these across 400 local transcripts, each one + * the entire message + * same class; today it only rides inside `tool_result` blocks, which the + * Claude reader already drops, so this is insurance against it moving + * Tag-structural, not content-guessing. The block is removed and any surrounding text KEPT — a + * message that is nothing but a wrapper then renders empty and is dropped as a no-content turn. */ const MEMORY_TAG_RE = - /<(hook_prompt|hindsight_memory|hindsight_memories|hindsight_bank|relevant_memories|user_feedback|hindsight_knowledge|hindsight_knowledge_refresh)\b[\s\S]*?<\/\1>/g; + /<(hook_prompt|task-notification|system-reminder|hindsight_memory|hindsight_memories|hindsight_bank|relevant_memories|user_feedback|hindsight_knowledge|hindsight_knowledge_refresh)\b[\s\S]*?<\/\1>/g; export function stripInjectedMemory(s: string): string { return s.replace(MEMORY_TAG_RE, ""); diff --git a/hindsight-integrations/coding-agents/src/core/transcript.test.ts b/hindsight-integrations/coding-agents/src/core/transcript.test.ts index 839e5211e8..4ce610d263 100644 --- a/hindsight-integrations/coding-agents/src/core/transcript.test.ts +++ b/hindsight-integrations/coding-agents/src/core/transcript.test.ts @@ -166,6 +166,68 @@ describe("readClaudeTranscript", () => { expect(result[0].content).not.toContain("hindsight_memories"); }); + it("drops a : the harness's background-task plumbing, not the user's words", () => { + // Claude Code delivers these as an ordinary type:"user" message with a string body and no + // isMeta flag, so nothing else filters them and extraction saw task ids and status lines as + // things the user said. Measured at 39 across 400 local transcripts, each the whole message + // (#3023 — which named skill bodies and ; both are already handled, this is + // the case that actually survived). + writeFileSync( + file, + JSON.stringify({ + type: "user", + message: { + role: "user", + content: + "\nb46ca19nr\n" + + "toolu_0127NeaVZbdiAsbacNvasB78\n" + + "stopped\nNo completion record\n", + }, + }) + ); + + expect(readClaudeTranscript(file)).toEqual([]); // renders empty -> no turn at all + }); + + it("keeps what the user wrote around a harness wrapper", () => { + // The stripper removes the BLOCK, never the message: replacing the whole turn with the tag's + // contents is what made the old plugin's strip_channel_envelope discard real user text (#3124). + writeFileSync( + file, + JSON.stringify({ + type: "user", + message: { + role: "user", + content: "before done after", + }, + }) + ); + + const result = readClaudeTranscript(file); + expect(result).toHaveLength(1); + expect(result[0].content).toBe("before after"); + expect(result[0].content).not.toContain("status"); + }); + + it("drops a block if the harness ever delivers one as user text", () => { + // Today these ride inside tool_result blocks, which this reader already drops entirely; the + // rule is tag-structural so it holds if that placement changes. + writeFileSync( + file, + JSON.stringify({ + type: "user", + message: { + role: "user", + content: "plan mode is active\nship the fix", + }, + }) + ); + + const result = readClaudeTranscript(file); + expect(result).toHaveLength(1); + expect(result[0].content).toBe("ship the fix"); + }); + it("strips the reflect hook's injection block (buildSystemInjection output)", () => { // The exact block the UserPromptSubmit hook injects — wrapper tags, preamble, attribution // text and the surfaced memory itself must ALL be gone from retained text, or the session