Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rules/local-agent-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ Agent tool definitions live in `src/pro/main/ipc/handlers/local_agent/tools/`. E
- When extending `handleLocalAgentStream` retry behavior, do not only match transport errors like `"terminated"`. Providers can emit structured stream errors such as `{ type: "error", error: { type: "server_error", ... } }`, and those transient 5xx / rate-limit failures need explicit retry classification too.
- Anthropic rejects any assistant `tool_use` unless the immediately following message contains every matching `tool_result`. When changing local-agent history assembly, retry replay, message injection, or `aiMessagesJson` persistence, run the transcript through the shared tool-call sanitizer at the provider/persistence boundary rather than relying only on the injection site to preserve ordering.
- In `prepareStep`-style paths, normalize the step message array even when `prepareStepMessages` returns `undefined`; split parallel tool results can still need merging on no-injection/no-compaction steps. Prefer the shared `sanitizeStepMessages` helper over ad hoc reference comparisons.
- Persisted assistant Git hashes (`sourceCommitHash` / `commitHash`) are database metadata, not part of `content` or `aiMessagesJson`. When local-agent replay needs that provenance, append an in-memory annotation only after `parseAiMessagesJson` has reconstructed the complete database message. Prefer the final `commitHash`; use `sourceCommitHash` only when no final commit exists, and never rewrite the stored transcript or insert the annotation inside a tool-call/tool-result pair.
- Persisted assistant Git hashes (`sourceCommitHash` / `commitHash`) are database metadata, not part of `content` or `aiMessagesJson`. When local-agent replay needs that provenance, append an in-memory annotation only after `parseAiMessagesJson` has reconstructed the complete database message. Prefer the final `commitHash`; use `sourceCommitHash` only when no final commit exists, and never rewrite the stored transcript or insert the annotation inside a tool-call/tool-result pair. Strip model-echoed `<dyad-git-context>` tags from both streamed display text and persisted AI messages so internal provenance cannot leak into the visible transcript or be replayed as model-authored content.
- Keep clean no-op turns unversioned: retain their `sourceCommitHash` for provenance but leave `commitHash` null. Do not attach the current `HEAD` merely because it exists; that attributes another turn's checkpoint and exposes unrelated files in version UI and snapshots.

## Metadata-only stop tools
Expand Down
7 changes: 7 additions & 0 deletions rules/typescript-strict-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ When the value must escape a callback (a `withLock` body, for example), return
it from that callback and destructure the result rather than closing over a
mutable binding.

## `flatMap` over heterogeneous unions may fail inference

`tsgo` can reject a `flatMap` callback over a discriminated-union array when
different branches return different member arrays. Build an accumulator typed
as `typeof sourceArray` and `push` narrowed members instead; this preserves the
complete union without broad casts.

## i18next `t()` keys are a literal union, not `string`

`useTranslation` returns a `t()` typed against a union of every key in the namespace. Passing a variable whose type widens to `string` (e.g., a `labelKey` field collected into an array) fails with `TS2345: Argument of type '[string]' is not assignable to parameter of type '[key: "added" | "..."]'`. Resolve the label at the call site (`{ label: t("groupToday") }`) instead of storing the key for later lookup (`{ labelKey: "groupToday" }` then `t(group.labelKey)`). If you really need late binding, narrow with `as const` so the literal type survives.
188 changes: 188 additions & 0 deletions src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";

import {
GitContextEchoSanitizer,
stripGitContextEchoes,
stripGitContextEchoesFromAssistantMessages,
} from "./git_context_sanitizer";

