Fix parsing dyad tags with nested tags: < > (#445)

Fixes #441
This commit is contained in:
Will Chen
2025-06-19 10:08:26 -07:00
committed by GitHub
parent b044bb69f7
commit 8464609ba8
8 changed files with 424 additions and 21 deletions

View File

@@ -9,6 +9,7 @@ import {
import fs from "node:fs";
import git from "isomorphic-git";
import { db } from "../db";
import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse";
// Mock fs with default export
vi.mock("node:fs", async () => {
@@ -139,6 +140,110 @@ console.log("TodoItem");`,
]);
});
it("should handle missing description", () => {
const result = getDyadWriteTags(`
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx">
import React from 'react';
</dyad-write>
`);
expect(result).toEqual([
{
path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx",
description: undefined,
content: `import React from 'react';`,
},
]);
});
it("should handle extra space", () => {
const result = getDyadWriteTags(
cleanFullResponse(`
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use <a> tags." >
import React from 'react';
</dyad-write>
`),
);
expect(result).toEqual([
{
path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx",
description: "Updating Highlands neighborhood page to use a tags.",
content: `import React from 'react';`,
},
]);
});
it("should handle nested tags", () => {
const result = getDyadWriteTags(
cleanFullResponse(`
BEFORE TAG
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use <a> tags.">
import React from 'react';
</dyad-write>
AFTER TAG
`),
);
expect(result).toEqual([
{
path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx",
description: "Updating Highlands neighborhood page to use a tags.",
content: `import React from 'react';`,
},
]);
});
it("should handle nested tags after preprocessing", () => {
// Simulate the preprocessing step that cleanFullResponse would do
const inputWithNestedTags = `
BEFORE TAG
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use <a> tags.">
import React from 'react';
</dyad-write>
AFTER TAG
`;
const cleanedInput = cleanFullResponse(inputWithNestedTags);
const result = getDyadWriteTags(cleanedInput);
expect(result).toEqual([
{
path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx",
description: "Updating Highlands neighborhood page to use a tags.",
content: `import React from 'react';`,
},
]);
});
it("should handle multiple nested tags after preprocessing", () => {
const inputWithMultipleNestedTags = `<dyad-write path="src/file.tsx" description="Testing <div> and <span> and <a> tags.">content</dyad-write>`;
// This simulates what cleanFullResponse should do
const cleanedInput = cleanFullResponse(inputWithMultipleNestedTags);
const result = getDyadWriteTags(cleanedInput);
expect(result).toEqual([
{
path: "src/file.tsx",
description: "Testing div and span and a tags.",
content: `content`,
},
]);
});
it("should handle nested tags in multiple attributes", () => {
const inputWithNestedInMultipleAttrs = `<dyad-write path="src/<component>.tsx" description="Testing <div> tags.">content</dyad-write>`;
// This simulates what cleanFullResponse should do
const cleanedInput = cleanFullResponse(inputWithNestedInMultipleAttrs);
const result = getDyadWriteTags(cleanedInput);
expect(result).toEqual([
{
path: "src/component.tsx",
description: "Testing div tags.",
content: `content`,
},
]);
});
it("should return an array of dyad-write tags", () => {
const result = getDyadWriteTags(
`I'll create a simple todo list app using React, TypeScript, and shadcn/ui components. Let's get started!

View File

@@ -0,0 +1,89 @@
import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse";
import { describe, it, expect } from "vitest";
describe("cleanFullResponse", () => {
it("should replace < characters in dyad-write attributes", () => {
const input = `<dyad-write path="src/file.tsx" description="Testing <a> tags.">content</dyad-write>`;
const expected = `<dyad-write path="src/file.tsx" description="Testing a tags.">content</dyad-write>`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should replace < characters in multiple attributes", () => {
const input = `<dyad-write path="src/<component>.tsx" description="Testing <div> tags.">content</dyad-write>`;
const expected = `<dyad-write path="src/component.tsx" description="Testing div tags.">content</dyad-write>`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should handle multiple nested HTML tags in a single attribute", () => {
const input = `<dyad-write path="src/file.tsx" description="Testing <div> and <span> and <a> tags.">content</dyad-write>`;
const expected = `<dyad-write path="src/file.tsx" description="Testing div and span and a tags.">content</dyad-write>`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should handle complex example with mixed content", () => {
const input = `
BEFORE TAG
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use <a> tags.">
import React from 'react';
</dyad-write>
AFTER TAG
`;
const expected = `
BEFORE TAG
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use a tags.">
import React from 'react';
</dyad-write>
AFTER TAG
`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should handle other dyad tag types", () => {
const input = `<dyad-rename from="src/<old>.tsx" to="src/<new>.tsx"></dyad-rename>`;
const expected = `<dyad-rename from="src/old.tsx" to="src/new.tsx"></dyad-rename>`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should handle dyad-delete tags", () => {
const input = `<dyad-delete path="src/<component>.tsx"></dyad-delete>`;
const expected = `<dyad-delete path="src/component.tsx"></dyad-delete>`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should not affect content outside dyad tags", () => {
const input = `Some text with <regular> HTML tags. <dyad-write path="test.tsx" description="With <nested> tags.">content</dyad-write> More <html> here.`;
const expected = `Some text with <regular> HTML tags. <dyad-write path="test.tsx" description="With nested tags.">content</dyad-write> More <html> here.`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should handle empty attributes", () => {
const input = `<dyad-write path="src/file.tsx">content</dyad-write>`;
const expected = `<dyad-write path="src/file.tsx">content</dyad-write>`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
it("should handle attributes without < characters", () => {
const input = `<dyad-write path="src/file.tsx" description="Normal description">content</dyad-write>`;
const expected = `<dyad-write path="src/file.tsx" description="Normal description">content</dyad-write>`;
const result = cleanFullResponse(input);
expect(result).toBe(expected);
});
});

View File

@@ -25,7 +25,7 @@ import {
getSupabaseClientCode,
} from "../../supabase_admin/supabase_context";
import { SUMMARIZE_CHAT_SYSTEM_PROMPT } from "../../prompts/summarize_chat_system_prompt";
import * as fs from "fs";
import fs from "node:fs";
import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
@@ -38,6 +38,7 @@ import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google";
import { getExtraProviderOptions } from "../utils/thinking_utils";
import { safeSend } from "../utils/safe_sender";
import { cleanFullResponse } from "../utils/cleanFullResponse";
const logger = log.scope("chat_stream_handlers");
@@ -258,7 +259,6 @@ ${componentSnippet}
abortController,
updatedChat,
);
fullResponse = cleanThinkingByEscapingDyadTags(fullResponse);
} else {
// Normal AI processing for non-test prompts
const settings = readSettings();
@@ -515,6 +515,7 @@ This conversation includes one or more image attachments. When the user uploads
}
fullResponse += chunk;
fullResponse = cleanFullResponse(fullResponse);
if (
fullResponse.includes("$$SUPABASE_CLIENT_CODE$$") &&
@@ -815,25 +816,6 @@ async function prepareMessageWithAttachments(
};
}
function cleanThinkingByEscapingDyadTags(text: string): string {
// Extract content inside <think> </think> tags
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
return text.replace(thinkRegex, (match, content) => {
// We are replacing the opening tag with a look-alike character
// to avoid issues where thinking content includes dyad tags
// and are mishandled by:
// 1. FE markdown parser
// 2. Main process response processor
const processedContent = content
.replace(/<dyad/g, "dyad")
.replace(/<\/dyad/g, "/dyad");
// Return the modified think tag with processed content
return `<think>${processedContent}</think>`;
});
}
function removeThinkingTags(text: string): string {
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
return text.replace(thinkRegex, "").trim();

View File

@@ -1,4 +1,5 @@
import { safeSend } from "../utils/safe_sender";
import { cleanFullResponse } from "../utils/cleanFullResponse";
// e.g. [dyad-qa=add-dep]
// Canned responses for test prompts
@@ -18,6 +19,12 @@ const TEST_RESPONSES: Record<string, string> = {
<dyad-add-dependency packages="react-router-dom react-query"></dyad-add-dependency>
EOM`,
"string-literal-leak": `BEFORE TAG
<dyad-write path="src/pages/locations/neighborhoods/louisville/Highlands.tsx" description="Updating Highlands neighborhood page to use <a> tags.">
import React from 'react';
</dyad-write>
AFTER TAG
`,
};
/**
@@ -64,6 +71,7 @@ export async function streamTestResponse(
// Add the word plus a space
fullResponse += chunk + " ";
fullResponse = cleanFullResponse(fullResponse);
// Send the current accumulated response
safeSend(event.sender, "chat:response:chunk", {

View File

@@ -0,0 +1,15 @@
export function cleanFullResponse(text: string): string {
// Replace < characters inside dyad-* attributes with fullwidth less-than sign
// This prevents parsing issues when attributes contain HTML tags like <a> or <div>
return text.replace(/<dyad-[^<>]*(?:"[^"]*"[^<>]*)*>/g, (match: string) => {
// Find all attribute values (content within quotes) and replace < with and > with
const processedMatch = match.replace(
/="([^"]*)"/g,
(attrMatch: string, attrValue: string) => {
const cleanedValue = attrValue.replace(/</g, "").replace(/>/g, "");
return `="${cleanedValue}"`;
},
);
return processedMatch;
});
}