+const message = "Hello 'world'";
+const regex = /]*>/g;
+`;
+ const result = hasUnclosedDyadWrite(text);
+ expect(result).toBe(false);
+ });
+
+ it("should handle text before and after dyad-write tags", () => {
+ const text = `Some text before the tag
+console.log('hello');
+Some text after the tag`;
+ const result = hasUnclosedDyadWrite(text);
+ expect(result).toBe(false);
+ });
+
+ it("should handle unclosed tag with text after", () => {
+ const text = `Some text before the tag
+console.log('hello');
+Some text after the unclosed tag`;
+ const result = hasUnclosedDyadWrite(text);
+ expect(result).toBe(true);
+ });
+
+ it("should handle empty dyad-write tags", () => {
+ const text = ``;
+ const result = hasUnclosedDyadWrite(text);
+ expect(result).toBe(false);
+ });
+
+ it("should handle unclosed empty dyad-write tags", () => {
+ const text = ``;
+ const result = hasUnclosedDyadWrite(text);
+ expect(result).toBe(true);
+ });
+
+ it("should focus on the last opening tag when there are mixed states", () => {
+ const text = `completed content
+ unclosed content
+ final content`;
+ const result = hasUnclosedDyadWrite(text);
+ expect(result).toBe(false);
+ });
+
+ it("should handle tags with special characters in attributes", () => {
+ const text = `content`;
+ const result = hasUnclosedDyadWrite(text);
+ expect(result).toBe(false);
+ });
+});
diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts
index f2d8ce6..cda6416 100644
--- a/src/ipc/handlers/chat_stream_handlers.ts
+++ b/src/ipc/handlers/chat_stream_handlers.ts
@@ -451,41 +451,86 @@ This conversation includes one or more image attachments. When the user uploads
];
}
- // When calling streamText, the messages need to be properly formatted for mixed content
- const { fullStream } = streamText({
- maxTokens: await getMaxTokens(settings.selectedModel),
- temperature: 0,
- maxRetries: 2,
- model: modelClient.model,
- providerOptions: {
- "dyad-gateway": getExtraProviderOptions(
- modelClient.builtinProviderId,
- ),
- google: {
- thinkingConfig: {
- includeThoughts: true,
- },
- } satisfies GoogleGenerativeAIProviderOptions,
- },
- system: systemPrompt,
- messages: chatMessages.filter((m) => m.content),
- onError: (error: any) => {
- logger.error("Error streaming text:", error);
- let errorMessage = (error as any)?.error?.message;
- const responseBody = error?.error?.responseBody;
- if (errorMessage && responseBody) {
- errorMessage += "\n\nDetails: " + responseBody;
- }
- const message = errorMessage || JSON.stringify(error);
- event.sender.send(
- "chat:response:error",
- `Sorry, there was an error from the AI: ${message}`,
+ const simpleStreamText = async ({
+ chatMessages,
+ }: {
+ chatMessages: CoreMessage[];
+ }) => {
+ return streamText({
+ maxTokens: await getMaxTokens(settings.selectedModel),
+ temperature: 0,
+ maxRetries: 2,
+ model: modelClient.model,
+ providerOptions: {
+ "dyad-gateway": getExtraProviderOptions(
+ modelClient.builtinProviderId,
+ ),
+ google: {
+ thinkingConfig: {
+ includeThoughts: true,
+ },
+ } satisfies GoogleGenerativeAIProviderOptions,
+ },
+ system: systemPrompt,
+ messages: chatMessages.filter((m) => m.content),
+ onError: (error: any) => {
+ logger.error("Error streaming text:", error);
+ let errorMessage = (error as any)?.error?.message;
+ const responseBody = error?.error?.responseBody;
+ if (errorMessage && responseBody) {
+ errorMessage += "\n\nDetails: " + responseBody;
+ }
+ const message = errorMessage || JSON.stringify(error);
+ event.sender.send(
+ "chat:response:error",
+ `Sorry, there was an error from the AI: ${message}`,
+ );
+ // Clean up the abort controller
+ activeStreams.delete(req.chatId);
+ },
+ abortSignal: abortController.signal,
+ });
+ };
+
+ const processResponseChunkUpdate = async ({
+ fullResponse,
+ }: {
+ fullResponse: string;
+ }) => {
+ if (
+ fullResponse.includes("$$SUPABASE_CLIENT_CODE$$") &&
+ updatedChat.app?.supabaseProjectId
+ ) {
+ const supabaseClientCode = await getSupabaseClientCode({
+ projectId: updatedChat.app?.supabaseProjectId,
+ });
+ fullResponse = fullResponse.replace(
+ "$$SUPABASE_CLIENT_CODE$$",
+ supabaseClientCode,
);
- // Clean up the abort controller
- activeStreams.delete(req.chatId);
- },
- abortSignal: abortController.signal,
- });
+ }
+ // Store the current partial response
+ partialResponses.set(req.chatId, fullResponse);
+
+ // Update the placeholder assistant message content in the messages array
+ const currentMessages = [...updatedChat.messages];
+ if (
+ currentMessages.length > 0 &&
+ currentMessages[currentMessages.length - 1].role === "assistant"
+ ) {
+ currentMessages[currentMessages.length - 1].content = fullResponse;
+ }
+
+ // Update the assistant message in the database
+ safeSend(event.sender, "chat:response:chunk", {
+ chatId: req.chatId,
+ messages: currentMessages,
+ });
+ return fullResponse;
+ };
+
+ // When calling streamText, the messages need to be properly formatted for mixed content
+ const { fullStream } = await simpleStreamText({ chatMessages });
// Process the stream as before
let inThinkingBlock = false;
@@ -520,36 +565,8 @@ This conversation includes one or more image attachments. When the user uploads
fullResponse += chunk;
fullResponse = cleanFullResponse(fullResponse);
-
- if (
- fullResponse.includes("$$SUPABASE_CLIENT_CODE$$") &&
- updatedChat.app?.supabaseProjectId
- ) {
- const supabaseClientCode = await getSupabaseClientCode({
- projectId: updatedChat.app?.supabaseProjectId,
- });
- fullResponse = fullResponse.replace(
- "$$SUPABASE_CLIENT_CODE$$",
- supabaseClientCode,
- );
- }
- // Store the current partial response
- partialResponses.set(req.chatId, fullResponse);
-
- // Update the placeholder assistant message content in the messages array
- const currentMessages = [...updatedChat.messages];
- if (
- currentMessages.length > 0 &&
- currentMessages[currentMessages.length - 1].role === "assistant"
- ) {
- currentMessages[currentMessages.length - 1].content =
- fullResponse;
- }
-
- // Update the assistant message in the database
- safeSend(event.sender, "chat:response:chunk", {
- chatId: req.chatId,
- messages: currentMessages,
+ fullResponse = await processResponseChunkUpdate({
+ fullResponse,
});
// If the stream was aborted, exit early
@@ -558,6 +575,45 @@ This conversation includes one or more image attachments. When the user uploads
break;
}
}
+
+ if (
+ !abortController.signal.aborted &&
+ settings.selectedChatMode !== "ask" &&
+ hasUnclosedDyadWrite(fullResponse)
+ ) {
+ let continuationAttempts = 0;
+ while (
+ hasUnclosedDyadWrite(fullResponse) &&
+ continuationAttempts < 2 &&
+ !abortController.signal.aborted
+ ) {
+ logger.warn(
+ `Received unclosed dyad-write tag, attempting to continue, attempt #${continuationAttempts + 1}`,
+ );
+ continuationAttempts++;
+
+ const { fullStream: contStream } = await simpleStreamText({
+ // Build messages: replay history then pre-fill assistant with current partial.
+ chatMessages: [
+ ...chatMessages,
+ { role: "assistant", content: fullResponse },
+ ],
+ });
+ for await (const part of contStream) {
+ // If the stream was aborted, exit early
+ if (abortController.signal.aborted) {
+ logger.log(`Stream for chat ${req.chatId} was aborted`);
+ break;
+ }
+ if (part.type !== "text-delta") continue; // ignore reasoning for continuation
+ fullResponse += part.textDelta;
+ fullResponse = cleanFullResponse(fullResponse);
+ fullResponse = await processResponseChunkUpdate({
+ fullResponse,
+ });
+ }
+ }
+ }
} catch (streamError) {
// Check if this was an abort error
if (abortController.signal.aborted) {
@@ -832,3 +888,25 @@ export function removeDyadTags(text: string): string {
const dyadRegex = /]*>[\s\S]*?<\/dyad-[^>]*>/g;
return text.replace(dyadRegex, "").trim();
}
+
+export function hasUnclosedDyadWrite(text: string): boolean {
+ // Find the last opening dyad-write tag
+ const openRegex = /]*>/g;
+ let lastOpenIndex = -1;
+ let match;
+
+ while ((match = openRegex.exec(text)) !== null) {
+ lastOpenIndex = match.index;
+ }
+
+ // If no opening tag found, there's nothing unclosed
+ if (lastOpenIndex === -1) {
+ return false;
+ }
+
+ // Look for a closing tag after the last opening tag
+ const textAfterLastOpen = text.substring(lastOpenIndex);
+ const hasClosingTag = /<\/dyad-write>/.test(textAfterLastOpen);
+
+ return !hasClosingTag;
+}
diff --git a/testing/fake-llm-server/chatCompletionHandler.ts b/testing/fake-llm-server/chatCompletionHandler.ts
index e65395e..4c5d792 100644
--- a/testing/fake-llm-server/chatCompletionHandler.ts
+++ b/testing/fake-llm-server/chatCompletionHandler.ts
@@ -64,35 +64,7 @@ export default Index;
)
: lastMessage.content.includes("[dump]"))
) {
- const timestamp = Date.now();
- const generatedDir = path.join(__dirname, "generated");
-
- // Create generated directory if it doesn't exist
- if (!fs.existsSync(generatedDir)) {
- fs.mkdirSync(generatedDir, { recursive: true });
- }
-
- const dumpFilePath = path.join(generatedDir, `${timestamp}.json`);
-
- try {
- fs.writeFileSync(
- dumpFilePath,
- JSON.stringify(
- {
- body: req.body,
- headers: { authorization: req.headers["authorization"] },
- },
- null,
- 2,
- ).replace(/\r\n/g, "\n"),
- "utf-8",
- );
- console.log(`* Dumped messages to: ${dumpFilePath}`);
- messageContent = `[[dyad-dump-path=${dumpFilePath}]]`;
- } catch (error) {
- console.error(`* Error writing dump file: ${error}`);
- messageContent = `Error: Could not write dump file: ${error}`;
- }
+ messageContent = generateDump(req);
}
if (lastMessage && lastMessage.content === "[increment]") {
@@ -133,6 +105,16 @@ export default Index;
}
}
+ if (
+ lastMessage &&
+ lastMessage.content &&
+ typeof lastMessage.content === "string" &&
+ lastMessage.content.trim().endsWith("[[STRING_TO_BE_FINISHED]]")
+ ) {
+ messageContent = `[[STRING_IS_FINISHED]]";\nFinished writing file.`;
+ messageContent += "\n\n" + generateDump(req);
+ }
+
// Non-streaming response
if (!stream) {
return res.json({
@@ -183,3 +165,35 @@ export default Index;
}
}, 10);
};
+
+function generateDump(req: Request) {
+ const timestamp = Date.now();
+ const generatedDir = path.join(__dirname, "generated");
+
+ // Create generated directory if it doesn't exist
+ if (!fs.existsSync(generatedDir)) {
+ fs.mkdirSync(generatedDir, { recursive: true });
+ }
+
+ const dumpFilePath = path.join(generatedDir, `${timestamp}.json`);
+
+ try {
+ fs.writeFileSync(
+ dumpFilePath,
+ JSON.stringify(
+ {
+ body: req.body,
+ headers: { authorization: req.headers["authorization"] },
+ },
+ null,
+ 2,
+ ).replace(/\r\n/g, "\n"),
+ "utf-8",
+ );
+ console.log(`* Dumped messages to: ${dumpFilePath}`);
+ return `[[dyad-dump-path=${dumpFilePath}]]`;
+ } catch (error) {
+ console.error(`* Error writing dump file: ${error}`);
+ return `Error: Could not write dump file: ${error}`;
+ }
+}