Skip to content

Strip internal Git context tags from agent responses - #4343

Closed
wwwillchen wants to merge 5 commits into
dyad-sh:mainfrom
wwwillchen:update-agent-docs-20260821110327
Closed

Strip internal Git context tags from agent responses#4343
wwwillchen wants to merge 5 commits into
dyad-sh:mainfrom
wwwillchen:update-agent-docs-20260821110327

Conversation

@wwwillchen

@wwwillchen wwwillchen commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Prevent model-echoed internal Git provenance markup from leaking into visible chat responses or being persisted for future replay.

  • Sanitize only assistant-authored text, leaving user literals and tool payloads untouched.
  • Handle tags split across streaming chunks so partial internal markup is never sent to the renderer.
  • Apply the same sanitization to finalized AI SDK messages before writing aiMessagesJson, preventing hallucinated provenance from re-entering later model history.
  • Preserve ordinary text between tag boundaries and similarly named non-internal tags.

Note

Medium Risk
Touches Local Agent stream assembly and persisted transcript text. Incorrect stripping could hide real assistant output or leave provenance tags in history, but the change is narrowly scoped to one tag family with tests.

Overview
Stops models from leaking internal <dyad-git-context> provenance into visible chat or persisted aiMessagesJson, so hallucinated Git hashes cannot re-enter later replay as model-authored text.

Streaming text-delta chunks now go through a stateful sanitizer that holds partial tags across chunk boundaries, then the same strip runs on assistant SDK messages before persistence. User literals and non-text tool parts are left alone; inner tag body text is kept.

Reviewed by Cursor Bugbot for commit 32fc247. Bugbot is set up for automated code reviews on this repo. Configure here.

Review in cubic

@cursor

cursor Bot commented Aug 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_1dee2ec4-d6d9-40ef-aa89-b76c28e714ef)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32fc2475bf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts Outdated

@dyad-assistant dyad-assistant 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.

Claude review: 2 inline finding(s).

Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts Outdated
Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts
@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: auto-fix

The overall approach is sound: a streaming-aware sanitizer that holds back partial markers, applied both to display text and to the finalized AI SDK messages, plus a rules-doc update. The diff is complete (not truncated), so confidence in the analysis below is good.

Two issues stand out. The blocking one is that stripping a text part down to nothing produces an empty text part that is then persisted into aiMessagesJson and replayed on every later turn β€” exactly the shape the model is most likely to echo, since the system prompt tells it Dyad appends <dyad-git-context> as its own trailing text part. The second is that a marker without a closing > silently deletes everything after it.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts:78 Stripped-empty assistant text parts persist to aiMessagesJson
🟑 MEDIUM src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts:34 Unterminated git-context marker swallows the rest of the response
🟒 Low Priority Notes (5 items)
  • Trailing flush runs before the refusal and abort checks - gitContextEchoSanitizer.finish() is flushed ahead of the modelRefused / signal.aborted branches. On a refusal, fullResponse was deliberately rewound to responseBeforeAttempt and passProducedChatText reset to false; the flush can append a held fragment (up to ~9 chars, e.g. <dyad-git) after MODEL_REFUSAL_WARNING and set passProducedChatText back to true. Moving the flush after those guards would keep the refusal rewind intact. (src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts)
  • Held text can be re-emitted out of order - getPotentialMarkerSuffixLength holds any suffix that is a prefix of a marker, including a bare <. If a text delta ends in < and the next stream part is a tool call, the tool XML is committed to fullResponse first and the held character surfaces after the tool card. Cosmetic, but it is a real reordering of assistant text. (src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts)
  • Compaction summaries are not covered - performCompaction summarizes history that legitimately contains <dyad-git-context> annotations and persists its summary as a DB message, but that path does not run through the new sanitizer, so the same echo can still re-enter model history from a compaction summary. Worth a follow-up if the goal is full coverage. (src/ipc/handlers/compaction/compaction_handler.ts)
  • Test gaps for the risky paths - The new tests cover split chunks, surrounding text, a similarly named tag, and mid-stream truncation, but not: a text part that sanitizes to empty, an unterminated <dyad-git-context ... with no >, or the flush interaction with the refusal/abort branches. Those are the cases most likely to regress. (src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.test.ts)
  • Matching is case-insensitive by design - findNextMarkerIndex lowercases before matching, so an uppercase <DYAD-GIT-CONTEXT> typed deliberately (e.g. the model explaining the tag to a user who asked about it) is also removed from the visible transcript. Probably intended, but worth being explicit about in the module doc comment. (src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts)

