Unverified 提交 384caf6e authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Local agent (#1967)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduce Agent v2 local tool‑calling mode with parallel tools, consent workflow, UI, and AI message persistence (incl. MCP integration and Supabase-aware ops). > > - **Agent v2 (Local Agent) • Tool-calling mode**: > - Add new chat mode (`local-agent`) with parallel tool calls and MCP tool support; dedicated system prompt and streaming handler. > - Built-in tools: `read_file`, `list_files`, `write_file`, `rename_file`, `delete_file`, `search_replace`, `add_dependency`, `add_integration`, `execute_sql`, `get_database_schema`, `set_chat_summary`. > - Consent workflow: per-tool “ask/always” defaults, inline consent banner, and settings page to manage consents. > - **UI**: > - Render new custom tags in `DyadMarkdownParser` (e.g., `dyad-list-files`, `dyad-database-schema`, MCP call/result), plus `AgentConsentBanner`. > - `ChatModeSelector` exposes “Agent v2 (experimental)”; settings add “Agent Permissions”. > - **Backend/IPC**: > - New local-agent handler, tool definitions, shared file ops (Git/Supabase deploy), provider/options refactor, MCP consent bridge; register agent tool IPC handlers. > - Persist AI SDK messages/tool calls via `messages.ai_messages_json` with size guard and startup cleanup. > - **DB**: > - Migration `0018_*` adds `ai_messages_json` column; snapshot/journal updated. > - **Testing**: > - E2E fixtures and specs for local-agent (parallel tools, consent, MCP); fake LLM server support; unit tests for utils/handler. > - **Docs**: > - Add `docs/agent_architecture.md` and link from `CONTRIBUTING.md`. > - **Deps**: > - Add `jsonrepair`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 27a18e8ec6ec4e41edd0abcddffc42ee3a9fda3a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Introduce Local Agent v2 with parallel tool calls and user consent, plus UI to manage and visualize tool activity. Adds DB persistence for AI tool-call messages and smarter Supabase auto-deploys. - **New Features** - New “Agent v2” chat mode with tool calls (read/list files, DB schema, write/rename/delete, search/replace, add dependency, add integration, execute SQL, set chat summary), parallel execution, and MCP tool support. - Consent system with defaults, “accept once/always/decline,” inline banner prompts, and a settings panel to manage consents. - UI rendering for tool activity: list files, database schema, and tool call/result/error blocks. - Streaming handler, XML tool translator, and a dedicated system prompt for Agent v2. - Database: messages.ai_messages_json to store AI SDK messages/tool calls with size limits and startup cleanup. - **Refactors** - Supabase: support functions/_shared modules, detect edits, and deploy all affected functions; centralized file operations for shared tooling. <sup>Written for commit 27a18e8ec6ec4e41edd0abcddffc42ee3a9fda3a. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 32093a4c
......@@ -4,7 +4,8 @@ Dyad is still a very early-stage project, thus the codebase is rapidly changing.
Before opening a pull request, please open an issue and discuss whether the change makes sense in Dyad. Ensuring a cohesive user experience sometimes means we can't include every possible feature or we need to consider the long-term design of how we want to support a feature area.
For a high-level overview of how Dyad works, please see the [Architecture Guide](./docs/architecture.md). Understanding the architecture will help ensure your contributions align with the overall design of the project.
- For a high-level overview of how Dyad works, please see the [Architecture Guide](./docs/architecture.md). Understanding the architecture will help ensure your contributions align with the overall design of the project.
- For a detailed architecture on how the new local agent mode (aka Agent v2) works, please read the [Agent Architecture Guide](./docs/agent_architecture.md)
## More than code contributions
......
# Agent Architecture
Previously, Dyad used a pseudo tool-calling strategy using custom XML instead of model's formal tool calling capabilities. Now that models have gotten much better with tool calling, particularly with parallel tool calling, it's beneficial to use a more standard tool calling approach which will also make it much easier to add new tools.
- The heart of the local agent is in `src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts` which contains the core agent loop: which keeps calling the LLM until it chooses not to do a tool call or hits the maximum number of steps for the turn.
- `src/pro/main/ipc/handlers/local_agent/tool_definitions.ts` contains the list of all the tools available to the Dyad local agent.
## Add a tool
If you want to add a new tool, you will want to create a new tool in the `src/pro/main/ipc/handlers/local_agent/tools` directory. You can look at the existing tools as examples.
Then, import the tool and include it in `src/pro/main/ipc/handlers/local_agent/tool_definitions.ts`
Finally, you will need to define how to render the custom XML tag (e.g. `<dyad-$foo-tool-name>`) inside `src/components/chat/DyadMarkdownParser.tsx` which will typically involve creating a new React component to render the custom XML tag.
## Testing
You can add an E2E test by looking at the existing local agent E2E tests which are named like `e2e-tests/local_agent*.spec.ts`
You can define a tool call testing fixture at `e2e-tests/fixtures/engine` which allows you to simulate a tool call.
ALTER TABLE `messages` ADD `ai_messages_json` text;
\ No newline at end of file
差异被折叠。
......@@ -127,6 +127,13 @@
"when": 1764804624402,
"tag": "0017_sharp_corsair",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1766124364939,
"tag": "0018_skinny_ezekiel",
"breakpoints": true
}
]
}
\ No newline at end of file
......@@ -3,3 +3,5 @@ Here is a simple response to test the context limit banner functionality.
This message simulates being close to the model's context window limit.
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Add a dependency that requires consent",
turns: [
{
text: "I'll add a dependency to your project.",
toolCalls: [
{
name: "add_dependency",
args: {
packages: ["@dyad-sh/supabase-management-js"],
},
},
],
},
{
text: "Dependency added done.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Create a simple TypeScript file",
turns: [
{
text: "I'll create a hello function for you.",
toolCalls: [
{
name: "write_file",
args: {
path: "src/hello.ts",
content: `export function hello() {
return "Hello, World!";
}
`,
description: "Create hello function",
},
},
],
},
{
text: "I've created the file successfully. The hello function is now available at src/hello.ts and is ready to use.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Call an MCP tool (calculator_add) from local-agent mode",
turns: [
{
text: "I'll calculate the sum of 5 and 3 using the calculator.",
toolCalls: [
{
// MCP tools are named as serverName__toolName
name: "testing-mcp-server__calculator_add" as any,
args: {
a: 5,
b: 3,
},
},
],
},
{
text: "The sum of 5 and 3 is 8. The calculation was performed successfully using the MCP calculator tool.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Multiple tool calls in a single turn (parallel execution)",
turns: [
{
text: "I'll create two files for you in parallel.",
toolCalls: [
{
name: "write_file",
args: {
path: "src/utils/math.ts",
content: `export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
`,
description: "Create math utilities",
},
},
{
name: "write_file",
args: {
path: "src/utils/string.ts",
content: `export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function lowercase(str: string): string {
return str.toLowerCase();
}
`,
description: "Create string utilities",
},
},
],
},
{
text: "I've created both utility files:\n\n1. src/utils/math.ts - Contains add and subtract functions\n2. src/utils/string.ts - Contains capitalize and lowercase functions\n\nBoth files are now ready to use in your project.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Read a file, then edit it with search/replace",
turns: [
{
text: "Let me first read the current file contents to understand what we're working with.",
toolCalls: [
{
name: "read_file",
args: {
path: "src/App.tsx",
},
},
],
},
{
text: "Now I'll update the welcome message to say Hello World instead.",
toolCalls: [
{
name: "search_replace",
args: {
path: "src/App.tsx",
search: "const App = () => <div>Minimal imported app</div>;",
replace: "const App = () => <div>UPDATED imported app</div>;",
description: "Update welcome message",
},
},
],
},
{
text: "Done! I've updated the title from 'Minimal imported app' to 'UPDATED imported app'. The change has been applied successfully.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Fix a security issue in the codebase",
turns: [
{
text: "I'll fix the security issue by removing the hardcoded secret and using environment variables instead.",
toolCalls: [
{
name: "search_replace",
args: {
path: "src/App.tsx",
search: "const App = () => <div>Minimal imported app</div>;",
replace:
"const App = () => <div>Secure app with env vars</div>;",
description: "Fix security vulnerability",
},
},
],
},
{
text: "I've fixed the security issue by replacing the hardcoded value with a more secure implementation using environment variables.",
},
],
};
......@@ -258,12 +258,18 @@ export class PageObject {
await this.selectTestModel();
}
async setUpDyadPro({ autoApprove = false }: { autoApprove?: boolean } = {}) {
async setUpDyadPro({
autoApprove = false,
localAgent = false,
}: { autoApprove?: boolean; localAgent?: boolean } = {}) {
await this.baseSetup();
await this.goToSettingsTab();
if (autoApprove) {
await this.toggleAutoApprove();
}
if (localAgent) {
await this.toggleLocalAgentMode();
}
await this.setUpDyadProvider();
await this.goToAppsTab();
}
......@@ -339,15 +345,26 @@ export class PageObject {
await this.page.getByRole("button", { name: "Import" }).click();
}
async selectChatMode(mode: "build" | "ask" | "agent") {
async selectChatMode(mode: "build" | "ask" | "agent" | "local-agent") {
await this.page.getByTestId("chat-mode-selector").click();
// local-agent appears as "Agent v2 (experimental)" in the UI
const optionName =
mode === "local-agent"
? "Agent v2 (experimental)"
: mode === "agent"
? "Build with MCP (experimental)"
: mode;
await this.page
.getByRole("option", {
name: mode === "agent" ? "Build with MCP (experimental)" : mode,
name: optionName,
})
.click();
}
async selectLocalAgentMode() {
await this.selectChatMode("local-agent");
}
async openContextFilesPicker() {
const contextButton = this.page.getByTestId("codebase-context-button");
await contextButton.click();
......@@ -973,6 +990,10 @@ export class PageObject {
await this.page.getByRole("switch", { name: "Auto-approve" }).click();
}
async toggleLocalAgentMode() {
await this.page.getByRole("switch", { name: "Enable Agent v2" }).click();
}
async toggleNativeGit() {
await this.page.getByRole("switch", { name: "Enable Native Git" }).click();
}
......@@ -1097,6 +1118,34 @@ export class PageObject {
async sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
////////////////////////////////
// Agent Tool Consent Banner
////////////////////////////////
getAgentConsentBanner() {
return this.page
.getByRole("button", { name: "Always allow" })
.locator("..");
}
async waitForAgentConsentBanner(timeout = Timeout.MEDIUM) {
await expect(
this.page.getByRole("button", { name: "Always allow" }),
).toBeVisible({ timeout });
}
async clickAgentConsentAlwaysAllow() {
await this.page.getByRole("button", { name: "Always allow" }).click();
}
async clickAgentConsentAllowOnce() {
await this.page.getByRole("button", { name: "Allow once" }).click();
}
async clickAgentConsentDecline() {
await this.page.getByRole("button", { name: "Decline" }).click();
}
}
interface ElectronConfig {
......
import path from "path";
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* Test for security review in local-agent mode
*/
testSkipIfWindows("local-agent - security review fix", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
// First, trigger a security review
await po.selectPreviewMode("security");
await po.page
.getByRole("button", { name: "Run Security Review" })
.first()
.click();
await po.waitForChatCompletion();
await po.snapshotServerDump("all-messages");
});
/**
* Test for mention apps feature in local-agent mode
*/
testSkipIfWindows("local-agent - mention apps", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
// Import app and reference it.
await po.importApp("minimal-with-ai-rules");
await po.goToAppsTab();
await po.selectLocalAgentMode();
// Use @app:minimal-with-ai-rules to reference the other app
await po.sendPrompt("[dump] @app:minimal-with-ai-rules hi");
await po.snapshotServerDump("request");
});
/**
* Test for MCP tool calls in local-agent mode
*/
testSkipIfWindows("local-agent - mcp tool call", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.goToSettingsTab();
await po.page.getByRole("button", { name: "Tools (MCP)" }).click();
// Configure the test MCP server
await po.page
.getByRole("textbox", { name: "My MCP Server" })
.fill("testing-mcp-server");
await po.page.getByRole("textbox", { name: "node" }).fill("node");
const testMcpServerPath = path.join(
__dirname,
"..",
"testing",
"fake-stdio-mcp-server.mjs",
);
await po.page
.getByRole("textbox", { name: "path/to/mcp-server.js --flag" })
.fill(testMcpServerPath);
await po.page.getByRole("button", { name: "Add Server" }).click();
await po.goToAppsTab();
await po.importApp("minimal");
await po.selectLocalAgentMode();
// Send prompt that triggers MCP tool call
await po.sendPrompt("tc=local-agent/mcp-calculator", {
skipWaitForCompletion: true,
});
// MCP tools require consent - wait for the consent banner
await po.waitForAgentConsentBanner();
await po.clickAgentConsentAlwaysAllow();
// Wait for chat to complete
await po.waitForChatCompletion();
await po.snapshotMessages();
});
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* E2E tests for local-agent mode (Agent v2)
* Tests multi-turn tool call conversations using the TypeScript DSL fixtures
*/
testSkipIfWindows("local-agent - dump request", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("request");
await po.snapshotServerDump("all-messages");
});
testSkipIfWindows("local-agent - read then edit", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
await po.sendPrompt("tc=local-agent/read-then-edit");
await po.snapshotMessages();
await po.snapshotAppFiles({
name: "after-edit",
files: ["src/App.tsx"],
});
});
testSkipIfWindows("local-agent - parallel tool calls", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
await po.sendPrompt("tc=local-agent/parallel-tools");
await po.snapshotMessages();
await po.snapshotAppFiles({
name: "after-parallel",
files: ["src/utils/math.ts", "src/utils/string.ts"],
});
});
import { expect } from "@playwright/test";
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* Tests for agent tool consent flow with add_dependency
*/
testSkipIfWindows(
"local-agent - add_dependency consent: always allow",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
// Send prompt that triggers add_dependency (requires consent)
await po.sendPrompt("tc=local-agent/add-dependency", {
skipWaitForCompletion: true,
});
// Wait for consent banner to appear
await po.waitForAgentConsentBanner();
// Click "Always allow" - should persist the consent
await po.clickAgentConsentAlwaysAllow();
// Wait for chat to complete
await po.waitForChatCompletion();
await po.snapshotMessages();
// Send prompt that triggers add_dependency (should not require consent this time)
await po.sendPrompt("tc=local-agent/add-dependency");
await expect(po.getAgentConsentBanner()).not.toBeVisible();
},
);
testSkipIfWindows(
"local-agent - add_dependency consent: allow once",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
// Send prompt that triggers add_dependency (requires consent)
await po.sendPrompt("tc=local-agent/add-dependency", {
skipWaitForCompletion: true,
});
// Wait for consent banner to appear
await po.waitForAgentConsentBanner();
// Click "Allow once" - should allow this execution only
await po.clickAgentConsentAllowOnce();
// Wait for chat to complete
await po.waitForChatCompletion();
await po.snapshotMessages();
},
);
testSkipIfWindows(
"local-agent - add_dependency consent: decline",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
// Send prompt that triggers add_dependency (requires consent)
await po.sendPrompt("tc=local-agent/add-dependency", {
skipWaitForCompletion: true,
});
// Wait for consent banner to appear
await po.waitForAgentConsentBanner();
// Click "Decline" - should reject the tool execution
await po.clickAgentConsentDecline();
// Wait for chat to complete (should show error about declined permission)
await po.waitForChatCompletion();
await po.snapshotMessages();
},
);
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/mcp-calculator
- paragraph: I'll calculate the sum of 5 and 3 using the calculator.
- img
- text: Tool Call
- img
- text: testing-mcp-server calculator_add
- img
- text: Tool Result
- img
- text: testing-mcp-server calculator_add
- paragraph: The sum of 5 and 3 is 8. The calculation was performed successfully using the MCP calculator tool.
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
- paragraph: /security-review
- paragraph: OK, let's review the security.
- paragraph: Here are variations with different severity levels.
- paragraph: Purposefully putting medium on top to make sure the severity levels are sorted correctly.
- heading "Medium Severity" [level=2]
- text: "<dyad-security-finding title=\"Unvalidated File Upload Extensions\" level=\"medium\"> **What**: The file upload endpoint accepts any file type without validating extensions or content, only checking file size"
- paragraph:
- strong: Risk
- text: ": An attacker could upload malicious files (e.g., .exe, .php) that might be executed if the server is misconfigured, or upload extremely large files to consume storage space"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem:
- text: Implement a whitelist of allowed file extensions (e.g.,
- code: "`.jpg`"
- text: ","
- code: "`.png`"
- text: ","
- code: "`.pdf`"
- text: )
- listitem: Validate file content type using magic numbers, not just the extension
- listitem: Store uploaded files outside the web root with random filenames
- listitem: Implement virus scanning for uploaded files using ClamAV or similar
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/api/upload.ts`"
- text: "</dyad-security-finding> <dyad-security-finding title=\"Missing CSRF Protection on State-Changing Operations\" level=\"medium\"> **What**: POST, PUT, and DELETE endpoints don't implement CSRF tokens, making them vulnerable to cross-site request forgery attacks"
- paragraph:
- strong: Risk
- text: ": An attacker could trick authenticated users into unknowingly performing actions like changing their email, making purchases, or deleting data by visiting a malicious website"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem:
- text: Implement CSRF tokens using a library like
- code: "`csurf`"
- text: for Express
- listitem:
- text: Set
- code: "`SameSite=Strict`"
- text: or
- code: "`SameSite=Lax`"
- text: on session cookies
- listitem:
- text: Verify the
- code: "`Origin`"
- text: or
- code: "`Referer`"
- text: header for sensitive operations
- listitem: For API-only applications, consider using custom headers that browsers can't set cross-origin
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/middleware/auth.ts`"
- text: ","
- code: "`src/api/*.ts`"
- text: </dyad-security-finding>
- heading "Critical Severity" [level=2]
- text: "<dyad-security-finding title=\"SQL Injection in User Lookup\" level=\"critical\"> **What**: User input flows directly into database queries without validation, allowing attackers to execute arbitrary SQL commands"
- paragraph:
- strong: Risk
- text: ": An attacker could steal all customer data, delete your entire database, or take over admin accounts by manipulating the URL"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem:
- text: "Use parameterized queries:"
- code: "`db.query('SELECT * FROM users WHERE id = ?', [userId])`"
- listitem:
- text: Add input validation to ensure
- code: "`userId`"
- text: is a number
- listitem: Implement an ORM like Prisma or TypeORM that prevents SQL injection by default
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/api/users.ts`"
- text: "</dyad-security-finding> <dyad-security-finding title=\"Hardcoded AWS Credentials in Source Code\" level=\"critical\"> **What**: AWS access keys are stored directly in the codebase and committed to version control, exposing full cloud infrastructure access"
- paragraph:
- strong: Risk
- text: ": Anyone with repository access (including former employees or compromised accounts) could spin up expensive resources, access S3 buckets with customer data, or destroy production infrastructure"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem: Immediately rotate the exposed credentials in AWS IAM
- listitem:
- text: Use environment variables and add
- code: "`.env`"
- text: to
- code: "`.gitignore`"
- listitem: Implement AWS Secrets Manager or similar vault solution
- listitem:
- text: Scan git history and purge the credentials using tools like
- code: "`git-filter-repo`"
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/config/aws.ts`"
- text: ","
- code: "`src/services/s3-uploader.ts`"
- text: </dyad-security-finding>
- heading "High Severity" [level=2]
- text: "<dyad-security-finding title=\"Missing Authentication on Admin Endpoints\" level=\"high\"> **What**: Administrative API endpoints can be accessed without authentication, relying only on URL obscurity"
- paragraph:
- strong: Risk
- text: ": An attacker who discovers these endpoints could modify user permissions, access sensitive reports, or change system configurations without credentials"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem:
- text: Add authentication middleware to all
- code: "`/admin/*`"
- text: routes
- listitem: Implement role-based access control (RBAC) to verify admin permissions
- listitem: Add audit logging for all administrative actions
- listitem: Consider implementing rate limiting on admin endpoints
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/api/admin/users.ts`"
- text: ","
- code: "`src/api/admin/settings.ts`"
- text: "</dyad-security-finding> <dyad-security-finding title=\"JWT Secret Using Default Value\" level=\"high\"> **What**: The application uses a hardcoded default JWT secret (\"your-secret-key\") for signing authentication tokens"
- paragraph:
- strong: Risk
- text: ": Attackers can forge valid JWT tokens to impersonate any user, including administrators, granting them unauthorized access to user accounts and sensitive data"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem:
- text: "Generate a strong random secret:"
- code: "/`openssl rand -base64 \\d+`/"
- listitem: Store the secret in environment variables
- listitem: Rotate the JWT secret, which will invalidate all existing sessions
- listitem: Consider using RS256 (asymmetric) instead of HS256 for better security
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/auth/jwt.ts`"
- text: </dyad-security-finding>
- heading "Low Severity" [level=2]
- text: "<dyad-security-finding title=\"Verbose Error Messages Expose Stack Traces\" level=\"low\"> **What**: Production error responses include full stack traces and internal file paths that are sent to end users"
- paragraph:
- strong: Risk
- text: ": Attackers can use this information to map your application structure, identify frameworks and versions, and find potential attack vectors more easily"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem: Configure different error handlers for production vs development
- listitem: Log detailed errors server-side but send generic messages to clients
- listitem:
- text: "Use an error handling middleware:"
- code: "`if (process.env.NODE_ENV === 'production') { /* hide details */ }`"
- listitem: Implement centralized error logging with tools like Sentry
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/middleware/error-handler.ts`"
- text: "</dyad-security-finding> <dyad-security-finding title=\"Missing Security Headers\" level=\"low\"> **What**: The application doesn't set recommended security headers like `X-Frame-Options`, `X-Content-Type-Options`, and `Strict-Transport-Security`"
- paragraph:
- strong: Risk
- text: ": Users may be vulnerable to clickjacking attacks, MIME-type sniffing, or man-in-the-middle attacks, though exploitation requires specific conditions"
- paragraph:
- strong: Potential Solutions
- text: ":"
- list:
- listitem:
- text: "Use Helmet.js middleware:"
- code: "`app.use(helmet())`"
- listitem: Configure headers manually in your web server (nginx/Apache) or application
- listitem:
- text: Set
- code: "`Content-Security-Policy`"
- text: to prevent XSS attacks
- listitem: Enable HSTS to enforce HTTPS connections
- paragraph:
- strong: Relevant Files
- text: ":"
- code: "`src/app.ts`"
- text: ","
- code: "`nginx.conf`"
- text: </dyad-security-finding>
- paragraph: /\[\[dyad-dump-path=\/Users\/will\/Documents\/GitHub\/dyad-zero\/testing\/fake-llm-server\/dist\/generated\/\d+\.json\]\]/
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img
\ No newline at end of file
===
role: system
message:
# Role
Security expert identifying vulnerabilities that could lead to data breaches, leaks, or unauthorized access.
# Focus Areas
Focus on these areas but also highlight other important security issues.
## Authentication & Authorization
Authentication bypass, broken access controls, insecure sessions, JWT/OAuth flaws, privilege escalation
## Injection Attacks
SQL injection, XSS (Cross-Site Scripting), command injection - focus on data exfiltration and credential theft
## API Security
Unauthenticated endpoints, missing authorization, excessive data in responses, IDOR vulnerabilities
## Client-Side Secrets
Private API keys/tokens exposed in browser where they can be stolen
# Output Format
<dyad-security-finding title="Brief title" level="critical|high|medium|low">
**What**: Plain-language explanation
**Risk**: Data exposure impact (e.g., "All customer emails could be stolen")
**Potential Solutions**: Options ranked by how effectively they address the issue
**Relevant Files**: Relevant file paths
</dyad-security-finding>
# Example:
<dyad-security-finding title="SQL Injection in User Lookup" level="critical">
**What**: User input flows directly into database queries without validation, allowing attackers to execute arbitrary SQL commands
**Risk**: An attacker could steal all customer data, delete your entire database, or take over admin accounts by manipulating the URL
**Potential Solutions**:
1. Use parameterized queries: `db.query('SELECT * FROM users WHERE id = ?', [userId])`
2. Add input validation to ensure `userId` is a number
3. Implement an ORM like Prisma or TypeORM that prevents SQL injection by default
**Relevant Files**: `src/api/users.ts`
</dyad-security-finding>
# Severity Levels
**critical**: Actively exploitable or trivially exploitable, leading to full system or data compromise with no mitigation in place.
**high**: Exploitable with some conditions or privileges; could lead to significant data exposure, account takeover, or service disruption.
**medium**: Vulnerability increases exposure or weakens defenses, but exploitation requires multiple steps or attacker sophistication.
**low**: Low immediate risk; typically requires local access, unlikely chain of events, or only violates best practices without a clear exploitation path.
# Instructions
1. Find real, exploitable vulnerabilities that lead to data breaches
2. Prioritize client-side exposed secrets and data leaks
3. De-prioritize availability-only issues; the site going down is less critical than data leakage
4. Use plain language with specific file paths
5. Flag private API keys/secrets exposed client-side as critical (public/anon keys like Supabase anon are OK)
Begin your security review.
===
role: user
message: /security-review
\ No newline at end of file
=== src/App.tsx ===
const App = () => <div>UPDATED imported app</div>;
export default App;
=== src/utils/math.ts ===
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
=== src/utils/string.ts ===
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function lowercase(str: string): string {
return str.toLowerCase();
}
{
"body": {
"model": "dyad/auto",
"max_tokens": 32000,
"temperature": 0,
"messages": [
{
"role": "system",
"content": "[[SYSTEM_MESSAGE]]"
},
{
"role": "user",
"content": "Generate an AI_RULES.md file for this app. Describe the tech stack in 5-10 bullet points and describe clear rules about what libraries to use for what."
},
{
"role": "assistant",
"content": "\n <dyad-write path=\"file1.txt\">\n A file (2)\n </dyad-write>\n More\n EOM"
},
{
"role": "user",
"content": "[dump]"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "write_file",
"description": "Create or completely overwrite a file in the codebase",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path relative to the app root"
},
"content": {
"type": "string",
"description": "The content to write to the file"
},
"description": {
"type": "string",
"description": "Brief description of the change"
}
},
"required": [
"path",
"content"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "delete_file",
"description": "Delete a file from the codebase",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to delete"
}
},
"required": [
"path"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "rename_file",
"description": "Rename or move a file in the codebase",
"parameters": {
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "The current file path"
},
"to": {
"type": "string",
"description": "The new file path"
}
},
"required": [
"from",
"to"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "add_dependency",
"description": "Install npm packages",
"parameters": {
"type": "object",
"properties": {
"packages": {
"type": "array",
"items": {
"type": "string"
},
"description": "Array of package names to install"
}
},
"required": [
"packages"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "search_replace",
"description": "Apply targeted search/replace edits to a file. This is the preferred tool for editing a file.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to edit"
},
"search": {
"type": "string",
"description": "Content to search for in the file. This should match the existing code that will be replaced"
},
"replace": {
"type": "string",
"description": "New content to replace the search content with"
},
"description": {
"type": "string",
"description": "Brief description of the changes"
}
},
"required": [
"path",
"search",
"replace"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the content of a file from the codebase.\n \n- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to read"
}
},
"required": [
"path"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "list_files",
"description": "List all files in the application directory recursively. If you are not sure, list all files by omitting the directory parameter.",
"parameters": {
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": "Optional subdirectory to list"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "set_chat_summary",
"description": "Set the title/summary for this chat message. You should always call this message at the end of the turn when you have finished calling all the other tools.",
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "A short summary/title for the chat"
}
},
"required": [
"summary"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
"name": "add_integration",
"description": "Add an integration provider to the app (e.g., Supabase for auth, database, or server-side functions). Once you have called this tool, stop and do not call any more tools because you need to wait for the user to set up the integration.",
"parameters": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"supabase"
],
"description": "The integration provider to add (e.g., 'supabase')"
}
},
"required": [
"provider"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
}
],
"tool_choice": "auto",
"stream": true,
"thinking": {
"type": "enabled",
"include_thoughts": true,
"budget_tokens": 4000
}
},
"headers": {
"authorization": "Bearer testdyadkey"
}
}
\ No newline at end of file
===
role: system
message:
<role>
You are Dyad, an AI assistant that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes.
You make efficient and effective changes to codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations.
</role>
<app_commands>
Do *not* tell the user to run shell commands. Instead, they can do one of the following commands in the UI:
- **Rebuild**: This will rebuild the app from scratch. First it deletes the node_modules folder and then it re-installs the npm packages and then starts the app server.
- **Restart**: This will restart the app server.
- **Refresh**: This will refresh the app preview page.
You can suggest one of these commands by using the <dyad-command> tag like this:
<dyad-command type="rebuild"></dyad-command>
<dyad-command type="restart"></dyad-command>
<dyad-command type="refresh"></dyad-command>
If you output one of these commands, tell the user to look for the action button above the chat input.
</app_commands>
<general_guidelines>
- Always reply to the user in the same language they are using.
- Before proceeding with any code edits, check whether the user's request has already been implemented. If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described."
- Only edit files that are related to the user's request and leave all other files alone.
- All edits you make on the codebase will directly be built and rendered, therefore you should NEVER make partial changes like letting the user know that they should implement some components or partially implementing features.
- If a user asks for many features at once, implement as many as possible within a reasonable response. Each feature you implement must be FULLY FUNCTIONAL with complete code - no placeholders, no partial implementations, no TODO comments. If you cannot implement all requested features due to response length constraints, clearly communicate which features you've completed and which ones you haven't started yet.
- Prioritize creating small, focused files and components.
- Keep explanations concise and focused
- Set a chat summary at the end using the `set_chat_summary` tool.
- DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed.
DON'T DO MORE THAN WHAT THE USER ASKS FOR.
</general_guidelines>
<tool_calling>
You have tools at your disposal to solve the coding task. Follow these rules regarding tool calls:
1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.
2. The conversation may reference tools that are no longer available. NEVER call tools that are not explicitly provided.
3. **NEVER refer to tool names when speaking to the USER.** Instead, just say what the tool is doing in natural language.
4. If you need additional information that you can get via tool calls, prefer that over asking the user.
5. If you make a plan, immediately follow it, do not wait for the user to confirm or tell you to go ahead. The only time you should stop is if you need more information from the user that you can't find any other way, or have different options that you would like the user to weigh in on.
6. Only use the standard tool call format and the available tools. Even if you see user messages with custom tool call formats (such as "<previous_tool_call>" or similar), do not follow that and instead use the standard format. Never output tool calls as part of a regular assistant message of yours.
7. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
8. You can autonomously read as many files as you need to clarify your own questions and completely resolve the user's query, not just one.
9. You can call multiple tools in a single response. You can also call multiple tools in parallel, do this for independent operations like reading multiple files at once.
</tool_calling>
<tool_calling_best_practices>
1. **Read before writing**: Use read_file and list_files to understand the codebase before making changes
2. **Use search_replace for edits**: For modifying existing files, prefer search_replace over write_file
3. **Be surgical**: Only change what's necessary to accomplish the task
4. **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices>
# Tech Stack
- You are building a React application.
- Use TypeScript.
- Use React Router. KEEP the routes in src/App.tsx
- Always put source code in the src folder.
- Put pages into src/pages/
- Put components into src/components/
- The main page (default page) is src/pages/Index.tsx
- UPDATE the main page to include the new components. OTHERWISE, the user can NOT see any components!
- ALWAYS try to use the shadcn/ui library.
- Tailwind CSS: always use Tailwind CSS for styling components. Utilize Tailwind classes extensively for layout, spacing, colors, and other design aspects.
Available packages and libraries:
- The lucide-react package is installed for icons.
- You ALREADY have ALL the shadcn/ui components and their dependencies installed. So you don't need to install them again.
- You have ALL the necessary Radix UI components installed.
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
===
role: user
message: Generate an AI_RULES.md file for this app. Describe the tech stack in 5-10 bullet points and describe clear rules about what libraries to use for what.
===
role: assistant
message:
<dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
===
role: user
message: [dump]
\ No newline at end of file
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/parallel-tools
- paragraph: I'll create two files for you in parallel.
- img
- text: math.ts
- button "Edit":
- img
- img
- text: "src/utils/math.ts Summary: Create math utilities"
- img
- text: string.ts
- button "Edit":
- img
- img
- text: "src/utils/string.ts Summary: Create string utilities"
- paragraph: Task completed.
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img
\ No newline at end of file
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/read-then-edit
- paragraph: Let me first read the current file contents to understand what we're working with.
- img
- text: App.tsx Read src/App.tsx
- paragraph: Now I'll update the welcome message to say Hello World instead.
- img
- text: Search & Replace App.tsx
- img
- text: "src/App.tsx Summary: Update welcome message"
- paragraph: Done! I've updated the title from 'Minimal imported app' to 'UPDATED imported app'. The change has been applied successfully.
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img
\ No newline at end of file
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/add-dependency
- paragraph: I'll add a dependency to your project.
- img
- text: Do you want to install these packages? @dyad-sh/supabase-management-js Make sure these packages are what you want.
- paragraph: Dependency added done.
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img
\ No newline at end of file
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/add-dependency
- paragraph: I'll add a dependency to your project.
- img
- text: Do you want to install these packages? @dyad-sh/supabase-management-js Make sure these packages are what you want.
- paragraph: Dependency added done.
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img
\ No newline at end of file
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/add-dependency
- paragraph: I'll add a dependency to your project.
- img
- text: Do you want to install these packages? @dyad-sh/supabase-management-js Make sure these packages are what you want.
- img
- text: "Error Tool 'add_dependency' failed: User denied permission for add_dependency..."
- img
- button "Copy":
- img
- button "Fix with AI":
- img
- paragraph: Dependency added done.
- button:
- img
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img
\ No newline at end of file
......@@ -72,6 +72,7 @@
"html-to-image": "^1.11.13",
"isomorphic-git": "^1.30.1",
"jotai": "^2.12.2",
"jsonrepair": "^3.13.1",
"kill-port": "^2.0.1",
"konva": "^10.0.12",
"lexical": "^0.33.1",
......@@ -13990,6 +13991,15 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonrepair": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz",
"integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==",
"license": "ISC",
"bin": {
"jsonrepair": "bin/cli.js"
}
},
"node_modules/junk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
......
......@@ -148,6 +148,7 @@
"html-to-image": "^1.11.13",
"isomorphic-git": "^1.30.1",
"jotai": "^2.12.2",
"jsonrepair": "^3.13.1",
"kill-port": "^2.0.1",
"konva": "^10.0.12",
"lexical": "^0.33.1",
......
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const dbMocks = vi.hoisted(() => {
const where = vi.fn();
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
return { update, set, where };
});
const schemaMocks = vi.hoisted(() => {
return {
messages: {
createdAt: "messages.createdAt",
},
};
});
const logMocks = vi.hoisted(() => {
return {
log: vi.fn(),
warn: vi.fn(),
};
});
const drizzleMocks = vi.hoisted(() => {
return {
lt: vi.fn<(a: unknown, b: unknown) => string>(() => "LT_EXPR"),
};
});
vi.mock("@/db", () => ({
db: {
update: dbMocks.update,
},
}));
vi.mock("@/db/schema", () => ({
messages: schemaMocks.messages,
}));
vi.mock("electron-log", () => ({
default: {
scope: vi.fn(() => logMocks),
},
}));
vi.mock("drizzle-orm", () => ({
lt: drizzleMocks.lt,
}));
import {
AI_MESSAGES_TTL_DAYS,
cleanupOldAiMessagesJson,
} from "@/pro/main/ipc/handlers/local_agent/ai_messages_cleanup";
describe("cleanupOldAiMessagesJson", () => {
beforeEach(() => {
dbMocks.update.mockClear();
dbMocks.set.mockClear();
dbMocks.where.mockClear();
drizzleMocks.lt.mockClear();
logMocks.log.mockClear();
logMocks.warn.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("should use the expected TTL constant", () => {
expect(AI_MESSAGES_TTL_DAYS).toBe(30);
});
it("should clear aiMessagesJson for messages older than the cutoff date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-31T00:00:00.000Z"));
dbMocks.where.mockResolvedValueOnce(undefined);
await cleanupOldAiMessagesJson();
// db.update(messages).set({ aiMessagesJson: null }).where(...)
expect(dbMocks.update).toHaveBeenCalledTimes(1);
expect(dbMocks.update).toHaveBeenCalledWith(schemaMocks.messages);
expect(dbMocks.set).toHaveBeenCalledWith({ aiMessagesJson: null });
expect(dbMocks.where).toHaveBeenCalledTimes(1);
// lt(messages.createdAt, cutoffDate)
expect(drizzleMocks.lt).toHaveBeenCalledTimes(1);
const [createdAtArg, cutoffDateArg] = drizzleMocks.lt.mock.calls[0];
expect(createdAtArg).toBe(schemaMocks.messages.createdAt);
const nowSeconds = Math.floor(Date.now() / 1000);
const expectedCutoffSeconds =
nowSeconds - AI_MESSAGES_TTL_DAYS * 24 * 60 * 60;
const expectedCutoffDate = new Date(expectedCutoffSeconds * 1000);
expect(cutoffDateArg).toEqual(expectedCutoffDate);
expect(logMocks.log).toHaveBeenCalledWith(
"Cleaned up old ai_messages_json entries",
);
expect(logMocks.warn).not.toHaveBeenCalled();
});
it("should not throw if the cleanup fails (logs a warning)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-31T00:00:00.000Z"));
const err = new Error("boom");
dbMocks.where.mockRejectedValueOnce(err);
await expect(cleanupOldAiMessagesJson()).resolves.toBeUndefined();
expect(logMocks.warn).toHaveBeenCalledTimes(1);
expect(logMocks.warn.mock.calls[0][0]).toBe(
"Failed to cleanup old ai_messages_json:",
);
expect(logMocks.warn.mock.calls[0][1]).toBe(err);
});
});
import { describe, it, expect } from "vitest";
import {
parseAiMessagesJson,
getAiMessagesJsonIfWithinLimit,
MAX_AI_MESSAGES_SIZE,
type DbMessageForParsing,
} from "@/ipc/utils/ai_messages_utils";
import { AI_MESSAGES_SDK_VERSION } from "@/db/schema";
import type { ModelMessage } from "ai";
describe("parseAiMessagesJson", () => {
describe("current format (v5 envelope)", () => {
it("should parse valid v5 envelope format", () => {
const msg: DbMessageForParsing = {
id: 1,
role: "assistant",
content: "fallback content",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
],
},
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
]);
});
it("should parse v5 envelope with complex tool messages", () => {
const toolMessage: ModelMessage = {
role: "assistant",
content: [
{ type: "text", text: "Let me help you with that" },
{
type: "tool-call",
toolCallId: "call-123",
toolName: "read_file",
input: { path: "/src/index.ts" },
},
],
};
const msg: DbMessageForParsing = {
id: 2,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [toolMessage],
},
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([toolMessage]);
});
});
describe("legacy format (direct array)", () => {
it("should parse legacy array format", () => {
const legacyMessages: ModelMessage[] = [
{ role: "user", content: "Old message" },
{ role: "assistant", content: "Old response" },
];
const msg: DbMessageForParsing = {
id: 3,
role: "assistant",
content: "fallback",
aiMessagesJson: legacyMessages,
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual(legacyMessages);
});
it("should handle legacy array with various message types", () => {
const legacyMessages: ModelMessage[] = [
{ role: "user", content: "Question" },
{ role: "assistant", content: "Answer" },
{ role: "user", content: "Follow up" },
];
const msg: DbMessageForParsing = {
id: 4,
role: "assistant",
content: "fallback",
aiMessagesJson: legacyMessages,
};
const result = parseAiMessagesJson(msg);
expect(result).toHaveLength(3);
expect(result[0].role).toBe("user");
expect(result[2].role).toBe("user");
});
});
describe("fallback behavior", () => {
it("should fallback to role/content when aiMessagesJson is null", () => {
const msg: DbMessageForParsing = {
id: 5,
role: "assistant",
content: "Direct content",
aiMessagesJson: null,
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([
{ role: "assistant", content: "Direct content" },
]);
});
it("should fallback for user messages", () => {
const msg: DbMessageForParsing = {
id: 6,
role: "user",
content: "User question",
aiMessagesJson: null,
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([{ role: "user", content: "User question" }]);
});
it("should fallback when sdkVersion mismatches", () => {
const msg: DbMessageForParsing = {
id: 7,
role: "assistant",
content: "fallback content",
aiMessagesJson: {
sdkVersion: "ai@v999" as any, // Wrong version
messages: [{ role: "assistant", content: "Should not be used" }],
},
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([
{ role: "assistant", content: "fallback content" },
]);
});
it("should fallback when messages array is missing role", () => {
const msg: DbMessageForParsing = {
id: 8,
role: "assistant",
content: "fallback content",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [{ content: "No role here" } as any],
},
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([
{ role: "assistant", content: "fallback content" },
]);
});
it("should fallback when aiMessagesJson is an empty object", () => {
const msg: DbMessageForParsing = {
id: 9,
role: "user",
content: "fallback content",
aiMessagesJson: {} as any,
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([{ role: "user", content: "fallback content" }]);
});
it("should fallback when legacy array contains invalid entries", () => {
const msg: DbMessageForParsing = {
id: 10,
role: "assistant",
content: "fallback content",
aiMessagesJson: [
{ role: "user", content: "valid" },
{ noRole: true } as any,
] as any,
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([
{ role: "assistant", content: "fallback content" },
]);
});
it("should fallback when messages is not an array", () => {
const msg: DbMessageForParsing = {
id: 11,
role: "assistant",
content: "fallback content",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: "not an array" as any,
},
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([
{ role: "assistant", content: "fallback content" },
]);
});
});
describe("edge cases", () => {
it("should handle empty content in fallback", () => {
const msg: DbMessageForParsing = {
id: 12,
role: "assistant",
content: "",
aiMessagesJson: null,
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([{ role: "assistant", content: "" }]);
});
it("should handle empty messages array in v5 format", () => {
const msg: DbMessageForParsing = {
id: 13,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [],
},
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([]);
});
it("should handle empty legacy array", () => {
const msg: DbMessageForParsing = {
id: 14,
role: "assistant",
content: "fallback",
aiMessagesJson: [],
};
const result = parseAiMessagesJson(msg);
expect(result).toEqual([]);
});
});
});
describe("getAiMessagesJsonIfWithinLimit", () => {
it("should return undefined for empty array", () => {
const result = getAiMessagesJsonIfWithinLimit([]);
expect(result).toBeUndefined();
});
it("should return undefined for null/undefined", () => {
const result = getAiMessagesJsonIfWithinLimit(null as any);
expect(result).toBeUndefined();
});
it("should return valid payload for small messages", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
];
const result = getAiMessagesJsonIfWithinLimit(messages);
expect(result).toEqual({
messages,
sdkVersion: AI_MESSAGES_SDK_VERSION,
});
});
it("should return undefined for messages exceeding size limit", () => {
// Create a message that exceeds 1MB
const largeContent = "x".repeat(MAX_AI_MESSAGES_SIZE + 1000);
const messages: ModelMessage[] = [
{ role: "assistant", content: largeContent },
];
const result = getAiMessagesJsonIfWithinLimit(messages);
expect(result).toBeUndefined();
});
it("should return payload at exactly the size limit", () => {
// Calculate how much content we can fit
const basePayload = {
messages: [{ role: "assistant", content: "" }],
sdkVersion: AI_MESSAGES_SDK_VERSION,
};
const baseSize = JSON.stringify(basePayload).length;
const remainingSpace = MAX_AI_MESSAGES_SIZE - baseSize;
const messages: ModelMessage[] = [
{ role: "assistant", content: "a".repeat(remainingSpace) },
];
const result = getAiMessagesJsonIfWithinLimit(messages);
expect(result).toBeDefined();
expect(result?.messages).toEqual(messages);
});
it("should handle messages with complex content types", () => {
const messages: ModelMessage[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Here is the result" },
{
type: "tool-call",
toolCallId: "call-abc",
toolName: "write_file",
input: { path: "/test.ts", content: "console.log('test')" },
},
],
},
];
const result = getAiMessagesJsonIfWithinLimit(messages);
expect(result).toBeDefined();
expect(result?.sdkVersion).toBe(AI_MESSAGES_SDK_VERSION);
expect(result?.messages[0]).toEqual(messages[0]);
});
});
差异被折叠。
import { describe, it, expect } from "vitest";
import {
parseMcpToolKey,
buildMcpToolKey,
sanitizeMcpName,
MCP_TOOL_KEY_SEPARATOR,
} from "@/ipc/utils/mcp_tool_utils";
describe("parseMcpToolKey", () => {
describe("valid tool keys", () => {
it("should parse a simple server__tool key", () => {
const result = parseMcpToolKey("my-server__my-tool");
expect(result).toEqual({
serverName: "my-server",
toolName: "my-tool",
});
});
it("should parse key with underscores in server name", () => {
const result = parseMcpToolKey("my_server_name__tool");
expect(result).toEqual({
serverName: "my_server_name",
toolName: "tool",
});
});
it("should parse key with underscores in tool name", () => {
const result = parseMcpToolKey("server__my_tool_name");
expect(result).toEqual({
serverName: "server",
toolName: "my_tool_name",
});
});
it("should use the last separator when multiple exist", () => {
// This handles edge case where server name contains double underscores
const result = parseMcpToolKey("server__with__underscores__tool");
expect(result).toEqual({
serverName: "server__with__underscores",
toolName: "tool",
});
});
it("should parse key with hyphens", () => {
const result = parseMcpToolKey("my-mcp-server__read-file");
expect(result).toEqual({
serverName: "my-mcp-server",
toolName: "read-file",
});
});
it("should handle numeric characters", () => {
const result = parseMcpToolKey("server123__tool456");
expect(result).toEqual({
serverName: "server123",
toolName: "tool456",
});
});
});
describe("edge cases", () => {
it("should return empty serverName when no separator exists", () => {
const result = parseMcpToolKey("toolWithoutServer");
expect(result).toEqual({
serverName: "",
toolName: "toolWithoutServer",
});
});
it("should handle empty string", () => {
const result = parseMcpToolKey("");
expect(result).toEqual({
serverName: "",
toolName: "",
});
});
it("should handle key that is just the separator", () => {
const result = parseMcpToolKey("__");
expect(result).toEqual({
serverName: "",
toolName: "",
});
});
it("should handle separator at the start", () => {
const result = parseMcpToolKey("__tool");
expect(result).toEqual({
serverName: "",
toolName: "tool",
});
});
it("should handle separator at the end", () => {
const result = parseMcpToolKey("server__");
expect(result).toEqual({
serverName: "server",
toolName: "",
});
});
it("should handle single underscore (not a separator)", () => {
const result = parseMcpToolKey("server_tool");
expect(result).toEqual({
serverName: "",
toolName: "server_tool",
});
});
});
});
describe("buildMcpToolKey", () => {
it("should build a valid tool key from server and tool names", () => {
const result = buildMcpToolKey("my-server", "my-tool");
expect(result).toBe("my-server__my-tool");
});
it("should handle empty server name", () => {
const result = buildMcpToolKey("", "tool");
expect(result).toBe("__tool");
});
it("should handle empty tool name", () => {
const result = buildMcpToolKey("server", "");
expect(result).toBe("server__");
});
it("should handle both empty", () => {
const result = buildMcpToolKey("", "");
expect(result).toBe("__");
});
it("should be reversible with parseMcpToolKey", () => {
const serverName = "test-server";
const toolName = "test-tool";
const key = buildMcpToolKey(serverName, toolName);
const parsed = parseMcpToolKey(key);
expect(parsed).toEqual({ serverName, toolName });
});
});
describe("sanitizeMcpName", () => {
it("should pass through alphanumeric characters", () => {
const result = sanitizeMcpName("myServer123");
expect(result).toBe("myServer123");
});
it("should preserve underscores", () => {
const result = sanitizeMcpName("my_server_name");
expect(result).toBe("my_server_name");
});
it("should preserve hyphens", () => {
const result = sanitizeMcpName("my-server-name");
expect(result).toBe("my-server-name");
});
it("should replace spaces with hyphens", () => {
const result = sanitizeMcpName("My Server Name");
expect(result).toBe("My-Server-Name");
});
it("should replace special characters with hyphens", () => {
const result = sanitizeMcpName("server@name#test");
expect(result).toBe("server-name-test");
});
it("should replace dots with hyphens", () => {
const result = sanitizeMcpName("server.name.v1");
expect(result).toBe("server-name-v1");
});
it("should replace slashes with hyphens", () => {
const result = sanitizeMcpName("path/to/server");
expect(result).toBe("path-to-server");
});
it("should handle unicode characters", () => {
const result = sanitizeMcpName("서버名前サーバー");
expect(result).toBe("--------");
});
it("should handle empty string", () => {
const result = sanitizeMcpName("");
expect(result).toBe("");
});
it("should handle string with only special characters", () => {
const result = sanitizeMcpName("@#$%^&*()");
// 9 special characters = 9 hyphens
expect(result).toBe("---------");
});
it("should handle mixed valid and invalid characters", () => {
const result = sanitizeMcpName("Valid123_name-with.special@chars");
expect(result).toBe("Valid123_name-with-special-chars");
});
});
describe("MCP_TOOL_KEY_SEPARATOR", () => {
it("should be the expected separator value", () => {
expect(MCP_TOOL_KEY_SEPARATOR).toBe("__");
});
});
describe("integration tests", () => {
it("should sanitize and build a key, then parse it back", () => {
const rawServerName = "My MCP Server v1.0";
const rawToolName = "read_file@v2";
const sanitizedServer = sanitizeMcpName(rawServerName);
const sanitizedTool = sanitizeMcpName(rawToolName);
const key = buildMcpToolKey(sanitizedServer, sanitizedTool);
const parsed = parseMcpToolKey(key);
expect(sanitizedServer).toBe("My-MCP-Server-v1-0");
expect(sanitizedTool).toBe("read_file-v2");
expect(key).toBe("My-MCP-Server-v1-0__read_file-v2");
expect(parsed).toEqual({
serverName: "My-MCP-Server-v1-0",
toolName: "read_file-v2",
});
});
it("should handle the typical MCP server naming pattern", () => {
const serverName = "filesystem";
const toolName = "read_file";
const key = buildMcpToolKey(
sanitizeMcpName(serverName),
sanitizeMcpName(toolName),
);
expect(key).toBe("filesystem__read_file");
const parsed = parseMcpToolKey(key);
expect(parsed).toEqual({
serverName: "filesystem",
toolName: "read_file",
});
});
});
......@@ -22,3 +22,14 @@ export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
export const attachmentsAtom = atom<FileAttachment[]>([]);
// Agent tool consent request queue
export interface PendingAgentConsent {
requestId: string;
chatId: number;
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
}
export const pendingAgentConsentsAtom = atom<PendingAgentConsent[]>([]);
......@@ -12,6 +12,7 @@ import {
} from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import type { ChatMode } from "@/lib/schemas";
import { isDyadProEnabled } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { detectIsMac } from "@/hooks/useChatModeToggle";
......@@ -19,6 +20,7 @@ export function ChatModeSelector() {
const { settings, updateSettings } = useSettings();
const selectedMode = settings?.selectedChatMode || "build";
const isProEnabled = settings ? isDyadProEnabled(settings) : false;
const handleModeChange = (value: string) => {
updateSettings({ selectedChatMode: value as ChatMode });
......@@ -32,6 +34,8 @@ export function ChatModeSelector() {
return "Ask";
case "agent":
return "Build (MCP)";
case "local-agent":
return "Agent";
default:
return "Build";
}
......@@ -46,7 +50,7 @@ export function ChatModeSelector() {
data-testid="chat-mode-selector"
className={cn(
"h-6 w-fit px-1.5 py-0 text-xs-sm font-medium shadow-none gap-0.5",
selectedMode === "build"
selectedMode === "build" || selectedMode === "local-agent"
? "bg-background hover:bg-muted/50 focus:bg-muted/50"
: "bg-primary/10 hover:bg-primary/20 focus:bg-primary/20 text-primary border-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 dark:focus:bg-primary/30",
)}
......@@ -89,6 +93,16 @@ export function ChatModeSelector() {
</span>
</div>
</SelectItem>
{isProEnabled && settings?.experiments?.enableLocalAgent && (
<SelectItem value="local-agent">
<div className="flex flex-col items-start">
<span className="font-medium">Agent v2 (experimental)</span>
<span className="text-xs text-muted-foreground">
More autonomous (note: may have bugs)
</span>
</div>
</SelectItem>
)}
</SelectContent>
</Select>
);
......
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo";
import { useAtom } from "jotai";
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
import { useSettings } from "@/hooks/useSettings";
import type { UserSettings } from "@/lib/schemas";
const SETTINGS_SECTIONS = [
type SettingsSection = {
id: string;
label: string;
isEnabled?: (settings: UserSettings | null) => boolean;
};
const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "general-settings", label: "General" },
{ id: "workflow-settings", label: "Workflow" },
{ id: "ai-settings", label: "AI" },
{ id: "provider-settings", label: "Model Providers" },
{ id: "telemetry", label: "Telemetry" },
{ id: "integrations", label: "Integrations" },
{
id: "agent-permissions",
label: "Agent Permissions",
isEnabled: (settings) => !!settings?.experiments?.enableLocalAgent,
},
{ id: "tools-mcp", label: "Tools (MCP)" },
{ id: "experiments", label: "Experiments" },
{ id: "danger-zone", label: "Danger Zone" },
......@@ -19,11 +32,18 @@ const SETTINGS_SECTIONS = [
export function SettingsList({ show }: { show: boolean }) {
const [activeSection, setActiveSection] = useAtom(activeSettingsSectionAtom);
const { settings } = useSettings();
const scrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
behavior: "smooth",
block: "start",
});
const settingsSections = useMemo(() => {
return SETTINGS_SECTIONS.filter(
(section) => !section.isEnabled || section.isEnabled(settings ?? null),
);
}, [settings]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
......@@ -37,7 +57,7 @@ export function SettingsList({ show }: { show: boolean }) {
{ rootMargin: "-20% 0px -80% 0px", threshold: 0 },
);
for (const section of SETTINGS_SECTIONS) {
for (const section of settingsSections) {
const el = document.getElementById(section.id);
if (el) {
observer.observe(el);
......@@ -47,7 +67,7 @@ export function SettingsList({ show }: { show: boolean }) {
return () => {
observer.disconnect();
};
}, []);
}, [settingsSections, setActiveSection]);
if (!show) {
return null;
......@@ -62,7 +82,7 @@ export function SettingsList({ show }: { show: boolean }) {
</div>
<ScrollArea className="flex-grow">
<div className="space-y-1 p-4 pt-0">
{SETTINGS_SECTIONS.map((section) => (
{settingsSections.map((section) => (
<button
key={section.id}
onClick={() => handleScrollAndNavigateTo(section.id)}
......
import React from "react";
import { Button } from "../ui/button";
import { X, Bot, Info, ShieldCheck, Check, Ban } from "lucide-react";
import type { PendingAgentConsent } from "@/atoms/chatAtoms";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
interface AgentConsentBannerProps {
consent: PendingAgentConsent;
onDecision: (decision: "accept-once" | "accept-always" | "decline") => void;
onClose: () => void;
/** Total number of consents in the queue */
queueTotal?: number;
}
export function AgentConsentBanner({
consent,
onDecision,
onClose,
queueTotal = 1,
}: AgentConsentBannerProps) {
const { toolName, toolDescription, inputPreview } = consent;
// Collapsible input preview state
const [isInputExpanded, setIsInputExpanded] = React.useState(false);
const [inputCollapsedMaxHeight, setInputCollapsedMaxHeight] =
React.useState<number>(0);
const [inputHasOverflow, setInputHasOverflow] = React.useState(false);
const inputRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!inputPreview) {
setInputHasOverflow(false);
return;
}
const element = inputRef.current;
if (!element) return;
const compute = () => {
const computedStyle = window.getComputedStyle(element);
const lineHeight = parseFloat(computedStyle.lineHeight || "16");
const maxLines = 6;
const maxHeightPx = Math.max(0, Math.round(lineHeight * maxLines));
setInputCollapsedMaxHeight(maxHeightPx);
setInputHasOverflow(element.scrollHeight > maxHeightPx + 1);
};
compute();
const onResize = () => compute();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [inputPreview]);
return (
<div className="border-b border-border bg-muted/50">
<div className="p-2">
<div className="flex items-center gap-2 mb-1">
<Bot className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium">
Allow <span className="font-mono">{toolName}</span> to run?
{queueTotal > 1 && (
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
(1 of {queueTotal})
</span>
)}
</span>
{toolDescription && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">{toolDescription}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<button
onClick={onClose}
className="ml-auto flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors rounded hover:bg-muted"
aria-label="Close"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{inputPreview && (
<div className="ml-6 mb-1.5">
<div
ref={inputRef}
className="bg-muted p-1.5 rounded text-sm whitespace-pre-wrap"
style={{
maxHeight: isInputExpanded ? "40vh" : inputCollapsedMaxHeight,
overflow: isInputExpanded ? "auto" : "hidden",
}}
>
{inputPreview}
</div>
{inputHasOverflow && (
<button
type="button"
className="mt-0.5 text-xs text-muted-foreground hover:text-foreground hover:underline"
onClick={() => setIsInputExpanded((v) => !v)}
>
{isInputExpanded ? "Show less" : "Show more"}
</button>
)}
</div>
)}
<div className="flex items-center gap-2 ml-6">
<Button
onClick={() => onDecision("accept-always")}
size="sm"
variant="outline"
className="h-7 px-3 text-xs"
>
<ShieldCheck className="w-3.5 h-3.5 mr-1" />
Always allow
</Button>
<Button
onClick={() => onDecision("accept-once")}
size="sm"
variant="outline"
className="h-7 px-3 text-xs"
>
<Check className="w-3.5 h-3.5 mr-1" />
Allow once
</Button>
<Button
onClick={() => onDecision("decline")}
size="sm"
variant="outline"
className="h-7 px-3 text-xs"
>
<Ban className="w-3.5 h-3.5 mr-1" />
Decline
</Button>
</div>
</div>
</div>
);
}
......@@ -27,6 +27,7 @@ import {
chatInputValueAtom,
chatMessagesByIdAtom,
selectedChatIdAtom,
pendingAgentConsentsAtom,
} from "@/atoms/chatAtoms";
import { atom, useAtom, useSetAtom, useAtomValue } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat";
......@@ -63,6 +64,7 @@ import { showExtraFilesToast } from "@/lib/toast";
import { useSummarizeInNewChat } from "./SummarizeInNewChatButton";
import { ChatInputControls } from "../ChatInputControls";
import { ChatErrorBox } from "./ChatErrorBox";
import { AgentConsentBanner } from "./AgentConsentBanner";
import {
selectedComponentsPreviewAtom,
previewIframeRefAtom,
......@@ -105,6 +107,14 @@ export function ChatInput({ chatId }: { chatId?: number }) {
currentComponentCoordinatesAtom,
);
const setPendingVisualChanges = useSetAtom(pendingVisualChangesAtom);
const [pendingAgentConsents, setPendingAgentConsents] = useAtom(
pendingAgentConsentsAtom,
);
// Get the first consent in the queue for this chat (if any)
const consentsForThisChat = pendingAgentConsents.filter(
(c) => c.chatId === chatId,
);
const pendingAgentConsent = consentsForThisChat[0] ?? null;
const { checkProblems } = useCheckProblems(appId);
const { refreshAppIframe } = useRunApp();
// Use the attachments hook
......@@ -132,6 +142,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1);
const disableSendButton =
settings?.selectedChatMode !== "local-agent" &&
lastMessage?.role === "assistant" &&
!lastMessage.approvalState &&
!!proposal &&
......@@ -302,10 +313,43 @@ export function ChatInput({ chatId }: { chatId?: number }) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Only render ChatInputActions if proposal is loaded */}
{proposal &&
{/* Show agent consent banner if there's a pending consent request */}
{pendingAgentConsent && (
<AgentConsentBanner
consent={pendingAgentConsent}
queueTotal={consentsForThisChat.length}
onDecision={(decision) => {
IpcClient.getInstance().respondToAgentConsentRequest({
requestId: pendingAgentConsent.requestId,
decision,
});
// Remove this consent from the queue by requestId
setPendingAgentConsents((prev) =>
prev.filter(
(c) => c.requestId !== pendingAgentConsent.requestId,
),
);
}}
onClose={() => {
IpcClient.getInstance().respondToAgentConsentRequest({
requestId: pendingAgentConsent.requestId,
decision: "decline",
});
// Remove this consent from the queue by requestId
setPendingAgentConsents((prev) =>
prev.filter(
(c) => c.requestId !== pendingAgentConsent.requestId,
),
);
}}
/>
)}
{/* Only render ChatInputActions if proposal is loaded and no pending consent */}
{!pendingAgentConsent &&
proposal &&
proposalResult?.chatId === chatId &&
settings.selectedChatMode !== "ask" && (
settings.selectedChatMode !== "ask" &&
settings.selectedChatMode !== "local-agent" && (
<ChatInputActions
proposal={proposal}
onApprove={handleApprove}
......
import React from "react";
import { CustomTagState } from "./stateTypes";
import { Database, Loader2 } from "lucide-react";
interface DyadDatabaseSchemaProps {
node: {
properties: {
state?: CustomTagState;
};
};
children: React.ReactNode;
}
export function DyadDatabaseSchema({
node,
children,
}: DyadDatabaseSchemaProps) {
const { state } = node.properties;
const isLoading = state === "pending";
const content = typeof children === "string" ? children : "";
return (
<div className="my-2 border rounded-md overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b">
{isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<Database className="size-4 text-muted-foreground" />
)}
<span className="font-medium text-sm">Database Schema</span>
</div>
{content && (
<div className="p-3 text-xs font-mono whitespace-pre-wrap max-h-60 overflow-y-auto bg-muted/20">
{content}
</div>
)}
</div>
);
}
import React from "react";
import { CustomTagState } from "./stateTypes";
import { FolderOpen, Loader2 } from "lucide-react";
interface DyadListFilesProps {
node: {
properties: {
directory?: string;
state?: CustomTagState;
};
};
children: React.ReactNode;
}
export function DyadListFiles({ node, children }: DyadListFilesProps) {
const { directory, state } = node.properties;
const isLoading = state === "pending";
const content = typeof children === "string" ? children : "";
return (
<div className="my-2 border rounded-md overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b">
{isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<FolderOpen className="size-4 text-muted-foreground" />
)}
<span className="font-medium text-sm">
{directory ? `List Files: ${directory}` : "List Files"}
</span>
</div>
{content && (
<div className="p-3 text-xs font-mono whitespace-pre-wrap max-h-60 overflow-y-auto bg-muted/20">
{content}
</div>
)}
</div>
);
}
......@@ -26,10 +26,39 @@ import { DyadWebCrawl } from "./DyadWebCrawl";
import { DyadCodeSearchResult } from "./DyadCodeSearchResult";
import { DyadCodeSearch } from "./DyadCodeSearch";
import { DyadRead } from "./DyadRead";
import { DyadListFiles } from "./DyadListFiles";
import { DyadDatabaseSchema } from "./DyadDatabaseSchema";
import { mapActionToButton } from "./ChatInput";
import { SuggestedAction } from "@/lib/schemas";
import { FixAllErrorsButton } from "./FixAllErrorsButton";
const DYAD_CUSTOM_TAGS = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
"dyad-web-search",
"dyad-web-crawl",
"dyad-code-search-result",
"dyad-code-search",
"dyad-read",
"think",
"dyad-command",
"dyad-mcp-tool-call",
"dyad-mcp-tool-result",
"dyad-list-files",
"dyad-database-schema",
];
interface DyadMarkdownParserProps {
content: string;
}
......@@ -162,35 +191,12 @@ function preprocessUnclosedTags(content: string): {
processedContent: string;
inProgressTags: Map<string, Set<number>>;
} {
const customTagNames = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
"dyad-web-search",
"dyad-web-crawl",
"dyad-read",
"think",
"dyad-command",
"dyad-mcp-tool-call",
"dyad-mcp-tool-result",
];
let processedContent = content;
// Map to track which tags are in progress and their positions
const inProgressTags = new Map<string, Set<number>>();
// For each tag type, check if there are unclosed tags
for (const tagName of customTagNames) {
for (const tagName of DYAD_CUSTOM_TAGS) {
// Count opening and closing tags
const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g");
const closeTagPattern = new RegExp(`</${tagName}>`, "g");
......@@ -236,33 +242,8 @@ function preprocessUnclosedTags(content: string): {
function parseCustomTags(content: string): ContentPiece[] {
const { processedContent, inProgressTags } = preprocessUnclosedTags(content);
const customTagNames = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
"dyad-web-search",
"dyad-web-crawl",
"dyad-code-search-result",
"dyad-code-search",
"dyad-read",
"think",
"dyad-command",
"dyad-mcp-tool-call",
"dyad-mcp-tool-result",
];
const tagPattern = new RegExp(
`<(${customTagNames.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
`<(${DYAD_CUSTOM_TAGS.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
"gs",
);
......@@ -604,6 +585,33 @@ function renderCustomTag(
}
return null;
case "dyad-list-files":
return (
<DyadListFiles
node={{
properties: {
directory: attributes.directory || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadListFiles>
);
case "dyad-database-schema":
return (
<DyadDatabaseSchema
node={{
properties: {
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadDatabaseSchema>
);
default:
return null;
}
......
import React, { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useAgentTools,
type AgentToolName,
type AgentTool,
} from "@/hooks/useAgentTools";
import { Loader2, ChevronRight } from "lucide-react";
import type { AgentToolConsent } from "@/ipc/ipc_types";
export function AgentToolsSettings() {
const { tools, isLoading, setConsent } = useAgentTools();
const [showAutoApproved, setShowAutoApproved] = useState(false);
const handleConsentChange = (
toolName: AgentToolName,
consent: AgentToolConsent,
) => {
setConsent({ toolName, consent });
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
const autoApprovedTools =
tools?.filter((t: AgentTool) => t.isAllowedByDefault) || [];
const requiresApprovalTools =
tools?.filter((t: AgentTool) => !t.isAllowedByDefault) || [];
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Configure permissions for Agent built-in tools.
</p>
{/* Requires approval tools */}
<div className="space-y-2">
{requiresApprovalTools.map((tool: AgentTool) => (
<ToolConsentRow
key={tool.name}
name={tool.name}
description={tool.description}
consent={tool.consent}
onConsentChange={(consent) =>
handleConsentChange(tool.name as AgentToolName, consent)
}
/>
))}
</div>
{/* Auto-approved tools (collapsed by default) */}
<div className="space-y-3">
<button
type="button"
onClick={() => setShowAutoApproved(!showAutoApproved)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={`size-4 transition-transform ${showAutoApproved ? "rotate-90" : ""}`}
/>
<span>Default allowed tools ({autoApprovedTools.length})</span>
</button>
{showAutoApproved && (
<div className="space-y-2 pl-6">
{autoApprovedTools.map((tool: AgentTool) => (
<ToolConsentRow
key={tool.name}
name={tool.name}
description={tool.description}
consent={tool.consent}
onConsentChange={(consent) =>
handleConsentChange(tool.name as AgentToolName, consent)
}
/>
))}
</div>
)}
</div>
</div>
);
}
function ToolConsentRow({
name,
description,
consent,
onConsentChange,
}: {
name: string;
description: string;
consent: AgentToolConsent;
onConsentChange: (consent: AgentToolConsent) => void;
}) {
return (
<div className="border rounded p-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="font-mono text-sm">{name}</div>
<div className="text-xs text-muted-foreground truncate">
{description}
</div>
</div>
<Select
value={consent}
onValueChange={(v) => onConsentChange(v as AgentToolConsent)}
>
<SelectTrigger className="w-[140px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="always">Always allow</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
import type { ModelMessage } from "ai";
export const AI_MESSAGES_SDK_VERSION = "ai@v5" as const;
export type AiMessagesJsonV5 = {
messages: ModelMessage[];
sdkVersion: typeof AI_MESSAGES_SDK_VERSION;
};
export const prompts = sqliteTable("prompts", {
id: integer("id").primaryKey({ autoIncrement: true }),
......@@ -79,6 +87,10 @@ export const messages = sqliteTable("messages", {
requestId: text("request_id"),
// Max tokens used for this message (only for assistant messages)
maxTokensUsed: integer("max_tokens_used"),
// AI SDK messages (v5 envelope) for preserving tool calls/results in agent mode
aiMessagesJson: text("ai_messages_json", {
mode: "json",
}).$type<AiMessagesJsonV5 | null>(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
......
/**
* Hook for managing agent tools and their consents
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type { AgentToolName } from "../pro/main/ipc/handlers/local_agent/tool_definitions";
import type { AgentTool } from "@/ipc/ipc_types";
import type { AgentToolConsent } from "@/ipc/ipc_types";
// Re-export types for convenience
export type { AgentToolName, AgentTool };
export function useAgentTools() {
const queryClient = useQueryClient();
const toolsQuery = useQuery({
queryKey: ["agent-tools"],
queryFn: async () => {
const ipcClient = IpcClient.getInstance();
return ipcClient.getAgentTools();
},
});
const setConsentMutation = useMutation({
mutationFn: async (params: {
toolName: AgentToolName;
consent: AgentToolConsent;
}) => {
const ipcClient = IpcClient.getInstance();
return ipcClient.setAgentToolConsent(params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["agent-tools"] });
},
});
return {
tools: toolsQuery.data,
isLoading: toolsQuery.isLoading,
setConsent: setConsentMutation.mutateAsync,
};
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论