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 @@
"type": "string",
"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": {
"description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer",
......@@ -95,6 +99,10 @@
"description": "Optional subdirectory to list",
"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": {
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
......@@ -121,6 +129,10 @@
"type": "string",
"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": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string"
......@@ -163,6 +175,10 @@
"query": {
"type": "string",
"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": [
......
......@@ -230,6 +230,10 @@
"type": "string",
"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": {
"description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer",
......@@ -261,6 +265,10 @@
"description": "Optional subdirectory to list",
"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": {
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
......@@ -285,6 +293,10 @@
"type": "string",
"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": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string"
......@@ -325,6 +337,10 @@
"query": {
"type": "string",
"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": [
......
......@@ -227,6 +227,10 @@
"type": "string",
"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": {
"description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer",
......@@ -260,6 +264,10 @@
"description": "Optional subdirectory to list",
"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": {
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
......@@ -286,6 +294,10 @@
"type": "string",
"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": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string"
......@@ -328,6 +340,10 @@
"query": {
"type": "string",
"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": [
......
......@@ -13,7 +13,9 @@ import {
interface DyadCodeSearchProps {
children?: ReactNode;
node?: { properties?: { query?: string; state?: CustomTagState } };
node?: {
properties?: { query?: string; state?: CustomTagState; appName?: string };
};
}
export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
......@@ -24,6 +26,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
const query =
node?.properties?.query || (typeof children === "string" ? children : "");
const state = node?.properties?.state as CustomTagState;
const appName = node?.properties?.appName || "";
const inProgress = state === "pending";
return (
......@@ -35,6 +38,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
>
<DyadCardHeader icon={<FileCode size={15} />} accentColor="indigo">
<DyadBadge color="indigo">Code Search</DyadBadge>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{!isExpanded && query && (
<span className="text-sm text-muted-foreground italic truncate">
{query}
......
......@@ -25,6 +25,7 @@ interface DyadGrepProps {
count?: string;
total?: string;
truncated?: string;
appName?: string;
};
};
}
......@@ -43,6 +44,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
const count = node?.properties?.count || "";
const total = node?.properties?.total || "";
const truncated = node?.properties?.truncated === "true";
const appName = node?.properties?.appName || "";
let description = `"${query}"`;
if (includePattern) {
......@@ -71,6 +73,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
>
<DyadCardHeader icon={<Search size={15} />} accentColor="violet">
<DyadBadge color="violet">GREP</DyadBadge>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
<span className="font-medium text-sm text-foreground truncate">
{description}
</span>
......
......@@ -17,13 +17,15 @@ interface DyadListFilesProps {
recursive?: string;
include_ignored?: string;
state?: CustomTagState;
appName?: string;
};
};
children: React.ReactNode;
}
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 isRecursive = recursive === "true";
const isIncludeIgnored = include_ignored === "true";
......@@ -44,6 +46,7 @@ export function DyadListFiles({ node, children }: DyadListFilesProps) {
<span className="font-medium text-sm text-foreground truncate">
{title}
</span>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{isRecursive && <DyadBadge color="slate">recursive</DyadBadge>}
{isIncludeIgnored && (
<DyadBadge color="slate">include ignored</DyadBadge>
......
......@@ -378,6 +378,7 @@ function renderCustomTag(
path: attributes.path || "",
startLine: attributes.start_line || "",
endLine: attributes.end_line || "",
appName: attributes.app_name || "",
},
}}
>
......@@ -426,6 +427,7 @@ function renderCustomTag(
properties: {
query: attributes.query || "",
state: getState({ isStreaming, inProgress }),
appName: attributes.app_name || "",
},
}}
>
......@@ -581,6 +583,7 @@ function renderCustomTag(
count: attributes.count || "",
total: attributes.total || "",
truncated: attributes.truncated || "",
appName: attributes.app_name || "",
},
}}
>
......@@ -713,6 +716,7 @@ function renderCustomTag(
include_ignored:
attributes.include_ignored || attributes.include_hidden || "",
state: getState({ isStreaming, inProgress }),
appName: attributes.app_name || "",
},
}}
>
......
import type React from "react";
import type { ReactNode } from "react";
import { FileText } from "lucide-react";
import { DyadBadge } from "./DyadCardPrimitives";
interface DyadReadProps {
children?: ReactNode;
......@@ -20,6 +21,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({
const path = pathProp || node?.properties?.path || "";
const startLine = startLineProp || node?.properties?.startLine || "";
const endLine = endLineProp || node?.properties?.endLine || "";
const appName = node?.properties?.appName || "";
const fileName = path ? path.split("/").pop() : "";
const dirPath = path
? path.slice(0, path.length - (fileName?.length || 0))
......@@ -43,6 +45,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({
<div className="flex items-center gap-1 py-1">
<FileText size={14} className="shrink-0 text-muted-foreground/50" />
<span className="text-[13px] font-medium text-foreground/70">Read</span>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{path && (
<span
className="text-[13px] truncate min-w-0"
......
......@@ -82,7 +82,12 @@ import {
appendCancelledResponseNotice,
filterCancelledMessagePairs,
} 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 {
parseMediaMentions,
......@@ -100,6 +105,7 @@ import { mcpManager } from "../utils/mcp_manager";
import z from "zod";
import {
isBasicAgentMode,
isLocalAgentBackedMode,
isSupabaseConnected,
isTurboEditsV2Enabled,
} from "@/lib/schemas";
......@@ -664,26 +670,48 @@ ${componentSnippet}
// Parse app mentions from the prompt
const mentionedAppNames = parseAppMentions(req.prompt);
// Extract codebases for mentioned apps
const mentionedAppsCodebases = await extractMentionedAppsCodebases(
const isLocalAgentMode = selectedChatMode === "local-agent";
const isAskMode = selectedChatMode === "ask";
const isPlanMode = selectedChatMode === "plan";
const willUseLocalAgentStream =
isLocalAgentBackedMode(selectedChatMode);
// 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
);
const willUseLocalAgentStream =
(selectedChatMode === "local-agent" || selectedChatMode === "ask") &&
!mentionedAppsCodebases.length;
} 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 =
isEngineEnabled &&
settings.enableProSmartFilesContextMode &&
// Anything besides balanced will use deep context.
settings.proSmartContextOption !== "balanced" &&
mentionedAppsCodebases.length === 0;
referencedAppsForAgent.length === 0;
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 = "";
if (mentionedAppsCodebases.length > 0) {
if (mentionedAppsCodebases.length > 0 && !useReferencedAppManifest) {
const mentionedAppsSection = mentionedAppsCodebases
.map(
({ appName, codebaseInfo }) =>
......@@ -794,7 +822,13 @@ ${componentSnippet}
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) {
const mentionedAppsList = mentionedAppsCodebases
.map(({ appName }) => appName)
......@@ -889,9 +923,8 @@ ${componentSnippet}
// 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
// it's not that critical to include the image analysis instructions.
const isAskMode = selectedChatMode === "ask";
if (hasUploadedAttachments) {
if (willUseLocalAgentStream && !isAskMode) {
if (isLocalAgentMode) {
systemPrompt += `
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="
The file paths are provided in the attachment information above.
`;
} else if (!isAskMode) {
} else if (!isAskMode && !isPlanMode) {
systemPrompt += `
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
// Handle ask mode: use local-agent in read-only mode
// This gives users access to code reading tools while in ask mode
// Ask mode does not consume free agent quota
if (selectedChatMode === "ask" && !mentionedAppsCodebases.length) {
if (isAskMode) {
// Reconstruct system prompt for local-agent read-only mode
const readOnlySystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1210,6 +1243,7 @@ This conversation includes one or more image attachments. When the user uploads
readOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
referencedApps: referencedAppsForAgent,
},
);
if (!streamSuccess) {
......@@ -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
// Plan mode is for requirements gathering and creating implementation plans
if (selectedChatMode === "plan" && !mentionedAppsCodebases.length) {
if (isPlanMode) {
// Reconstruct system prompt for plan mode
const planModeSystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1238,17 +1272,18 @@ This conversation includes one or more image attachments. When the user uploads
planModeOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
referencedApps: referencedAppsForAgent,
});
return;
}
// Handle local-agent mode (Agent v2)
// Mentioned apps can't be handled by the local agent (defer to balanced smart context
// in build mode)
if (
selectedChatMode === "local-agent" &&
!mentionedAppsCodebases.length
) {
// Handle local-agent mode (Agent v2).
// Referenced apps (from `@app:Name` mentions) are accessed by the
// agent via tool calls with an `app_name` parameter — see
// resolveTargetAppPath in the local agent tools. handleLocalAgentStream
// injects a `<system-reminder>` into the user's latest message telling
// the agent which `app_name` values are valid.
if (isLocalAgentMode) {
// Check quota for Basic Agent mode (non-Pro users)
const isBasicAgentModeRequest = isBasicAgentMode(settings);
if (isBasicAgentModeRequest) {
......@@ -1284,6 +1319,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]",
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
referencedApps: referencedAppsForAgent,
},
);
} finally {
......@@ -1368,7 +1404,7 @@ This conversation includes one or more image attachments. When the user uploads
});
fullResponse = result.fullResponse;
if (selectedChatMode !== "ask" && isTurboEditsV2Enabled(settings)) {
if (isTurboEditsV2Enabled(settings)) {
let issues = await dryRunSearchReplace({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
......@@ -1467,7 +1503,6 @@ ${formattedSearchReplaceIssues}`,
if (
!abortController.signal.aborted &&
selectedChatMode !== "ask" &&
hasUnclosedDyadWrite(fullResponse)
) {
let continuationAttempts = 0;
......@@ -1520,8 +1555,7 @@ ${formattedSearchReplaceIssues}`,
// because there's going to be type errors since the packages aren't
// installed yet.
addDependencies.length === 0 &&
settings.enableAutoFixProblems &&
selectedChatMode !== "ask"
settings.enableAutoFixProblems
) {
try {
// IF auto-fix is enabled
......
......@@ -26,7 +26,7 @@ import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings";
import { extractMentionedAppsCodebases } from "../utils/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 { resolveChatModeForTurn } from "./chat_mode_resolution";
......@@ -145,14 +145,21 @@ export function registerTokenCountHandlers() {
);
}
// Extract codebases for mentioned apps
// Agent/ask/plan modes reach referenced apps via tool calls rather than
// injecting full codebases into the prompt, so mentioned apps contribute
// ~0 tokens upfront. Match the extraction behavior in chat_stream_handlers
// so the UI estimate tracks what's actually sent.
const willUseLocalAgentStream = isLocalAgentBackedMode(
settings.selectedChatMode,
);
let mentionedAppsTokens = 0;
if (!willUseLocalAgentStream) {
const mentionedAppsCodebases = await extractMentionedAppsCodebases(
mentionedAppNames,
chat.app?.id, // Exclude current app
);
// Calculate tokens for mentioned apps
let mentionedAppsTokens = 0;
if (mentionedAppsCodebases.length > 0) {
const mentionedAppsContent = mentionedAppsCodebases
.map(
......@@ -167,6 +174,7 @@ export function registerTokenCountHandlers() {
`Extracted ${mentionedAppsCodebases.length} mentioned app codebases, tokens: ${mentionedAppsTokens}`,
);
}
}
// Calculate total tokens
const totalTokens =
......
......@@ -6,16 +6,24 @@ import log from "electron-log";
const logger = log.scope("mention_apps");
// Helper function to extract codebases from mentioned apps
export async function extractMentionedAppsCodebases(
export interface MentionedAppReference {
appName: string;
appPath: string;
}
export interface MentionedAppCodebaseEntry extends MentionedAppReference {
codebaseInfo: string;
files: CodebaseFile[];
}
async function resolveMentionedApps(
mentionedAppNames: string[],
excludeCurrentAppId?: number,
): Promise<{ appName: string; codebaseInfo: string; files: CodebaseFile[] }[]> {
) {
if (mentionedAppNames.length === 0) {
return [];
}
// Get all apps
const allApps = await db.query.apps.findMany();
const mentionedApps = allApps.filter(
......@@ -25,13 +33,58 @@ export async function extractMentionedAppsCodebases(
) && app.id !== excludeCurrentAppId,
);
const results: {
appName: string;
codebaseInfo: string;
files: CodebaseFile[];
}[] = [];
// Deduplicate by case-insensitive name: referenced apps are keyed by name
// downstream (e.g., AgentContext.referencedApps Map), so two apps sharing a
// name would silently collide. Keep the first match and warn.
const dedupedApps: typeof mentionedApps = [];
const seenNames = new Set<string>();
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 {
const appPath = getDyadAppPath(app.path);
const chatContext = validateChatContext(app.chatContext);
......@@ -43,6 +96,7 @@ export async function extractMentionedAppsCodebases(
results.push({
appName: app.name,
appPath,
codebaseInfo: formattedOutput,
files,
});
......
......@@ -163,6 +163,17 @@ export type StoredChatMode = z.infer<typeof StoredChatModeSchema>;
export const ChatModeSchema = z.enum(["build", "ask", "local-agent", "plan"]);
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({
accessToken: SecretSchema.nullable(),
});
......
......@@ -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(
chatMessages: Array<{
id: number;
......@@ -272,6 +300,7 @@ export async function handleLocalAgentStream(
planModeOnly = false,
messageOverride,
settingsOverride,
referencedApps = [],
}: {
placeholderMessageId: number;
systemPrompt: string;
......@@ -292,6 +321,14 @@ export async function handleLocalAgentStream(
*/
messageOverride?: ModelMessage[];
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> {
const settings = settingsOverride ?? readSettings();
......@@ -332,10 +369,13 @@ export async function handleLocalAgentStream(
!isDyadProEnabled(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", {
chatId: req.chatId,
error:
"Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.",
error: errorMessage,
});
return false;
}
......@@ -506,10 +546,14 @@ export async function handleLocalAgentStream(
// Build tool execute context
const fileEditTracker: FileEditTracker = Object.create(null);
const referencedAppsMap = new Map(
referencedApps.map((ref) => [ref.appName.toLowerCase(), ref.appPath]),
);
const ctx: AgentContext = {
event,
appId: chat.app.id,
appPath,
referencedApps: referencedAppsMap,
chatId: chat.id,
supabaseProjectId: chat.app.supabaseProjectId,
supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug,
......@@ -597,6 +641,13 @@ export async function handleLocalAgentStream(
? messageOverride
: 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.
let baseMessageHistoryCount = messageHistory.length;
let compactBeforeNextStep = false;
......@@ -771,6 +822,16 @@ export async function handleLocalAgentStream(
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;
// The compacted history includes the compaction summary, but the
// 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 {
import { extractCodebase } from "../../../../../../utils/codebase";
import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
filterDyadInternalFiles,
resolveTargetAppPath,
} from "./resolve_app_context";
const logger = log.scope("code_search");
const codeSearchSchema = z.object({
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({
......@@ -25,15 +35,31 @@ const codeSearchResponseSchema = z.object({
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(
params: {
query: string;
app_name?: string;
filesContext: z.infer<typeof FileContextSchema>[];
},
ctx: AgentContext,
): Promise<string[]> {
// 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", {
method: "POST",
......@@ -71,8 +97,7 @@ Skip this tool for:
3. Simple symbol lookups (use \`grep\`)
`;
export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
{
export const codeSearchTool: ToolDefinition<CodeSearchArgs> = {
name: "code_search",
description: DESCRIPTION,
inputSchema: codeSearchSchema,
......@@ -81,20 +106,24 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
// Requires Dyad Pro engine API
isEnabled: (ctx) => ctx.isDyadPro,
getConsentPreview: (args) => `Search for "${args.query}"`,
getConsentPreview: (args) =>
args.app_name
? `Search for "${args.query}" (app: ${args.app_name})`
: `Search for "${args.query}"`,
buildXml: (args, isComplete) => {
if (!args.query) return undefined;
if (isComplete) return undefined;
return `<dyad-code-search query="${escapeXmlAttr(args.query)}">Searching...`;
return `<dyad-code-search${buildCodeSearchAttributes(args)}>Searching...`;
},
execute: async (args, ctx: AgentContext) => {
logger.log(`Executing code search: ${args.query}`);
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
// Gather all files from the project
const { files } = await extractCodebase({
appPath: ctx.appPath,
appPath: targetAppPath,
chatContext: {
contextPaths: [],
smartContextAutoIncludes: [],
......@@ -102,8 +131,10 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
},
});
const filteredFiles = filterDyadInternalFiles(files, args.app_name);
// Map files to FileContext format
const filesContext = files.map((file) => ({
const filesContext = filteredFiles.map((file) => ({
path: file.path,
content: file.content,
}));
......@@ -116,6 +147,7 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
const relevantFiles = await callCodeSearch(
{
query: args.query,
app_name: args.app_name,
filesContext,
},
ctx,
......@@ -129,7 +161,7 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
// Write final result to UI and DB with dyad-code-search wrapper
ctx.onXmlComplete(
`<dyad-code-search query="${escapeXmlAttr(args.query)}">${escapeXmlContent(resultText)}</dyad-code-search>`,
`<dyad-code-search${buildCodeSearchAttributes(args)}>${escapeXmlContent(resultText)}</dyad-code-search>`,
);
logger.log(`Code search completed for query: ${args.query}`);
......@@ -140,4 +172,4 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
return `Found ${relevantFiles.length} relevant file(s):\n${resultText}`;
},
};
};
......@@ -41,6 +41,7 @@ describe("deleteFileTool", () => {
event: {} as any,
appId: 1,
appPath: "/test/app",
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......
......@@ -88,6 +88,7 @@ function deepHello() {
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......@@ -584,5 +585,69 @@ function deepHello() {
});
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 {
MAX_FILE_SEARCH_SIZE,
RIPGREP_EXCLUDED_GLOBS,
} from "@/ipc/utils/ripgrep_utils";
import {
DYAD_INTERNAL_RIPGREP_EXCLUDE,
resolveTargetAppPath,
} from "./resolve_app_context";
import log from "electron-log";
const logger = log.scope("grep");
......@@ -21,6 +25,12 @@ const MAX_LINE_LENGTH = 500;
const grepSchema = z.object({
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
.string()
.optional()
......@@ -66,6 +76,9 @@ function buildGrepAttributes(
if (args.query) {
attrs.push(`query="${escapeXmlAttr(args.query)}"`);
}
if (args.app_name) {
attrs.push(`app_name="${escapeXmlAttr(args.app_name)}"`);
}
if (args.include_pattern) {
attrs.push(`include="${escapeXmlAttr(args.include_pattern)}"`);
}
......@@ -103,6 +116,7 @@ async function runRipgrep({
includeIgnored,
caseSensitive,
maxMatches,
excludeDyadFolder,
}: {
appPath: string;
query: string;
......@@ -111,6 +125,7 @@ async function runRipgrep({
includeIgnored?: boolean;
caseSensitive?: boolean;
maxMatches?: number;
excludeDyadFolder?: boolean;
}): Promise<{ matches: RipgrepMatch[]; stoppedEarly: boolean }> {
return new Promise((resolve, reject) => {
const results: RipgrepMatch[] = [];
......@@ -149,6 +164,10 @@ async function runRipgrep({
: RIPGREP_EXCLUDED_GLOBS;
args.push(...exclusionGlobs.flatMap((glob) => ["--glob", glob]));
if (excludeDyadFolder) {
args.push("--glob", DYAD_INTERNAL_RIPGREP_EXCLUDE);
}
args.push("--", query, ".");
const rg = spawn(getRgExecutablePath(), args, { cwd: appPath });
......@@ -252,6 +271,9 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
if (args.include_ignored) {
preview += " including ignored files";
}
if (args.app_name) {
preview += ` (app: ${args.app_name})`;
}
return preview;
},
......@@ -267,17 +289,19 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
},
execute: async (args, ctx: AgentContext) => {
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
const includePatWasWildcard = args.include_pattern === "*";
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
const { matches: allMatches, stoppedEarly } = await runRipgrep({
appPath: ctx.appPath,
appPath: targetAppPath,
query: args.query,
includePat: args.include_pattern,
excludePat: args.exclude_pattern,
includeIgnored: args.include_ignored,
caseSensitive: args.case_sensitive,
maxMatches: args.include_ignored ? limit + 1 : undefined,
excludeDyadFolder: Boolean(args.app_name),
});
const totalCount = allMatches.length;
......
......@@ -19,14 +19,31 @@ vi.mock("electron-log", () => ({
describe("listFilesTool", () => {
let testDir: string;
let otherAppDir: string;
let mockContext: AgentContext;
beforeEach(async () => {
testDir = await fs.promises.mkdtemp(
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, "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"), {
recursive: true,
});
......@@ -45,10 +62,28 @@ describe("listFilesTool", () => {
"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 = {
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......@@ -71,6 +106,7 @@ describe("listFilesTool", () => {
afterEach(async () => {
await fs.promises.rm(testDir, { recursive: true, force: true });
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
vi.clearAllMocks();
});
......@@ -169,4 +205,137 @@ describe("listFilesTool", () => {
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 {
import { extractCodebase } from "../../../../../../utils/codebase";
import { resolveDirectoryWithinAppPath } from "./path_safety";
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 listFilesSchema = z.object({
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
.boolean()
.optional()
......@@ -51,6 +62,9 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) {
const dirAttr = args.directory
? ` directory="${escapeXmlAttr(args.directory)}"`
: "";
const appNameAttr = args.app_name
? ` app_name="${escapeXmlAttr(args.app_name)}"`
: "";
const recursiveAttr =
args.recursive !== undefined ? ` recursive="${args.recursive}"` : "";
const includeIgnoredAttr =
......@@ -61,7 +75,7 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) {
const totalAttr =
total !== undefined && total > (count ?? 0) ? ` total="${total}"` : "";
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> = {
......@@ -74,9 +88,9 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
getConsentPreview: (args) => {
const recursiveText = args.recursive ? " (recursive)" : "";
const ignoredText = args.include_ignored ? " (include ignored)" : "";
return args.directory
? `List ${args.directory}${recursiveText}${ignoredText}`
: `List all files${recursiveText}${ignoredText}`;
const appSuffix = args.app_name ? ` (app: ${args.app_name})` : "";
const target = args.directory ?? "all files";
return `List ${target}${recursiveText}${ignoredText}${appSuffix}`;
},
buildXml: (args, isComplete) => {
......@@ -87,11 +101,13 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
},
execute: async (args, ctx: AgentContext) => {
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
// Validate directory path to prevent path traversal attacks
let sanitizedDirectory: string | undefined;
if (args.directory) {
const relativePathFromApp = resolveDirectoryWithinAppPath({
appPath: ctx.appPath,
appPath: targetAppPath,
directory: args.directory,
});
......@@ -121,18 +137,21 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
let allPaths: ListedPath[];
if (args.include_ignored) {
const normalizedAppPath = ctx.appPath.replace(/\\/g, "/");
const normalizedAppPath = targetAppPath.replace(/\\/g, "/");
const globPattern = `${normalizedAppPath}/${globPath}`;
const ignoredGlobs = args.app_name
? ["**/.git", "**/.git/**", DYAD_INTERNAL_GLOB]
: ["**/.git", "**/.git/**"];
const ignoredPaths = await glob(globPattern, {
withFileTypes: true,
dot: true,
ignore: ["**/.git", "**/.git/**"],
ignore: ignoredGlobs,
});
allPaths = sortListedPaths(
ignoredPaths.map((entry) => ({
path: path
.relative(ctx.appPath, entry.fullpath())
.relative(targetAppPath, entry.fullpath())
.split(path.sep)
.join("/"),
isDirectory: entry.isDirectory(),
......@@ -140,7 +159,7 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
);
} else {
const { files } = await extractCodebase({
appPath: ctx.appPath,
appPath: targetAppPath,
chatContext: {
contextPaths: [{ globPath }],
smartContextAutoIncludes: [],
......@@ -148,9 +167,11 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
},
});
const filteredFiles = filterDyadInternalFiles(files, args.app_name);
// Build the list of file paths
allPaths = sortListedPaths(
files.map((file) => ({
filteredFiles.map((file) => ({
path: file.path,
isDirectory: false,
})),
......
......@@ -53,6 +53,7 @@ line 5`;
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......@@ -435,5 +436,155 @@ line 5`;
expect(result).toContain("&lt;");
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";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
assertDyadInternalAccessAllowed,
resolveTargetAppPath,
} from "./resolve_app_context";
const readFile = fs.promises.readFile;
const readFileSchema = z
.object({
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
.number()
.int()
......@@ -51,21 +61,27 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
defaultConsent: "always",
getConsentPreview: (args) => {
const location = args.app_name
? `${args.app_name}:${args.path}`
: args.path;
const start = args.start_line_one_indexed;
const end = args.end_line_one_indexed_inclusive;
if (start != null && end != null) {
return `Read ${args.path} (lines ${start}-${end})`;
return `Read ${location} (lines ${start}-${end})`;
} else if (start != null) {
return `Read ${args.path} (from line ${start})`;
return `Read ${location} (from line ${start})`;
} 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) => {
if (!args.path) return undefined;
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) {
attrs.push(
`start_line="${escapeXmlAttr(String(args.start_line_one_indexed))}"`,
......@@ -80,11 +96,20 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
},
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)) {
const appContext = args.app_name ? ` (in app: ${args.app_name})` : "";
throw new DyadError(
`File does not exist: ${args.path}`,
`File does not exist: ${args.path}${appContext}`,
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", () => {
event: {} as any,
appId: 1,
appPath: "/test/app",
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......
......@@ -45,6 +45,14 @@ export interface AgentContext {
event: IpcMainInvokeEvent;
appId: number;
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;
supabaseProjectId: string | null;
supabaseOrganizationSlug: string | null;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论