Generated by Dyadbot persona-based code review

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts
Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts Outdated
Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts Outdated
Comment thread src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts Outdated
Comment thread src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts
Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts Outdated
@github-actions github-actions Bot added the needs-human:review-issue ai agent flagged an issue that requires human review label Aug 21, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c8ea0aea9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts Outdated
- Sanitize reasoning and cross-part assistant content safely\n- Bound incomplete tag buffering and preserve Unicode indexes\n- Drop empty assistant blocks and flush text at stream boundaries
Drop short, distinctive Git-context prefixes when a stream ends.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bae88afd3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts
Comment thread src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts Outdated
@keppo-bot

keppo-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

πŸ€– Claude Code Review Summary

PR Confidence: 3/5

All known review findings are resolved and local tests, formatting, lint, and type-checks pass, but the latest CI/review cycle remains pending and the automated Codex review has a runner authentication failure.

Unresolved Threads

No unresolved threads

Resolved Threads

Issue Rationale Link
Sanitize reasoning output Applied the same stateful sanitization to streamed and persisted reasoning text. Codex, Cubic
Remove empty assistant blocks Filtered tag-only text/reasoning parts and dropped assistant messages with no remaining content. Dyad Assistant, Cubic
Bound unterminated markers Bounded incomplete tag buffering and preserved unterminated prose literally. Dyad Assistant, Cubic
Preserve Unicode indexes Replaced Unicode-wide lowercasing with length-preserving ASCII case folding. Cubic
Preserve stream event order Flushed buffered ordinary text before non-text events and reasoning transitions. Cubic
Sanitize across content parts Reused one sanitizer across all text/reasoning parts in each assistant message. Cubic
Drop truncated tag prefixes Recognized and discarded short, distinctive <dyad-git fragments and exact unterminated markers at stream finalization. Short prefix, Exact marker
Avoid replaying invalid signed reasoning Dropped provider-bound reasoning parts when sanitization changes their signed text. Codex
Product Principle Suggestions

No suggestions


πŸ€– Generated by Claude Code

- Drop modified provider-bound reasoning parts\n- Discard exact unterminated Git-context markers

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5bb9dbc7fe

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +73 to +79
const sanitizer = new GitContextEchoSanitizer();
const sanitizedTextByIndex = new Map<number, string>();
let lastSanitizedPartIndex = -1;

