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
{
"version": "6",
"dialect": "sqlite",
"id": "22201ae3-5058-4e52-a244-e2a6a17ecd9f",
"prevId": "071199d7-dfb5-4681-85b7-228f1de3123a",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_parent_project_id": {
"name": "supabase_parent_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_servers": {
"name": "mcp_servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transport": {
"name": "transport",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env_json": {
"name": "env_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_tool_consents": {
"name": "mcp_tool_consents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consent": {
"name": "consent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ask'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"uniq_mcp_consent": {
"name": "uniq_mcp_consent",
"columns": [
"server_id",
"tool_name"
],
"isUnique": true
}
},
"foreignKeys": {
"mcp_tool_consents_server_id_mcp_servers_id_fk": {
"name": "mcp_tool_consents_server_id_mcp_servers_id_fk",
"tableFrom": "mcp_tool_consents",
"tableTo": "mcp_servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_commit_hash": {
"name": "source_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_tokens_used": {
"name": "max_tokens_used",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_messages_json": {
"name": "ai_messages_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
\ 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, vi, beforeEach } from "vitest";
import type { IpcMainInvokeEvent, WebContents } from "electron";
// ============================================================================
// Test Fakes & Builders
// ============================================================================
/**
* Creates a fake WebContents that records all sent messages
*/
function createFakeWebContents() {
const sentMessages: Array<{ channel: string; args: unknown[] }> = [];
return {
sender: {
isDestroyed: () => false,
isCrashed: () => false,
send: (channel: string, ...args: unknown[]) => {
sentMessages.push({ channel, args });
},
} as unknown as WebContents,
sentMessages,
getMessagesByChannel(channel: string) {
return sentMessages.filter((m) => m.channel === channel);
},
};
}
/**
* Creates a fake IPC event with a recordable sender
*/
function createFakeEvent() {
const webContents = createFakeWebContents();
return {
event: { sender: webContents.sender } as IpcMainInvokeEvent,
...webContents,
};
}
/**
* Builder for creating test chat/app data
*/
function buildTestChat(
overrides: {
chatId?: number;
appId?: number;
appPath?: string;
messages?: Array<{
id: number;
role: "user" | "assistant";
content: string;
aiMessagesJson?: unknown;
createdAt?: Date;
}>;
supabaseProjectId?: string | null;
} = {},
) {
const chatId = overrides.chatId ?? 1;
const appId = overrides.appId ?? 100;
const messages = overrides.messages ?? [
{
id: 1,
role: "user" as const,
content: "Hello",
createdAt: new Date("2025-01-01"),
},
];
return {
id: chatId,
appId,
title: "Test Chat",
createdAt: new Date(),
messages,
app: {
id: appId,
name: "Test App",
path: overrides.appPath ?? "test-app-path",
createdAt: new Date(),
updatedAt: new Date(),
supabaseProjectId: overrides.supabaseProjectId ?? null,
},
};
}
/**
* Creates a minimal settings object for testing
*/
function buildTestSettings(
overrides: {
enableDyadPro?: boolean;
hasApiKey?: boolean;
selectedModel?: string;
} = {},
) {
const baseSettings = {
selectedModel: overrides.selectedModel ?? "gpt-4",
};
if (overrides.enableDyadPro && overrides.hasApiKey !== false) {
return {
...baseSettings,
enableDyadPro: true,
providerSettings: {
auto: {
apiKey: { value: "test-api-key" },
},
},
};
}
return baseSettings;
}
/**
* Creates an async iterable that yields stream parts for testing
*/
function createFakeStream(
parts: Array<{
type: string;
text?: string;
id?: string;
toolName?: string;
delta?: string;
[key: string]: unknown;
}>,
) {
return {
fullStream: (async function* () {
for (const part of parts) {
yield part;
}
})(),
response: Promise.resolve({ messages: [] }),
};
}
// ============================================================================
// Mocks
// ============================================================================
// Mock electron-log
vi.mock("electron-log", () => ({
default: {
scope: () => ({
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
},
}));
// Track database operations
const dbOperations: {
updates: Array<{ table: string; id: number; data: Record<string, unknown> }>;
queries: Array<{ table: string; where: Record<string, unknown> }>;
} = { updates: [], queries: [] };
let mockChatData: ReturnType<typeof buildTestChat> | null = null;
vi.mock("@/db", () => ({
db: {
query: {
chats: {
findFirst: vi.fn(async () => mockChatData),
},
},
update: vi.fn(() => ({
set: vi.fn((data: Record<string, unknown>) => ({
where: vi.fn((condition: any) => {
dbOperations.updates.push({
table: "messages",
id: condition?.id ?? 0,
data,
});
return Promise.resolve();
}),
})),
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
})),
})),
},
}));
let mockSettings: ReturnType<typeof buildTestSettings> = buildTestSettings();
vi.mock("@/main/settings", () => ({
readSettings: vi.fn(() => mockSettings),
writeSettings: vi.fn(),
}));
vi.mock("@/paths/paths", () => ({
getDyadAppPath: vi.fn((appPath: string) => `/mock/apps/${appPath}`),
}));
// Track IPC messages sent via safeSend
vi.mock("@/ipc/utils/safe_sender", () => ({
safeSend: vi.fn((sender, channel, ...args) => {
if (sender && !sender.isDestroyed()) {
sender.send(channel, ...args);
}
}),
}));
let mockStreamResult: ReturnType<typeof createFakeStream> | null = null;
vi.mock("ai", () => ({
streamText: vi.fn(() => mockStreamResult),
stepCountIs: vi.fn((n: number) => ({ steps: n })),
}));
vi.mock("@/ipc/utils/get_model_client", () => ({
getModelClient: vi.fn(async () => ({
modelClient: {
model: { id: "test-model" },
builtinProviderId: "openai",
},
})),
}));
vi.mock("@/ipc/utils/token_utils", () => ({
getMaxTokens: vi.fn(async () => 4096),
getTemperature: vi.fn(async () => 0.7),
}));
vi.mock("@/ipc/utils/provider_options", () => ({
getProviderOptions: vi.fn(() => ({})),
getAiHeaders: vi.fn(() => ({})),
}));
vi.mock("@/ipc/utils/mcp_manager", () => ({
mcpManager: {
getClient: vi.fn(async () => ({
tools: vi.fn(async () => ({})),
})),
},
}));
vi.mock("@/pro/main/ipc/handlers/local_agent/tool_definitions", () => ({
TOOL_DEFINITIONS: [],
buildAgentToolSet: vi.fn(() => ({})),
requireAgentToolConsent: vi.fn(async () => true),
clearPendingConsentsForChat: vi.fn(),
}));
vi.mock(
"@/pro/main/ipc/handlers/local_agent/processors/file_operations",
() => ({
deployAllFunctionsIfNeeded: vi.fn(async () => {}),
commitAllChanges: vi.fn(async () => ({ commitHash: "abc123" })),
}),
);
// ============================================================================
// Import the function under test AFTER mocks are set up
// ============================================================================
import { handleLocalAgentStream } from "@/pro/main/ipc/handlers/local_agent/local_agent_handler";
// ============================================================================
// Tests
// ============================================================================
describe("handleLocalAgentStream", () => {
beforeEach(() => {
vi.clearAllMocks();
dbOperations.updates = [];
dbOperations.queries = [];
mockChatData = null;
mockSettings = buildTestSettings();
mockStreamResult = null;
});
describe("Pro status validation", () => {
it("should send error when Dyad Pro is not enabled", async () => {
// Arrange
const { event, getMessagesByChannel } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: false });
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert
const errorMessages = getMessagesByChannel("chat:response:error");
expect(errorMessages).toHaveLength(1);
expect(errorMessages[0].args[0]).toMatchObject({
chatId: 1,
error: expect.stringContaining("Agent v2 requires Dyad Pro"),
});
});
it("should send error when API key is missing even if Pro is enabled", async () => {
// Arrange
const { event, getMessagesByChannel } = createFakeEvent();
mockSettings = buildTestSettings({
enableDyadPro: true,
hasApiKey: false,
});
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert
const errorMessages = getMessagesByChannel("chat:response:error");
expect(errorMessages).toHaveLength(1);
});
});
describe("Chat lookup", () => {
it("should throw error when chat is not found", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = null; // Chat not found
// Act & Assert
await expect(
handleLocalAgentStream(
event,
{ chatId: 999, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
),
).rejects.toThrow("Chat not found: 999");
});
it("should throw error when chat has no associated app", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = { ...buildTestChat(), app: null } as any;
// Act & Assert
await expect(
handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
),
).rejects.toThrow("Chat not found: 1");
});
});
describe("Stream processing - text content", () => {
it("should accumulate text-delta parts and update database", async () => {
// Arrange
const { event, getMessagesByChannel } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat({
messages: [{ id: 1, role: "user", content: "Hello" }],
});
mockStreamResult = createFakeStream([
{ type: "text-delta", text: "Hello, " },
{ type: "text-delta", text: "world!" },
]);
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert - check that chunks were sent
const chunkMessages = getMessagesByChannel("chat:response:chunk");
expect(chunkMessages.length).toBeGreaterThan(0);
// Assert - check that end message was sent
const endMessages = getMessagesByChannel("chat:response:end");
expect(endMessages).toHaveLength(1);
expect(endMessages[0].args[0]).toMatchObject({
chatId: 1,
updatedFiles: true,
});
// Assert - verify database was updated with accumulated content
const contentUpdates = dbOperations.updates.filter(
(u) => u.data.content !== undefined,
);
expect(contentUpdates.length).toBeGreaterThan(0);
// Final content should contain both chunks
const lastContentUpdate = contentUpdates[contentUpdates.length - 1];
expect(lastContentUpdate.data.content).toContain("Hello, ");
expect(lastContentUpdate.data.content).toContain("world!");
});
});
describe("Stream processing - reasoning blocks", () => {
it("should wrap reasoning content in think tags", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat();
mockStreamResult = createFakeStream([
{ type: "reasoning-start" },
{ type: "reasoning-delta", text: "Let me think..." },
{ type: "reasoning-end" },
{ type: "text-delta", text: "Here is my answer." },
]);
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert - find the final content update
const contentUpdates = dbOperations.updates.filter(
(u) => u.data.content !== undefined,
);
expect(contentUpdates.length).toBeGreaterThan(0);
const finalContent = contentUpdates[contentUpdates.length - 1].data
.content as string;
expect(finalContent).toContain("<think>");
expect(finalContent).toContain("Let me think...");
expect(finalContent).toContain("</think>");
expect(finalContent).toContain("Here is my answer.");
});
it("should close thinking block when transitioning to text", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat();
// Simulate reasoning-delta without explicit reasoning-end before text
mockStreamResult = createFakeStream([
{ type: "reasoning-delta", text: "Thinking here" },
{ type: "text-delta", text: "Answer" },
]);
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert
const contentUpdates = dbOperations.updates.filter(
(u) => u.data.content !== undefined,
);
const finalContent = contentUpdates[contentUpdates.length - 1].data
.content as string;
// The thinking block should be closed before the answer
expect(finalContent).toContain("<think>");
expect(finalContent).toContain("</think>");
expect(finalContent).toContain("Answer");
// Verify order: </think> comes before "Answer"
const thinkEndIndex = finalContent.indexOf("</think>");
const answerIndex = finalContent.indexOf("Answer");
expect(thinkEndIndex).toBeLessThan(answerIndex);
});
});
describe("Abort handling", () => {
it("should stop processing stream chunks when abort signal is triggered", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat();
const abortController = new AbortController();
// Create a stream that will be aborted mid-way
let yieldCount = 0;
mockStreamResult = {
fullStream: (async function* () {
yield { type: "text-delta", text: "First " };
yieldCount++;
// Abort after first chunk
abortController.abort();
yield { type: "text-delta", text: "Second" };
yieldCount++;
})(),
response: Promise.resolve({ messages: [] }),
};
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
abortController,
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert - only first chunk should be processed (stream breaks on abort)
expect(yieldCount).toBe(1);
// Verify only the first chunk made it into the response
const contentUpdates = dbOperations.updates.filter(
(u) => u.data.content !== undefined,
);
expect(contentUpdates.length).toBeGreaterThan(0);
const finalContent = contentUpdates[contentUpdates.length - 1].data
.content as string;
expect(finalContent).toContain("First ");
expect(finalContent).not.toContain("Second");
});
it("should save partial response with cancellation note when aborted", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat();
const abortController = new AbortController();
mockStreamResult = {
fullStream: (async function* () {
yield { type: "text-delta", text: "Partial response" };
abortController.abort();
// This will not be processed due to abort
throw new Error("Simulated abort error");
})(),
response: Promise.resolve({ messages: [] }),
};
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
abortController,
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert - should have saved cancellation message
const contentUpdates = dbOperations.updates.filter(
(u) => u.data.content !== undefined,
);
const hasCancellationNote = contentUpdates.some((u) =>
(u.data.content as string).includes("[Response cancelled by user]"),
);
expect(hasCancellationNote).toBe(true);
});
});
describe("Commit handling", () => {
it("should save commit hash after successful stream", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat();
mockStreamResult = createFakeStream([
{ type: "text-delta", text: "Done" },
]);
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert - commit hash should be saved
const commitUpdates = dbOperations.updates.filter(
(u) => u.data.commitHash !== undefined,
);
expect(commitUpdates).toHaveLength(1);
expect(commitUpdates[0].data.commitHash).toBe("abc123");
});
it("should set approval state to approved after completion", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat();
mockStreamResult = createFakeStream([
{ type: "text-delta", text: "Done" },
]);
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{ placeholderMessageId: 10, systemPrompt: "You are helpful" },
);
// Assert - approval state should be set
const approvalUpdates = dbOperations.updates.filter(
(u) => u.data.approvalState !== undefined,
);
expect(approvalUpdates).toHaveLength(1);
expect(approvalUpdates[0].data.approvalState).toBe("approved");
});
});
});
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,
};
}
......@@ -53,11 +53,11 @@ import { readFile, writeFile, unlink } from "fs/promises";
import { getMaxTokens, getTemperature } from "../utils/token_utils";
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
import { validateChatContext } from "../utils/context_paths_utils";
import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google";
import { getProviderOptions, getAiHeaders } from "../utils/provider_options";
import { mcpServers } from "../../db/schema";
import { requireMcpToolConsent } from "../utils/mcp_consent";
import { getExtraProviderOptions } from "../utils/thinking_utils";
import { handleLocalAgentStream } from "../../pro/main/ipc/handlers/local_agent/local_agent_handler";
import { safeSend } from "../utils/safe_sender";
import { cleanFullResponse } from "../utils/cleanFullResponse";
......@@ -72,7 +72,6 @@ import {
} from "../utils/dyad_tag_parser";
import { fileExists } from "../utils/file_utils";
import { FileUploadsState } from "../utils/file_uploads_state";
import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import { prompts as promptsTable } from "../../db/schema";
......@@ -85,8 +84,9 @@ import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { getCurrentCommitHash } from "../utils/git_utils";
import {
processChatMessagesWithVersionedFiles as getVersionedFiles,
VersionedFiles as VersionedFiles,
VersionedFiles,
} from "../utils/versioned_codebase_context";
import { getAiMessagesJsonIfWithinLimit } from "../utils/ai_messages_utils";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
......@@ -220,6 +220,7 @@ async function processStreamChunks({
export function registerChatStreamHandlers() {
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
let attachmentPaths: string[] = [];
try {
const fileUploadsState = FileUploadsState.getInstance();
let dyadRequestId: string | undefined;
......@@ -278,7 +279,6 @@ export function registerChatStreamHandlers() {
// Process attachments if any
let attachmentInfo = "";
let attachmentPaths: string[] = [];
if (req.attachments && req.attachments.length > 0) {
attachmentInfo = "\n\nAttachments:\n";
......@@ -397,14 +397,15 @@ ${componentSnippet}
}
}
await db
const [insertedUserMessage] = await db
.insert(messages)
.values({
chatId: req.chatId,
role: "user",
content: userPrompt,
})
.returning();
.returning({ id: messages.id });
const userMessageId = insertedUserMessage.id;
const settings = readSettings();
// Only Dyad Pro requests have request ids.
if (settings.enableDyadPro) {
......@@ -603,8 +604,10 @@ ${componentSnippet}
);
}
const aiRules = await readAiRules(getDyadAppPath(updatedChat.app.path));
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
aiRules,
chatMode:
settings.selectedChatMode === "agent"
? "build"
......@@ -651,12 +654,17 @@ ${componentSnippet}
"\n\n" +
SUPABASE_AVAILABLE_SYSTEM_PROMPT +
"\n\n" +
(await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId,
}));
// For local agent, we will explicitly fetch the database context when needed.
(settings.selectedChatMode === "local-agent"
? ""
: await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId,
}));
} else if (
// Neon projects don't need Supabase.
!updatedChat.app?.neonProjectId &&
// In local agent mode, we will suggest supabase as part of the add-integration tool
settings.selectedChatMode !== "local-agent" &&
// If in security review mode, we don't need to mention supabase is available.
!isSecurityReviewIntent
) {
......@@ -768,17 +776,35 @@ This conversation includes one or more image attachments. When the user uploads
];
// Check if the last message should include attachments
if (chatMessages.length >= 2 && attachmentPaths.length > 0) {
if (chatMessages.length >= 2) {
const lastUserIndex = chatMessages.length - 2;
const lastUserMessage = chatMessages[lastUserIndex];
if (lastUserMessage.role === "user") {
// Replace the last message with one that includes attachments
chatMessages[lastUserIndex] = await prepareMessageWithAttachments(
lastUserMessage,
attachmentPaths,
);
if (attachmentPaths.length > 0) {
// Replace the last message with one that includes attachments
chatMessages[lastUserIndex] = await prepareMessageWithAttachments(
lastUserMessage,
attachmentPaths,
);
}
if (settings.selectedChatMode === "local-agent") {
// Insert into DB (with size guard)
const userAiMessagesJson = getAiMessagesJsonIfWithinLimit([
chatMessages[lastUserIndex],
]);
if (userAiMessagesJson) {
await db
.update(messages)
.set({ aiMessagesJson: userAiMessagesJson })
.where(eq(messages.id, userMessageId));
}
}
}
} else {
logger.warn(
"Unexpected number of chat messages:",
chatMessages.length,
);
}
if (isSummarizeIntent) {
......@@ -833,65 +859,22 @@ This conversation includes one or more image attachments. When the user uploads
const smartContextMode: SmartContextMode = isDeepContextEnabled
? "deep"
: "balanced";
// Build provider options with correct Google/Vertex thinking config gating
const providerOptions: Record<string, any> = {
"dyad-engine": {
dyadAppId: updatedChat.app.id,
dyadRequestId,
dyadDisableFiles,
dyadSmartContextMode: smartContextMode,
dyadFiles: versionedFiles ? undefined : files,
dyadVersionedFiles: versionedFiles,
dyadMentionedApps: mentionedAppsCodebases.map(
({ files, appName }) => ({
appName,
files,
}),
),
},
"dyad-gateway": getExtraProviderOptions(
modelClient.builtinProviderId,
settings,
),
openai: {
reasoningSummary: "auto",
} satisfies OpenAIResponsesProviderOptions,
};
// Conditionally include Google thinking config only for supported models
const selectedModelName = settings.selectedModel.name || "";
const providerId = modelClient.builtinProviderId;
const isVertex = providerId === "vertex";
const isGoogle = providerId === "google";
const isAnthropic = providerId === "anthropic";
const isPartnerModel = selectedModelName.includes("/");
const isGeminiModel = selectedModelName.startsWith("gemini");
const isFlashLite = selectedModelName.includes("flash-lite");
// Keep Google provider behavior unchanged: always include includeThoughts
if (isGoogle) {
providerOptions.google = {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions;
}
// Vertex-specific fix: only enable thinking on supported Gemini models
if (isVertex && isGeminiModel && !isFlashLite && !isPartnerModel) {
providerOptions.google = {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions;
}
const providerOptions = getProviderOptions({
dyadAppId: updatedChat.app.id,
dyadRequestId,
dyadDisableFiles,
smartContextMode,
files,
versionedFiles,
mentionedAppsCodebases,
builtinProviderId: modelClient.builtinProviderId,
settings,
});
const streamResult = streamText({
headers: isAnthropic
? {
"anthropic-beta": "context-1m-2025-08-07",
}
: undefined,
headers: getAiHeaders({
builtinProviderId: modelClient.builtinProviderId,
}),
maxOutputTokens: await getMaxTokens(settings.selectedModel),
temperature: await getTemperature(settings.selectedModel),
maxRetries: 2,
......@@ -1006,6 +989,20 @@ This conversation includes one or more image attachments. When the user uploads
return fullResponse;
};
// 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 (
settings.selectedChatMode === "local-agent" &&
!mentionedAppsCodebases.length
) {
await handleLocalAgentStream(event, req, abortController, {
placeholderMessageId: placeholderAssistantMessage.id,
systemPrompt,
});
return;
}
if (settings.selectedChatMode === "agent") {
const tools = await getMcpTools(event);
......@@ -1414,6 +1411,22 @@ ${problemReport.problems
}
}
// Return the chat ID for backwards compatibility
return req.chatId;
} catch (error) {
logger.error("Error calling LLM:", error);
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error: `Sorry, there was an error processing your request: ${error}`,
});
// Clean up file uploads state on error
FileUploadsState.getInstance().clear(req.chatId);
return "error";
} finally {
// Clean up the abort controller
activeStreams.delete(req.chatId);
// Clean up any temporary files
if (attachmentPaths.length > 0) {
for (const filePath of attachmentPaths) {
......@@ -1434,20 +1447,6 @@ ${problemReport.problems
}
}
}
// Return the chat ID for backwards compatibility
return req.chatId;
} catch (error) {
logger.error("Error calling LLM:", error);
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error: `Sorry, there was an error processing your request: ${error}`,
});
// Clean up the abort controller
activeStreams.delete(req.chatId);
// Clean up file uploads state on error
FileUploadsState.getInstance().clear(req.chatId);
return "error";
}
});
......
......@@ -63,7 +63,11 @@ export function registerTokenCountHandlers() {
// Count system prompt tokens
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode: settings.selectedChatMode,
chatMode:
settings.selectedChatMode === "agent" ||
settings.selectedChatMode === "local-agent"
? "build"
: settings.selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
});
let supabaseContext = "";
......
......@@ -72,6 +72,10 @@ import type {
SelectNodeFolderResult,
ApplyVisualEditingChangesParams,
AnalyseComponentParams,
AgentTool,
SetAgentToolConsentParams,
AgentToolConsentRequestPayload,
AgentToolConsentResponseParams,
} from "./ipc_types";
import type { Template } from "../shared/templates";
import type {
......@@ -126,12 +130,17 @@ export class IpcClient {
}
>;
private mcpConsentHandlers: Map<string, (payload: any) => void>;
private agentConsentHandlers: Map<string, (payload: any) => void>;
// Global handlers called for any chat stream completion (used for cleanup)
private globalChatStreamEndHandlers: Set<(chatId: number) => void>;
private constructor() {
this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer;
this.chatStreams = new Map();
this.appStreams = new Map();
this.helpStreams = new Map();
this.mcpConsentHandlers = new Map();
this.agentConsentHandlers = new Map();
this.globalChatStreamEndHandlers = new Set();
// Set up listeners for stream events
this.ipcRenderer.on("chat:response:chunk", (data) => {
if (
......@@ -191,6 +200,10 @@ export class IpcClient {
),
);
}
// Notify global handlers (used for cleanup like clearing pending consents)
for (const handler of this.globalChatStreamEndHandlers) {
handler(chatId);
}
});
this.ipcRenderer.on("chat:response:error", (payload) => {
......@@ -212,6 +225,10 @@ export class IpcClient {
this.chatStreams,
);
}
// Notify global handlers (used for cleanup like clearing pending consents)
for (const handler of this.globalChatStreamEndHandlers) {
handler(chatId);
}
} else {
console.error("[IPC] Invalid error data received:", payload);
}
......@@ -264,6 +281,12 @@ export class IpcClient {
const handler = this.mcpConsentHandlers.get("consent");
if (handler) handler(payload);
});
// Agent tool consent request from main
this.ipcRenderer.on("agent-tool:consent-request", (payload) => {
const handler = this.agentConsentHandlers.get("consent");
if (handler) handler(payload);
});
}
public static getInstance(): IpcClient {
......@@ -912,6 +935,40 @@ export class IpcClient {
});
}
// --- Agent Tool Methods ---
public async getAgentTools(): Promise<AgentTool[]> {
return this.ipcRenderer.invoke("agent-tool:get-tools");
}
public async setAgentToolConsent(params: SetAgentToolConsentParams) {
return this.ipcRenderer.invoke("agent-tool:set-consent", params);
}
public onAgentToolConsentRequest(
handler: (payload: AgentToolConsentRequestPayload) => void,
) {
this.agentConsentHandlers.set("consent", handler as any);
return () => {
this.agentConsentHandlers.delete("consent");
};
}
public respondToAgentConsentRequest(params: AgentToolConsentResponseParams) {
this.ipcRenderer.invoke("agent-tool:consent-response", params);
}
/**
* Subscribe to be notified when any chat stream ends (either successfully or with an error).
* Useful for cleanup tasks like clearing pending consent requests.
* @returns Unsubscribe function
*/
public onChatStreamEnd(handler: (chatId: number) => void): () => void {
this.globalChatStreamEndHandlers.add(handler);
return () => {
this.globalChatStreamEndHandlers.delete(handler);
};
}
// Get proposal details
public async getProposal(chatId: number): Promise<ProposalResult | null> {
try {
......
......@@ -33,6 +33,7 @@ import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
import { registerMcpHandlers } from "./handlers/mcp_handlers";
import { registerSecurityHandlers } from "./handlers/security_handlers";
import { registerVisualEditingHandlers } from "../pro/main/ipc/handlers/visual_editing_handlers";
import { registerAgentToolHandlers } from "../pro/main/ipc/handlers/local_agent/agent_tool_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
......@@ -71,4 +72,5 @@ export function registerIpcHandlers() {
registerMcpHandlers();
registerSecurityHandlers();
registerVisualEditingHandlers();
registerAgentToolHandlers();
}
......@@ -582,3 +582,40 @@ export interface AnalyseComponentParams {
appId: number;
componentId: string;
}
// --- Agent Tool Types ---
export interface AgentTool {
name: string;
description: string;
isAllowedByDefault: boolean;
consent: AgentToolConsent;
}
export interface SetAgentToolConsentParams {
toolName: string;
consent: AgentToolConsent;
}
export interface AgentToolConsentRequestPayload {
requestId: string;
chatId: number;
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
}
export type AgentToolConsentDecision =
| "accept-once"
| "accept-always"
| "decline";
export interface AgentToolConsentResponseParams {
requestId: string;
decision: AgentToolConsentDecision;
}
// ============================================================================
// Consent Types
// ============================================================================
export type AgentToolConsent = "ask" | "always";
import { AI_MESSAGES_SDK_VERSION, AiMessagesJsonV5 } from "@/db/schema";
import type { ModelMessage } from "ai";
import log from "electron-log";
const logger = log.scope("ai_messages_utils");
/** Maximum size in bytes for ai_messages_json (1MB) */
export const MAX_AI_MESSAGES_SIZE = 1_000_000;
/**
* Check if ai_messages_json is within size limits and return the value to save.
* Returns undefined if the messages exceed the size limit.
*/
export function getAiMessagesJsonIfWithinLimit(
aiMessages: ModelMessage[],
): AiMessagesJsonV5 | undefined {
if (!aiMessages || aiMessages.length === 0) {
return undefined;
}
const payload: AiMessagesJsonV5 = {
messages: aiMessages,
sdkVersion: AI_MESSAGES_SDK_VERSION,
};
const jsonStr = JSON.stringify(payload);
if (jsonStr.length <= MAX_AI_MESSAGES_SIZE) {
return payload;
}
logger.warn(
`ai_messages_json too large (${jsonStr.length} bytes), skipping save`,
);
return undefined;
}
// Type for a message from the database used by parseAiMessagesJson
export type DbMessageForParsing = {
id: number;
role: string;
content: string;
aiMessagesJson: AiMessagesJsonV5 | ModelMessage[] | null;
};
/**
* Parse ai_messages_json with graceful fallback to simple content reconstruction.
* If aiMessagesJson is missing, malformed, or incompatible with the current AI SDK,
* falls back to constructing a basic message from role and content.
*
* This is a pure function - it doesn't log or have side effects.
*/
export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
if (msg.aiMessagesJson) {
const parsed = msg.aiMessagesJson;
// Legacy shape: stored directly as a ModelMessage[]
if (
Array.isArray(parsed) &&
parsed.every((m) => m && typeof m.role === "string")
) {
return parsed;
}
// Current shape: { messages: ModelMessage[]; sdkVersion: "ai@v5" }
if (
parsed &&
typeof parsed === "object" &&
"sdkVersion" in parsed &&
(parsed as AiMessagesJsonV5).sdkVersion === AI_MESSAGES_SDK_VERSION &&
"messages" in parsed &&
Array.isArray((parsed as AiMessagesJsonV5).messages) &&
(parsed as AiMessagesJsonV5).messages.every(
(m: ModelMessage) => m && typeof m.role === "string",
)
) {
return (parsed as AiMessagesJsonV5).messages;
}
}
// Fallback for legacy messages, missing data, or incompatible formats
return [
{
role: msg.role as "user" | "assistant",
content: msg.content,
},
];
}
......@@ -2,6 +2,7 @@ import { db } from "../../db";
import { mcpToolConsents } from "../../db/schema";
import { and, eq } from "drizzle-orm";
import { IpcMainInvokeEvent } from "electron";
import crypto from "node:crypto";
export type Consent = "ask" | "always" | "denied";
......@@ -90,7 +91,7 @@ export async function requireMcpToolConsent(
if (current === "denied") return false;
// Ask renderer for a decision via event bridge
const requestId = `${params.serverId}:${params.toolName}:${Date.now()}`;
const requestId = `${params.serverId}:${params.toolName}:${crypto.randomUUID()}`;
(event.sender as any).send("mcp:tool-consent-request", {
requestId,
...params,
......
/**
* Utility functions for MCP (Model Context Protocol) tools
*/
/**
* Separator used between server name and tool name in MCP tool keys.
*/
export const MCP_TOOL_KEY_SEPARATOR = "__";
/**
* Parse an MCP tool key into its component parts.
* Tool keys are formatted as "serverName__toolName".
*
* @param toolKey - The composite tool key (e.g., "my-server__my-tool")
* @returns An object containing the serverName and toolName.
* If no separator is found, serverName will be empty and toolName will be the full key.
*/
export function parseMcpToolKey(toolKey: string): {
serverName: string;
toolName: string;
} {
const lastIndex = toolKey.lastIndexOf(MCP_TOOL_KEY_SEPARATOR);
if (lastIndex === -1) {
return { serverName: "", toolName: toolKey };
}
const serverName = toolKey.slice(0, lastIndex);
const toolName = toolKey.slice(lastIndex + MCP_TOOL_KEY_SEPARATOR.length);
return { serverName, toolName };
}
/**
* Build an MCP tool key from server name and tool name.
*
* @param serverName - The name of the MCP server
* @param toolName - The name of the tool
* @returns The composite tool key
*/
export function buildMcpToolKey(serverName: string, toolName: string): string {
return `${serverName}${MCP_TOOL_KEY_SEPARATOR}${toolName}`;
}
/**
* Sanitize a name for use in an MCP tool key.
* Replaces any characters that aren't alphanumeric, underscore, or hyphen with a hyphen.
*
* @param name - The name to sanitize
* @returns The sanitized name safe for use in tool keys
*/
export function sanitizeMcpName(name: string): string {
return String(name).replace(/[^a-zA-Z0-9_-]/g, "-");
}
import type { SmartContextMode, UserSettings } from "../../lib/schemas";
import type { CodebaseFile } from "../../utils/codebase";
import type { VersionedFiles } from "./versioned_codebase_context";
import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google";
import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
import { getExtraProviderOptions } from "./thinking_utils";
export interface MentionedAppCodebase {
appName: string;
files: CodebaseFile[];
}
export interface GetProviderOptionsParams {
dyadAppId: number;
dyadRequestId?: string;
dyadDisableFiles?: boolean;
smartContextMode?: SmartContextMode;
files: CodebaseFile[];
versionedFiles?: VersionedFiles;
mentionedAppsCodebases: MentionedAppCodebase[];
builtinProviderId: string | undefined;
settings: UserSettings;
}
/**
* Builds provider options for the AI SDK streamText call.
* Handles provider-specific configuration including thinking configs for Google/Vertex.
*/
export function getProviderOptions({
dyadAppId,
dyadRequestId,
dyadDisableFiles,
smartContextMode,
files,
versionedFiles,
mentionedAppsCodebases,
builtinProviderId,
settings,
}: GetProviderOptionsParams): Record<string, any> {
const providerOptions: Record<string, any> = {
"dyad-engine": {
dyadAppId,
dyadRequestId,
dyadDisableFiles,
dyadSmartContextMode: smartContextMode,
dyadFiles: versionedFiles ? undefined : files,
dyadVersionedFiles: versionedFiles,
dyadMentionedApps: mentionedAppsCodebases.map(({ files, appName }) => ({
appName,
files,
})),
},
"dyad-gateway": getExtraProviderOptions(builtinProviderId, settings),
openai: {
reasoningSummary: "auto",
} satisfies OpenAIResponsesProviderOptions,
};
// Conditionally include Google thinking config only for supported models
const selectedModelName = settings.selectedModel.name || "";
const providerId = builtinProviderId;
const isVertex = providerId === "vertex";
const isGoogle = providerId === "google";
const isPartnerModel = selectedModelName.includes("/");
const isGeminiModel = selectedModelName.startsWith("gemini");
const isFlashLite = selectedModelName.includes("flash-lite");
// Keep Google provider behavior unchanged: always include includeThoughts
if (isGoogle) {
providerOptions.google = {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions;
}
// Vertex-specific fix: only enable thinking on supported Gemini models
if (isVertex && isGeminiModel && !isFlashLite && !isPartnerModel) {
providerOptions.google = {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions;
}
return providerOptions;
}
export interface GetAiHeadersParams {
builtinProviderId: string | undefined;
}
/**
* Returns AI request headers based on the provider.
* Currently adds Anthropic-specific beta header for extended context.
*/
export function getAiHeaders({
builtinProviderId,
}: GetAiHeadersParams): Record<string, string> | undefined {
if (builtinProviderId === "anthropic") {
return {
"anthropic-beta": "context-1m-2025-08-07",
};
}
return undefined;
}
......@@ -142,7 +142,7 @@ export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
export const RuntimeMode2Schema = z.enum(["host", "docker"]);
export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>;
export const ChatModeSchema = z.enum(["build", "ask", "agent"]);
export const ChatModeSchema = z.enum(["build", "ask", "agent", "local-agent"]);
export type ChatMode = z.infer<typeof ChatModeSchema>;
export const GitHubSecretsSchema = z.object({
......@@ -172,6 +172,7 @@ export const NeonSchema = z.object({
export type Neon = z.infer<typeof NeonSchema>;
export const ExperimentsSchema = z.object({
enableLocalAgent: z.boolean().optional(),
// Deprecated
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
enableFileEditing: z.boolean().describe("DEPRECATED").optional(),
......@@ -220,12 +221,17 @@ export const SmartContextModeSchema = z.enum([
"deep",
]);
export type SmartContextMode = z.infer<typeof SmartContextModeSchema>;
export const AgentToolConsentSchema = z.enum(["ask", "always"]);
export type AgentToolConsent = z.infer<typeof AgentToolConsentSchema>;
/**
* Zod schema for user settings
*/
export const UserSettingsSchema = z.object({
selectedModel: LargeLanguageModelSchema,
providerSettings: z.record(z.string(), ProviderSettingSchema),
agentToolConsents: z.record(z.string(), AgentToolConsentSchema).optional(),
githubUser: GithubUserSchema.optional(),
githubAccessToken: SecretSchema.optional(),
vercelAccessToken: SecretSchema.optional(),
......
......@@ -28,6 +28,7 @@ import {
startPerformanceMonitoring,
stopPerformanceMonitoring,
} from "./utils/performance_monitor";
import { cleanupOldAiMessagesJson } from "./pro/main/ipc/handlers/local_agent/ai_messages_cleanup";
import fs from "fs";
log.errorHandler.startCatching();
......@@ -85,6 +86,10 @@ export async function onReady() {
logger.error("Error initializing backup manager", e);
}
initializeDatabase();
// Cleanup old ai_messages_json entries to prevent database bloat
cleanupOldAiMessagesJson();
const settings = readSettings();
// Check if app was force-closed
......
......@@ -26,6 +26,7 @@ import { NeonIntegration } from "@/components/NeonIntegration";
import { RuntimeModeSelector } from "@/components/RuntimeModeSelector";
import { NodePathSelector } from "@/components/NodePathSelector";
import { ToolsMcpSettings } from "@/components/settings/ToolsMcpSettings";
import { AgentToolsSettings } from "@/components/settings/AgentToolsSettings";
import { ZoomSelector } from "@/components/ZoomSelector";
import { useSetAtom } from "jotai";
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
......@@ -129,6 +130,19 @@ export default function SettingsPage() {
</div>
</div>
{/* Agent v2 Permissions */}
{settings?.experiments?.enableLocalAgent && (
<div
id="agent-permissions"
className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6"
>
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Agent Permissions
</h2>
<AgentToolsSettings />
</div>
)}
{/* Tools (MCP) */}
<div
id="tools-mcp"
......@@ -167,6 +181,27 @@ export default function SettingsPage() {
a faster, native-Git performance experience.
</div>
</div>
<div className="space-y-1 mt-4">
<div className="flex items-center space-x-2">
<Switch
id="enable-local-agent"
checked={!!settings?.experiments?.enableLocalAgent}
onCheckedChange={(checked) => {
updateSettings({
experiments: {
...settings?.experiments,
enableLocalAgent: checked,
},
});
}}
/>
<Label htmlFor="enable-local-agent">Enable Agent v2</Label>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Enables the local agent with enhanced capabilities and tool
permissions.
</div>
</div>
</div>
</div>
......
......@@ -123,6 +123,10 @@ const validInvokeChannels = [
"mcp:set-tool-consent",
// MCP consent response from renderer to main
"mcp:tool-consent-response",
// Agent Tools (Local Agent v2)
"agent-tool:get-tools",
"agent-tool:set-consent",
"agent-tool:consent-response",
// Help
"take-screenshot",
// Help bot
......@@ -161,6 +165,8 @@ const validReceiveChannels = [
"help:chat:response:error",
// MCP consent request from main to renderer
"mcp:tool-consent-request",
// Agent tool consent request from main to renderer
"agent-tool:consent-request",
] as const;
type ValidInvokeChannel = (typeof validInvokeChannels)[number];
......
/**
* IPC handlers for agent tool consent management
*/
import {
getAllAgentToolConsents,
setAgentToolConsent,
resolveAgentToolConsent,
TOOL_DEFINITIONS,
getDefaultConsent,
type AgentToolName,
} from "./tool_definitions";
import { createLoggedHandler } from "@/ipc/handlers/safe_handle";
import log from "electron-log";
import type {
AgentTool,
SetAgentToolConsentParams,
AgentToolConsentResponseParams,
} from "@/ipc/ipc_types";
const logger = log.scope("agent_tool_handlers");
const handle = createLoggedHandler(logger);
export function registerAgentToolHandlers() {
// Get list of available tools with their consent settings
handle("agent-tool:get-tools", async (): Promise<AgentTool[]> => {
const consents = getAllAgentToolConsents();
return TOOL_DEFINITIONS.map((tool) => ({
name: tool.name,
description: tool.description,
isAllowedByDefault: getDefaultConsent(tool.name) === "always",
consent: consents[tool.name],
}));
});
// Set consent for a single tool
handle(
"agent-tool:set-consent",
async (_event, params: SetAgentToolConsentParams) => {
setAgentToolConsent(params.toolName as AgentToolName, params.consent);
return { success: true };
},
);
// Handle consent response from renderer
handle(
"agent-tool:consent-response",
async (_event, params: AgentToolConsentResponseParams) => {
resolveAgentToolConsent(params.requestId, params.decision);
},
);
}
import log from "electron-log";
import { lt } from "drizzle-orm";
import { db } from "@/db";
import { messages } from "@/db/schema";
const logger = log.scope("ai_messages_cleanup");
export const AI_MESSAGES_TTL_DAYS = 30;
/**
* Clear ai_messages_json for messages older than TTL.
* Run on app startup to prevent database bloat.
*/
export async function cleanupOldAiMessagesJson() {
const cutoffSeconds =
Math.floor(Date.now() / 1000) - AI_MESSAGES_TTL_DAYS * 24 * 60 * 60;
const cutoffDate = new Date(cutoffSeconds * 1000);
try {
await db
.update(messages)
.set({ aiMessagesJson: null })
.where(lt(messages.createdAt, cutoffDate));
logger.log("Cleaned up old ai_messages_json entries");
} catch (err) {
logger.warn("Failed to cleanup old ai_messages_json:", err);
}
}
/**
* Local Agent v2 Handler
* Main orchestrator for tool-based agent mode with parallel execution
*/
import { IpcMainInvokeEvent } from "electron";
import { streamText, ToolSet, stepCountIs, ModelMessage } from "ai";
import log from "electron-log";
import { db } from "@/db";
import { chats, messages } from "@/db/schema";
import { eq } from "drizzle-orm";
import { isDyadProEnabled } from "@/lib/schemas";
import { readSettings } from "@/main/settings";
import { getDyadAppPath } from "@/paths/paths";
import { getModelClient } from "@/ipc/utils/get_model_client";
import { safeSend } from "@/ipc/utils/safe_sender";
import { getMaxTokens, getTemperature } from "@/ipc/utils/token_utils";
import { getProviderOptions, getAiHeaders } from "@/ipc/utils/provider_options";
import {
AgentToolName,
buildAgentToolSet,
requireAgentToolConsent,
clearPendingConsentsForChat,
} from "./tool_definitions";
import {
deployAllFunctionsIfNeeded,
commitAllChanges,
} from "./processors/file_operations";
import { mcpManager } from "@/ipc/utils/mcp_manager";
import { mcpServers } from "@/db/schema";
import { requireMcpToolConsent } from "@/ipc/utils/mcp_consent";
import { getAiMessagesJsonIfWithinLimit } from "@/ipc/utils/ai_messages_utils";
import type { ChatStreamParams, ChatResponseEnd } from "@/ipc/ipc_types";
import {
AgentContext,
parsePartialJson,
escapeXmlAttr,
escapeXmlContent,
} from "./tools/types";
import { TOOL_DEFINITIONS } from "./tool_definitions";
import { parseAiMessagesJson } from "@/ipc/utils/ai_messages_utils";
import { parseMcpToolKey, sanitizeMcpName } from "@/ipc/utils/mcp_tool_utils";
const logger = log.scope("local_agent_handler");
// ============================================================================
// Tool Streaming State Management
// ============================================================================
/**
* Track streaming state per tool call ID
*/
interface ToolStreamingEntry {
toolName: string;
argsAccumulated: string;
}
const toolStreamingEntries = new Map<string, ToolStreamingEntry>();
function getOrCreateStreamingEntry(
id: string,
toolName?: string,
): ToolStreamingEntry | undefined {
let entry = toolStreamingEntries.get(id);
if (!entry && toolName) {
entry = {
toolName,
argsAccumulated: "",
};
toolStreamingEntries.set(id, entry);
}
return entry;
}
function cleanupStreamingEntry(id: string): void {
toolStreamingEntries.delete(id);
}
function findToolDefinition(toolName: string) {
return TOOL_DEFINITIONS.find((t) => t.name === toolName);
}
/**
* Handle a chat stream in local-agent mode
*/
export async function handleLocalAgentStream(
event: IpcMainInvokeEvent,
req: ChatStreamParams,
abortController: AbortController,
{
placeholderMessageId,
systemPrompt,
}: { placeholderMessageId: number; systemPrompt: string },
): Promise<void> {
const settings = readSettings();
// Check Pro status
if (!isDyadProEnabled(settings)) {
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error:
"Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.",
});
return;
}
// Get the chat and app
const chat = await db.query.chats.findFirst({
where: eq(chats.id, req.chatId),
with: {
messages: {
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
},
app: true,
},
});
if (!chat || !chat.app) {
throw new Error(`Chat not found: ${req.chatId}`);
}
const appPath = getDyadAppPath(chat.app.path);
// Generate request ID
// Send initial message update
safeSend(event.sender, "chat:response:chunk", {
chatId: req.chatId,
messages: chat.messages,
});
let fullResponse = "";
let streamingPreview = ""; // Temporary preview for current tool, not persisted
try {
// Get model client
const { modelClient } = await getModelClient(
settings.selectedModel,
settings,
);
// Build tool execute context
const ctx: AgentContext = {
event,
appPath,
chatId: chat.id,
supabaseProjectId: chat.app.supabaseProjectId,
messageId: placeholderMessageId,
isSharedModulesChanged: false,
onXmlStream: (accumulatedXml: string) => {
// Stream accumulated XML to UI without persisting
streamingPreview = accumulatedXml;
sendResponseChunk(
event,
req.chatId,
chat,
fullResponse + streamingPreview,
);
},
onXmlComplete: (finalXml: string) => {
// Write final XML to DB and UI
fullResponse += finalXml + "\n";
streamingPreview = ""; // Clear preview
updateResponseInDb(placeholderMessageId, fullResponse);
sendResponseChunk(event, req.chatId, chat, fullResponse);
},
requireConsent: async (params: {
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
}) => {
return requireAgentToolConsent(event, {
chatId: chat.id,
toolName: params.toolName as AgentToolName,
toolDescription: params.toolDescription,
inputPreview: params.inputPreview,
});
},
};
// Build tool set (agent tools + MCP tools)
const agentTools = buildAgentToolSet(ctx);
const mcpTools = await getMcpTools(event, ctx);
const allTools: ToolSet = { ...agentTools, ...mcpTools };
// Prepare message history with graceful fallback
const messageHistory: ModelMessage[] = chat.messages
.filter((msg) => msg.content || msg.aiMessagesJson)
.flatMap((msg) => parseAiMessagesJson(msg));
// Stream the response
const streamResult = streamText({
model: modelClient.model,
headers: getAiHeaders({
builtinProviderId: modelClient.builtinProviderId,
}),
providerOptions: getProviderOptions({
dyadAppId: chat.app.id,
dyadDisableFiles: true, // Local agent uses tools, not file injection
files: [],
mentionedAppsCodebases: [],
builtinProviderId: modelClient.builtinProviderId,
settings,
}),
maxOutputTokens: await getMaxTokens(settings.selectedModel),
temperature: await getTemperature(settings.selectedModel),
maxRetries: 2,
system: systemPrompt,
messages: messageHistory,
tools: allTools,
stopWhen: stepCountIs(25), // Allow multiple tool call rounds
abortSignal: abortController.signal,
onFinish: async (response) => {
const totalTokens = response.usage?.totalTokens;
const inputTokens = response.usage?.inputTokens;
const cachedInputTokens = response.usage?.cachedInputTokens;
logger.log(
"Total tokens used:",
totalTokens,
"Input tokens:",
inputTokens,
"Cached input tokens:",
cachedInputTokens,
"Cache hit ratio:",
cachedInputTokens ? (cachedInputTokens ?? 0) / (inputTokens ?? 0) : 0,
);
if (typeof totalTokens === "number") {
await db
.update(messages)
.set({ maxTokensUsed: totalTokens })
.where(eq(messages.id, placeholderMessageId))
.catch((err) => logger.error("Failed to save token count", err));
}
},
onError: (error: any) => {
const errorMessage = error?.error?.message || JSON.stringify(error);
logger.error("Local agent stream error:", errorMessage);
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error: `AI error: ${errorMessage}`,
});
},
});
// Process the stream
let inThinkingBlock = false;
for await (const part of streamResult.fullStream) {
if (abortController.signal.aborted) {
logger.log(`Stream aborted for chat ${req.chatId}`);
// Clean up pending consent requests to prevent stale UI banners
clearPendingConsentsForChat(req.chatId);
break;
}
let chunk = "";
// Handle thinking block transitions
if (
inThinkingBlock &&
!["reasoning-delta", "reasoning-end", "reasoning-start"].includes(
part.type,
)
) {
chunk = "</think>\n";
inThinkingBlock = false;
}
switch (part.type) {
case "text-delta":
chunk += part.text;
break;
case "reasoning-start":
if (!inThinkingBlock) {
chunk = "<think>";
inThinkingBlock = true;
}
break;
case "reasoning-delta":
if (!inThinkingBlock) {
chunk = "<think>";
inThinkingBlock = true;
}
chunk += part.text;
break;
case "reasoning-end":
if (inThinkingBlock) {
chunk = "</think>\n";
inThinkingBlock = false;
}
break;
case "tool-input-start": {
// Initialize streaming state for this tool call
getOrCreateStreamingEntry(part.id, part.toolName);
break;
}
case "tool-input-delta": {
// Accumulate args and stream XML preview
const entry = getOrCreateStreamingEntry(part.id);
if (entry) {
entry.argsAccumulated += part.delta;
const toolDef = findToolDefinition(entry.toolName);
if (toolDef?.buildXml) {
const argsPartial = parsePartialJson(entry.argsAccumulated);
const xml = toolDef.buildXml(argsPartial, false);
if (xml) {
ctx.onXmlStream(xml);
}
}
}
break;
}
case "tool-input-end": {
// Build final XML and persist
const entry = getOrCreateStreamingEntry(part.id);
if (entry) {
const toolDef = findToolDefinition(entry.toolName);
if (toolDef?.buildXml) {
const argsPartial = parsePartialJson(entry.argsAccumulated);
const xml = toolDef.buildXml(argsPartial, true);
if (xml) {
ctx.onXmlComplete(xml);
}
}
}
cleanupStreamingEntry(part.id);
break;
}
case "tool-call":
// Tool execution happens via execute callbacks
break;
case "tool-result":
// Tool results are already handled by the execute callback
break;
}
if (chunk) {
fullResponse += chunk;
await updateResponseInDb(placeholderMessageId, fullResponse);
sendResponseChunk(event, req.chatId, chat, fullResponse);
}
}
// Close thinking block if still open
if (inThinkingBlock) {
fullResponse += "</think>\n";
await updateResponseInDb(placeholderMessageId, fullResponse);
}
// Save the AI SDK messages for multi-turn tool call preservation
try {
const response = await streamResult.response;
const aiMessagesJson = getAiMessagesJsonIfWithinLimit(response.messages);
if (aiMessagesJson) {
await db
.update(messages)
.set({ aiMessagesJson })
.where(eq(messages.id, placeholderMessageId));
}
} catch (err) {
logger.warn("Failed to save AI messages JSON:", err);
}
// Deploy all Supabase functions if shared modules changed
await deployAllFunctionsIfNeeded(ctx);
// Commit all changes
const commitResult = await commitAllChanges(ctx, ctx.chatSummary);
if (commitResult.commitHash) {
await db
.update(messages)
.set({ commitHash: commitResult.commitHash })
.where(eq(messages.id, placeholderMessageId));
}
// Mark as approved (auto-approve for local-agent)
await db
.update(messages)
.set({ approvalState: "approved" })
.where(eq(messages.id, placeholderMessageId));
// Send completion
safeSend(event.sender, "chat:response:end", {
chatId: req.chatId,
updatedFiles: true,
} satisfies ChatResponseEnd);
return;
} catch (error) {
// Clean up any pending consent requests for this chat to prevent
// stale UI banners and orphaned promises
clearPendingConsentsForChat(req.chatId);
if (abortController.signal.aborted) {
// Handle cancellation
if (fullResponse) {
await db
.update(messages)
.set({ content: `${fullResponse}\n\n[Response cancelled by user]` })
.where(eq(messages.id, placeholderMessageId));
}
return;
}
logger.error("Local agent error:", error);
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error: `Error: ${error}`,
});
return;
}
}
async function updateResponseInDb(messageId: number, content: string) {
await db
.update(messages)
.set({ content })
.where(eq(messages.id, messageId))
.catch((err) => logger.error("Failed to update message", err));
}
function sendResponseChunk(
event: IpcMainInvokeEvent,
chatId: number,
chat: any,
fullResponse: string,
) {
const currentMessages = [...chat.messages];
if (currentMessages.length > 0) {
const lastMsg = currentMessages[currentMessages.length - 1];
if (lastMsg.role === "assistant") {
lastMsg.content = fullResponse;
}
}
safeSend(event.sender, "chat:response:chunk", {
chatId,
messages: currentMessages,
});
}
async function getMcpTools(
event: IpcMainInvokeEvent,
ctx: AgentContext,
): Promise<ToolSet> {
const mcpToolSet: ToolSet = {};
try {
const servers = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.enabled, true as any));
for (const s of servers) {
const client = await mcpManager.getClient(s.id);
const toolSet = await client.tools();
for (const [name, tool] of Object.entries(toolSet)) {
const key = `${sanitizeMcpName(s.name || "")}__${sanitizeMcpName(name)}`;
const original = tool;
mcpToolSet[key] = {
description: original?.description,
inputSchema: original?.inputSchema,
execute: async (args: any, execCtx: any) => {
try {
const inputPreview =
typeof args === "string"
? args
: Array.isArray(args)
? args.join(" ")
: JSON.stringify(args).slice(0, 500);
const ok = await requireMcpToolConsent(event, {
serverId: s.id,
serverName: s.name,
toolName: name,
toolDescription: original?.description,
inputPreview,
});
if (!ok) throw new Error(`User declined running tool ${key}`);
// Emit XML for UI (MCP tools don't stream, so use onXmlComplete directly)
const { serverName, toolName } = parseMcpToolKey(key);
const content = JSON.stringify(args, null, 2);
ctx.onXmlComplete(
`<dyad-mcp-tool-call server="${serverName}" tool="${toolName}">\n${content}\n</dyad-mcp-tool-call>`,
);
const res = await original.execute?.(args, execCtx);
const resultStr =
typeof res === "string" ? res : JSON.stringify(res);
ctx.onXmlComplete(
`<dyad-mcp-tool-result server="${serverName}" tool="${toolName}">\n${resultStr}\n</dyad-mcp-tool-result>`,
);
return resultStr;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorStack =
error instanceof Error && error.stack ? error.stack : "";
ctx.onXmlComplete(
`<dyad-output type="error" message="MCP tool '${key}' failed: ${escapeXmlAttr(errorMessage)}">${escapeXmlContent(errorStack || errorMessage)}</dyad-output>`,
);
throw error;
}
},
};
}
}
} catch (e) {
logger.warn("Failed building MCP toolset for local-agent", e);
}
return mcpToolSet;
}
/**
* Shared file operations for both XML-based (Build mode) and Tool-based (Local Agent) processing
*/
import log from "electron-log";
import {
gitCommit,
gitAddAll,
getGitUncommittedFiles,
} from "@/ipc/utils/git_utils";
import { deployAllSupabaseFunctions } from "../../../../../../supabase_admin/supabase_utils";
import type { AgentContext } from "../tools/types";
const logger = log.scope("file_operations");
export interface FileOperationResult {
success: boolean;
error?: string;
warning?: string;
}
/**
* Deploy all Supabase functions (after shared module changes)
*/
export async function deployAllFunctionsIfNeeded(
ctx: Pick<
AgentContext,
"appPath" | "supabaseProjectId" | "isSharedModulesChanged"
>,
): Promise<FileOperationResult> {
if (!ctx.supabaseProjectId || !ctx.isSharedModulesChanged) {
return { success: true };
}
try {
logger.info("Shared modules changed, redeploying all Supabase functions");
const deployErrors = await deployAllSupabaseFunctions({
appPath: ctx.appPath,
supabaseProjectId: ctx.supabaseProjectId,
});
if (deployErrors.length > 0) {
return {
success: true,
warning: `Some Supabase functions failed to deploy: ${deployErrors.join(", ")}`,
};
}
return { success: true };
} catch (error) {
return {
success: false,
error: `Failed to redeploy Supabase functions: ${error}`,
};
}
}
/**
* Commit all changes
*/
export async function commitAllChanges(
ctx: Pick<AgentContext, "appPath" | "supabaseProjectId">,
chatSummary?: string,
): Promise<{
commitHash?: string;
}> {
try {
// Check for uncommitted changes
const uncommittedFiles = await getGitUncommittedFiles({
path: ctx.appPath,
});
const message = chatSummary
? `[dyad] ${chatSummary}`
: `[dyad] (${uncommittedFiles.length} files changed)`;
let commitHash: string | undefined;
if (uncommittedFiles.length > 0) {
await gitAddAll({ path: ctx.appPath });
try {
commitHash = await gitCommit({
path: ctx.appPath,
message: message,
});
} catch (error) {
logger.error(
`Failed to commit extra files: ${uncommittedFiles.join(", ")}`,
error,
);
}
}
return {
commitHash,
};
} catch (error) {
logger.error(`Failed to commit changes: ${error}`);
throw new Error(`Failed to commit changes: ${error}`);
}
}
/**
* Tool definitions for Local Agent v2
* Each tool includes a zod schema, description, and execute function
*/
import { IpcMainInvokeEvent } from "electron";
import crypto from "node:crypto";
import { readSettings, writeSettings } from "@/main/settings";
import { writeFileTool } from "./tools/write_file";
import { deleteFileTool } from "./tools/delete_file";
import { renameFileTool } from "./tools/rename_file";
import { addDependencyTool } from "./tools/add_dependency";
import { executeSqlTool } from "./tools/execute_sql";
import { searchReplaceTool } from "./tools/search_replace";
import { readFileTool } from "./tools/read_file";
import { listFilesTool } from "./tools/list_files";
import { getDatabaseSchemaTool } from "./tools/get_database_schema";
import { setChatSummaryTool } from "./tools/set_chat_summary";
import { addIntegrationTool } from "./tools/add_integration";
import {
escapeXmlAttr,
escapeXmlContent,
type ToolDefinition,
type AgentContext,
} from "./tools/types";
import type { AgentToolConsent } from "@/ipc/ipc_types";
import { getSupabaseClientCode } from "@/supabase_admin/supabase_context";
// Combined tool definitions array
export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
writeFileTool,
deleteFileTool,
renameFileTool,
addDependencyTool,
executeSqlTool,
searchReplaceTool,
readFileTool,
listFilesTool,
getDatabaseSchemaTool,
setChatSummaryTool,
addIntegrationTool,
];
// ============================================================================
// Agent Tool Name Type (derived from TOOL_DEFINITIONS)
// ============================================================================
export type AgentToolName = (typeof TOOL_DEFINITIONS)[number]["name"];
// ============================================================================
// Agent Tool Consent Management
// ============================================================================
interface PendingConsentEntry {
chatId: number;
resolve: (d: "accept-once" | "accept-always" | "decline") => void;
}
const pendingConsentResolvers = new Map<string, PendingConsentEntry>();
export function waitForAgentToolConsent(
requestId: string,
chatId: number,
): Promise<"accept-once" | "accept-always" | "decline"> {
return new Promise((resolve) => {
pendingConsentResolvers.set(requestId, { chatId, resolve });
});
}
export function resolveAgentToolConsent(
requestId: string,
decision: "accept-once" | "accept-always" | "decline",
) {
const entry = pendingConsentResolvers.get(requestId);
if (entry) {
pendingConsentResolvers.delete(requestId);
entry.resolve(decision);
}
}
/**
* Clean up all pending consent requests for a given chat.
* Called when a stream is cancelled/aborted to prevent orphaned promises
* and stale UI banners.
*/
export function clearPendingConsentsForChat(chatId: number): void {
for (const [requestId, entry] of pendingConsentResolvers) {
if (entry.chatId === chatId) {
pendingConsentResolvers.delete(requestId);
// Resolve with decline so the tool execution fails gracefully
entry.resolve("decline");
}
}
}
export function getDefaultConsent(toolName: AgentToolName): AgentToolConsent {
const tool = TOOL_DEFINITIONS.find((t) => t.name === toolName);
return tool?.defaultConsent ?? "ask";
}
export function getAgentToolConsent(toolName: AgentToolName): AgentToolConsent {
const settings = readSettings();
const stored = settings.agentToolConsents?.[toolName];
if (stored) {
return stored;
}
return getDefaultConsent(toolName);
}
export function setAgentToolConsent(
toolName: AgentToolName,
consent: AgentToolConsent,
): void {
const settings = readSettings();
writeSettings({
agentToolConsents: {
...settings.agentToolConsents,
[toolName]: consent,
},
});
}
export function getAllAgentToolConsents(): Record<
AgentToolName,
AgentToolConsent
> {
const settings = readSettings();
const stored = settings.agentToolConsents ?? {};
const result: Record<string, AgentToolConsent> = {};
// Start with defaults, override with stored values
for (const tool of TOOL_DEFINITIONS) {
const storedConsent = stored[tool.name];
if (storedConsent) {
result[tool.name] = storedConsent;
} else {
result[tool.name] = getDefaultConsent(tool.name as AgentToolName);
}
}
return result as Record<AgentToolName, AgentToolConsent>;
}
export async function requireAgentToolConsent(
event: IpcMainInvokeEvent,
params: {
chatId: number;
toolName: AgentToolName;
toolDescription?: string | null;
inputPreview?: string | null;
},
): Promise<boolean> {
const current = getAgentToolConsent(params.toolName);
if (current === "always") return true;
// Ask renderer for a decision via event bridge
const requestId = `agent:${params.toolName}:${crypto.randomUUID()}`;
(event.sender as any).send("agent-tool:consent-request", {
requestId,
...params,
});
const response = await waitForAgentToolConsent(requestId, params.chatId);
if (response === "accept-always") {
setAgentToolConsent(params.toolName, "always");
return true;
}
if (response === "decline") {
return false;
}
return response === "accept-once";
}
// ============================================================================
// Build Agent Tool Set
// ============================================================================
/**
* Process placeholders in tool args (e.g. $$SUPABASE_CLIENT_CODE$$)
* Recursively processes all string values in the args object.
*/
async function processArgPlaceholders<T extends Record<string, any>>(
args: T,
ctx: AgentContext,
): Promise<T> {
if (!ctx.supabaseProjectId) {
return args;
}
// Check if any string values contain the placeholder
const argsStr = JSON.stringify(args);
if (!argsStr.includes("$$SUPABASE_CLIENT_CODE$$")) {
return args;
}
// Fetch the replacement value once
const supabaseClientCode = await getSupabaseClientCode({
projectId: ctx.supabaseProjectId,
});
// Process all string values in args
const processValue = (value: any): any => {
if (typeof value === "string") {
return value.replace(/\$\$SUPABASE_CLIENT_CODE\$\$/g, supabaseClientCode);
}
if (Array.isArray(value)) {
return value.map(processValue);
}
if (value && typeof value === "object") {
const result: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
result[k] = processValue(v);
}
return result;
}
return value;
};
return processValue(args) as T;
}
/**
* Build ToolSet for AI SDK from tool definitions
*/
export function buildAgentToolSet(ctx: AgentContext) {
const toolSet: Record<string, any> = {};
for (const tool of TOOL_DEFINITIONS) {
if (tool.isEnabled && !tool.isEnabled(ctx)) {
continue;
}
toolSet[tool.name] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (args: any) => {
try {
const processedArgs = await processArgPlaceholders(args, ctx);
// Check consent before executing the tool
const allowed = await ctx.requireConsent({
toolName: tool.name,
toolDescription: tool.description,
inputPreview: tool.getConsentPreview?.(processedArgs) ?? null,
});
if (!allowed) {
throw new Error(`User denied permission for ${tool.name}`);
}
return await tool.execute(processedArgs, ctx);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorStack =
error instanceof Error && error.stack ? error.stack : "";
ctx.onXmlComplete(
`<dyad-output type="error" message="Tool '${tool.name}' failed: ${escapeXmlAttr(errorMessage)}">${escapeXmlContent(errorStack || errorMessage)}</dyad-output>`,
);
throw error;
}
},
};
}
return toolSet;
}
import { z } from "zod";
import { eq } from "drizzle-orm";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { db } from "../../../../../../db";
import { messages } from "../../../../../../db/schema";
import { executeAddDependency } from "@/ipc/processors/executeAddDependency";
const addDependencySchema = z.object({
packages: z.array(z.string()).describe("Array of package names to install"),
});
export const addDependencyTool: ToolDefinition<
z.infer<typeof addDependencySchema>
> = {
name: "add_dependency",
description: "Install npm packages",
inputSchema: addDependencySchema,
defaultConsent: "ask",
getConsentPreview: (args) => `Install ${args.packages.join(", ")}`,
buildXml: (args, _isComplete) => {
if (!args.packages || args.packages.length === 0) return undefined;
return `<dyad-add-dependency packages="${escapeXmlAttr(args.packages.join(" "))}"></dyad-add-dependency>`;
},
execute: async (args, ctx: AgentContext) => {
const message = ctx.messageId
? await db.query.messages.findFirst({
where: eq(messages.id, ctx.messageId),
})
: undefined;
if (!message) {
throw new Error("Message not found for adding dependencies");
}
await executeAddDependency({
packages: args.packages,
message,
appPath: ctx.appPath,
});
return `Successfully installed ${args.packages.join(", ")}`;
},
};
import { z } from "zod";
import { ToolDefinition, escapeXmlAttr } from "./types";
const SUPPORTED_PROVIDERS = ["supabase"] as const;
const addIntegrationSchema = z.object({
provider: z
.enum(SUPPORTED_PROVIDERS)
.describe("The integration provider to add (e.g., 'supabase')"),
});
export const addIntegrationTool: ToolDefinition<
z.infer<typeof addIntegrationSchema>
> = {
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.",
inputSchema: addIntegrationSchema,
defaultConsent: "always",
isEnabled: (ctx) => !ctx.supabaseProjectId,
getConsentPreview: (args) => `Add ${args.provider} integration`,
buildXml: (args, _isComplete) => {
if (!args.provider) return undefined;
return `<dyad-add-integration provider="${escapeXmlAttr(args.provider)}"></dyad-add-integration>`;
},
execute: async (args) => {
// The actual integration setup is handled by the UI when user clicks the button
// This tool just emits the XML that renders the integration prompt
return `Integration prompt for ${args.provider} displayed. User can click to set up the integration.`;
},
};
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { gitRemove } from "@/ipc/utils/git_utils";
import { deleteSupabaseFunction } from "../../../../../../supabase_admin/supabase_management_client";
import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
const logger = log.scope("delete_file");
function getFunctionNameFromPath(input: string): string {
return path.basename(path.extname(input) ? path.dirname(input) : input);
}
const deleteFileSchema = z.object({
path: z.string().describe("The file path to delete"),
});
export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> =
{
name: "delete_file",
description: "Delete a file from the codebase",
inputSchema: deleteFileSchema,
defaultConsent: "always",
getConsentPreview: (args) => `Delete ${args.path}`,
buildXml: (args, _isComplete) => {
if (!args.path) return undefined;
return `<dyad-delete path="${escapeXmlAttr(args.path)}"></dyad-delete>`;
},
execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path);
// Track if this is a shared module
if (isSharedServerModule(args.path)) {
ctx.isSharedModulesChanged = true;
}
if (fs.existsSync(fullFilePath)) {
if (fs.lstatSync(fullFilePath).isDirectory()) {
fs.rmdirSync(fullFilePath, { recursive: true });
} else {
fs.unlinkSync(fullFilePath);
}
logger.log(`Successfully deleted file: ${fullFilePath}`);
// Remove from git
try {
await gitRemove({ path: ctx.appPath, filepath: args.path });
} catch (error) {
logger.warn(`Failed to git remove deleted file ${args.path}:`, error);
}
// Delete Supabase function if applicable
if (ctx.supabaseProjectId && isServerFunction(args.path)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.path),
});
} catch (error) {
return `File deleted, but failed to delete Supabase function: ${error}`;
}
}
} else {
logger.warn(`File to delete does not exist: ${fullFilePath}`);
}
return `Successfully deleted ${args.path}`;
},
};
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { executeSupabaseSql } from "../../../../../../supabase_admin/supabase_management_client";
import { writeMigrationFile } from "../../../../../../ipc/utils/file_utils";
import { readSettings } from "../../../../../../main/settings";
const executeSqlSchema = z.object({
query: z.string().describe("The SQL query to execute"),
description: z.string().optional().describe("Brief description of the query"),
});
export const executeSqlTool: ToolDefinition<z.infer<typeof executeSqlSchema>> =
{
name: "execute_sql",
description: "Execute SQL on the Supabase database",
inputSchema: executeSqlSchema,
defaultConsent: "ask",
isEnabled: (ctx) => !!ctx.supabaseProjectId,
getConsentPreview: (args) =>
args.query.slice(0, 100) + (args.query.length > 100 ? "..." : ""),
buildXml: (args, isComplete) => {
if (args.query == undefined) return undefined;
let xml = `<dyad-execute-sql description="${escapeXmlAttr(args.description ?? "")}">\n${args.query}`;
if (isComplete) {
xml += "\n</dyad-execute-sql>";
}
return xml;
},
execute: async (args, ctx: AgentContext) => {
if (!ctx.supabaseProjectId) {
throw new Error("Supabase is not connected to this app");
}
await executeSupabaseSql({
supabaseProjectId: ctx.supabaseProjectId,
query: args.query,
});
// Write migration file if enabled
const settings = readSettings();
if (settings.enableSupabaseWriteSqlMigration) {
try {
await writeMigrationFile(ctx.appPath, args.query, args.description);
} catch (error) {
return `SQL executed, but failed to write migration file: ${error}`;
}
}
return "Successfully executed SQL query";
},
};
import { z } from "zod";
import { ToolDefinition, AgentContext } from "./types";
import { getSupabaseContext } from "../../../../../../supabase_admin/supabase_context";
const getDatabaseSchemaSchema = z.object({});
const XML_TAG = "<dyad-database-schema></dyad-database-schema>";
export const getDatabaseSchemaTool: ToolDefinition<
z.infer<typeof getDatabaseSchemaSchema>
> = {
name: "get_database_schema",
description: "Fetch the database schema from Supabase",
inputSchema: getDatabaseSchemaSchema,
defaultConsent: "always",
isEnabled: (ctx) => !!ctx.supabaseProjectId,
getConsentPreview: () => "Get Supabase schema",
buildXml: (_args, _isComplete) => {
// This tool has no inputs, so always return the same XML
return XML_TAG;
},
execute: async (_args, ctx: AgentContext) => {
if (!ctx.supabaseProjectId) {
throw new Error("Supabase is not connected to this app");
}
const schema = await getSupabaseContext({
supabaseProjectId: ctx.supabaseProjectId,
});
return schema || "";
},
};
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { extractCodebase } from "../../../../../../utils/codebase";
const listFilesSchema = z.object({
directory: z.string().optional().describe("Optional subdirectory to list"),
});
export const listFilesTool: ToolDefinition<z.infer<typeof listFilesSchema>> = {
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.",
inputSchema: listFilesSchema,
defaultConsent: "always",
getConsentPreview: (args) =>
args.directory ? `List ${args.directory}` : "List all files",
buildXml: (args, _isComplete) => {
const dirAttr = args.directory
? ` directory="${escapeXmlAttr(args.directory)}"`
: "";
return `<dyad-list-files${dirAttr}></dyad-list-files>`;
},
execute: async (args, ctx: AgentContext) => {
const { files } = await extractCodebase({
appPath: ctx.appPath,
// TODO
chatContext: {
contextPaths: args.directory
? [{ globPath: args.directory + "/**" }]
: [],
smartContextAutoIncludes: [],
excludePaths: [],
},
});
return files.map((file) => " - " + file.path).join("\n") || "";
},
};
import fs from "node:fs";
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
const readFile = fs.promises.readFile;
const readFileSchema = z.object({
path: z.string().describe("The file path to read"),
});
export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
name: "read_file",
description: `Read the content of a file from the codebase.
- 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.`,
inputSchema: readFileSchema,
defaultConsent: "always",
getConsentPreview: (args) => `Read ${args.path}`,
buildXml: (args, _isComplete) => {
if (!args.path) return undefined;
return `<dyad-read path="${escapeXmlAttr(args.path)}"></dyad-read>`;
},
execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path);
if (!fs.existsSync(fullFilePath)) {
throw new Error(`File does not exist: ${args.path}`);
}
const content = await readFile(fullFilePath, "utf8");
return content || "";
},
};
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { gitAdd, gitRemove } from "@/ipc/utils/git_utils";
import {
deploySupabaseFunction,
deleteSupabaseFunction,
} from "../../../../../../supabase_admin/supabase_management_client";
import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
const logger = log.scope("rename_file");
function getFunctionNameFromPath(input: string): string {
return path.basename(path.extname(input) ? path.dirname(input) : input);
}
const renameFileSchema = z.object({
from: z.string().describe("The current file path"),
to: z.string().describe("The new file path"),
});
export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> =
{
name: "rename_file",
description: "Rename or move a file in the codebase",
inputSchema: renameFileSchema,
defaultConsent: "always",
getConsentPreview: (args) => `Rename ${args.from} to ${args.to}`,
buildXml: (args, _isComplete) => {
if (!args.from || !args.to) return undefined;
return `<dyad-rename from="${escapeXmlAttr(args.from)}" to="${escapeXmlAttr(args.to)}"></dyad-rename>`;
},
execute: async (args, ctx: AgentContext) => {
const fromFullPath = safeJoin(ctx.appPath, args.from);
const toFullPath = safeJoin(ctx.appPath, args.to);
// Track if this involves shared modules
if (isSharedServerModule(args.from) || isSharedServerModule(args.to)) {
ctx.isSharedModulesChanged = true;
}
// Ensure target directory exists
const dirPath = path.dirname(toFullPath);
fs.mkdirSync(dirPath, { recursive: true });
if (fs.existsSync(fromFullPath)) {
fs.renameSync(fromFullPath, toFullPath);
logger.log(
`Successfully renamed file: ${fromFullPath} -> ${toFullPath}`,
);
// Update git
await gitAdd({ path: ctx.appPath, filepath: args.to });
try {
await gitRemove({ path: ctx.appPath, filepath: args.from });
} catch (error) {
logger.warn(`Failed to git remove old file ${args.from}:`, error);
}
// Handle Supabase functions
if (ctx.supabaseProjectId) {
if (isServerFunction(args.from)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.from),
});
} catch (error) {
logger.warn(
`Failed to delete old Supabase function: ${args.from}`,
error,
);
}
}
if (isServerFunction(args.to) && !ctx.isSharedModulesChanged) {
try {
await deploySupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.to),
appPath: ctx.appPath,
});
} catch (error) {
return `File renamed, but failed to deploy Supabase function: ${error}`;
}
}
}
} else {
logger.warn(`Source file for rename does not exist: ${fromFullPath}`);
}
return `Successfully renamed ${args.from} to ${args.to}`;
},
};
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { deploySupabaseFunction } from "../../../../../../supabase_admin/supabase_management_client";
import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { applySearchReplace } from "../../../../../../pro/main/ipc/processors/search_replace_processor";
const readFile = fs.promises.readFile;
const logger = log.scope("search_replace");
const searchReplaceSchema = z.object({
path: z.string().describe("The file path to edit"),
search: z
.string()
.describe(
"Content to search for in the file. This should match the existing code that will be replaced",
),
replace: z
.string()
.describe("New content to replace the search content with"),
description: z
.string()
.optional()
.describe("Brief description of the changes"),
});
export const searchReplaceTool: ToolDefinition<
z.infer<typeof searchReplaceSchema>
> = {
name: "search_replace",
description:
"Apply targeted search/replace edits to a file. This is the preferred tool for editing a file.",
inputSchema: searchReplaceSchema,
defaultConsent: "always",
getConsentPreview: (args) => `Edit ${args.path}`,
buildXml: (args, isComplete) => {
if (!args.path) return undefined;
let xml = `<dyad-search-replace path="${escapeXmlAttr(args.path)}" description="${escapeXmlAttr(args.description ?? "")}">\n<<<<<<< SEARCH\n${args.search ?? ""}`;
// Add separator and replace content if replace has started
if (args.replace !== undefined) {
xml += `\n=======\n${args.replace}`;
}
if (isComplete) {
if (args.replace == undefined) {
xml += "\n=======\n";
}
xml += "\n>>>>>>> REPLACE\n</dyad-search-replace>";
}
return xml;
},
execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path);
// Track if this is a shared module
if (isSharedServerModule(args.path)) {
ctx.isSharedModulesChanged = true;
}
if (!fs.existsSync(fullFilePath)) {
throw new Error(`File does not exist: ${args.path}`);
}
const original = await readFile(fullFilePath, "utf8");
// Construct the operations string in the expected format
const operations = `<<<<<<< SEARCH\n${args.search}\n=======\n${args.replace}\n>>>>>>> REPLACE`;
const result = applySearchReplace(original, operations);
if (!result.success || typeof result.content !== "string") {
throw new Error(
`Failed to apply search-replace: ${result.error ?? "unknown"}`,
);
}
fs.writeFileSync(fullFilePath, result.content);
logger.log(`Successfully applied search-replace to: ${fullFilePath}`);
// Deploy Supabase function if applicable
if (
ctx.supabaseProjectId &&
isServerFunction(args.path) &&
!ctx.isSharedModulesChanged
) {
try {
await deploySupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)),
appPath: ctx.appPath,
});
} catch (error) {
return `Search-replace applied, but failed to deploy Supabase function: ${error}`;
}
}
return `Successfully applied edits to ${args.path}`;
},
};
import { z } from "zod";
import { ToolDefinition, AgentContext } from "./types";
import { db } from "@/db";
import { chats } from "@/db/schema";
import { and, eq, isNull } from "drizzle-orm";
const setChatSummarySchema = z.object({
summary: z.string().describe("A short summary/title for the chat"),
});
export const setChatSummaryTool: ToolDefinition<
z.infer<typeof setChatSummarySchema>
> = {
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.",
inputSchema: setChatSummarySchema,
defaultConsent: "always",
getConsentPreview: (args) => args.summary,
buildXml: (args, _isComplete) => {
if (args.summary == undefined) return undefined;
// No XML needed for this tool
return ``;
},
execute: async (args, ctx: AgentContext) => {
if (args.summary) {
await db
.update(chats)
.set({ title: args.summary })
.where(and(eq(chats.id, ctx.chatId), isNull(chats.title)));
ctx.chatSummary = args.summary;
}
return `Chat summary set to: ${args.summary}`;
},
};
/**
* Shared types and utilities for Local Agent tools
*/
import { z } from "zod";
import { IpcMainInvokeEvent } from "electron";
import { jsonrepair } from "jsonrepair";
import { AgentToolConsent } from "@/ipc/ipc_types";
// ============================================================================
// XML Escape Helpers
// ============================================================================
export function escapeXmlAttr(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function escapeXmlContent(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export interface AgentContext {
event: IpcMainInvokeEvent;
appPath: string;
chatId: number;
supabaseProjectId?: string | null;
messageId?: number;
isSharedModulesChanged?: boolean;
chatSummary?: string;
/**
* Streams accumulated XML to UI without persisting to DB (for live preview).
* Call this repeatedly with the full accumulated XML so far.
*/
onXmlStream: (accumulatedXml: string) => void;
/**
* Writes final XML to UI and persists to DB.
* Call this once when the tool's XML output is complete.
*/
onXmlComplete: (finalXml: string) => void;
requireConsent: (params: {
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
}) => Promise<boolean>;
}
// ============================================================================
// Partial JSON Parser
// ============================================================================
/**
* Parse partial/streaming JSON into a partial object using jsonrepair.
* Handles incomplete JSON gracefully during streaming.
*/
export function parsePartialJson<T extends Record<string, unknown>>(
jsonText: string,
): Partial<T> {
if (!jsonText.trim()) {
return {} as Partial<T>;
}
try {
const repaired = jsonrepair(jsonText);
return JSON.parse(repaired) as Partial<T>;
} catch {
// If jsonrepair fails, return empty object
return {} as Partial<T>;
}
}
// ============================================================================
// Tool Definition Interface
// ============================================================================
export interface ToolDefinition<T = any> {
readonly name: string;
readonly description: string;
readonly inputSchema: z.ZodType<T>;
readonly defaultConsent: AgentToolConsent;
execute: (args: T, ctx: AgentContext) => Promise<string>;
/**
* If defined, returns whether the tool should be available in the current context.
* If it returns false, the tool will be filtered out.
*/
isEnabled?: (ctx: AgentContext) => boolean;
/**
* Returns a preview string describing what the tool will do with the given args.
* Used for consent prompts. If not provided, no inputPreview will be shown.
*
* @param args - The parsed args for the tool call
* @returns A human-readable description of the operation
*/
getConsentPreview?: (args: T) => string;
/**
* Build XML from parsed partial args.
* Called by the handler during streaming and on completion.
*
* @param args - Partial args parsed from accumulated JSON (type inferred from inputSchema)
* @param isComplete - True if this is the final call (include closing tags)
* @returns The XML string, or undefined if not enough args yet
*/
buildXml?: (args: Partial<T>, isComplete: boolean) => string | undefined;
}
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { deploySupabaseFunction } from "../../../../../../supabase_admin/supabase_management_client";
import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
const logger = log.scope("write_file");
const writeFileSchema = z.object({
path: z.string().describe("The file path relative to the app root"),
content: z.string().describe("The content to write to the file"),
description: z
.string()
.optional()
.describe("Brief description of the change"),
});
export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = {
name: "write_file",
description: "Create or completely overwrite a file in the codebase",
inputSchema: writeFileSchema,
defaultConsent: "always",
getConsentPreview: (args) => `Write to ${args.path}`,
buildXml: (args, isComplete) => {
if (!args.path) return undefined;
let xml = `<dyad-write path="${escapeXmlAttr(args.path)}" description="${escapeXmlAttr(args.description ?? "")}">\n${args.content ?? ""}`;
if (isComplete) {
xml += "\n</dyad-write>";
}
return xml;
},
execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path);
// Track if this is a shared module
if (isSharedServerModule(args.path)) {
ctx.isSharedModulesChanged = true;
}
// Ensure directory exists
const dirPath = path.dirname(fullFilePath);
fs.mkdirSync(dirPath, { recursive: true });
// Write file content
fs.writeFileSync(fullFilePath, args.content);
logger.log(`Successfully wrote file: ${fullFilePath}`);
// Deploy Supabase function if applicable
if (
ctx.supabaseProjectId &&
isServerFunction(args.path) &&
!ctx.isSharedModulesChanged
) {
try {
await deploySupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)),
appPath: ctx.appPath,
});
} catch (error) {
return `File written, but failed to deploy Supabase function: ${error}`;
}
}
return `Successfully wrote ${args.path}`;
},
};
/**
* Bidirectional XML <-> Tool Call translator for Local Agent v2
*
* Converts between AI SDK tool call format and XML strings for:
* - Storage in database (messages.content)
* - Rendering in UI (DyadMarkdownParser)
* - Feeding back to model in native tool call format
*/
import type { ToolCallPart } from "ai";
// Escape XML content (less strict than attributes)
function escapeXmlContent(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/**
* Wrap thinking text in think tags
*/
export function wrapThinking(text: string): string {
return `<think>${escapeXmlContent(text)}</think>`;
}
// Regex patterns for parsing XML tags
interface ParsedToolCall {
toolName: string;
toolCallId: string;
args: Record<string, unknown>;
}
interface ParsedContent {
type: "text" | "tool-call" | "tool-result" | "thinking";
content?: string;
toolCall?: ParsedToolCall;
toolResult?: { toolCallId: string; result: unknown };
}
/**
* Convert parsed content back to AI SDK message format with tool calls
* for feeding historical messages back to the model
*/
export function parsedContentToToolCallParts(
parsed: ParsedContent[],
): (ToolCallPart | { type: "text"; text: string })[] {
const parts: (ToolCallPart | { type: "text"; text: string })[] = [];
for (const item of parsed) {
if (item.type === "text" && item.content) {
parts.push({ type: "text", text: item.content });
} else if (item.type === "tool-call" && item.toolCall) {
parts.push({
type: "tool-call",
toolCallId: item.toolCall.toolCallId,
toolName: item.toolCall.toolName,
input: item.toolCall.args,
});
} else if (item.type === "thinking" && item.content) {
// Thinking blocks are converted to text for context
parts.push({ type: "text", text: `<think>${item.content}</think>` });
}
}
return parts;
}
/**
* System prompt for Local Agent v2 mode
* Tool-based agent with parallel execution support
*/
export const LOCAL_AGENT_SYSTEM_PROMPT = `
<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>
[[AI_RULES]]
`;
const DEFAULT_AI_RULES = `# 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.
`;
export function constructLocalAgentPrompt(aiRules: string | undefined): string {
return LOCAL_AGENT_SYSTEM_PROMPT.replace(
"[[AI_RULES]]",
aiRules ?? DEFAULT_AI_RULES,
);
}
......@@ -2,6 +2,7 @@ import path from "node:path";
import fs from "node:fs";
import log from "electron-log";
import { TURBO_EDITS_V2_SYSTEM_PROMPT } from "../pro/main/prompts/turbo_edits_v2_prompt";
import { constructLocalAgentPrompt } from "./local_agent_prompt";
const logger = log.scope("system_prompt");
......@@ -509,9 +510,13 @@ export const constructSystemPrompt = ({
enableTurboEditsV2,
}: {
aiRules: string | undefined;
chatMode?: "build" | "ask" | "agent";
chatMode?: "build" | "ask" | "agent" | "local-agent";
enableTurboEditsV2: boolean;
}) => {
if (chatMode === "local-agent") {
return constructLocalAgentPrompt(aiRules);
}
const systemPrompt = getSystemPromptForChatMode({
chatMode,
enableTurboEditsV2,
......
......@@ -13,6 +13,8 @@ import {
} from "@tanstack/react-query";
import { showError, showMcpConsentToast } from "./lib/toast";
import { IpcClient } from "./ipc/ipc_client";
import { useSetAtom } from "jotai";
import { pendingAgentConsentsAtom } from "./atoms/chatAtoms";
// @ts-ignore
console.log("Running in mode:", import.meta.env.MODE);
......@@ -124,6 +126,37 @@ function App() {
return () => unsubscribe();
}, []);
// Agent v2 tool consent requests - queue consents instead of overwriting
const setPendingAgentConsents = useSetAtom(pendingAgentConsentsAtom);
useEffect(() => {
const ipc = IpcClient.getInstance();
const unsubscribe = ipc.onAgentToolConsentRequest((payload) => {
setPendingAgentConsents((prev) => [
...prev,
{
requestId: payload.requestId,
chatId: payload.chatId,
toolName: payload.toolName,
toolDescription: payload.toolDescription,
inputPreview: payload.inputPreview,
},
]);
});
return () => unsubscribe();
}, [setPendingAgentConsents]);
// Clear pending agent consents when a chat stream ends or errors
// This prevents stale consent banners from remaining visible after cancellation
useEffect(() => {
const ipc = IpcClient.getInstance();
const unsubscribe = ipc.onChatStreamEnd((chatId) => {
setPendingAgentConsents((prev) =>
prev.filter((consent) => consent.chatId !== chatId),
);
});
return () => unsubscribe();
}, [setPendingAgentConsents]);
return <RouterProvider router={router} />;
}
......
......@@ -2,6 +2,10 @@ import { Request, Response } from "express";
import fs from "fs";
import path from "path";
import { CANNED_MESSAGE, createStreamChunk } from ".";
import {
handleLocalAgentFixture,
extractLocalAgentFixture,
} from "./localAgentHandler";
let globalCounter = 0;
......@@ -23,6 +27,35 @@ export const createChatCompletionHandler =
});
}
// Check for local-agent fixture requests (tc=local-agent/*)
// This needs to be checked on the first user message, not the last (which might be tool results)
const lastUserMessage = messages
.slice()
.reverse()
.find((m: any) => m.role === "user");
if (lastUserMessage) {
// Handle both string content and array content (AI SDK format)
let textContent = "";
if (typeof lastUserMessage.content === "string") {
textContent = lastUserMessage.content;
} else if (Array.isArray(lastUserMessage.content)) {
const textPart = lastUserMessage.content.find(
(p: any) => p.type === "text",
);
if (textPart) {
textContent = textPart.text;
}
}
const localAgentFixture = extractLocalAgentFixture(textContent);
console.error(
`[local-agent] Checking message: "${textContent.slice(0, 50)}", fixture: ${localAgentFixture}`,
);
if (localAgentFixture) {
return handleLocalAgentFixture(req, res, localAgentFixture);
}
}
let messageContent = CANNED_MESSAGE;
if (
......@@ -208,7 +241,8 @@ export default Index;
lastMessage &&
lastMessage.content &&
typeof lastMessage.content === "string" &&
lastMessage.content.startsWith("tc=")
lastMessage.content.startsWith("tc=") &&
!lastMessage.content.startsWith("tc=local-agent/")
) {
const testCaseName = lastMessage.content.slice(3).split("[")[0].trim(); // Remove "tc=" prefix
console.error(`* Loading test case: ${testCaseName}`);
......
/**
* Handler for Local Agent E2E testing fixtures
* Manages multi-turn tool call conversations
*/
import { Request, Response } from "express";
import crypto from "crypto";
import path from "path";
import fs from "fs";
import type { LocalAgentFixture, Turn } from "./localAgentTypes";
// Register ts-node to allow loading .ts fixture files directly
try {
require("ts-node/register");
} catch {
// ts-node not available, will fall back to .js files
}
// Map of session ID -> current turn index
// Cache loaded fixtures to avoid re-importing
const fixtureCache = new Map<string, LocalAgentFixture>();
/**
* Generate a session ID from the first user message
* This allows us to track conversation state across requests
*/
function getSessionId(messages: any[]): string {
// Find the first user message to use as session identifier
const firstUserMsg = messages.find((m) => m.role === "user");
if (!firstUserMsg) {
return crypto.randomUUID();
}
return crypto
.createHash("md5")
.update(JSON.stringify(firstUserMsg))
.digest("hex");
}
/**
* Count the number of tool result messages to determine which turn we're on
*/
function countToolResultRounds(messages: any[]): number {
let rounds = 0;
for (const msg of messages) {
if (msg?.role === "tool") {
rounds++;
} else if (Array.isArray(msg?.content)) {
if (msg.content.some((p: any) => p.type === "tool-result")) {
rounds++;
}
}
}
return rounds;
}
/**
* Load a fixture file dynamically
* Tries .ts first (for dev mode with ts-node), then .js
*/
async function loadFixture(fixtureName: string): Promise<LocalAgentFixture> {
if (fixtureCache.has(fixtureName)) {
return fixtureCache.get(fixtureName)!;
}
const fixtureDir = path.join(
__dirname,
"..",
"..",
"..",
"e2e-tests",
"fixtures",
"engine",
"local-agent",
);
// Try .ts first, then .js
let fixturePath = path.join(fixtureDir, `${fixtureName}.ts`);
if (!fs.existsSync(fixturePath)) {
fixturePath = path.join(fixtureDir, `${fixtureName}.js`);
}
try {
// Clear require cache to allow fixture updates during development
delete require.cache[require.resolve(fixturePath)];
const module = require(fixturePath);
const fixture = module.fixture as LocalAgentFixture;
if (!fixture || !fixture.turns) {
throw new Error(
`Invalid fixture: missing 'fixture' export or 'turns' array`,
);
}
fixtureCache.set(fixtureName, fixture);
return fixture;
} catch (error) {
console.error(`Failed to load fixture: ${fixturePath}`, error);
throw error;
}
}
/**
* Create a streaming chunk in OpenAI format
*/
function createStreamChunk(
content: string,
role: string = "assistant",
isLast: boolean = false,
finishReason: string | null = null,
) {
const chunk: any = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "fake-local-agent-model",
choices: [
{
index: 0,
delta: isLast ? {} : { content, role },
finish_reason: finishReason,
},
],
};
return `data: ${JSON.stringify(chunk)}\n\n${isLast ? "data: [DONE]\n\n" : ""}`;
}
/**
* Stream a text-only turn response
*/
async function streamTextResponse(res: Response, text: string) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Send role first
res.write(createStreamChunk("", "assistant"));
// Stream text in batches
const batchSize = 32;
for (let i = 0; i < text.length; i += batchSize) {
const batch = text.slice(i, i + batchSize);
res.write(createStreamChunk(batch));
await new Promise((resolve) => setTimeout(resolve, 5));
}
// Send final chunk
res.write(createStreamChunk("", "assistant", true, "stop"));
res.end();
}
/**
* Stream a turn with tool calls
*/
async function streamToolCallResponse(res: Response, turn: Turn) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const now = Date.now();
const mkChunk = (delta: any, finish: string | null = null) => {
const chunk = {
id: `chatcmpl-${now}`,
object: "chat.completion.chunk",
created: Math.floor(now / 1000),
model: "fake-local-agent-model",
choices: [
{
index: 0,
delta,
finish_reason: finish,
},
],
};
return `data: ${JSON.stringify(chunk)}\n\n`;
};
// 1) Send role
res.write(mkChunk({ role: "assistant" }));
// 2) Send text content if any
if (turn.text) {
const batchSize = 32;
for (let i = 0; i < turn.text.length; i += batchSize) {
const batch = turn.text.slice(i, i + batchSize);
res.write(mkChunk({ content: batch }));
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
// 3) Send tool calls
if (turn.toolCalls && turn.toolCalls.length > 0) {
for (let idx = 0; idx < turn.toolCalls.length; idx++) {
const toolCall = turn.toolCalls[idx];
const toolCallId = `call_${now}_${idx}`;
// Send tool call init with id + name + empty args
res.write(
mkChunk({
tool_calls: [
{
index: idx,
id: toolCallId,
type: "function",
function: {
name: toolCall.name,
arguments: "",
},
},
],
}),
);
// Stream arguments gradually
const args = JSON.stringify(toolCall.args);
const argBatchSize = 20;
for (let i = 0; i < args.length; i += argBatchSize) {
const part = args.slice(i, i + argBatchSize);
res.write(
mkChunk({
tool_calls: [{ index: idx, function: { arguments: part } }],
}),
);
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
}
// 4) Send finish
const finishReason =
turn.toolCalls && turn.toolCalls.length > 0 ? "tool_calls" : "stop";
res.write(mkChunk({}, finishReason));
res.write("data: [DONE]\n\n");
res.end();
}
/**
* Handle a local-agent fixture request
*/
export async function handleLocalAgentFixture(
req: Request,
res: Response,
fixtureName: string,
): Promise<void> {
const { messages = [] } = req.body;
console.log(`[local-agent] Loading fixture: ${fixtureName}`);
console.log(`[local-agent] Messages count: ${messages.length}`);
try {
const fixture = await loadFixture(fixtureName);
const sessionId = getSessionId(messages);
// Determine which turn we're on based on tool result rounds
const toolResultRounds = countToolResultRounds(messages);
const turnIndex = toolResultRounds;
console.log(
`[local-agent] Session: ${sessionId}, Turn: ${turnIndex}, Tool rounds: ${toolResultRounds}`,
);
if (turnIndex >= fixture.turns.length) {
// All turns exhausted, send a simple completion message
console.log(`[local-agent] All turns exhausted, sending completion`);
await streamTextResponse(res, "Task completed.");
return;
}
const turn = fixture.turns[turnIndex];
console.log(`[local-agent] Executing turn ${turnIndex}:`, {
hasText: !!turn.text,
toolCallCount: turn.toolCalls?.length ?? 0,
});
// If this turn has tool calls, stream them
if (turn.toolCalls && turn.toolCalls.length > 0) {
await streamToolCallResponse(res, turn);
} else {
// Text-only turn
await streamTextResponse(res, turn.text || "Done.");
}
} catch (error) {
console.error(`[local-agent] Error handling fixture:`, error);
res.status(500).json({
error: {
message: `Failed to load fixture: ${fixtureName}`,
type: "server_error",
},
});
}
}
/**
* Check if a message content matches a local-agent fixture pattern
* Returns the fixture name if matched, null otherwise
*/
export function extractLocalAgentFixture(content: string): string | null {
if (!content) return null;
// Match tc=local-agent/FIXTURE_NAME, allowing trailing whitespace
const match = content.trim().match(/^tc=local-agent\/([^\s[]+)/);
return match ? match[1] : null;
}
/**
* TypeScript types for the Local Agent E2E testing DSL
*/
export type ToolCall = {
/** The name of the tool to call */
name: string;
/** Arguments to pass to the tool */
args: Record<string, unknown>;
};
export type Turn = {
/** Optional text content to output before tool calls */
text?: string;
/** Tool calls to execute in this turn */
toolCalls?: ToolCall[];
/** Text to output after tool results are received (final turn only) */
textAfterTools?: string;
};
export type LocalAgentFixture = {
/** Description for debugging */
description?: string;
/** Ordered turns in the conversation */
turns: Turn[];
};
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论