describe("GitContextEchoSanitizer", () => {
it("strips an echoed Git-context tag split across stream chunks", () => {
const sanitizer = new GitContextEchoSanitizer();

const output = [
sanitizer.push("Done.<dyad-git-con"),
sanitizer.push('text commit="fake">'),
sanitizer.push("</dyad-git-context>"),
sanitizer.finish(),
].join("");

expect(output).toBe("Done.");
});

it("preserves ordinary text around internal tag markup", () => {
expect(
stripGitContextEchoes(
'Before <dyad-git-context commit="fake">unexpected</dyad-git-context> after',
),
).toBe("Before unexpected after");
});

it("does not strip similarly named user text", () => {
expect(stripGitContextEchoes("Use <dyad-git-contextual> here.")).toBe(
"Use <dyad-git-contextual> here.",
);
});

it("preserves unterminated prose instead of buffering it without bound", () => {
const sanitizer = new GitContextEchoSanitizer();
const prose = `<dyad-git-context ${"ordinary prose ".repeat(24)}`;

expect(sanitizer.push(prose)).toBe(prose);
expect(sanitizer.finish()).toBe("");
});

it("keeps marker indexes aligned after Unicode with expanding lowercase forms", () => {
expect(
stripGitContextEchoes(
'İ<dyad-git-context commit="fake"></dyad-git-context>after',
),
).toBe("İafter");
});

it("drops a distinctive internal tag fragment if the stream ends", () => {
const sanitizer = new GitContextEchoSanitizer();

expect(sanitizer.push("Done.<dyad-git-con")).toBe("Done.");
expect(sanitizer.finish()).toBe("");

const shortPrefixSanitizer = new GitContextEchoSanitizer();
expect(shortPrefixSanitizer.push("Done.<dyad-git")).toBe("Done.");
expect(shortPrefixSanitizer.finish()).toBe("");

const exactMarkerSanitizer = new GitContextEchoSanitizer();
expect(exactMarkerSanitizer.push("Done.<dyad-git-context")).toBe("Done.");
expect(exactMarkerSanitizer.finish()).toBe("");
});

it("removes echoes from assistant message text only", () => {
const tag = '<dyad-git-context commit="fake"></dyad-git-context>';
const messages = stripGitContextEchoesFromAssistantMessages([
{ role: "user", content: `User literal: ${tag}` },
{
role: "assistant",
content: [
{ type: "text", text: `Finished.${tag}` },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "read_file",
input: {},
},
],
},
]);

expect(messages[0]).toEqual({
role: "user",
content: `User literal: ${tag}`,
});
expect(messages[1]).toMatchObject({
role: "assistant",
content: [
{ type: "text", text: "Finished." },
{ type: "tool-call", toolCallId: "call-1" },
],
});
});

it("sanitizes tags split across assistant text parts", () => {
const messages = stripGitContextEchoesFromAssistantMessages([
{
role: "assistant",
content: [
{ type: "text", text: "Before <dyad-git-con" },
{ type: "text", text: 'text commit="fake">inside</dyad-git-' },
{ type: "text", text: "context> after" },
],
},
]);

expect(messages).toEqual([
{
role: "assistant",
content: [
{ type: "text", text: "Before " },
{ type: "text", text: "inside" },
{ type: "text", text: " after" },
],
},
]);
});

it("sanitizes reasoning parts and drops empty assistant content", () => {
const tag = '<dyad-git-context commit="fake"></dyad-git-context>';
const messages = stripGitContextEchoesFromAssistantMessages([
{ role: "assistant", content: tag },
{
role: "assistant",
content: [{ type: "text", text: tag }],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: `Consider.${tag}` },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "read_file",
input: {},
},
{ type: "text", text: tag },
],
},
]);

expect(messages).toEqual([
{
role: "assistant",
content: [
{ type: "reasoning", text: "Consider." },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "read_file",
input: {},
},
],
},
]);
});

it("drops modified reasoning parts with provider-bound metadata", () => {
const tag = '<dyad-git-context commit="fake"></dyad-git-context>';

expect(
stripGitContextEchoesFromAssistantMessages([
{
role: "assistant",
content: [
{
type: "reasoning",
text: `Signed thought.${tag}`,
providerOptions: {
anthropic: { signature: "signed-original-reasoning" },
},
},
{ type: "text", text: "Answer" },
],
},
]),
).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "Answer" }],
},
]);
});
});
176 changes: 176 additions & 0 deletions src/pro/main/ipc/handlers/local_agent/git_context_sanitizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { ModelMessage } from "ai";

const GIT_CONTEXT_TAG_MARKERS = [
"<dyad-git-context",
"</dyad-git-context",
] as const;
const MAX_TAG_MARKUP_LENGTH = 256;

