Unverified 提交 a5f19192 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Handle cross-app references in a scalable way (#3219)

上级 5f823117
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -62,6 +62,10 @@ ...@@ -62,6 +62,10 @@
"type": "string", "type": "string",
"description": "The file path to read" "description": "The file path to read"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
"type": "string"
},
"start_line_one_indexed": { "start_line_one_indexed": {
"description": "The one-indexed line number to start reading from (inclusive).", "description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer", "type": "integer",
...@@ -95,6 +99,10 @@ ...@@ -95,6 +99,10 @@
"description": "Optional subdirectory to list", "description": "Optional subdirectory to list",
"type": "string" "type": "string"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
"type": "string"
},
"recursive": { "recursive": {
"description": "Whether to list files recursively (default: false)", "description": "Whether to list files recursively (default: false)",
"type": "boolean" "type": "boolean"
...@@ -121,6 +129,10 @@ ...@@ -121,6 +129,10 @@
"type": "string", "type": "string",
"description": "The regex pattern to search for" "description": "The regex pattern to search for"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
},
"include_pattern": { "include_pattern": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)", "description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string" "type": "string"
...@@ -163,6 +175,10 @@ ...@@ -163,6 +175,10 @@
"query": { "query": {
"type": "string", "type": "string",
"description": "Search query to find relevant files" "description": "Search query to find relevant files"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
} }
}, },
"required": [ "required": [
......
...@@ -230,6 +230,10 @@ ...@@ -230,6 +230,10 @@
"type": "string", "type": "string",
"description": "The file path to read" "description": "The file path to read"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
"type": "string"
},
"start_line_one_indexed": { "start_line_one_indexed": {
"description": "The one-indexed line number to start reading from (inclusive).", "description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer", "type": "integer",
...@@ -261,6 +265,10 @@ ...@@ -261,6 +265,10 @@
"description": "Optional subdirectory to list", "description": "Optional subdirectory to list",
"type": "string" "type": "string"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
"type": "string"
},
"recursive": { "recursive": {
"description": "Whether to list files recursively (default: false)", "description": "Whether to list files recursively (default: false)",
"type": "boolean" "type": "boolean"
...@@ -285,6 +293,10 @@ ...@@ -285,6 +293,10 @@
"type": "string", "type": "string",
"description": "The regex pattern to search for" "description": "The regex pattern to search for"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
},
"include_pattern": { "include_pattern": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)", "description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string" "type": "string"
...@@ -325,6 +337,10 @@ ...@@ -325,6 +337,10 @@
"query": { "query": {
"type": "string", "type": "string",
"description": "Search query to find relevant files" "description": "Search query to find relevant files"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
} }
}, },
"required": [ "required": [
......
...@@ -227,6 +227,10 @@ ...@@ -227,6 +227,10 @@
"type": "string", "type": "string",
"description": "The file path to read" "description": "The file path to read"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
"type": "string"
},
"start_line_one_indexed": { "start_line_one_indexed": {
"description": "The one-indexed line number to start reading from (inclusive).", "description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer", "type": "integer",
...@@ -260,6 +264,10 @@ ...@@ -260,6 +264,10 @@
"description": "Optional subdirectory to list", "description": "Optional subdirectory to list",
"type": "string" "type": "string"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
"type": "string"
},
"recursive": { "recursive": {
"description": "Whether to list files recursively (default: false)", "description": "Whether to list files recursively (default: false)",
"type": "boolean" "type": "boolean"
...@@ -286,6 +294,10 @@ ...@@ -286,6 +294,10 @@
"type": "string", "type": "string",
"description": "The regex pattern to search for" "description": "The regex pattern to search for"
}, },
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
},
"include_pattern": { "include_pattern": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)", "description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string" "type": "string"
...@@ -328,6 +340,10 @@ ...@@ -328,6 +340,10 @@
"query": { "query": {
"type": "string", "type": "string",
"description": "Search query to find relevant files" "description": "Search query to find relevant files"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
} }
}, },
"required": [ "required": [
......
...@@ -13,7 +13,9 @@ import { ...@@ -13,7 +13,9 @@ import {
interface DyadCodeSearchProps { interface DyadCodeSearchProps {
children?: ReactNode; children?: ReactNode;
node?: { properties?: { query?: string; state?: CustomTagState } }; node?: {
properties?: { query?: string; state?: CustomTagState; appName?: string };
};
} }
export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
...@@ -24,6 +26,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({ ...@@ -24,6 +26,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
const query = const query =
node?.properties?.query || (typeof children === "string" ? children : ""); node?.properties?.query || (typeof children === "string" ? children : "");
const state = node?.properties?.state as CustomTagState; const state = node?.properties?.state as CustomTagState;
const appName = node?.properties?.appName || "";
const inProgress = state === "pending"; const inProgress = state === "pending";
return ( return (
...@@ -35,6 +38,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({ ...@@ -35,6 +38,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
> >
<DyadCardHeader icon={<FileCode size={15} />} accentColor="indigo"> <DyadCardHeader icon={<FileCode size={15} />} accentColor="indigo">
<DyadBadge color="indigo">Code Search</DyadBadge> <DyadBadge color="indigo">Code Search</DyadBadge>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{!isExpanded && query && ( {!isExpanded && query && (
<span className="text-sm text-muted-foreground italic truncate"> <span className="text-sm text-muted-foreground italic truncate">
{query} {query}
......
...@@ -25,6 +25,7 @@ interface DyadGrepProps { ...@@ -25,6 +25,7 @@ interface DyadGrepProps {
count?: string; count?: string;
total?: string; total?: string;
truncated?: string; truncated?: string;
appName?: string;
}; };
}; };
} }
...@@ -43,6 +44,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => { ...@@ -43,6 +44,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
const count = node?.properties?.count || ""; const count = node?.properties?.count || "";
const total = node?.properties?.total || ""; const total = node?.properties?.total || "";
const truncated = node?.properties?.truncated === "true"; const truncated = node?.properties?.truncated === "true";
const appName = node?.properties?.appName || "";
let description = `"${query}"`; let description = `"${query}"`;
if (includePattern) { if (includePattern) {
...@@ -71,6 +73,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => { ...@@ -71,6 +73,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
> >
<DyadCardHeader icon={<Search size={15} />} accentColor="violet"> <DyadCardHeader icon={<Search size={15} />} accentColor="violet">
<DyadBadge color="violet">GREP</DyadBadge> <DyadBadge color="violet">GREP</DyadBadge>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
<span className="font-medium text-sm text-foreground truncate"> <span className="font-medium text-sm text-foreground truncate">
{description} {description}
</span> </span>
......
...@@ -17,13 +17,15 @@ interface DyadListFilesProps { ...@@ -17,13 +17,15 @@ interface DyadListFilesProps {
recursive?: string; recursive?: string;
include_ignored?: string; include_ignored?: string;
state?: CustomTagState; state?: CustomTagState;
appName?: string;
}; };
}; };
children: React.ReactNode; children: React.ReactNode;
} }
export function DyadListFiles({ node, children }: DyadListFilesProps) { export function DyadListFiles({ node, children }: DyadListFilesProps) {
const { directory, recursive, include_ignored, state } = node.properties; const { directory, recursive, include_ignored, state, appName } =
node.properties;
const isLoading = state === "pending"; const isLoading = state === "pending";
const isRecursive = recursive === "true"; const isRecursive = recursive === "true";
const isIncludeIgnored = include_ignored === "true"; const isIncludeIgnored = include_ignored === "true";
...@@ -44,6 +46,7 @@ export function DyadListFiles({ node, children }: DyadListFilesProps) { ...@@ -44,6 +46,7 @@ export function DyadListFiles({ node, children }: DyadListFilesProps) {
<span className="font-medium text-sm text-foreground truncate"> <span className="font-medium text-sm text-foreground truncate">
{title} {title}
</span> </span>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{isRecursive && <DyadBadge color="slate">recursive</DyadBadge>} {isRecursive && <DyadBadge color="slate">recursive</DyadBadge>}
{isIncludeIgnored && ( {isIncludeIgnored && (
<DyadBadge color="slate">include ignored</DyadBadge> <DyadBadge color="slate">include ignored</DyadBadge>
......
...@@ -378,6 +378,7 @@ function renderCustomTag( ...@@ -378,6 +378,7 @@ function renderCustomTag(
path: attributes.path || "", path: attributes.path || "",
startLine: attributes.start_line || "", startLine: attributes.start_line || "",
endLine: attributes.end_line || "", endLine: attributes.end_line || "",
appName: attributes.app_name || "",
}, },
}} }}
> >
...@@ -426,6 +427,7 @@ function renderCustomTag( ...@@ -426,6 +427,7 @@ function renderCustomTag(
properties: { properties: {
query: attributes.query || "", query: attributes.query || "",
state: getState({ isStreaming, inProgress }), state: getState({ isStreaming, inProgress }),
appName: attributes.app_name || "",
}, },
}} }}
> >
...@@ -581,6 +583,7 @@ function renderCustomTag( ...@@ -581,6 +583,7 @@ function renderCustomTag(
count: attributes.count || "", count: attributes.count || "",
total: attributes.total || "", total: attributes.total || "",
truncated: attributes.truncated || "", truncated: attributes.truncated || "",
appName: attributes.app_name || "",
}, },
}} }}
> >
...@@ -713,6 +716,7 @@ function renderCustomTag( ...@@ -713,6 +716,7 @@ function renderCustomTag(
include_ignored: include_ignored:
attributes.include_ignored || attributes.include_hidden || "", attributes.include_ignored || attributes.include_hidden || "",
state: getState({ isStreaming, inProgress }), state: getState({ isStreaming, inProgress }),
appName: attributes.app_name || "",
}, },
}} }}
> >
......
import type React from "react"; import type React from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { DyadBadge } from "./DyadCardPrimitives";
interface DyadReadProps { interface DyadReadProps {
children?: ReactNode; children?: ReactNode;
...@@ -20,6 +21,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({ ...@@ -20,6 +21,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({
const path = pathProp || node?.properties?.path || ""; const path = pathProp || node?.properties?.path || "";
const startLine = startLineProp || node?.properties?.startLine || ""; const startLine = startLineProp || node?.properties?.startLine || "";
const endLine = endLineProp || node?.properties?.endLine || ""; const endLine = endLineProp || node?.properties?.endLine || "";
const appName = node?.properties?.appName || "";
const fileName = path ? path.split("/").pop() : ""; const fileName = path ? path.split("/").pop() : "";
const dirPath = path const dirPath = path
? path.slice(0, path.length - (fileName?.length || 0)) ? path.slice(0, path.length - (fileName?.length || 0))
...@@ -43,6 +45,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({ ...@@ -43,6 +45,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({
<div className="flex items-center gap-1 py-1"> <div className="flex items-center gap-1 py-1">
<FileText size={14} className="shrink-0 text-muted-foreground/50" /> <FileText size={14} className="shrink-0 text-muted-foreground/50" />
<span className="text-[13px] font-medium text-foreground/70">Read</span> <span className="text-[13px] font-medium text-foreground/70">Read</span>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{path && ( {path && (
<span <span
className="text-[13px] truncate min-w-0" className="text-[13px] truncate min-w-0"
......
...@@ -82,7 +82,12 @@ import { ...@@ -82,7 +82,12 @@ import {
appendCancelledResponseNotice, appendCancelledResponseNotice,
filterCancelledMessagePairs, filterCancelledMessagePairs,
} from "@/shared/chatCancellation"; } from "@/shared/chatCancellation";
import { extractMentionedAppsCodebases } from "../utils/mention_apps"; import {
extractMentionedAppsCodebases,
extractMentionedAppsReferences,
type MentionedAppCodebaseEntry,
type MentionedAppReference,
} from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps"; import { parseAppMentions } from "@/shared/parse_mention_apps";
import { import {
parseMediaMentions, parseMediaMentions,
...@@ -100,6 +105,7 @@ import { mcpManager } from "../utils/mcp_manager"; ...@@ -100,6 +105,7 @@ import { mcpManager } from "../utils/mcp_manager";
import z from "zod"; import z from "zod";
import { import {
isBasicAgentMode, isBasicAgentMode,
isLocalAgentBackedMode,
isSupabaseConnected, isSupabaseConnected,
isTurboEditsV2Enabled, isTurboEditsV2Enabled,
} from "@/lib/schemas"; } from "@/lib/schemas";
...@@ -664,26 +670,48 @@ ${componentSnippet} ...@@ -664,26 +670,48 @@ ${componentSnippet}
// Parse app mentions from the prompt // Parse app mentions from the prompt
const mentionedAppNames = parseAppMentions(req.prompt); const mentionedAppNames = parseAppMentions(req.prompt);
// Extract codebases for mentioned apps const isLocalAgentMode = selectedChatMode === "local-agent";
const mentionedAppsCodebases = await extractMentionedAppsCodebases( const isAskMode = selectedChatMode === "ask";
mentionedAppNames, const isPlanMode = selectedChatMode === "plan";
updatedChat.app.id, // Exclude current app
);
const willUseLocalAgentStream = const willUseLocalAgentStream =
(selectedChatMode === "local-agent" || selectedChatMode === "ask") && isLocalAgentBackedMode(selectedChatMode);
!mentionedAppsCodebases.length;
// Agent/ask/plan modes reach referenced apps via tool calls (`app_name`
// on read-only tools), so we only need name/path pairs — skip the heavy
// codebase extraction entirely. Build mode still injects full codebases.
let mentionedAppsCodebases: MentionedAppCodebaseEntry[] = [];
let referencedAppsForAgent: MentionedAppReference[] = [];
if (willUseLocalAgentStream) {
referencedAppsForAgent = await extractMentionedAppsReferences(
mentionedAppNames,
updatedChat.app.id, // Exclude current app
);
} else {
mentionedAppsCodebases = await extractMentionedAppsCodebases(
mentionedAppNames,
updatedChat.app.id, // Exclude current app
);
referencedAppsForAgent = mentionedAppsCodebases.map(
({ appName, appPath }) => ({ appName, appPath }),
);
}
const useReferencedAppManifest =
willUseLocalAgentStream && referencedAppsForAgent.length > 0;
const isDeepContextEnabled = const isDeepContextEnabled =
isEngineEnabled && isEngineEnabled &&
settings.enableProSmartFilesContextMode && settings.enableProSmartFilesContextMode &&
// Anything besides balanced will use deep context. // Anything besides balanced will use deep context.
settings.proSmartContextOption !== "balanced" && settings.proSmartContextOption !== "balanced" &&
mentionedAppsCodebases.length === 0; referencedAppsForAgent.length === 0;
logger.log(`isDeepContextEnabled: ${isDeepContextEnabled}`); logger.log(`isDeepContextEnabled: ${isDeepContextEnabled}`);
// Combine current app codebase with mentioned apps' codebases // Combine current app codebase with mentioned apps' codebases.
// In agent/ask/plan modes we skip the full codebase injection — the
// model can read referenced apps on-demand via tool calls with `app_name`
// instead of carrying their full contents in the system prompt.
let otherAppsCodebaseInfo = ""; let otherAppsCodebaseInfo = "";
if (mentionedAppsCodebases.length > 0) { if (mentionedAppsCodebases.length > 0 && !useReferencedAppManifest) {
const mentionedAppsSection = mentionedAppsCodebases const mentionedAppsSection = mentionedAppsCodebases
.map( .map(
({ appName, codebaseInfo }) => ({ appName, codebaseInfo }) =>
...@@ -794,7 +822,13 @@ ${componentSnippet} ...@@ -794,7 +822,13 @@ ${componentSnippet}
basicAgentMode: isBasicAgentMode(settings), basicAgentMode: isBasicAgentMode(settings),
}); });
// Add information about mentioned apps if any // Add information about mentioned apps for build mode only.
// Full codebase injection (build mode): full file contents already
// concatenated into `otherAppsCodebaseInfo`.
//
// Agent/ask/plan modes don't need anything in the system prompt —
// handleLocalAgentStream injects a `<system-reminder>` into the
// user's latest message so the system prompt stays static.
if (otherAppsCodebaseInfo) { if (otherAppsCodebaseInfo) {
const mentionedAppsList = mentionedAppsCodebases const mentionedAppsList = mentionedAppsCodebases
.map(({ appName }) => appName) .map(({ appName }) => appName)
...@@ -889,9 +923,8 @@ ${componentSnippet} ...@@ -889,9 +923,8 @@ ${componentSnippet}
// print out the dyad-write tags. // print out the dyad-write tags.
// Usually, AI models will want to use the image as reference to generate code (e.g. UI mockups) anyways, so // Usually, AI models will want to use the image as reference to generate code (e.g. UI mockups) anyways, so
// it's not that critical to include the image analysis instructions. // it's not that critical to include the image analysis instructions.
const isAskMode = selectedChatMode === "ask";
if (hasUploadedAttachments) { if (hasUploadedAttachments) {
if (willUseLocalAgentStream && !isAskMode) { if (isLocalAgentMode) {
systemPrompt += ` systemPrompt += `
When files are attached for upload to the codebase, use the \`copy_file\` tool to copy them from their path into the project. When files are attached for upload to the codebase, use the \`copy_file\` tool to copy them from their path into the project.
...@@ -903,7 +936,7 @@ copy_file(from=".dyad/media/abc123.png", to="src/assets/logo.png", description=" ...@@ -903,7 +936,7 @@ copy_file(from=".dyad/media/abc123.png", to="src/assets/logo.png", description="
The file paths are provided in the attachment information above. The file paths are provided in the attachment information above.
`; `;
} else if (!isAskMode) { } else if (!isAskMode && !isPlanMode) {
systemPrompt += ` systemPrompt += `
When files are attached for upload to the codebase, copy them into the project using this format: When files are attached for upload to the codebase, copy them into the project using this format:
...@@ -1180,7 +1213,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1180,7 +1213,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle ask mode: use local-agent in read-only mode // Handle ask mode: use local-agent in read-only mode
// This gives users access to code reading tools while in ask mode // This gives users access to code reading tools while in ask mode
// Ask mode does not consume free agent quota // Ask mode does not consume free agent quota
if (selectedChatMode === "ask" && !mentionedAppsCodebases.length) { if (isAskMode) {
// Reconstruct system prompt for local-agent read-only mode // Reconstruct system prompt for local-agent read-only mode
const readOnlySystemPrompt = constructSystemPrompt({ const readOnlySystemPrompt = constructSystemPrompt({
aiRules, aiRules,
...@@ -1210,6 +1243,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1210,6 +1243,7 @@ This conversation includes one or more image attachments. When the user uploads
readOnly: true, readOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined, messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings, settingsOverride: settings,
referencedApps: referencedAppsForAgent,
}, },
); );
if (!streamSuccess) { if (!streamSuccess) {
...@@ -1222,7 +1256,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1222,7 +1256,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle plan mode: use local-agent with plan tools only // Handle plan mode: use local-agent with plan tools only
// Plan mode is for requirements gathering and creating implementation plans // Plan mode is for requirements gathering and creating implementation plans
if (selectedChatMode === "plan" && !mentionedAppsCodebases.length) { if (isPlanMode) {
// Reconstruct system prompt for plan mode // Reconstruct system prompt for plan mode
const planModeSystemPrompt = constructSystemPrompt({ const planModeSystemPrompt = constructSystemPrompt({
aiRules, aiRules,
...@@ -1238,17 +1272,18 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1238,17 +1272,18 @@ This conversation includes one or more image attachments. When the user uploads
planModeOnly: true, planModeOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined, messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings, settingsOverride: settings,
referencedApps: referencedAppsForAgent,
}); });
return; return;
} }
// Handle local-agent mode (Agent v2) // Handle local-agent mode (Agent v2).
// Mentioned apps can't be handled by the local agent (defer to balanced smart context // Referenced apps (from `@app:Name` mentions) are accessed by the
// in build mode) // agent via tool calls with an `app_name` parameter — see
if ( // resolveTargetAppPath in the local agent tools. handleLocalAgentStream
selectedChatMode === "local-agent" && // injects a `<system-reminder>` into the user's latest message telling
!mentionedAppsCodebases.length // the agent which `app_name` values are valid.
) { if (isLocalAgentMode) {
// Check quota for Basic Agent mode (non-Pro users) // Check quota for Basic Agent mode (non-Pro users)
const isBasicAgentModeRequest = isBasicAgentMode(settings); const isBasicAgentModeRequest = isBasicAgentMode(settings);
if (isBasicAgentModeRequest) { if (isBasicAgentModeRequest) {
...@@ -1284,6 +1319,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1284,6 +1319,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]", dyadRequestId: dyadRequestId ?? "[no-request-id]",
messageOverride: isSummarizeIntent ? chatMessages : undefined, messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings, settingsOverride: settings,
referencedApps: referencedAppsForAgent,
}, },
); );
} finally { } finally {
...@@ -1368,7 +1404,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1368,7 +1404,7 @@ This conversation includes one or more image attachments. When the user uploads
}); });
fullResponse = result.fullResponse; fullResponse = result.fullResponse;
if (selectedChatMode !== "ask" && isTurboEditsV2Enabled(settings)) { if (isTurboEditsV2Enabled(settings)) {
let issues = await dryRunSearchReplace({ let issues = await dryRunSearchReplace({
fullResponse, fullResponse,
appPath: getDyadAppPath(updatedChat.app.path), appPath: getDyadAppPath(updatedChat.app.path),
...@@ -1467,7 +1503,6 @@ ${formattedSearchReplaceIssues}`, ...@@ -1467,7 +1503,6 @@ ${formattedSearchReplaceIssues}`,
if ( if (
!abortController.signal.aborted && !abortController.signal.aborted &&
selectedChatMode !== "ask" &&
hasUnclosedDyadWrite(fullResponse) hasUnclosedDyadWrite(fullResponse)
) { ) {
let continuationAttempts = 0; let continuationAttempts = 0;
...@@ -1520,8 +1555,7 @@ ${formattedSearchReplaceIssues}`, ...@@ -1520,8 +1555,7 @@ ${formattedSearchReplaceIssues}`,
// because there's going to be type errors since the packages aren't // because there's going to be type errors since the packages aren't
// installed yet. // installed yet.
addDependencies.length === 0 && addDependencies.length === 0 &&
settings.enableAutoFixProblems && settings.enableAutoFixProblems
selectedChatMode !== "ask"
) { ) {
try { try {
// IF auto-fix is enabled // IF auto-fix is enabled
......
...@@ -26,7 +26,7 @@ import { validateChatContext } from "../utils/context_paths_utils"; ...@@ -26,7 +26,7 @@ import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings"; import { readSettings } from "@/main/settings";
import { extractMentionedAppsCodebases } from "../utils/mention_apps"; import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps"; import { parseAppMentions } from "@/shared/parse_mention_apps";
import { isTurboEditsV2Enabled } from "@/lib/schemas"; import { isLocalAgentBackedMode, isTurboEditsV2Enabled } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { resolveChatModeForTurn } from "./chat_mode_resolution"; import { resolveChatModeForTurn } from "./chat_mode_resolution";
...@@ -145,27 +145,35 @@ export function registerTokenCountHandlers() { ...@@ -145,27 +145,35 @@ export function registerTokenCountHandlers() {
); );
} }
// Extract codebases for mentioned apps // Agent/ask/plan modes reach referenced apps via tool calls rather than
const mentionedAppsCodebases = await extractMentionedAppsCodebases( // injecting full codebases into the prompt, so mentioned apps contribute
mentionedAppNames, // ~0 tokens upfront. Match the extraction behavior in chat_stream_handlers
chat.app?.id, // Exclude current app // so the UI estimate tracks what's actually sent.
const willUseLocalAgentStream = isLocalAgentBackedMode(
settings.selectedChatMode,
); );
// Calculate tokens for mentioned apps
let mentionedAppsTokens = 0; let mentionedAppsTokens = 0;
if (mentionedAppsCodebases.length > 0) { if (!willUseLocalAgentStream) {
const mentionedAppsContent = mentionedAppsCodebases const mentionedAppsCodebases = await extractMentionedAppsCodebases(
.map( mentionedAppNames,
({ appName, codebaseInfo }) => chat.app?.id, // Exclude current app
`\n\n=== Referenced App: ${appName} ===\n${codebaseInfo}`, );
)
.join("");
mentionedAppsTokens = estimateTokens(mentionedAppsContent); if (mentionedAppsCodebases.length > 0) {
const mentionedAppsContent = mentionedAppsCodebases
.map(
({ appName, codebaseInfo }) =>
`\n\n=== Referenced App: ${appName} ===\n${codebaseInfo}`,
)
.join("");
logger.log( mentionedAppsTokens = estimateTokens(mentionedAppsContent);
`Extracted ${mentionedAppsCodebases.length} mentioned app codebases, tokens: ${mentionedAppsTokens}`,
); logger.log(
`Extracted ${mentionedAppsCodebases.length} mentioned app codebases, tokens: ${mentionedAppsTokens}`,
);
}
} }
// Calculate total tokens // Calculate total tokens
......
...@@ -6,16 +6,24 @@ import log from "electron-log"; ...@@ -6,16 +6,24 @@ import log from "electron-log";
const logger = log.scope("mention_apps"); const logger = log.scope("mention_apps");
// Helper function to extract codebases from mentioned apps export interface MentionedAppReference {
export async function extractMentionedAppsCodebases( appName: string;
appPath: string;
}
export interface MentionedAppCodebaseEntry extends MentionedAppReference {
codebaseInfo: string;
files: CodebaseFile[];
}
async function resolveMentionedApps(
mentionedAppNames: string[], mentionedAppNames: string[],
excludeCurrentAppId?: number, excludeCurrentAppId?: number,
): Promise<{ appName: string; codebaseInfo: string; files: CodebaseFile[] }[]> { ) {
if (mentionedAppNames.length === 0) { if (mentionedAppNames.length === 0) {
return []; return [];
} }
// Get all apps
const allApps = await db.query.apps.findMany(); const allApps = await db.query.apps.findMany();
const mentionedApps = allApps.filter( const mentionedApps = allApps.filter(
...@@ -25,13 +33,58 @@ export async function extractMentionedAppsCodebases( ...@@ -25,13 +33,58 @@ export async function extractMentionedAppsCodebases(
) && app.id !== excludeCurrentAppId, ) && app.id !== excludeCurrentAppId,
); );
const results: { // Deduplicate by case-insensitive name: referenced apps are keyed by name
appName: string; // downstream (e.g., AgentContext.referencedApps Map), so two apps sharing a
codebaseInfo: string; // name would silently collide. Keep the first match and warn.
files: CodebaseFile[]; const dedupedApps: typeof mentionedApps = [];
}[] = []; const seenNames = new Set<string>();
for (const app of mentionedApps) { for (const app of mentionedApps) {
const key = app.name.toLowerCase();
if (seenNames.has(key)) {
logger.warn(
`Multiple apps share the name "${app.name}"; skipping duplicate (app id: ${app.id}). Rename apps to disambiguate references.`,
);
continue;
}
seenNames.add(key);
dedupedApps.push(app);
}
return dedupedApps;
}
/**
* Lightweight resolver for `@app:Name` mentions. Returns only name/path pairs
* without reading any file contents — use this when the caller just needs
* to expose referenced apps to on-demand tools (agent/ask/plan modes).
*/
export async function extractMentionedAppsReferences(
mentionedAppNames: string[],
excludeCurrentAppId?: number,
): Promise<MentionedAppReference[]> {
const dedupedApps = await resolveMentionedApps(
mentionedAppNames,
excludeCurrentAppId,
);
return dedupedApps.map((app) => ({
appName: app.name,
appPath: getDyadAppPath(app.path),
}));
}
// Helper function to extract codebases from mentioned apps
export async function extractMentionedAppsCodebases(
mentionedAppNames: string[],
excludeCurrentAppId?: number,
): Promise<MentionedAppCodebaseEntry[]> {
const dedupedApps = await resolveMentionedApps(
mentionedAppNames,
excludeCurrentAppId,
);
const results: MentionedAppCodebaseEntry[] = [];
for (const app of dedupedApps) {
try { try {
const appPath = getDyadAppPath(app.path); const appPath = getDyadAppPath(app.path);
const chatContext = validateChatContext(app.chatContext); const chatContext = validateChatContext(app.chatContext);
...@@ -43,6 +96,7 @@ export async function extractMentionedAppsCodebases( ...@@ -43,6 +96,7 @@ export async function extractMentionedAppsCodebases(
results.push({ results.push({
appName: app.name, appName: app.name,
appPath,
codebaseInfo: formattedOutput, codebaseInfo: formattedOutput,
files, files,
}); });
......
...@@ -163,6 +163,17 @@ export type StoredChatMode = z.infer<typeof StoredChatModeSchema>; ...@@ -163,6 +163,17 @@ export type StoredChatMode = z.infer<typeof StoredChatModeSchema>;
export const ChatModeSchema = z.enum(["build", "ask", "local-agent", "plan"]); export const ChatModeSchema = z.enum(["build", "ask", "local-agent", "plan"]);
export type ChatMode = z.infer<typeof ChatModeSchema>; export type ChatMode = z.infer<typeof ChatModeSchema>;
/**
* Modes that stream through the local agent (tool-calling) path rather than
* the build-mode path that injects full codebases into the prompt. Keep this
* in sync with the chat-stream and token-count handlers: whenever a new mode
* routes through the local agent, add it here so the token estimate matches
* what's actually sent to the model.
*/
export function isLocalAgentBackedMode(mode: ChatMode | undefined): boolean {
return mode === "local-agent" || mode === "ask" || mode === "plan";
}
export const GitHubSecretsSchema = z.object({ export const GitHubSecretsSchema = z.object({
accessToken: SecretSchema.nullable(), accessToken: SecretSchema.nullable(),
}); });
......
...@@ -228,6 +228,34 @@ function buildChatMessageHistory( ...@@ -228,6 +228,34 @@ function buildChatMessageHistory(
); );
} }
/**
* Append a `<system-reminder>` to the latest user message listing referenced
* apps so the agent knows which `app_name` values it can pass to read-only
* tools (`read_file`, `list_files`, `grep`, `code_search`). Mutates the last
* user message in-place to avoid copying unrelated parts of the history.
*/
function injectReferencedAppsReminder(
messageHistory: ModelMessage[],
referencedApps: readonly { appName: string }[],
): void {
const list = referencedApps.map(({ appName }) => `\`${appName}\``).join(", ");
const reminder = `\n\n<system-reminder>\nThe user has mentioned the following apps in their prompt: ${list}. These apps are separate from the current app and are READ-ONLY. To inspect them, pass the app name as the \`app_name\` parameter to read-only tools (\`read_file\`, \`list_files\`, \`grep\`, \`code_search\`); matching is case-insensitive. Write tools cannot target these apps. Omit \`app_name\` to operate on the current app.\n</system-reminder>`;
for (let i = messageHistory.length - 1; i >= 0; i--) {
const msg = messageHistory[i];
if (msg.role !== "user") continue;
if (typeof msg.content === "string") {
messageHistory[i] = { ...msg, content: msg.content + reminder };
} else {
messageHistory[i] = {
...msg,
content: [...msg.content, { type: "text", text: reminder }],
};
}
return;
}
}
function getMidTurnCompactionSummaryIds( function getMidTurnCompactionSummaryIds(
chatMessages: Array<{ chatMessages: Array<{
id: number; id: number;
...@@ -272,6 +300,7 @@ export async function handleLocalAgentStream( ...@@ -272,6 +300,7 @@ export async function handleLocalAgentStream(
planModeOnly = false, planModeOnly = false,
messageOverride, messageOverride,
settingsOverride, settingsOverride,
referencedApps = [],
}: { }: {
placeholderMessageId: number; placeholderMessageId: number;
systemPrompt: string; systemPrompt: string;
...@@ -292,6 +321,14 @@ export async function handleLocalAgentStream( ...@@ -292,6 +321,14 @@ export async function handleLocalAgentStream(
*/ */
messageOverride?: ModelMessage[]; messageOverride?: ModelMessage[];
settingsOverride?: UserSettings; settingsOverride?: UserSettings;
/**
* Apps referenced via `@app:Name` mentions in the user's prompt.
* Read-only tools can target these via an `app_name` parameter.
*/
referencedApps?: {
appName: string;
appPath: string;
}[];
}, },
): Promise<boolean> { ): Promise<boolean> {
const settings = settingsOverride ?? readSettings(); const settings = settingsOverride ?? readSettings();
...@@ -332,10 +369,13 @@ export async function handleLocalAgentStream( ...@@ -332,10 +369,13 @@ export async function handleLocalAgentStream(
!isDyadProEnabled(settings) && !isDyadProEnabled(settings) &&
!isBasicAgentMode(settings) !isBasicAgentMode(settings)
) { ) {
const errorMessage =
referencedApps.length > 0
? "Referencing other apps (@app:Name) in local-agent mode requires Dyad Pro. Please enable Dyad Pro in Settings → Pro."
: "Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.";
safeSend(event.sender, "chat:response:error", { safeSend(event.sender, "chat:response:error", {
chatId: req.chatId, chatId: req.chatId,
error: error: errorMessage,
"Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.",
}); });
return false; return false;
} }
...@@ -506,10 +546,14 @@ export async function handleLocalAgentStream( ...@@ -506,10 +546,14 @@ export async function handleLocalAgentStream(
// Build tool execute context // Build tool execute context
const fileEditTracker: FileEditTracker = Object.create(null); const fileEditTracker: FileEditTracker = Object.create(null);
const referencedAppsMap = new Map(
referencedApps.map((ref) => [ref.appName.toLowerCase(), ref.appPath]),
);
const ctx: AgentContext = { const ctx: AgentContext = {
event, event,
appId: chat.app.id, appId: chat.app.id,
appPath, appPath,
referencedApps: referencedAppsMap,
chatId: chat.id, chatId: chat.id,
supabaseProjectId: chat.app.supabaseProjectId, supabaseProjectId: chat.app.supabaseProjectId,
supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug, supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug,
...@@ -597,6 +641,13 @@ export async function handleLocalAgentStream( ...@@ -597,6 +641,13 @@ export async function handleLocalAgentStream(
? messageOverride ? messageOverride
: buildChatMessageHistory(chat.messages); : buildChatMessageHistory(chat.messages);
// Inject the referenced-apps manifest into the user's latest message as a
// `<system-reminder>` block (instead of appending it to the system prompt)
// so the system prompt stays static and cacheable.
if (referencedApps.length > 0) {
injectReferencedAppsReminder(messageHistory, referencedApps);
}
// Used to swap out pre-compaction history while preserving in-flight turn steps. // Used to swap out pre-compaction history while preserving in-flight turn steps.
let baseMessageHistoryCount = messageHistory.length; let baseMessageHistoryCount = messageHistory.length;
let compactBeforeNextStep = false; let compactBeforeNextStep = false;
...@@ -771,6 +822,16 @@ export async function handleLocalAgentStream( ...@@ -771,6 +822,16 @@ export async function handleLocalAgentStream(
excludeMessageIds: new Set([placeholderMessageId]), excludeMessageIds: new Set([placeholderMessageId]),
}, },
); );
// The referenced-apps reminder lives only in-memory on the
// latest user message and is not persisted, so rebuilding
// history from the DB drops it. Re-inject so post-compaction
// tool steps keep the explicit app_name allow-list.
if (referencedApps.length > 0) {
injectReferencedAppsReminder(
compactedMessageHistory,
referencedApps,
);
}
baseMessageHistoryCount = compactedMessageHistory.length; baseMessageHistoryCount = compactedMessageHistory.length;
// The compacted history includes the compaction summary, but the // The compacted history includes the compaction summary, but the
// AI SDK's initialMessages does not. Track the delta so we can // AI SDK's initialMessages does not. Track the delta so we can
......
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { codeSearchTool } from "./code_search";
import type { AgentContext } from "./types";
vi.mock("electron-log", () => ({
default: {
scope: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
},
}));
const engineFetchMock = vi.fn();
vi.mock("./engine_fetch", () => ({
engineFetch: (...args: any[]) => engineFetchMock(...args),
}));
function mockEngineResponse(relevantFiles: string[]) {
engineFetchMock.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
text: async () => "",
json: async () => ({ relevantFiles }),
} as any);
}
describe("codeSearchTool", () => {
let testDir: string;
let otherAppDir: string;
let mockContext: AgentContext;
beforeEach(async () => {
testDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "code-search-test-"),
);
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "code-search-other-"),
);
await fs.promises.writeFile(
path.join(testDir, "current.ts"),
`export const foo = "current-app-file";`,
);
await fs.promises.writeFile(
path.join(otherAppDir, "other.ts"),
`export const bar = "other-app-file";`,
);
mockContext = {
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
neonProjectId: null,
neonActiveBranchId: null,
frameworkType: null,
messageId: 1,
isSharedModulesChanged: false,
isDyadPro: true,
todos: [],
dyadRequestId: "test-request",
fileEditTracker: {},
onXmlStream: vi.fn(),
onXmlComplete: vi.fn(),
requireConsent: vi.fn().mockResolvedValue(true),
appendUserMessage: vi.fn(),
onUpdateTodos: vi.fn(),
};
engineFetchMock.mockReset();
});
afterEach(async () => {
await fs.promises.rm(testDir, { recursive: true, force: true });
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
vi.clearAllMocks();
});
describe("schema", () => {
it("has the correct name", () => {
expect(codeSearchTool.name).toBe("code_search");
});
it("accepts optional app_name", () => {
const parsed = codeSearchTool.inputSchema.parse({
query: "foo",
app_name: "other-app",
});
expect(parsed.app_name).toBe("other-app");
});
});
describe("getConsentPreview", () => {
it("omits app label when app_name is not provided", () => {
const preview = codeSearchTool.getConsentPreview?.({ query: "foo" });
expect(preview).toBe('Search for "foo"');
});
it("appends (app: <name>) when app_name is provided", () => {
const preview = codeSearchTool.getConsentPreview?.({
query: "foo",
app_name: "other-app",
});
expect(preview).toBe('Search for "foo" (app: other-app)');
});
});
describe("buildXml", () => {
it("includes app_name attribute while streaming when provided", () => {
const xml = codeSearchTool.buildXml?.(
{ query: "foo", app_name: "other-app" },
false,
);
expect(xml).toContain('app_name="other-app"');
expect(xml).toContain('query="foo"');
});
it("omits app_name attribute while streaming when not provided", () => {
const xml = codeSearchTool.buildXml?.({ query: "foo" }, false);
expect(xml).not.toContain("app_name=");
});
it("returns undefined when complete (execute handles final XML)", () => {
const xml = codeSearchTool.buildXml?.(
{ query: "foo", app_name: "other-app" },
true,
);
expect(xml).toBeUndefined();
});
});
describe("execute - app_name (referenced apps)", () => {
it("routes to the referenced app's path when app_name matches", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
mockEngineResponse(["other.ts"]);
await codeSearchTool.execute(
{ query: "bar", app_name: "other-app" },
mockContext,
);
expect(engineFetchMock).toHaveBeenCalledTimes(1);
const [, , opts] = engineFetchMock.mock.calls[0];
const body = JSON.parse(opts.body);
// The referenced app's file should be the one searched — not the current app's file.
const searchedPaths = body.filesContext.map(
(f: { path: string }) => f.path,
);
expect(searchedPaths).toContain("other.ts");
expect(searchedPaths).not.toContain("current.ts");
});
it("throws a clear error when app_name is not in the allow-list", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
codeSearchTool.execute(
{ query: "bar", app_name: "does-not-exist" },
mockContext,
),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
expect(engineFetchMock).not.toHaveBeenCalled();
});
it("emits app_name in the final XML output", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
mockEngineResponse(["other.ts"]);
await codeSearchTool.execute(
{ query: "bar", app_name: "other-app" },
mockContext,
);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).toContain('app_name="other-app"');
expect(xmlCall).toContain('query="bar"');
});
it("omits app_name from final XML when not provided", async () => {
mockEngineResponse(["current.ts"]);
await codeSearchTool.execute({ query: "foo" }, mockContext);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).not.toContain("app_name=");
});
});
});
...@@ -9,11 +9,21 @@ import { ...@@ -9,11 +9,21 @@ import {
import { extractCodebase } from "../../../../../../utils/codebase"; import { extractCodebase } from "../../../../../../utils/codebase";
import { engineFetch } from "./engine_fetch"; import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
filterDyadInternalFiles,
resolveTargetAppPath,
} from "./resolve_app_context";
const logger = log.scope("code_search"); const logger = log.scope("code_search");
const codeSearchSchema = z.object({ const codeSearchSchema = z.object({
query: z.string().describe("Search query to find relevant files"), query: z.string().describe("Search query to find relevant files"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
),
}); });
const FileContextSchema = z.object({ const FileContextSchema = z.object({
...@@ -25,15 +35,31 @@ const codeSearchResponseSchema = z.object({ ...@@ -25,15 +35,31 @@ const codeSearchResponseSchema = z.object({
relevantFiles: z.array(z.string()).describe("Paths of relevant files"), relevantFiles: z.array(z.string()).describe("Paths of relevant files"),
}); });
type CodeSearchArgs = z.infer<typeof codeSearchSchema>;
function buildCodeSearchAttributes(args: Partial<CodeSearchArgs>) {
const queryAttr = args.query ? ` query="${escapeXmlAttr(args.query)}"` : "";
const appNameAttr = args.app_name
? ` app_name="${escapeXmlAttr(args.app_name)}"`
: "";
return `${queryAttr}${appNameAttr}`;
}
async function callCodeSearch( async function callCodeSearch(
params: { params: {
query: string; query: string;
app_name?: string;
filesContext: z.infer<typeof FileContextSchema>[]; filesContext: z.infer<typeof FileContextSchema>[];
}, },
ctx: AgentContext, ctx: AgentContext,
): Promise<string[]> { ): Promise<string[]> {
// Stream initial state to UI // Stream initial state to UI
ctx.onXmlStream(`<dyad-code-search query="${escapeXmlAttr(params.query)}">`); ctx.onXmlStream(
`<dyad-code-search${buildCodeSearchAttributes({
query: params.query,
app_name: params.app_name,
})}>`,
);
const response = await engineFetch(ctx, "/tools/code-search", { const response = await engineFetch(ctx, "/tools/code-search", {
method: "POST", method: "POST",
...@@ -71,73 +97,79 @@ Skip this tool for: ...@@ -71,73 +97,79 @@ Skip this tool for:
3. Simple symbol lookups (use \`grep\`) 3. Simple symbol lookups (use \`grep\`)
`; `;
export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> = export const codeSearchTool: ToolDefinition<CodeSearchArgs> = {
{ name: "code_search",
name: "code_search", description: DESCRIPTION,
description: DESCRIPTION, inputSchema: codeSearchSchema,
inputSchema: codeSearchSchema, defaultConsent: "always",
defaultConsent: "always",
// Requires Dyad Pro engine API
// Requires Dyad Pro engine API isEnabled: (ctx) => ctx.isDyadPro,
isEnabled: (ctx) => ctx.isDyadPro,
getConsentPreview: (args) =>
getConsentPreview: (args) => `Search for "${args.query}"`, args.app_name
? `Search for "${args.query}" (app: ${args.app_name})`
buildXml: (args, isComplete) => { : `Search for "${args.query}"`,
if (!args.query) return undefined;
if (isComplete) return undefined; buildXml: (args, isComplete) => {
return `<dyad-code-search query="${escapeXmlAttr(args.query)}">Searching...`; if (!args.query) return undefined;
}, if (isComplete) return undefined;
return `<dyad-code-search${buildCodeSearchAttributes(args)}>Searching...`;
execute: async (args, ctx: AgentContext) => { },
logger.log(`Executing code search: ${args.query}`);
execute: async (args, ctx: AgentContext) => {
// Gather all files from the project logger.log(`Executing code search: ${args.query}`);
const { files } = await extractCodebase({ const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
appPath: ctx.appPath,
chatContext: { // Gather all files from the project
contextPaths: [], const { files } = await extractCodebase({
smartContextAutoIncludes: [], appPath: targetAppPath,
excludePaths: [], chatContext: {
}, contextPaths: [],
}); smartContextAutoIncludes: [],
excludePaths: [],
// Map files to FileContext format },
const filesContext = files.map((file) => ({ });
path: file.path,
content: file.content, const filteredFiles = filterDyadInternalFiles(files, args.app_name);
}));
// Map files to FileContext format
logger.log( const filesContext = filteredFiles.map((file) => ({
`Searching ${filesContext.length} files for query: "${args.query}"`, path: file.path,
); content: file.content,
}));
// Call the code-search endpoint
const relevantFiles = await callCodeSearch( logger.log(
{ `Searching ${filesContext.length} files for query: "${args.query}"`,
query: args.query, );
filesContext,
}, // Call the code-search endpoint
ctx, const relevantFiles = await callCodeSearch(
); {
query: args.query,
// Format results app_name: args.app_name,
const resultText = filesContext,
relevantFiles.length === 0 },
? "No relevant files found." ctx,
: relevantFiles.map((f) => ` - ${f}`).join("\n"); );
// Write final result to UI and DB with dyad-code-search wrapper // Format results
ctx.onXmlComplete( const resultText =
`<dyad-code-search query="${escapeXmlAttr(args.query)}">${escapeXmlContent(resultText)}</dyad-code-search>`, relevantFiles.length === 0
); ? "No relevant files found."
: relevantFiles.map((f) => ` - ${f}`).join("\n");
logger.log(`Code search completed for query: ${args.query}`);
// Write final result to UI and DB with dyad-code-search wrapper
if (relevantFiles.length === 0) { ctx.onXmlComplete(
return "No relevant files found for the given query."; `<dyad-code-search${buildCodeSearchAttributes(args)}>${escapeXmlContent(resultText)}</dyad-code-search>`,
} );
return `Found ${relevantFiles.length} relevant file(s):\n${resultText}`; logger.log(`Code search completed for query: ${args.query}`);
},
}; if (relevantFiles.length === 0) {
return "No relevant files found for the given query.";
}
return `Found ${relevantFiles.length} relevant file(s):\n${resultText}`;
},
};
...@@ -41,6 +41,7 @@ describe("deleteFileTool", () => { ...@@ -41,6 +41,7 @@ describe("deleteFileTool", () => {
event: {} as any, event: {} as any,
appId: 1, appId: 1,
appPath: "/test/app", appPath: "/test/app",
referencedApps: new Map(),
chatId: 1, chatId: 1,
supabaseProjectId: null, supabaseProjectId: null,
supabaseOrganizationSlug: null, supabaseOrganizationSlug: null,
......
...@@ -88,6 +88,7 @@ function deepHello() { ...@@ -88,6 +88,7 @@ function deepHello() {
event: {} as any, event: {} as any,
appId: 1, appId: 1,
appPath: testDir, appPath: testDir,
referencedApps: new Map(),
chatId: 1, chatId: 1,
supabaseProjectId: null, supabaseProjectId: null,
supabaseOrganizationSlug: null, supabaseOrganizationSlug: null,
...@@ -584,5 +585,69 @@ function deepHello() { ...@@ -584,5 +585,69 @@ function deepHello() {
}); });
expect(preview).toBe('Search for "hello" including ignored files'); expect(preview).toBe('Search for "hello" including ignored files');
}); });
it("includes app_name in preview", () => {
const preview = grepTool.getConsentPreview?.({
query: "hello",
app_name: "other-app",
});
expect(preview).toBe('Search for "hello" (app: other-app)');
});
});
describe("app_name (referenced apps)", () => {
let otherAppDir: string;
beforeEach(async () => {
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "grep-other-app-"),
);
await fs.promises.writeFile(
path.join(otherAppDir, "only-in-other.ts"),
`const onlyInOther = "unique-other-app-token";`,
);
});
afterEach(async () => {
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
});
it("searches the referenced app when app_name is provided", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await grepTool.execute(
{ query: "unique-other-app-token", app_name: "other-app" },
mockContext,
);
expect(result).toContain("only-in-other.ts");
expect(result).toContain("unique-other-app-token");
});
it("does not see current-app matches when app_name targets another app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await grepTool.execute(
{ query: "goodbye", app_name: "other-app" },
mockContext,
);
expect(result).toBe("No matches found.");
});
it("throws on unknown app_name", async () => {
await expect(
grepTool.execute(
{ query: "hello", app_name: "does-not-exist" },
mockContext,
),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
});
it("includes app_name in the final XML output", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await grepTool.execute(
{ query: "unique-other-app-token", app_name: "other-app" },
mockContext,
);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).toContain('app_name="other-app"');
});
}); });
}); });
...@@ -11,6 +11,10 @@ import { ...@@ -11,6 +11,10 @@ import {
MAX_FILE_SEARCH_SIZE, MAX_FILE_SEARCH_SIZE,
RIPGREP_EXCLUDED_GLOBS, RIPGREP_EXCLUDED_GLOBS,
} from "@/ipc/utils/ripgrep_utils"; } from "@/ipc/utils/ripgrep_utils";
import {
DYAD_INTERNAL_RIPGREP_EXCLUDE,
resolveTargetAppPath,
} from "./resolve_app_context";
import log from "electron-log"; import log from "electron-log";
const logger = log.scope("grep"); const logger = log.scope("grep");
...@@ -21,6 +25,12 @@ const MAX_LINE_LENGTH = 500; ...@@ -21,6 +25,12 @@ const MAX_LINE_LENGTH = 500;
const grepSchema = z.object({ const grepSchema = z.object({
query: z.string().describe("The regex pattern to search for"), query: z.string().describe("The regex pattern to search for"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
),
include_pattern: z include_pattern: z
.string() .string()
.optional() .optional()
...@@ -66,6 +76,9 @@ function buildGrepAttributes( ...@@ -66,6 +76,9 @@ function buildGrepAttributes(
if (args.query) { if (args.query) {
attrs.push(`query="${escapeXmlAttr(args.query)}"`); attrs.push(`query="${escapeXmlAttr(args.query)}"`);
} }
if (args.app_name) {
attrs.push(`app_name="${escapeXmlAttr(args.app_name)}"`);
}
if (args.include_pattern) { if (args.include_pattern) {
attrs.push(`include="${escapeXmlAttr(args.include_pattern)}"`); attrs.push(`include="${escapeXmlAttr(args.include_pattern)}"`);
} }
...@@ -103,6 +116,7 @@ async function runRipgrep({ ...@@ -103,6 +116,7 @@ async function runRipgrep({
includeIgnored, includeIgnored,
caseSensitive, caseSensitive,
maxMatches, maxMatches,
excludeDyadFolder,
}: { }: {
appPath: string; appPath: string;
query: string; query: string;
...@@ -111,6 +125,7 @@ async function runRipgrep({ ...@@ -111,6 +125,7 @@ async function runRipgrep({
includeIgnored?: boolean; includeIgnored?: boolean;
caseSensitive?: boolean; caseSensitive?: boolean;
maxMatches?: number; maxMatches?: number;
excludeDyadFolder?: boolean;
}): Promise<{ matches: RipgrepMatch[]; stoppedEarly: boolean }> { }): Promise<{ matches: RipgrepMatch[]; stoppedEarly: boolean }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const results: RipgrepMatch[] = []; const results: RipgrepMatch[] = [];
...@@ -149,6 +164,10 @@ async function runRipgrep({ ...@@ -149,6 +164,10 @@ async function runRipgrep({
: RIPGREP_EXCLUDED_GLOBS; : RIPGREP_EXCLUDED_GLOBS;
args.push(...exclusionGlobs.flatMap((glob) => ["--glob", glob])); args.push(...exclusionGlobs.flatMap((glob) => ["--glob", glob]));
if (excludeDyadFolder) {
args.push("--glob", DYAD_INTERNAL_RIPGREP_EXCLUDE);
}
args.push("--", query, "."); args.push("--", query, ".");
const rg = spawn(getRgExecutablePath(), args, { cwd: appPath }); const rg = spawn(getRgExecutablePath(), args, { cwd: appPath });
...@@ -252,6 +271,9 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = { ...@@ -252,6 +271,9 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
if (args.include_ignored) { if (args.include_ignored) {
preview += " including ignored files"; preview += " including ignored files";
} }
if (args.app_name) {
preview += ` (app: ${args.app_name})`;
}
return preview; return preview;
}, },
...@@ -267,17 +289,19 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = { ...@@ -267,17 +289,19 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
}, },
execute: async (args, ctx: AgentContext) => { execute: async (args, ctx: AgentContext) => {
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
const includePatWasWildcard = args.include_pattern === "*"; const includePatWasWildcard = args.include_pattern === "*";
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT); const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
const { matches: allMatches, stoppedEarly } = await runRipgrep({ const { matches: allMatches, stoppedEarly } = await runRipgrep({
appPath: ctx.appPath, appPath: targetAppPath,
query: args.query, query: args.query,
includePat: args.include_pattern, includePat: args.include_pattern,
excludePat: args.exclude_pattern, excludePat: args.exclude_pattern,
includeIgnored: args.include_ignored, includeIgnored: args.include_ignored,
caseSensitive: args.case_sensitive, caseSensitive: args.case_sensitive,
maxMatches: args.include_ignored ? limit + 1 : undefined, maxMatches: args.include_ignored ? limit + 1 : undefined,
excludeDyadFolder: Boolean(args.app_name),
}); });
const totalCount = allMatches.length; const totalCount = allMatches.length;
......
...@@ -19,14 +19,31 @@ vi.mock("electron-log", () => ({ ...@@ -19,14 +19,31 @@ vi.mock("electron-log", () => ({
describe("listFilesTool", () => { describe("listFilesTool", () => {
let testDir: string; let testDir: string;
let otherAppDir: string;
let mockContext: AgentContext; let mockContext: AgentContext;
beforeEach(async () => { beforeEach(async () => {
testDir = await fs.promises.mkdtemp( testDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "list-files-test-"), path.join(os.tmpdir(), "list-files-test-"),
); );
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "list-files-other-"),
);
await fs.promises.writeFile(path.join(testDir, "src.ts"), "source"); await fs.promises.writeFile(path.join(testDir, "src.ts"), "source");
await fs.promises.writeFile(
path.join(testDir, "current-a.ts"),
"export const a = 1;",
);
await fs.promises.writeFile(
path.join(testDir, "current-b.ts"),
"export const b = 2;",
);
await fs.promises.mkdir(path.join(testDir, "nested"));
await fs.promises.writeFile(
path.join(testDir, "nested", "deep.ts"),
"export const deep = 3;",
);
await fs.promises.mkdir(path.join(testDir, "node_modules", "pkg"), { await fs.promises.mkdir(path.join(testDir, "node_modules", "pkg"), {
recursive: true, recursive: true,
}); });
...@@ -45,10 +62,28 @@ describe("listFilesTool", () => { ...@@ -45,10 +62,28 @@ describe("listFilesTool", () => {
"should stay hidden", "should stay hidden",
); );
await fs.promises.writeFile(
path.join(otherAppDir, "other-a.ts"),
"export const otherA = 1;",
);
await fs.promises.mkdir(path.join(otherAppDir, "other-nested"));
await fs.promises.writeFile(
path.join(otherAppDir, "other-nested", "inside.ts"),
"export const inside = 2;",
);
// Hidden .dyad directory in the referenced app for include_ignored tests
await fs.promises.mkdir(path.join(otherAppDir, ".dyad"));
await fs.promises.writeFile(
path.join(otherAppDir, ".dyad", "rules.md"),
"# rules",
);
mockContext = { mockContext = {
event: {} as any, event: {} as any,
appId: 1, appId: 1,
appPath: testDir, appPath: testDir,
referencedApps: new Map(),
chatId: 1, chatId: 1,
supabaseProjectId: null, supabaseProjectId: null,
supabaseOrganizationSlug: null, supabaseOrganizationSlug: null,
...@@ -71,6 +106,7 @@ describe("listFilesTool", () => { ...@@ -71,6 +106,7 @@ describe("listFilesTool", () => {
afterEach(async () => { afterEach(async () => {
await fs.promises.rm(testDir, { recursive: true, force: true }); await fs.promises.rm(testDir, { recursive: true, force: true });
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
vi.clearAllMocks(); vi.clearAllMocks();
}); });
...@@ -169,4 +205,137 @@ describe("listFilesTool", () => { ...@@ -169,4 +205,137 @@ describe("listFilesTool", () => {
expect.stringContaining('truncated="true"'), expect.stringContaining('truncated="true"'),
); );
}); });
describe("schema", () => {
it("has the correct name", () => {
expect(listFilesTool.name).toBe("list_files");
});
it("accepts optional app_name", () => {
const parsed = listFilesTool.inputSchema.parse({
app_name: "other-app",
});
expect(parsed.app_name).toBe("other-app");
});
});
describe("getConsentPreview", () => {
it("omits app suffix when app_name is absent", () => {
expect(listFilesTool.getConsentPreview?.({ directory: "src" })).toBe(
"List src",
);
expect(listFilesTool.getConsentPreview?.({})).toBe("List all files");
});
it("uses consistent trailing (app: <name>) format for both dir and no-dir cases", () => {
expect(
listFilesTool.getConsentPreview?.({
directory: "src/components",
app_name: "other-app",
}),
).toBe("List src/components (app: other-app)");
expect(listFilesTool.getConsentPreview?.({ app_name: "other-app" })).toBe(
"List all files (app: other-app)",
);
});
it("includes recursive and include_ignored flags before app suffix", () => {
expect(
listFilesTool.getConsentPreview?.({
directory: "src",
recursive: true,
include_ignored: true,
app_name: "other-app",
}),
).toBe("List src (recursive) (include ignored) (app: other-app)");
});
});
describe("buildXml (streaming)", () => {
it("includes app_name attribute when provided", () => {
const xml = listFilesTool.buildXml?.(
{ directory: "src", app_name: "other-app" },
false,
);
expect(xml).toContain('app_name="other-app"');
expect(xml).toContain('directory="src"');
});
it("omits app_name attribute when not provided", () => {
const xml = listFilesTool.buildXml?.({ directory: "src" }, false);
expect(xml).not.toContain("app_name=");
});
it("returns undefined when complete (execute handles final XML)", () => {
const xml = listFilesTool.buildXml?.({ app_name: "other-app" }, true);
expect(xml).toBeUndefined();
});
});
describe("execute - app_name (referenced apps)", () => {
it("lists files from the referenced app's path (non-recursive)", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{ app_name: "other-app" },
mockContext,
);
expect(result).toContain("other-a.ts");
expect(result).not.toContain("current-a.ts");
});
it("lists files recursively from the referenced app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{ app_name: "other-app", recursive: true },
mockContext,
);
expect(result).toContain("other-a.ts");
expect(result).toContain("other-nested/inside.ts");
});
it("throws a clear error when app_name is unknown", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
listFilesTool.execute({ app_name: "does-not-exist" }, mockContext),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
});
it("excludes .dyad files from referenced apps even when include_ignored is true", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{
app_name: "other-app",
directory: ".dyad",
include_ignored: true,
recursive: true,
},
mockContext,
);
expect(result).not.toContain(".dyad/rules.md");
});
it("excludes .dyad files from referenced apps in the default (non-include_ignored) listing", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{ app_name: "other-app", recursive: true },
mockContext,
);
expect(result).not.toContain(".dyad/rules.md");
expect(result).toContain("other-a.ts");
});
it("emits app_name attribute in the final XML output", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await listFilesTool.execute({ app_name: "other-app" }, mockContext);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).toContain('app_name="other-app"');
});
it("operates on current app when app_name is omitted even if referencedApps is populated", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute({}, mockContext);
expect(result).toContain("current-a.ts");
expect(result).not.toContain("other-a.ts");
});
});
}); });
...@@ -10,11 +10,22 @@ import { ...@@ -10,11 +10,22 @@ import {
import { extractCodebase } from "../../../../../../utils/codebase"; import { extractCodebase } from "../../../../../../utils/codebase";
import { resolveDirectoryWithinAppPath } from "./path_safety"; import { resolveDirectoryWithinAppPath } from "./path_safety";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
DYAD_INTERNAL_GLOB,
filterDyadInternalFiles,
resolveTargetAppPath,
} from "./resolve_app_context";
const MAX_PATHS_TO_RETURN = 1_000; const MAX_PATHS_TO_RETURN = 1_000;
const listFilesSchema = z.object({ const listFilesSchema = z.object({
directory: z.string().optional().describe("Optional subdirectory to list"), directory: z.string().optional().describe("Optional subdirectory to list"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
),
recursive: z recursive: z
.boolean() .boolean()
.optional() .optional()
...@@ -51,6 +62,9 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) { ...@@ -51,6 +62,9 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) {
const dirAttr = args.directory const dirAttr = args.directory
? ` directory="${escapeXmlAttr(args.directory)}"` ? ` directory="${escapeXmlAttr(args.directory)}"`
: ""; : "";
const appNameAttr = args.app_name
? ` app_name="${escapeXmlAttr(args.app_name)}"`
: "";
const recursiveAttr = const recursiveAttr =
args.recursive !== undefined ? ` recursive="${args.recursive}"` : ""; args.recursive !== undefined ? ` recursive="${args.recursive}"` : "";
const includeIgnoredAttr = const includeIgnoredAttr =
...@@ -61,7 +75,7 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) { ...@@ -61,7 +75,7 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) {
const totalAttr = const totalAttr =
total !== undefined && total > (count ?? 0) ? ` total="${total}"` : ""; total !== undefined && total > (count ?? 0) ? ` total="${total}"` : "";
const truncatedAttr = totalAttr ? ` truncated="true"` : ""; const truncatedAttr = totalAttr ? ` truncated="true"` : "";
return `${dirAttr}${recursiveAttr}${includeIgnoredAttr}${countAttr}${totalAttr}${truncatedAttr}`; return `${dirAttr}${appNameAttr}${recursiveAttr}${includeIgnoredAttr}${countAttr}${totalAttr}${truncatedAttr}`;
} }
export const listFilesTool: ToolDefinition<ListFilesArgs> = { export const listFilesTool: ToolDefinition<ListFilesArgs> = {
...@@ -74,9 +88,9 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = { ...@@ -74,9 +88,9 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
getConsentPreview: (args) => { getConsentPreview: (args) => {
const recursiveText = args.recursive ? " (recursive)" : ""; const recursiveText = args.recursive ? " (recursive)" : "";
const ignoredText = args.include_ignored ? " (include ignored)" : ""; const ignoredText = args.include_ignored ? " (include ignored)" : "";
return args.directory const appSuffix = args.app_name ? ` (app: ${args.app_name})` : "";
? `List ${args.directory}${recursiveText}${ignoredText}` const target = args.directory ?? "all files";
: `List all files${recursiveText}${ignoredText}`; return `List ${target}${recursiveText}${ignoredText}${appSuffix}`;
}, },
buildXml: (args, isComplete) => { buildXml: (args, isComplete) => {
...@@ -87,11 +101,13 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = { ...@@ -87,11 +101,13 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
}, },
execute: async (args, ctx: AgentContext) => { execute: async (args, ctx: AgentContext) => {
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
// Validate directory path to prevent path traversal attacks // Validate directory path to prevent path traversal attacks
let sanitizedDirectory: string | undefined; let sanitizedDirectory: string | undefined;
if (args.directory) { if (args.directory) {
const relativePathFromApp = resolveDirectoryWithinAppPath({ const relativePathFromApp = resolveDirectoryWithinAppPath({
appPath: ctx.appPath, appPath: targetAppPath,
directory: args.directory, directory: args.directory,
}); });
...@@ -121,18 +137,21 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = { ...@@ -121,18 +137,21 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
let allPaths: ListedPath[]; let allPaths: ListedPath[];
if (args.include_ignored) { if (args.include_ignored) {
const normalizedAppPath = ctx.appPath.replace(/\\/g, "/"); const normalizedAppPath = targetAppPath.replace(/\\/g, "/");
const globPattern = `${normalizedAppPath}/${globPath}`; const globPattern = `${normalizedAppPath}/${globPath}`;
const ignoredGlobs = args.app_name
? ["**/.git", "**/.git/**", DYAD_INTERNAL_GLOB]
: ["**/.git", "**/.git/**"];
const ignoredPaths = await glob(globPattern, { const ignoredPaths = await glob(globPattern, {
withFileTypes: true, withFileTypes: true,
dot: true, dot: true,
ignore: ["**/.git", "**/.git/**"], ignore: ignoredGlobs,
}); });
allPaths = sortListedPaths( allPaths = sortListedPaths(
ignoredPaths.map((entry) => ({ ignoredPaths.map((entry) => ({
path: path path: path
.relative(ctx.appPath, entry.fullpath()) .relative(targetAppPath, entry.fullpath())
.split(path.sep) .split(path.sep)
.join("/"), .join("/"),
isDirectory: entry.isDirectory(), isDirectory: entry.isDirectory(),
...@@ -140,7 +159,7 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = { ...@@ -140,7 +159,7 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
); );
} else { } else {
const { files } = await extractCodebase({ const { files } = await extractCodebase({
appPath: ctx.appPath, appPath: targetAppPath,
chatContext: { chatContext: {
contextPaths: [{ globPath }], contextPaths: [{ globPath }],
smartContextAutoIncludes: [], smartContextAutoIncludes: [],
...@@ -148,9 +167,11 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = { ...@@ -148,9 +167,11 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
}, },
}); });
const filteredFiles = filterDyadInternalFiles(files, args.app_name);
// Build the list of file paths // Build the list of file paths
allPaths = sortListedPaths( allPaths = sortListedPaths(
files.map((file) => ({ filteredFiles.map((file) => ({
path: file.path, path: file.path,
isDirectory: false, isDirectory: false,
})), })),
......
...@@ -53,6 +53,7 @@ line 5`; ...@@ -53,6 +53,7 @@ line 5`;
event: {} as any, event: {} as any,
appId: 1, appId: 1,
appPath: testDir, appPath: testDir,
referencedApps: new Map(),
chatId: 1, chatId: 1,
supabaseProjectId: null, supabaseProjectId: null,
supabaseOrganizationSlug: null, supabaseOrganizationSlug: null,
...@@ -435,5 +436,155 @@ line 5`; ...@@ -435,5 +436,155 @@ line 5`;
expect(result).toContain("&lt;"); expect(result).toContain("&lt;");
expect(result).toContain("&gt;"); expect(result).toContain("&gt;");
}); });
it("includes app_name attribute when provided", () => {
const result = readFileTool.buildXml?.(
{ path: "src/App.tsx", app_name: "other-app" },
false,
);
expect(result).toBe(
'<dyad-read path="src/App.tsx" app_name="other-app"></dyad-read>',
);
});
});
describe("execute - app_name (referenced apps)", () => {
let otherAppDir: string;
beforeEach(async () => {
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "read-file-other-app-"),
);
await fs.promises.writeFile(
path.join(otherAppDir, "other.txt"),
"hello from the other app",
);
});
afterEach(async () => {
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
});
it("reads from referenced app when app_name matches", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await readFileTool.execute(
{ path: "other.txt", app_name: "other-app" },
mockContext,
);
expect(result).toBe("hello from the other app");
});
it("reads from current app when app_name is omitted even if referencedApps is populated", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await readFileTool.execute(
{ path: "test.txt" },
mockContext,
);
expect(result).toBe(testFileContent);
});
it("throws a clear error when app_name is not in the allow-list", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
readFileTool.execute(
{ path: "other.txt", app_name: "does-not-exist" },
mockContext,
),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
});
it("error lists available referenced apps", async () => {
mockContext.referencedApps.set("app-a", otherAppDir);
mockContext.referencedApps.set("app-b", otherAppDir);
await expect(
readFileTool.execute(
{ path: "other.txt", app_name: "nope" },
mockContext,
),
).rejects.toThrow(/app-a, app-b/);
});
it("error indicates none available when referencedApps is empty", async () => {
await expect(
readFileTool.execute(
{ path: "other.txt", app_name: "whatever" },
mockContext,
),
).rejects.toThrow(/\(none available\)/);
});
it("file-not-found error includes app_name when reading from a referenced app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
readFileTool.execute(
{ path: "missing.txt", app_name: "other-app" },
mockContext,
),
).rejects.toThrow("File does not exist: missing.txt (in app: other-app)");
});
it("blocks .dyad/ paths when targeting a referenced app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
readFileTool.execute(
{ path: ".dyad/chats/secret.md", app_name: "other-app" },
mockContext,
),
).rejects.toThrow(/Cannot read \.dyad\/ paths from referenced apps/);
});
it("blocks .dyad/ paths reached via traversal aliases (e.g. src/../.dyad/...)", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const dyadDir = path.join(otherAppDir, ".dyad");
await fs.promises.mkdir(dyadDir, { recursive: true });
await fs.promises.writeFile(
path.join(dyadDir, "secret.md"),
"should not be exposed",
);
await fs.promises.mkdir(path.join(otherAppDir, "src"), {
recursive: true,
});
await expect(
readFileTool.execute(
{ path: "src/../.dyad/secret.md", app_name: "other-app" },
mockContext,
),
).rejects.toThrow(/Cannot read \.dyad\/ paths from referenced apps/);
});
it("allows .dyad/ paths on the current app (no app_name)", async () => {
const dyadDir = path.join(testDir, ".dyad");
await fs.promises.mkdir(dyadDir, { recursive: true });
await fs.promises.writeFile(
path.join(dyadDir, "notes.md"),
"local dyad metadata",
);
const result = await readFileTool.execute(
{ path: ".dyad/notes.md" },
mockContext,
);
expect(result).toBe("local dyad metadata");
});
});
describe("getConsentPreview with app_name", () => {
it("prefixes the location with the app_name", () => {
const preview = readFileTool.getConsentPreview?.({
path: "src/App.tsx",
app_name: "other-app",
});
expect(preview).toBe("Read other-app:src/App.tsx");
});
it("prefixes the location when line range is provided", () => {
const preview = readFileTool.getConsentPreview?.({
path: "src/App.tsx",
app_name: "other-app",
start_line_one_indexed: 10,
end_line_one_indexed_inclusive: 50,
});
expect(preview).toBe("Read other-app:src/App.tsx (lines 10-50)");
});
}); });
}); });
...@@ -3,12 +3,22 @@ import { z } from "zod"; ...@@ -3,12 +3,22 @@ import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types"; import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils"; import { safeJoin } from "@/ipc/utils/path_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
assertDyadInternalAccessAllowed,
resolveTargetAppPath,
} from "./resolve_app_context";
const readFile = fs.promises.readFile; const readFile = fs.promises.readFile;
const readFileSchema = z const readFileSchema = z
.object({ .object({
path: z.string().describe("The file path to read"), path: z.string().describe("The file path to read"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
),
start_line_one_indexed: z start_line_one_indexed: z
.number() .number()
.int() .int()
...@@ -51,21 +61,27 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = { ...@@ -51,21 +61,27 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
defaultConsent: "always", defaultConsent: "always",
getConsentPreview: (args) => { getConsentPreview: (args) => {
const location = args.app_name
? `${args.app_name}:${args.path}`
: args.path;
const start = args.start_line_one_indexed; const start = args.start_line_one_indexed;
const end = args.end_line_one_indexed_inclusive; const end = args.end_line_one_indexed_inclusive;
if (start != null && end != null) { if (start != null && end != null) {
return `Read ${args.path} (lines ${start}-${end})`; return `Read ${location} (lines ${start}-${end})`;
} else if (start != null) { } else if (start != null) {
return `Read ${args.path} (from line ${start})`; return `Read ${location} (from line ${start})`;
} else if (end != null) { } else if (end != null) {
return `Read ${args.path} (to line ${end})`; return `Read ${location} (to line ${end})`;
} }
return `Read ${args.path}`; return `Read ${location}`;
}, },
buildXml: (args, _isComplete) => { buildXml: (args, _isComplete) => {
if (!args.path) return undefined; if (!args.path) return undefined;
const attrs = [`path="${escapeXmlAttr(args.path)}"`]; const attrs = [`path="${escapeXmlAttr(args.path)}"`];
if (args.app_name) {
attrs.push(`app_name="${escapeXmlAttr(args.app_name)}"`);
}
if (args.start_line_one_indexed != null) { if (args.start_line_one_indexed != null) {
attrs.push( attrs.push(
`start_line="${escapeXmlAttr(String(args.start_line_one_indexed))}"`, `start_line="${escapeXmlAttr(String(args.start_line_one_indexed))}"`,
...@@ -80,11 +96,20 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = { ...@@ -80,11 +96,20 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
}, },
execute: async (args, ctx: AgentContext) => { execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path); const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
const fullFilePath = safeJoin(targetAppPath, args.path);
assertDyadInternalAccessAllowed({
targetAppPath,
fullFilePath,
appName: args.app_name,
});
if (!fs.existsSync(fullFilePath)) { if (!fs.existsSync(fullFilePath)) {
const appContext = args.app_name ? ` (in app: ${args.app_name})` : "";
throw new DyadError( throw new DyadError(
`File does not exist: ${args.path}`, `File does not exist: ${args.path}${appContext}`,
DyadErrorKind.NotFound, DyadErrorKind.NotFound,
); );
} }
......
import path from "node:path";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import type { AgentContext } from "./types";
/**
* Resolve the app path a read-only tool should target.
*
* - Omitted `appName` → current app (`ctx.appPath`).
* - Provided `appName` → must match a referenced app from the current turn's
* `@app:Name` mentions. Any other value is rejected.
*
* Write tools do not call this — they operate only on `ctx.appPath` so that
* referenced apps remain structurally unreachable for modification.
*/
export function resolveTargetAppPath(
ctx: AgentContext,
appName: string | undefined,
): string {
if (!appName) {
return ctx.appPath;
}
const appPath = ctx.referencedApps.get(appName.toLowerCase());
if (appPath) {
return appPath;
}
const available = [...ctx.referencedApps.keys()];
const availableStr =
available.length > 0 ? available.join(", ") : "(none available)";
throw new DyadError(
`Unknown app_name '${appName}'. Available referenced apps: ${availableStr}`,
DyadErrorKind.NotFound,
);
}
/**
* Glob pattern for `.dyad/` internals, for use in the node `glob` library's
* ignore list.
*
* A referenced app's `.dyad/` folder (rules, chat history, snapshots, etc.) is
* not part of the `@app:Name` reference contract and must not be exposed to
* read-only tools when targeting another app.
*/
export const DYAD_INTERNAL_GLOB = "**/.dyad/**";
/**
* Negated glob for ripgrep's `--glob` flag, excluding `.dyad/` at the app root
* (ripgrep globs are relative to cwd, which is the target app path).
*/
export const DYAD_INTERNAL_RIPGREP_EXCLUDE = "!.dyad/**";
/**
* Is `relativePath` inside a `.dyad/` folder at the app root?
*
* Accepts slashes in either direction and a leading `./`; callers should pass a
* path already resolved relative to the app root (so traversal aliases like
* `src/../.dyad/...` normalize correctly before being checked).
*/
export function isDyadInternalPath(relativePath: string): boolean {
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
return normalized.split("/")[0] === ".dyad";
}
/**
* Strip `.dyad/` entries from a file list when targeting a referenced app.
* No-op for the current app (`appName` omitted) — the user's own `.dyad/`
* internals are always visible to them.
*/
export function filterDyadInternalFiles<T extends { path: string }>(
files: T[],
appName: string | undefined,
): T[] {
if (!appName) {
return files;
}
return files.filter((file) => !isDyadInternalPath(file.path));
}
/**
* Throw if a resolved path inside a referenced app points into its `.dyad/`
* folder. No-op when `appName` is omitted (current app). The relative path is
* computed from the resolved `fullFilePath`, so normalized traversal aliases
* (e.g. `src/../.dyad/...`) are caught.
*/
export function assertDyadInternalAccessAllowed({
targetAppPath,
fullFilePath,
appName,
}: {
targetAppPath: string;
fullFilePath: string;
appName: string | undefined;
}): void {
if (!appName) {
return;
}
const relativeFromApp = path.relative(targetAppPath, fullFilePath);
if (isDyadInternalPath(relativeFromApp)) {
throw new DyadError(
`Cannot read .dyad/ paths from referenced apps — these files are not part of the @app reference contract.`,
DyadErrorKind.Validation,
);
}
}
...@@ -40,6 +40,7 @@ describe("searchReplaceTool", () => { ...@@ -40,6 +40,7 @@ describe("searchReplaceTool", () => {
event: {} as any, event: {} as any,
appId: 1, appId: 1,
appPath: "/test/app", appPath: "/test/app",
referencedApps: new Map(),
chatId: 1, chatId: 1,
supabaseProjectId: null, supabaseProjectId: null,
supabaseOrganizationSlug: null, supabaseOrganizationSlug: null,
......
...@@ -45,6 +45,14 @@ export interface AgentContext { ...@@ -45,6 +45,14 @@ export interface AgentContext {
event: IpcMainInvokeEvent; event: IpcMainInvokeEvent;
appId: number; appId: number;
appPath: string; appPath: string;
/**
* Apps referenced via `@app:Name` in the current turn. Read-only tools
* can target these via an `app_name` parameter; write tools cannot reach them.
* Keyed by lowercased app name so lookups are case-insensitive (matching
* the mention-extraction pipeline in `mention_apps.ts`). Value is the
* absolute app path.
*/
referencedApps: Map<string, string>;
chatId: number; chatId: number;
supabaseProjectId: string | null; supabaseProjectId: string | null;
supabaseOrganizationSlug: string | null; supabaseOrganizationSlug: string | null;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论