Smart Context: deep (#1527)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Introduce a new "deep" Smart Context mode that supplies versioned
files (by commit) to the engine, adds code search rendering, stores
source commit hashes, improves search-replace recovery, and updates
UI/tests.
> 
> - **Smart Context (deep)**:
> - Replace `conservative` with `deep`; limit context to ~200 turns;
send `sourceCommitHash` per message.
> - Build and pass `versioned_files` (hash-id map + per-message file
refs) and `app_id` to engine.
> - **DB**:
>   - Add `messages.source_commit_hash` (+ migration/snapshot).
> - **Engine/Processing**:
> - Retry Turbo Edits v2: first re-read then fallback to `dyad-write` if
search-replace fails.
> - Include provider options and versioned files in requests; add
`getCurrentCommitHash`/`getFileAtCommit`.
> - **UI**:
>   - Pro mode selector: new `deep` option; tooltips polish.
> - Add `DyadCodeSearch` and `DyadCodeSearchResult` components; parser
supports new tags.
> - **Tests/E2E**:
> - New `smart_context_deep` e2e; update snapshots to include `app_id`
and deep mode; adjust Playwright timeout.
>   - Unit tests for versioned codebase context.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e3d3bffabb2bc6caf52103461f9d6f2d5ad39df8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Will Chen
2025-11-06 10:45:39 -08:00
committed by GitHub
parent ae1ec68453
commit 06ad1a7546
46 changed files with 3623 additions and 560 deletions

View File

@@ -81,6 +81,11 @@ import { mcpManager } from "../utils/mcp_manager";
import z from "zod";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { getCurrentCommitHash } from "../utils/git_utils";
import {
processChatMessagesWithVersionedFiles as getVersionedFiles,
VersionedFiles as VersionedFiles,
} from "../utils/versioned_codebase_context";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -407,6 +412,9 @@ ${componentSnippet}
role: "assistant",
content: "", // Start with empty content
requestId: dyadRequestId,
sourceCommitHash: await getCurrentCommitHash({
path: getDyadAppPath(chat.app.path),
}),
})
.returning();
@@ -523,12 +531,20 @@ ${componentSnippet}
const messageHistory = updatedChat.messages.map((message) => ({
role: message.role as "user" | "assistant" | "system",
content: message.content,
sourceCommitHash: message.sourceCommitHash,
}));
// For Dyad Pro + Deep Context, we set to 200 chat turns (+1)
// this is to enable more cache hits. Practically, users should
// rarely go over this limit because they will hit the model's
// context window limit.
//
// Limit chat history based on maxChatTurnsInContext setting
// We add 1 because the current prompt counts as a turn.
const maxChatTurns =
(settings.maxChatTurnsInContext || MAX_CHAT_TURNS_IN_CONTEXT) + 1;
isEngineEnabled && settings.proSmartContextOption === "deep"
? 201
: (settings.maxChatTurnsInContext || MAX_CHAT_TURNS_IN_CONTEXT) + 1;
// If we need to limit the context, we take only the most recent turns
let limitedMessageHistory = messageHistory;
@@ -713,6 +729,11 @@ This conversation includes one or more image attachments. When the user uploads
settings.selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content),
providerOptions: {
"dyad-engine": {
sourceCommitHash: msg.sourceCommitHash,
},
},
}));
let chatMessages: ModelMessage[] = [
@@ -776,12 +797,22 @@ This conversation includes one or more image attachments. When the user uploads
} else {
logger.log("sending AI request");
}
let versionedFiles: VersionedFiles | undefined;
if (isEngineEnabled && settings.proSmartContextOption === "deep") {
versionedFiles = await getVersionedFiles({
files,
chatMessages,
appPath,
});
}
// Build provider options with correct Google/Vertex thinking config gating
const providerOptions: Record<string, any> = {
"dyad-engine": {
dyadAppId: updatedChat.app.id,
dyadRequestId,
dyadDisableFiles,
dyadFiles: files,
dyadFiles: versionedFiles ? undefined : files,
dyadVersionedFiles: versionedFiles,
dyadMentionedApps: mentionedAppsCodebases.map(
({ files, appName }) => ({
appName,
@@ -979,21 +1010,48 @@ This conversation includes one or more image attachments. When the user uploads
settings.selectedChatMode !== "ask" &&
isTurboEditsV2Enabled(settings)
) {
const issues = await dryRunSearchReplace({
let issues = await dryRunSearchReplace({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
});
if (issues.length > 0) {
let searchReplaceFixAttempts = 0;
const originalFullResponse = fullResponse;
const previousAttempts: ModelMessage[] = [];
while (
issues.length > 0 &&
searchReplaceFixAttempts < 2 &&
!abortController.signal.aborted
) {
logger.warn(
`Detected search-replace issues: ${issues.map((i) => i.error).join(", ")}`,
`Detected search-replace issues (attempt #${searchReplaceFixAttempts + 1}): ${issues.map((i) => i.error).join(", ")}`,
);
const formattedSearchReplaceIssues = issues
.map(({ filePath, error }) => {
return `File path: ${filePath}\nError: ${error}`;
})
.join("\n\n");
const originalFullResponse = fullResponse;
fullResponse += `<dyad-output type="warning" message="Could not apply Turbo Edits properly for some of the files; re-generating code...">${formattedSearchReplaceIssues}</dyad-output>`;
await processResponseChunkUpdate({
fullResponse,
});
logger.info(
`Attempting to fix search-replace issues, attempt #${searchReplaceFixAttempts + 1}`,
);
const fixSearchReplacePrompt =
searchReplaceFixAttempts === 0
? `There was an issue with the following \`dyad-search-replace\` tags. Make sure you use \`dyad-read\` to read the latest version of the file and then trying to do search & replace again.`
: `There was an issue with the following \`dyad-search-replace\` tags. Please fix the errors by generating the code changes using \`dyad-write\` tags instead.`;
searchReplaceFixAttempts++;
const userPrompt = {
role: "user",
content: `${fixSearchReplacePrompt}
${formattedSearchReplaceIssues}`,
} as const;
const { fullStream: fixSearchReplaceStream } =
await simpleStreamText({
@@ -1001,16 +1059,13 @@ This conversation includes one or more image attachments. When the user uploads
chatMessages: [
...chatMessages,
{ role: "assistant", content: originalFullResponse },
{
role: "user",
content: `There was an issue with the following \`dyad-search-replace\` tags. Please fix them by generating the code changes using \`dyad-write\` tags instead.
${formattedSearchReplaceIssues}`,
},
...previousAttempts,
userPrompt,
],
modelClient,
files: files,
});
previousAttempts.push(userPrompt);
const result = await processStreamChunks({
fullStream: fixSearchReplaceStream,
fullResponse,
@@ -1019,6 +1074,16 @@ ${formattedSearchReplaceIssues}`,
processResponseChunkUpdate,
});
fullResponse = result.fullResponse;
previousAttempts.push({
role: "assistant",
content: removeNonEssentialTags(result.incrementalResponse),
});
// Re-check for issues after the fix attempt
issues = await dryRunSearchReplace({
fullResponse: result.incrementalResponse,
appPath: getDyadAppPath(updatedChat.app.path),
});
}
}

View File

@@ -368,7 +368,7 @@ export async function processFullResponseActions(
const original = await readFile(fullFilePath, "utf8");
const result = applySearchReplace(original, tag.content);
if (!result.success || typeof result.content !== "string") {
// Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it.
// Do not show warning to user because we already attempt to do a <dyad-write> and/or a subsequent <dyad-search-replace> tag to fix it.
logger.warn(
`Failed to apply search-replace to ${filePath}: ${result.error ?? "unknown"}`,
);

View File

@@ -6,7 +6,8 @@ import pathModule from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { readSettings } from "../../main/settings";
import log from "electron-log";
const logger = log.scope("git_utils");
const execAsync = promisify(exec);
async function verboseExecAsync(
@@ -26,6 +27,18 @@ async function verboseExecAsync(
}
}
export async function getCurrentCommitHash({
path,
}: {
path: string;
}): Promise<string> {
return await git.resolveRef({
fs,
dir: path,
ref: "HEAD",
});
}
export async function gitCommit({
path,
message,
@@ -166,3 +179,45 @@ export async function gitAddAll({ path }: { path: string }): Promise<void> {
return git.add({ fs, dir: path, filepath: "." });
}
}
export async function getFileAtCommit({
path,
filePath,
commitHash,
}: {
path: string;
filePath: string;
commitHash: string;
}): Promise<string | null> {
const settings = readSettings();
if (settings.enableNativeGit) {
try {
const { stdout } = await execAsync(
`git -C "${path}" show "${commitHash}:${filePath}"`,
);
return stdout;
} catch (error: any) {
logger.error(
`Error getting file at commit ${commitHash}: ${error.message}`,
);
// File doesn't exist at this commit
return null;
}
} else {
try {
const { blob } = await git.readBlob({
fs,
dir: path,
oid: commitHash,
filepath: filePath,
});
return Buffer.from(blob).toString("utf-8");
} catch (error: any) {
logger.error(
`Error getting file at commit ${commitHash}: ${error.message}`,
);
// File doesn't exist at this commit
return null;
}
}
}

View File

@@ -42,7 +42,7 @@ or to provide a custom fetch implementation for e.g. testing.
enableLazyEdits?: boolean;
enableSmartFilesContext?: boolean;
enableWebSearch?: boolean;
smartContextMode?: "balanced" | "conservative";
smartContextMode?: "balanced" | "conservative" | "deep";
};
settings: UserSettings;
}
@@ -125,6 +125,10 @@ export function createDyadEngine(
options.settings,
),
};
const dyadVersionedFiles = parsedBody.dyadVersionedFiles;
if ("dyadVersionedFiles" in parsedBody) {
delete parsedBody.dyadVersionedFiles;
}
const dyadFiles = parsedBody.dyadFiles;
if ("dyadFiles" in parsedBody) {
delete parsedBody.dyadFiles;
@@ -133,6 +137,10 @@ export function createDyadEngine(
if ("dyadRequestId" in parsedBody) {
delete parsedBody.dyadRequestId;
}
const dyadAppId = parsedBody.dyadAppId;
if ("dyadAppId" in parsedBody) {
delete parsedBody.dyadAppId;
}
const dyadDisableFiles = parsedBody.dyadDisableFiles;
if ("dyadDisableFiles" in parsedBody) {
delete parsedBody.dyadDisableFiles;
@@ -151,14 +159,16 @@ export function createDyadEngine(
}
// Add files to the request if they exist
if (dyadFiles?.length && !dyadDisableFiles) {
if (!dyadDisableFiles) {
parsedBody.dyad_options = {
files: dyadFiles,
versioned_files: dyadVersionedFiles,
enable_lazy_edits: options.dyadOptions.enableLazyEdits,
enable_smart_files_context:
options.dyadOptions.enableSmartFilesContext,
smart_context_mode: options.dyadOptions.smartContextMode,
enable_web_search: options.dyadOptions.enableWebSearch,
app_id: dyadAppId,
};
if (dyadMentionedApps?.length) {
parsedBody.dyad_options.mentioned_apps = dyadMentionedApps;

View File

@@ -0,0 +1,219 @@
import { CodebaseFile, CodebaseFileReference } from "@/utils/codebase";
import { ModelMessage } from "@ai-sdk/provider-utils";
import crypto from "node:crypto";
import log from "electron-log";
import { getFileAtCommit } from "./git_utils";
import { normalizePath } from "../../../shared/normalizePath";
const logger = log.scope("versioned_codebase_context");
export interface VersionedFiles {
fileIdToContent: Record<string, string>;
fileReferences: CodebaseFileReference[];
messageIndexToFilePathToFileId: Record<number, Record<string, string>>;
}
interface DyadEngineProviderOptions {
sourceCommitHash: string;
}
/**
* Parse file paths from assistant message content.
* Extracts files from <dyad-read> and <dyad-code-search-result> tags.
*/
export function parseFilesFromMessage(content: string): string[] {
const filePaths: string[] = [];
const seenPaths = new Set<string>();
// Create an array of matches with their positions to maintain order
interface TagMatch {
index: number;
filePaths: string[];
}
const matches: TagMatch[] = [];
// Parse <dyad-read path="$filePath"></dyad-read>
const dyadReadRegex = /<dyad-read\s+path="([^"]+)"\s*><\/dyad-read>/gs;
let match: RegExpExecArray | null;
while ((match = dyadReadRegex.exec(content)) !== null) {
const filePath = normalizePath(match[1].trim());
if (filePath) {
matches.push({
index: match.index,
filePaths: [filePath],
});
}
}
// Parse <dyad-code-search-result>...</dyad-code-search-result>
const codeSearchRegex =
/<dyad-code-search-result>(.*?)<\/dyad-code-search-result>/gs;
while ((match = codeSearchRegex.exec(content)) !== null) {
const innerContent = match[1];
const paths: string[] = [];
// Split by newlines and extract each file path
const lines = innerContent.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (
trimmedLine &&
!trimmedLine.startsWith("<") &&
!trimmedLine.startsWith(">")
) {
paths.push(normalizePath(trimmedLine));
}
}
if (paths.length > 0) {
matches.push({
index: match.index,
filePaths: paths,
});
}
}
// Sort matches by their position in the original content
matches.sort((a, b) => a.index - b.index);
// Add file paths in order, deduplicating as we go
for (const match of matches) {
for (const path of match.filePaths) {
if (!seenPaths.has(path)) {
seenPaths.add(path);
filePaths.push(path);
}
}
}
return filePaths;
}
export async function processChatMessagesWithVersionedFiles({
files,
chatMessages,
appPath,
}: {
files: CodebaseFile[];
chatMessages: ModelMessage[];
appPath: string;
}): Promise<VersionedFiles> {
const fileIdToContent: Record<string, string> = {};
const fileReferences: CodebaseFileReference[] = [];
const messageIndexToFilePathToFileId: Record<
number,
Record<string, string>
> = {};
for (const file of files) {
// Generate SHA-256 hash of content as fileId
const fileId = crypto
.createHash("sha256")
.update(file.content)
.digest("hex");
fileIdToContent[fileId] = file.content;
const { content: _content, ...restOfFile } = file;
fileReferences.push({
...restOfFile,
fileId,
});
}
for (
let messageIndex = 0;
messageIndex < chatMessages.length;
messageIndex++
) {
const message = chatMessages[messageIndex];
// Only process assistant messages
if (message.role !== "assistant") {
continue;
}
// Extract sourceCommitHash from providerOptions
const engineOptions = message.providerOptions?.[
"dyad-engine"
] as unknown as DyadEngineProviderOptions;
const sourceCommitHash = engineOptions?.sourceCommitHash;
// Skip messages without sourceCommitHash
if (!sourceCommitHash) {
continue;
}
// Get message content as text
const content = message.content;
let textContent: string;
if (typeof content !== "string") {
// Handle array of parts (text, images, etc.)
textContent = content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
if (!textContent) {
continue;
}
} else {
// Message content is already a string
textContent = content;
}
// Parse file paths from message content
const filePaths = parseFilesFromMessage(textContent);
const filePathsToFileIds: Record<string, string> = {};
messageIndexToFilePathToFileId[messageIndex] = filePathsToFileIds;
// Parallelize file content fetching
const fileContentPromises = filePaths.map((filePath) =>
getFileAtCommit({
path: appPath,
filePath,
commitHash: sourceCommitHash,
}).then(
(content) => ({ filePath, content, status: "fulfilled" as const }),
(error) => ({ filePath, error, status: "rejected" as const }),
),
);
const results = await Promise.all(fileContentPromises);
for (const result of results) {
if (result.status === "rejected") {
logger.error(
`Error reading file ${result.filePath} at commit ${sourceCommitHash}:`,
result.error,
);
continue;
}
const { filePath, content: fileContent } = result;
if (fileContent === null) {
logger.warn(
`File ${filePath} not found at commit ${sourceCommitHash} for message ${messageIndex}`,
);
continue;
}
// Generate SHA-256 hash of content as fileId
const fileId = crypto
.createHash("sha256")
.update(fileContent)
.digest("hex");
// Store in fileIdToContent
fileIdToContent[fileId] = fileContent;
// Add to this message's file IDs
filePathsToFileIds[filePath] = fileId;
}
}
return {
fileIdToContent,
fileReferences,
messageIndexToFilePathToFileId,
};
}