/**
* Removes internal Git-context tag markup without exposing partial tags while
* model text is streaming. Text between an opening and closing tag is
* preserved; only the internal markup itself is metadata.
*/
export class GitContextEchoSanitizer {
private pending = "";

push(chunk: string): string {
this.pending += chunk;
let output = "";

while (this.pending.length > 0) {
const markerIndex = findNextMarkerIndex(this.pending);
if (markerIndex === -1) {
const heldSuffixLength = getPotentialMarkerSuffixLength(this.pending);
const emitLength = this.pending.length - heldSuffixLength;
output += this.pending.slice(0, emitLength);
this.pending = this.pending.slice(emitLength);
break;
}

output += this.pending.slice(0, markerIndex);
this.pending = this.pending.slice(markerIndex);

const tagEndIndex = this.pending.indexOf(">");
if (tagEndIndex === -1) {
Comment thread
keppo-bot[bot] marked this conversation as resolved.
if (this.pending.length > MAX_TAG_MARKUP_LENGTH) {
output += this.pending;
this.pending = "";
}
break;
}
this.pending = this.pending.slice(tagEndIndex + 1);
}

return output;
}

finish(): string {
const pending = this.pending;
this.pending = "";
return startsWithDistinctivePartialMarker(pending) ? "" : pending;
}
}

export function stripGitContextEchoes(text: string): string {
const sanitizer = new GitContextEchoSanitizer();
return sanitizer.push(text) + sanitizer.finish();
}

export function stripGitContextEchoesFromAssistantMessages(
messages: ModelMessage[],
): ModelMessage[] {
return messages.flatMap((message) => {
if (message.role !== "assistant") {
return [message];
}

if (typeof message.content === "string") {
const content = stripGitContextEchoes(message.content);
return content.length > 0 ? [{ ...message, content }] : [];
}

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));
Comment on lines +73 to +79

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 👍 / 👎.

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.

lastSanitizedPartIndex = index;
}
});

if (lastSanitizedPartIndex !== -1) {
const trailingText = sanitizer.finish();
sanitizedTextByIndex.set(
lastSanitizedPartIndex,
(sanitizedTextByIndex.get(lastSanitizedPartIndex) ?? "") + trailingText,
);
}

const content: typeof message.content = [];
message.content.forEach((part, index) => {
if (part.type !== "text" && part.type !== "reasoning") {
content.push(part);
return;
}
const text = sanitizedTextByIndex.get(index) ?? "";
if (text.length > 0) {
if (
part.type === "reasoning" &&
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>

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.

}
content.push({ ...part, text });
Comment thread
keppo-bot[bot] marked this conversation as resolved.
}
});

return content.length > 0 ? [{ ...message, content } as ModelMessage] : [];
});
}

function findNextMarkerIndex(text: string): number {
const normalized = foldAsciiCase(text);
let earliest = -1;
for (const marker of GIT_CONTEXT_TAG_MARKERS) {
let searchFrom = 0;
while (searchFrom < normalized.length) {
const index = normalized.indexOf(marker, searchFrom);
if (index === -1) {
break;
}
if (isTagBoundary(normalized[index + marker.length])) {
if (earliest === -1 || index < earliest) {
earliest = index;
}
break;
}
searchFrom = index + 1;
}
}
return earliest;
}

function getPotentialMarkerSuffixLength(text: string): number {
const normalized = foldAsciiCase(text);
const maxLength = Math.min(
normalized.length,
Math.max(...GIT_CONTEXT_TAG_MARKERS.map((marker) => marker.length - 1)),
);

for (let length = maxLength; length > 0; length -= 1) {
const suffix = normalized.slice(-length);
if (GIT_CONTEXT_TAG_MARKERS.some((marker) => marker.startsWith(suffix))) {
return length;
}
}
return 0;
}

function startsWithDistinctivePartialMarker(text: string): boolean {
const normalized = foldAsciiCase(text);
const minimumDistinctivePrefix = "<dyad-git";
return (
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>

)
);
}

function foldAsciiCase(text: string): string {
return text.replace(/[A-Z]/g, (character) => character.toLowerCase());
}

function isTagBoundary(character: string | undefined): boolean {
return (
character === undefined ||
character === ">" ||
character === "/" ||
/\s/.test(character)
Comment thread
keppo-bot[bot] marked this conversation as resolved.
);
}
Loading
Loading