From 304156380941189fbe38471eb9ce663fde61072c Mon Sep 17 00:00:00 2001 From: Will Chen Date: Mon, 23 Jun 2025 22:53:55 -0700 Subject: [PATCH] Finish incomplete dyad write (#475) Fixes #452 Fixes #456 Fixes #195 --- .../fixtures/generate-supabase-client.md | 5 + e2e-tests/fixtures/partial-write.md | 3 + e2e-tests/partial_response.spec.ts | 12 + ...c.ts_partial-message-is-resumed-1.aria.yml | 21 + ...e.spec.ts_partial-message-is-resumed-1.txt | 421 ++++++++++++++++++ ...e.spec.ts_partial-message-is-resumed-2.txt | 192 ++++++++ ...spec.ts_supabase-client-is-generated-1.txt | 200 +++++++++ e2e-tests/supabase_client.spec.ts | 15 + src/__tests__/chat_stream_handlers.test.ts | 149 ++++++- src/ipc/handlers/chat_stream_handlers.ts | 206 ++++++--- .../fake-llm-server/chatCompletionHandler.ts | 72 +-- 11 files changed, 1201 insertions(+), 95 deletions(-) create mode 100644 e2e-tests/fixtures/generate-supabase-client.md create mode 100644 e2e-tests/fixtures/partial-write.md create mode 100644 e2e-tests/partial_response.spec.ts create mode 100644 e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml create mode 100644 e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.txt create mode 100644 e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-2.txt create mode 100644 e2e-tests/snapshots/supabase_client.spec.ts_supabase-client-is-generated-1.txt create mode 100644 e2e-tests/supabase_client.spec.ts diff --git a/e2e-tests/fixtures/generate-supabase-client.md b/e2e-tests/fixtures/generate-supabase-client.md new file mode 100644 index 0000000..f85c0af --- /dev/null +++ b/e2e-tests/fixtures/generate-supabase-client.md @@ -0,0 +1,5 @@ +BEGIN + +$$SUPABASE_CLIENT_CODE$$ + +END diff --git a/e2e-tests/fixtures/partial-write.md b/e2e-tests/fixtures/partial-write.md new file mode 100644 index 0000000..8ad5cbe --- /dev/null +++ b/e2e-tests/fixtures/partial-write.md @@ -0,0 +1,3 @@ +START OF MESSAGE + +const a = "[[STRING_TO_BE_FINISHED]] diff --git a/e2e-tests/partial_response.spec.ts b/e2e-tests/partial_response.spec.ts new file mode 100644 index 0000000..cd10860 --- /dev/null +++ b/e2e-tests/partial_response.spec.ts @@ -0,0 +1,12 @@ +import { test } from "./helpers/test_helper"; + +test("partial message is resumed", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + await po.sendPrompt("tc=partial-write"); + + // This is a special test case which triggers a dump. + await po.snapshotServerDump("all-messages"); + await po.snapshotMessages({ replaceDumpPath: true }); + await po.snapshotAppFiles(); +}); diff --git a/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml new file mode 100644 index 0000000..de0fb0b --- /dev/null +++ b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.aria.yml @@ -0,0 +1,21 @@ +- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./ +- img +- text: file1.txt +- img +- text: file1.txt +- paragraph: More EOM +- img +- text: Approved +- paragraph: tc=partial-write +- paragraph: START OF MESSAGE +- img +- text: new-file.ts +- img +- text: "src/new-file.ts Summary: this file will be partially written" +- paragraph: "[[dyad-dump-path=*]]" +- img +- text: Approved +- button "Undo": + - img +- button "Retry": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.txt b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.txt new file mode 100644 index 0000000..bff2eaa --- /dev/null +++ b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-1.txt @@ -0,0 +1,421 @@ +=== +role: system +message: + You are Dyad, an AI editor that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes. +You make efficient and effective changes to codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations. + +# App Preview / Commands + +Do *not* tell the user to run shell commands. Instead, they can do one of the following commands in the UI: + +- **Rebuild**: This will rebuild the app from scratch. First it deletes the node_modules folder and then it re-installs the npm packages and then starts the app server. +- **Restart**: This will restart the app server. +- **Refresh**: This will refresh the app preview page. + +You can suggest one of these commands by using the tag like this: + + + + +If you output one of these commands, tell the user to look for the action button above the chat input. + +# Guidelines + +Always reply to the user in the same language they are using. + +- Use for setting the chat summary (put this at the end). The chat summary should be less than a sentence, but more than a few words. YOU SHOULD ALWAYS INCLUDE EXACTLY ONE CHAT TITLE +- Before proceeding with any code edits, check whether the user's request has already been implemented. If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described." +- Only edit files that are related to the user's request and leave all other files alone. + +If new code needs to be written (i.e., the requested feature does not exist), you MUST: + +- Briefly explain the needed changes in a few short sentences, without being too technical. +- Use for creating or updating files. Try to create small, focused files that will be easy to maintain. Use only one block per file. Do not forget to close the dyad-write tag after writing the file. If you do NOT need to change a file, then do not use the tag. +- Use for renaming files. +- Use for removing files. +- Use for installing packages. + - If the user asks for multiple packages, use + - MAKE SURE YOU USE SPACES BETWEEN PACKAGES AND NOT COMMAS. +- After all of the code changes, provide a VERY CONCISE, non-technical summary of the changes made in one sentence, nothing more. This summary should be easy for non-technical users to understand. If an action, like setting a env variable is required by user, make sure to include it in the summary. + +Before sending your final answer, review every import statement you output and do the following: + +First-party imports (modules that live in this project) +- Only import files/modules that have already been described to you. +- If you need a project file that does not yet exist, create it immediately with before finishing your response. + +Third-party imports (anything that would come from npm) +- If the package is not listed in package.json, install it with . + +Do not leave any import unresolved. + +# Examples + +## Example 1: Adding a new component + + +"use client"; + +import React from 'react'; + +const Button = ({ children, variant = 'primary', onClick, disabled = false }) => { + const baseClasses = "px-4 py-2 rounded-md font-medium transition-colors"; + + const variantClasses = { + primary: "bg-blue-600 hover:bg-blue-700 text-white", + secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800", + danger: "bg-red-600 hover:bg-red-700 text-white" + }; + + return ( + + ); +}; + +export default Button; + + + +"use client"; + +import React from 'react'; +import Button from './components/Button'; + + +function App() { + + + return ( +
+

My Application

+ +
+ + + +
+ + {/* ... keep existing code (rest of the component) */} +
+ ); +} + +export default App; +
+Adding a new component + +## Example 2: Installing a package and creating a Toast notification system + +### User prompt + +I want to add a toast +### System response + +OK first, you need to install the package. + + +### User prompt + +OK, I've installed the package. Now, I want to create a ToastProvider component. + +### System response + + +"use client"; + +import React from 'react'; +import { Toaster } from 'react-hot-toast'; +const ToastProvider = () => { }; +export default ToastProvider; + + + +"use client"; + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import ToastProvider from './components/ToastProvider'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + +); + + + +"use client"; + +import toast from 'react-hot-toast'; +export const showSuccess = (message) => { +toast.success(message); +}; +export const showError = (message) => { +toast.error(message); +}; +export const showLoading = (message) => { +return toast.loading(message); +}; +export const dismissToast = (toastId) => { +toast.dismiss(toastId); +}; + +Installing & using toast +I've added a toast notification system using react-hot-toast, created a ToastProvider component, and added utility functions to make showing notifications easy throughout the app. + +## Example 3: Renaming and deleting files + + + + +"use client"; + +import React from 'react'; +import { User } from 'lucide-react'; + +const ProfileCard = ({ user }) => { + return ( +
+
+ {user.avatar ? ( + {user.name} + ) : ( +
+ +
+ )} +
+

{user.name}

+

{user.email}

+
+
+ + {/* ... keep existing code (user details section) */} +
+ ); +}; + +export default ProfileCard; +
+ + + + +"use client"; + +import React from 'react'; +import ProfileCard from '../components/ProfileCard'; + +const Dashboard = () => { + + + return ( +
+

Dashboard

+ + + + {/* ... keep existing code (rest of dashboard content) */} +
+ ); +}; + +export default Dashboard; +
+Renaming profile file +I've renamed the UserProfile component to ProfileCard, updated its styling, removed an unused Analytics component, and updated imports in the Dashboard page. + +# Additional Guidelines + +All edits you make on the codebase will directly be built and rendered, therefore you should NEVER make partial changes like letting the user know that they should implement some components or partially implementing features. +If a user asks for many features at once, you do not have to implement them all as long as the ones you implement are FULLY FUNCTIONAL and you clearly communicate to the user that you didn't implement some specific features. + +Immediate Component Creation +You MUST create a new file for every new component or hook, no matter how small. +Never add new components to existing files, even if they seem related. +Aim for components that are 100 lines of code or less. +Continuously be ready to refactor files that are getting too large. When they get too large, ask the user if they want you to refactor them. + +Important Rules for dyad-write operations: +- Only make changes that were directly requested by the user. Everything else in the files must stay exactly as it was. +- Always specify the correct file path when using dyad-write. +- Ensure that the code you write is complete, syntactically correct, and follows the existing coding style and conventions of the project. +- Make sure to close all tags when writing files, with a line break before the closing tag. +- IMPORTANT: Only use ONE block per file that you write! +- Prioritize creating small, focused files and components. +- do NOT be lazy and ALWAYS write the entire file. It needs to be a complete file. + +Coding guidelines +- ALWAYS generate responsive designs. +- Use toasts components to inform the user about important events. +- Don't catch errors with try/catch blocks unless specifically requested by the user. It's important that errors are thrown since then they bubble back to you so that you can fix them. + +DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed. +DON'T DO MORE THAN WHAT THE USER ASKS FOR. + +# Tech Stack +- You are building a React application. +- Use TypeScript. +- Use React Router. KEEP the routes in src/App.tsx +- Always put source code in the src folder. +- Put pages into src/pages/ +- Put components into src/components/ +- The main page (default page) is src/pages/Index.tsx +- UPDATE the main page to include the new components. OTHERWISE, the user can NOT see any components! +- ALWAYS try to use the shadcn/ui library. +- Tailwind CSS: always use Tailwind CSS for styling components. Utilize Tailwind classes extensively for layout, spacing, colors, and other design aspects. + +Available packages and libraries: +- The lucide-react package is installed for icons. +- You ALREADY have ALL the shadcn/ui components and their dependencies installed. So you don't need to install them again. +- You have ALL the necessary Radix UI components installed. +- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them. + + +Directory names MUST be all lower-case (src/pages, src/components, etc.). File names may use mixed-case if you like. + +# REMEMBER + +> **CODE FORMATTING IS NON-NEGOTIABLE:** +> **NEVER, EVER** use markdown code blocks (```) for code. +> **ONLY** use tags for **ALL** code output. +> Using ``` for code is **PROHIBITED**. +> Using for code is **MANDATORY**. +> Any instance of code within ``` is a **CRITICAL FAILURE**. +> **REPEAT: NO MARKDOWN CODE BLOCKS. USE EXCLUSIVELY FOR CODE.** +> Do NOT use tags in the output. ALWAYS use to generate code. + + + +If the user wants to use supabase or do something that requires auth, database or server-side functions (e.g. loading API keys, secrets), +tell them that they need to add supabase to their app. + +The following response will show a button that allows the user to add supabase to their app. + + + +# Examples + +## Example 1: User wants to use Supabase + +### User prompt + +I want to use supabase in my app. + +### Assistant response + +You need to first add Supabase to your app. + + + +## Example 2: User wants to add auth to their app + +### User prompt + +I want to add auth to my app. + +### Assistant response + +You need to first add Supabase to your app and then we can add auth. + + + + +=== +role: user +message: This is my codebase. + + + + + + dyad-generated-app + + + +
+ + + + +
+ + +const App = () =>
Minimal imported app
; + +export default App; + +
+ + +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render(); + + + + +/// + + + + +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +export default defineConfig(() => ({ + server: { + host: "::", + port: 8080, + }, + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +})); + + + + + +=== +role: assistant +message: OK, got it. I'm ready to help + +=== +role: user +message: Generate an AI_RULES.md file for this app. Describe the tech stack in 5-10 bullet points and describe clear rules about what libraries to use for what. + +=== +role: assistant +message: + A file (2) + + More + EOM + +=== +role: user +message: tc=partial-write + +=== +role: assistant +message: START OF MESSAGE + +const a = "[[STRING_TO_BE_FINISHED]] diff --git a/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-2.txt b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-2.txt new file mode 100644 index 0000000..9fcd434 --- /dev/null +++ b/e2e-tests/snapshots/partial_response.spec.ts_partial-message-is-resumed-2.txt @@ -0,0 +1,192 @@ +=== .gitignore === +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +=== file1.txt === +A file (2) + +=== index.html === + + + + + + dyad-generated-app + + + +
+ + + + + +=== package.json === +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.5.5", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.9.0", + "typescript": "^5.5.3", + "vite": "^6.3.4" + }, + "packageManager": "" +} + +=== src/App.tsx === +const App = () =>
Minimal imported app
; + +export default App; + + +=== src/main.tsx === +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render(); + + +=== src/new-file.ts === +const a = "[[STRING_TO_BE_FINISHED]] +[[STRING_IS_FINISHED]]"; + +=== src/vite-env.d.ts === +/// + + +=== tsconfig.app.json === +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} + + +=== tsconfig.json === +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "noImplicitAny": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "allowJs": true, + "noUnusedLocals": false, + "strictNullChecks": false + } +} + + +=== tsconfig.node.json === +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} + + +=== vite.config.ts === +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +export default defineConfig(() => ({ + server: { + host: "::", + port: 8080, + }, + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +})); diff --git a/e2e-tests/snapshots/supabase_client.spec.ts_supabase-client-is-generated-1.txt b/e2e-tests/snapshots/supabase_client.spec.ts_supabase-client-is-generated-1.txt new file mode 100644 index 0000000..f407464 --- /dev/null +++ b/e2e-tests/snapshots/supabase_client.spec.ts_supabase-client-is-generated-1.txt @@ -0,0 +1,200 @@ +=== .gitignore === +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +=== file1.txt === +A file (2) + +=== index.html === + + + + + + dyad-generated-app + + + +
+ + + + + +=== package.json === +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.5.5", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.9.0", + "typescript": "^5.5.3", + "vite": "^6.3.4" + }, + "packageManager": "" +} + +=== src/App.tsx === +const App = () =>
Minimal imported app
; + +export default App; + + +=== src/integrations/supabase/client.ts === +// This file is automatically generated. Do not edit it directly. +import { createClient } from '@supabase/supabase-js'; + +const SUPABASE_URL = "https://fake-project-id.supabase.co"; +const SUPABASE_PUBLISHABLE_KEY = "test-publishable-key"; + +// Import the supabase client like this: +// import { supabase } from "@/integrations/supabase/client"; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY); + +=== src/main.tsx === +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render(); + + +=== src/vite-env.d.ts === +/// + + +=== tsconfig.app.json === +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} + + +=== tsconfig.json === +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "noImplicitAny": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "allowJs": true, + "noUnusedLocals": false, + "strictNullChecks": false + } +} + + +=== tsconfig.node.json === +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} + + +=== vite.config.ts === +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +export default defineConfig(() => ({ + server: { + host: "::", + port: 8080, + }, + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +})); diff --git a/e2e-tests/supabase_client.spec.ts b/e2e-tests/supabase_client.spec.ts new file mode 100644 index 0000000..9509d6f --- /dev/null +++ b/e2e-tests/supabase_client.spec.ts @@ -0,0 +1,15 @@ +import { testSkipIfWindows } from "./helpers/test_helper"; + +testSkipIfWindows("supabase client is generated", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + await po.sendPrompt("tc=add-supabase"); + + // Connect to Supabase + await po.page.getByText("Set up supabase").click(); + await po.clickConnectSupabaseButton(); + await po.clickBackButton(); + + await po.sendPrompt("tc=generate-supabase-client"); + await po.snapshotAppFiles(); +}); diff --git a/src/__tests__/chat_stream_handlers.test.ts b/src/__tests__/chat_stream_handlers.test.ts index 608d4c3..d124028 100644 --- a/src/__tests__/chat_stream_handlers.test.ts +++ b/src/__tests__/chat_stream_handlers.test.ts @@ -6,7 +6,10 @@ import { processFullResponseActions, getDyadAddDependencyTags, } from "../ipc/processors/response_processor"; -import { removeDyadTags } from "../ipc/handlers/chat_stream_handlers"; +import { + removeDyadTags, + hasUnclosedDyadWrite, +} from "../ipc/handlers/chat_stream_handlers"; import fs from "node:fs"; import git from "isomorphic-git"; import { db } from "../db"; @@ -1040,7 +1043,7 @@ const component = ; it("should handle dyad tags with special characters in content", () => { const text = ` -const regex = /]*>.*?<\/div>/g; +const regex = /]*>.*?/g; const special = "Special chars: @#$%^&*()[]{}|\\"; `; const result = removeDyadTags(text); @@ -1059,3 +1062,145 @@ const special = "Special chars: @#$%^&*()[]{}|\\"; expect(result).toBe("Before After"); }); }); + +describe("hasUnclosedDyadWrite", () => { + it("should return false when there are no dyad-write tags", () => { + const text = "This is just regular text without any dyad tags."; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return false when dyad-write tag is properly closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return true when dyad-write tag is not closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should return false when dyad-write tag with attributes is properly closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return true when dyad-write tag with attributes is not closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should return false when there are multiple closed dyad-write tags", () => { + const text = `code1 + Some text in between + code2`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return true when the last dyad-write tag is unclosed", () => { + const text = `code1 + Some text in between + code2`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should return false when first tag is unclosed but last tag is closed", () => { + const text = `code1 + Some text in between + code2`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle multiline content correctly", () => { + const text = ` +import React from 'react'; + +const Component = () => { + return ( +
+

Hello World

+
+ ); +}; + +export default Component; +
`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle multiline unclosed content correctly", () => { + const text = ` +import React from 'react'; + +const Component = () => { + return ( +
+

Hello World

+
+ ); +}; + +export default Component;`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should handle complex attributes correctly", () => { + const text = ` +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}`; + } +}