message.content.forEach((part, index) => {
if (part.type === "text" || part.type === "reasoning") {
sanitizedTextByIndex.set(index, sanitizer.push(part.text));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve partial-marker text in its original part

When an assistant reasoning or text part ends with any prefix of the markerβ€”even a lone <β€”and another text-like part follows, this shared sanitizer carries the buffered suffix into that later part. For example, signed reasoning ending in < is rewritten without that character, causing the providerOptions branch below to discard the entire reasoning block while prepending < to the answer, even though no Git-context tag existed; this corrupts persisted aiMessagesJson and subsequent replay. Flush while preserving the owning content part, or maintain separate state across only genuinely contiguous compatible parts. rules/local-agent-tools.mdL231-L231

Useful? React with πŸ‘Β / πŸ‘Ž.

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 2 files (changes from recent commits).

Confidence score: 2/5

  • In src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts, a provider-bound reasoning part can be dropped when it ends with a buffered partial marker even without a Git-context tag, while the suffix is moved into following text; preserve the reasoning content and add coverage for this boundary case.
  • In src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts, the <= bound also treats full-length markers as partial matches, making the helper’s behavior inconsistent with its name and framing; restrict the match to strict prefixes or update the helper contract and tests.
Prompt for AI agents (unresolved issues)

Check if these issues are valid β€” if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts">

<violation number="1" location="src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts:105">
P1: When a provider-bound reasoning part merely ends with a buffered partial marker, this `return` drops the entire reasoning part even though no Git-context tag exists, while the buffered suffix moves into the next text part. Preserve the partial suffix with its owning part or isolate sanitizer state to genuinely contiguous compatible parts before discarding the reasoning part.</violation>

<violation number="2" location="src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts:160">
P3: Changing the bound to `<=` makes `startsWithDistinctivePartialMarker` match full (equal-length) markers, not just strict prefixes, so its name and the 'strip partial tags' framing are now inaccurate for the equal-length case. Rename it to something like `startsWithOrEqualsGitTagMarker` (or add a comment) so future readers don't assume full, unclosed markers are treated differently than the current fix intends.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

text !== part.text &&
part.providerOptions
) {
return;

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.

P1: When a provider-bound reasoning part merely ends with a buffered partial marker, this return drops the entire reasoning part even though no Git-context tag exists, while the buffered suffix moves into the next text part. Preserve the partial suffix with its owning part or isolate sanitizer state to genuinely contiguous compatible parts before discarding the reasoning part.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts, line 105:

<comment>When a provider-bound reasoning part merely ends with a buffered partial marker, this `return` drops the entire reasoning part even though no Git-context tag exists, while the buffered suffix moves into the next text part. Preserve the partial suffix with its owning part or isolate sanitizer state to genuinely contiguous compatible parts before discarding the reasoning part.</comment>

<file context>
@@ -97,6 +97,13 @@ export function stripGitContextEchoesFromAssistantMessages(
+          text !== part.text &&
+          part.providerOptions
+        ) {
+          return;
+        }
         content.push({ ...part, text });
</file context>

normalized.length >= minimumDistinctivePrefix.length &&
GIT_CONTEXT_TAG_MARKERS.some(
(marker) =>
normalized.length <= marker.length && marker.startsWith(normalized),

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.

P3: Changing the bound to <= makes startsWithDistinctivePartialMarker match full (equal-length) markers, not just strict prefixes, so its name and the 'strip partial tags' framing are now inaccurate for the equal-length case. Rename it to something like startsWithOrEqualsGitTagMarker (or add a comment) so future readers don't assume full, unclosed markers are treated differently than the current fix intends.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts, line 160:

<comment>Changing the bound to `<=` makes `startsWithDistinctivePartialMarker` match full (equal-length) markers, not just strict prefixes, so its name and the 'strip partial tags' framing are now inaccurate for the equal-length case. Rename it to something like `startsWithOrEqualsGitTagMarker` (or add a comment) so future readers don't assume full, unclosed markers are treated differently than the current fix intends.</comment>

<file context>
@@ -150,7 +157,7 @@ function startsWithDistinctivePartialMarker(text: string): boolean {
     GIT_CONTEXT_TAG_MARKERS.some(
       (marker) =>
-        normalized.length < marker.length && marker.startsWith(normalized),
+        normalized.length <= marker.length && marker.startsWith(normalized),
     )
   );
</file context>

@dyad-assistant dyad-assistant 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.

Claude review: 3 inline finding(s).

text !== part.text &&
part.providerOptions
) {
return;

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.

🟑 MEDIUM

Dropping signed reasoning parts can break provider replay

When a reasoning part's text changes and it carries provider-bound metadata (e.g. an Anthropic thinking signature), the part is dropped entirely. The sanitized array feeds both aiMessagesJson persistence and currentMessageHistory for the next pass in the same turn, so the assistant message can end up keeping its tool-call parts while losing the thinking block that preceded them. Providers that require thinking blocks to accompany tool_use reject that shape, which would fail the whole agent turn. The trigger is not exotic: the local-agent system prompt explicitly describes , so a model quoting the tag while reasoning about recent history is a realistic path into this branch.

πŸ’‘ Suggestion: Prefer leaving reasoning parts with provider-bound signatures untouched (the streamed display text is already sanitized separately) rather than deleting them, or drop the part only when it is not accompanied by tool-call parts in the same message.

const response = await streamResult.response;
steps = (await streamResult.steps) ?? [];
responseMessages = response.messages;
responseMessages = stripGitContextEchoesFromAssistantMessages(

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.

🟑 MEDIUM

Sanitizing before mid-turn compaction slicing can misalign the offset

responseMessages is now the sanitized array, but the mid-turn compaction path slices it using prevStepMessages.length taken from the unsanitized steps[...].response.messages. stripGitContextEchoesFromAssistantMessages can remove whole messages (an assistant message whose only content was an echoed tag returns an empty array), so the two lengths can disagree and responseMessages.slice(prevStepMessages.length) then drops real post-compaction messages or keeps pre-compaction ones. That silently corrupts the persisted transcript and the history replayed for the rest of the turn.

πŸ’‘ Suggestion: Compute the slice from the raw response.messages and sanitize afterwards (sanitize messagesToAccumulate), so the offset and the array being sliced come from the same source.


message.content.forEach((part, index) => {
if (part.type === "text" || part.type === "reasoning") {
sanitizedTextByIndex.set(index, sanitizer.push(part.text));

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.

🟑 MEDIUM

Shared sanitizer state bleeds held text across assistant content parts

A single GitContextEchoSanitizer is pushed through every text and reasoning part of an assistant message, and the trailing finish() output is appended to the last sanitized part regardless of where it originated. Any part ending in a marker-like prefix (a trailing '<' or '<d') is held and then emitted into a later part, so for content like [reasoning '...<', tool-call, text 'Answer'] the held reasoning fragment is concatenated onto the visible text part - moving content across a tool-call boundary and leaking reasoning text into the answer. The streaming path correctly uses two separate sanitizers for text and reasoning; this path does not.

πŸ’‘ Suggestion: Use a separate sanitizer per contiguous run of same-type parts (or at minimum one for text and one for reasoning), and flush each run's trailing buffer into the last part of that run rather than the last part of the message.

@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: πŸ€” NOT SURE - Potential issues
Recommendation: ready

The core idea is sound: sanitize only assistant-authored text, hold partial tags across chunk boundaries, and apply the same strip before aiMessagesJson persistence. The streaming integration is careful about ordering β€” buffered text is flushed before the </think> transition logic and before the tool XML for the next part, the sanitizers are re-created per retry attempt, and the trailing flush runs before activeRetryReplayEvents is cleared. Case folding is deliberately ASCII-only so marker indexes stay aligned with the raw string, and the MAX_TAG_MARKUP_LENGTH escape hatch prevents unbounded buffering of unterminated prose. Test coverage for the sanitizer itself is good.

Three MEDIUM issues are worth a look before merge; none of them block on their own. The diff was complete (not truncated), so confidence in the reading of the changed code is high; the provider-rejection risk in the first item depends on runtime provider behavior I could not verify from the diff alone.

Issues Summary

Severity File Issue
🟑 MEDIUM src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts:105 Dropping signed reasoning parts can break provider replay
🟑 MEDIUM src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts:1771 Sanitizing before mid-turn compaction slicing can misalign the offset
🟑 MEDIUM src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts:79 Shared sanitizer state bleeds held text across assistant content parts
🟒 Low Priority Notes (4 items)
  • Empty thinking block when reasoning is fully stripped - reasoning-delta still opens <think> before gitContextReasoningSanitizer.push() returns anything, so a reasoning delta that consists only of an echoed tag renders an empty thinking block in the UI. (src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts)
  • Premature flush if a non-delta part interleaves a split tag - the top-of-loop finish() runs on any non-text-delta part. If a provider ever emits a part between two text-deltas that split a tag, the first half is dropped by the distinctive-prefix rule and the second half (text commit="...">) is emitted as visible text. Unlikely with current providers, but it is the exact failure the PR is trying to prevent. (src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts)
  • Legitimate discussion of the tag is silently mangled - the local-agent system prompt tells the model about <dyad-git-context>, so an assistant explaining the tag to the user (or a message legitimately ending in <dyad-git) gets that text removed with no indication. Acceptable trade-off, but worth knowing. (src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts)
  • Four near-duplicate flush blocks - the text/reasoning flush bodies are repeated at the top of the stream loop and again after it, with subtle asymmetries (only the text path sets passProducedChatText and captures retry-replay text). A small helper would keep the two copies from drifting. (src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts)

Generated by Dyadbot persona-based code review

@github-actions

Copy link
Copy Markdown
Contributor

🎭 Playwright Test Results

βœ… All tests passed!

OS Passed Flaky Skipped
🍎 macOS 294 0 12

Total: 294 tests passed (12 skipped)

πŸ“Š View full report

@keppo-bot

keppo-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Superseded by #4375, which uses the narrower user-message reminder approach discussed in review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human:review-issue ai agent flagged an issue that requires human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant