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

Persist todos across chat turns (#2713)

## Summary Add persistence layer for todos so they survive across multiple turns in a chat session. Todos are now saved to disk after each update and automatically loaded and injected into the conversation context when a new turn begins. ## Key Changes - **New module `todo_persistence.ts`**: Provides utilities to save, load, and delete todo JSON files stored in `.dyad/todos/<chatId>.json` - `getTodosFilePath()`: Constructs the file path for a chat's todos - `saveTodos()`: Persists todos to disk with metadata (updatedAt timestamp) - `loadTodos()`: Loads previously saved todos, gracefully handling missing or corrupted files - `deleteTodos()`: Removes the todos file when all todos are completed - **Updated `local_agent_handler.ts`**: - Load persisted todos at the start of each turn - Emit loaded todos to the renderer immediately so the UI reflects them - Initialize the agent context with persisted todos instead of an empty array - Inject a synthetic system message reminding the LLM of incomplete todos from the previous turn, following the same pattern as existing todo reminder logic - Ensure `.dyad/` directory is gitignored (idempotent operation) - **Updated `update_todos.ts`**: - Persist todos to disk after each tool execution - Delete the todos file when all todos are marked as completed ## Implementation Details - Todos are stored as JSON with an `updatedAt` timestamp for potential future use - File I/O errors are logged as warnings but don't interrupt the agent flow (graceful degradation) - The synthetic message injection for persisted todos only occurs if there are incomplete todos, avoiding unnecessary context pollution - The `.dyad/` gitignore step is idempotent and aligns with existing patterns in the codebase <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2713" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com> Co-authored-by: 's avatarMohamed Aziz Mejri <mohamedazizmejri@Mohameds-Mac-mini.local>
上级 0e2f0025
...@@ -11,6 +11,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi ...@@ -11,6 +11,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi
| File | Read when... | | File | Read when... |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| [rules/electron-ipc.md](rules/electron-ipc.md) | Adding/modifying IPC endpoints, handlers, React Query hooks, or renderer-to-main communication | | [rules/electron-ipc.md](rules/electron-ipc.md) | Adding/modifying IPC endpoints, handlers, React Query hooks, or renderer-to-main communication |
| [rules/local-agent-tools.md](rules/local-agent-tools.md) | Adding/modifying local agent tools, tool flags (`modifiesState`), or read-only/plan-only guards |
| [rules/e2e-testing.md](rules/e2e-testing.md) | Writing or debugging E2E tests (Playwright, Base UI radio clicks, Lexical editor, test fixtures) | | [rules/e2e-testing.md](rules/e2e-testing.md) | Writing or debugging E2E tests (Playwright, Base UI radio clicks, Lexical editor, test fixtures) |
| [rules/git-workflow.md](rules/git-workflow.md) | Pushing branches, creating PRs, or dealing with fork/upstream remotes | | [rules/git-workflow.md](rules/git-workflow.md) | Pushing branches, creating PRs, or dealing with fork/upstream remotes |
| [rules/base-ui-components.md](rules/base-ui-components.md) | Using TooltipTrigger, ToggleGroupItem, or other Base UI wrapper components | | [rules/base-ui-components.md](rules/base-ui-components.md) | Using TooltipTrigger, ToggleGroupItem, or other Base UI wrapper components |
......
...@@ -31,6 +31,11 @@ testSkipIfWindows( ...@@ -31,6 +31,11 @@ testSkipIfWindows(
await po.sendPrompt("[dump] hi"); await po.sendPrompt("[dump] hi");
await po.snapshotServerDump("all-messages"); await po.snapshotServerDump("all-messages");
// Wait for all message content (including file card buttons) to fully render
// before snapshotting to avoid flaky aria text mismatches.
await expect(po.page.getByRole("button", { name: "Retry" })).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Snapshot the messages to capture the compaction summary + second response // Snapshot the messages to capture the compaction summary + second response
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
}, },
...@@ -61,6 +66,11 @@ testSkipIfWindows( ...@@ -61,6 +66,11 @@ testSkipIfWindows(
await po.sendPrompt("[dump] hi"); await po.sendPrompt("[dump] hi");
await po.snapshotServerDump("all-messages"); await po.snapshotServerDump("all-messages");
// Wait for all message content (including file card buttons) to fully render
// before snapshotting to avoid flaky aria text mismatches.
await expect(po.page.getByRole("button", { name: "Retry" })).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Snapshot the messages to capture the compaction summary + second response // Snapshot the messages to capture the compaction summary + second response
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
}, },
......
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
/**
* Fixture that tests persistent todos across turns (turn 2 of 2).
*
* This runs after persistent-todos.ts which left 2 incomplete todos on disk:
* - todo-2: "Add error handling" (pending)
* - todo-3: "Write tests" (pending)
*
* The handler loads these from .dyad/todos/<chatId>.json and injects a
* synthetic "[System] You have unfinished todos..." user message before
* this prompt. The agent then picks up the remaining work and completes
* all todos.
*/
export const fixture: LocalAgentFixture = {
description: "Turn 2: Resume and complete persisted todos from previous turn",
turns: [
{
text: "I see there are unfinished todos from last time. Let me continue.",
toolCalls: [
{
name: "update_todos",
args: {
merge: true,
todos: [
{
id: "todo-2",
status: "in_progress",
},
],
},
},
],
},
{
text: "Adding error handling to the utility module.",
toolCalls: [
{
name: "write_file",
args: {
path: "src/lib/utils.ts",
content:
'export function formatDate(d: Date): string {\n return d.toISOString();\n}\n\nexport function safeParseJSON(str: string): unknown {\n try {\n return JSON.parse(str);\n } catch {\n return null;\n }\n}\n',
description: "Add error handling utility",
},
},
],
},
{
text: "Now writing the tests.",
toolCalls: [
{
name: "write_file",
args: {
path: "src/lib/utils.test.ts",
content:
'import { formatDate, safeParseJSON } from "./utils";\n\ntest("formatDate returns ISO string", () => {\n const d = new Date("2024-01-01");\n expect(formatDate(d)).toBe("2024-01-01T00:00:00.000Z");\n});\n\ntest("safeParseJSON parses valid JSON", () => {\n expect(safeParseJSON(\'{"a":1}\')).toEqual({ a: 1 });\n});\n\ntest("safeParseJSON returns null for invalid JSON", () => {\n expect(safeParseJSON("not json")).toBeNull();\n});\n',
description: "Write tests for utility module",
},
},
],
},
{
text: "Marking all remaining tasks as done.",
toolCalls: [
{
name: "update_todos",
args: {
merge: true,
todos: [
{
id: "todo-2",
status: "completed",
},
{
id: "todo-3",
status: "completed",
},
],
},
},
],
},
{
text: "All tasks from the previous turn are now complete! I created the utility module, added error handling, and wrote the tests.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
/**
* Fixture that tests persistent todos across turns (turn 1 of 2).
*
* Pass 1: Agent creates 3 todos, completes only 1, writes a file, then emits
* text. The outer loop detects incomplete todos and sends a reminder.
*
* Pass 2: After receiving the todo reminder, agent acknowledges but does NOT
* complete the remaining todos (simulating running out of context/time).
*
* After both passes, 2 incomplete todos remain and are persisted to disk.
* The follow-up test (persistent-todos-resume) sends a second prompt to verify
* that the handler loads the persisted todos and injects a synthetic message.
*/
export const fixture: LocalAgentFixture = {
description:
"Turn 1: Create todos, partially complete, leave rest for next turn",
passes: [
{
// First pass: Create todos and partially complete them
turns: [
{
text: "I'll create a task list to track the work.",
toolCalls: [
{
name: "update_todos",
args: {
merge: false,
todos: [
{
id: "todo-1",
content: "Create utility module",
status: "in_progress",
},
{
id: "todo-2",
content: "Add error handling",
status: "pending",
},
{
id: "todo-3",
content: "Write tests",
status: "pending",
},
],
},
},
],
},
{
text: "Let me create the utility module first.",
toolCalls: [
{
name: "write_file",
args: {
path: "src/lib/utils.ts",
content:
'export function formatDate(d: Date): string {\n return d.toISOString();\n}\n',
description: "Create utility module",
},
},
],
},
{
text: "Marking the first task as done.",
toolCalls: [
{
name: "update_todos",
args: {
merge: true,
todos: [
{
id: "todo-1",
status: "completed",
},
],
},
},
],
},
{
// Text-only response triggers the outer loop check.
// Since there are still incomplete todos, it will inject a reminder.
text: "I've completed the utility module. I'll continue with the remaining tasks.",
},
],
},
{
// Second pass (after todo reminder): acknowledge but don't complete.
// This simulates running out of budget/context. The outer loop won't
// fire again (maxTodoFollowUpLoops = 1), so the incomplete todos
// persist to disk for the next turn.
turns: [
{
text: "I see there are remaining tasks. I'll pick these up in the next turn.",
},
],
},
],
};
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* E2E test for persistent todos across turns.
*
* This tests that when an agent creates a todo list but doesn't complete
* all items by the end of a turn, the incomplete todos are:
* 1. Persisted to disk (.dyad/todos/<chatId>.json)
* 2. Loaded at the start of the next turn
* 3. Injected as a synthetic "[System]" message so the LLM is aware of them
* 4. Completed by the agent in the subsequent turn
*
* Turn 1 (persistent-todos fixture):
* - Creates 3 todos, completes 1, leaves 2 incomplete
* - Followup loop fires once but doesn't complete remaining todos
* - Incomplete todos are saved to disk
*
* Turn 2 (persistent-todos-resume fixture):
* - Handler loads persisted todos and injects synthetic message
* - Agent picks up remaining work and completes all todos
* - Todos file is cleaned up (all completed)
*/
testSkipIfWindows(
"local-agent - persistent todos across turns",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.chatActions.selectLocalAgentMode();
// Turn 1: Creates incomplete todos that get persisted to disk
await po.sendPrompt("tc=local-agent/persistent-todos");
// Turn 2: Handler loads persisted todos, injects synthetic message,
// and the agent completes the remaining work
await po.sendPrompt("tc=local-agent/persistent-todos-resume");
// Snapshot the final messages to verify:
// 1. Turn 1 created todos and partially completed them
// 2. Turn 2 resumed from persisted todos and completed them
await po.snapshotMessages();
// Verify files were created/updated across both turns
await po.snapshotAppFiles({
name: "after-persistent-todos",
files: [
"src/lib/utils.ts", // Created in turn 1, updated in turn 2
"src/lib/utils.test.ts", // Created in turn 2
],
});
},
);
...@@ -15,6 +15,8 @@ ...@@ -15,6 +15,8 @@
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
...@@ -68,7 +70,7 @@ ...@@ -68,7 +70,7 @@
- paragraph: This tool call will trigger compaction. - paragraph: This tool call will trigger compaction.
- img - img
- text: Read src/App.tsx - text: Read src/App.tsx
- button "Conversation compacted": - button "Conversation compacted" [expanded]:
- img - img
- text: "" - text: ""
- img - img
...@@ -102,8 +104,6 @@ ...@@ -102,8 +104,6 @@
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
......
- 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\./ - 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\./
- button "file1.txt file1.txt Edit" - button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM - paragraph: More EOM
- button "Copy": - button "Copy":
- img - img
...@@ -9,8 +15,11 @@ ...@@ -9,8 +15,11 @@
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: ""
- paragraph: tc=local-agent/compaction-trigger - paragraph: tc=local-agent/compaction-trigger
- paragraph: I've completed the initial analysis of the codebase. Here are the findings. - paragraph: I've completed the initial analysis of the codebase. Here are the findings.
- button "Copy": - button "Copy":
...@@ -23,8 +32,10 @@ ...@@ -23,8 +32,10 @@
- text: less than a minute ago - text: less than a minute ago
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- button "Conversation compacted": - text: ""
- button "Conversation compacted" [expanded]:
- img - img
- text: ""
- img - img
- heading "Key Decisions Made" [level=2] - heading "Key Decisions Made" [level=2]
- list: - list:
...@@ -46,10 +57,9 @@ ...@@ -46,10 +57,9 @@
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: ""
- paragraph: "[dump] hi" - paragraph: "[dump] hi"
- paragraph: "[[dyad-dump-path=*]]" - paragraph: "[[dyad-dump-path=*]]"
- button "Copy": - button "Copy":
...@@ -60,7 +70,10 @@ ...@@ -60,7 +70,10 @@
- text: less than a minute ago - text: less than a minute ago
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: ""
- button "Undo": - button "Undo":
- img - img
- text: ""
- button "Retry": - button "Retry":
- img - img
- text: ""
\ No newline at end of file
...@@ -21,10 +21,6 @@ ...@@ -21,10 +21,6 @@
- img - img
- textbox "node_modules/**/*" - textbox "node_modules/**/*"
- button "Add" - button "Add"
- button "src/components/**"
- text: /2 files, ~\d+ tokens/
- button:
- img
- button "exclude/exclude.ts" - button "exclude/exclude.ts"
- text: /1 files, ~\d+ tokens/ - text: /1 files, ~\d+ tokens/
- button: - button:
...@@ -45,4 +41,5 @@ ...@@ -45,4 +41,5 @@
- button: - button:
- img - img
- button "Close": - button "Close":
- img - img
\ No newline at end of file - text: ""
\ No newline at end of file
...@@ -276,58 +276,6 @@ ...@@ -276,58 +276,6 @@
} }
} }
}, },
{
"type": "function",
"function": {
"name": "update_todos",
"description": "\n### When to Use This Tool\n\nUse proactively for:\n1. Complex multi-step tasks (3+ distinct steps)\n2. Non-trivial tasks requiring careful planning\n3. User explicitly requests todo list\n4. User provides multiple tasks (numbered/comma-separated)\n5. After completing tasks - mark complete with merge=true and add follow-ups\n6. When starting new tasks - mark as in_progress (ideally only one at a time)\n\n### When NOT to Use\n\nSkip for:\n1. Single, straightforward tasks\n2. Trivial tasks with no organizational benefit\n3. Tasks completable in < 3 trivial steps\n4. Purely conversational/informational requests\n5. Todo items should NOT include operational actions done in service of higher-level tasks.\n\nNEVER INCLUDE THESE IN TODOS: linting; testing; searching or examining the codebase.\n\n### Examples\n\n<example>\nUser: Add dark mode toggle to settings\nAssistant:\n- *Creates todo list:*\n1. Add state management [in_progress]\n2. Implement styles\n3. Create toggle component\n4. Update components\n- [Immediately begins working on todo 1 in the same tool call batch]\n<reasoning>\nMulti-step feature with dependencies.\n</reasoning>\n</example>\n\n<example>\n// User: Implement user registration, product catalog, shopping cart, checkout flow.\nAssistant: *Creates todo list breaking down each feature into specific tasks*\n<reasoning>\nMultiple complex features provided as list requiring organized task management.\n</reasoning>\n</example>\n\n### Task States and Management\n\n1. **Task States:**\n- pending: Not yet started\n- in_progress: Currently working on\n- completed: Finished successfully\n\n2. **Task Management:**\n- Update status in real-time\n- Mark complete IMMEDIATELY after finishing\n- Only ONE task in_progress at a time\n- Complete current tasks before starting new ones\n\n3. **Task Breakdown:**\n- Create specific, actionable items\n- Break complex tasks into manageable steps\n- Use clear, descriptive names\n\n4. **Parallel Todo Writes:**\n- Prefer creating the first todo as in_progress\n- Start working on todos by using tool calls in the same tool call batch as the todo write\n- Batch todo updates with other tool calls for better latency and lower costs for the user\n",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"merge": {
"type": "boolean",
"description": "Whether to merge the todos with the existing todos. If true, the todos will be merged into the existing todos based on the id field. You can leave unchanged properties undefined. If false, the new todos will replace the existing todos."
},
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the todo item"
},
"content": {
"description": "The description/content of the todo item",
"type": "string"
},
"status": {
"description": "The current status of the todo item",
"type": "string",
"enum": [
"pending",
"in_progress",
"completed"
]
}
},
"required": [
"id"
],
"additionalProperties": false
},
"description": "Array of todo items. When merge is true, only include todos that need updates. When merge is false, this is the complete list."
}
},
"required": [
"merge",
"todos"
],
"additionalProperties": false
}
}
},
{ {
"type": "function", "type": "function",
"function": { "function": {
......
=== src/lib/utils.test.ts ===
import { formatDate, safeParseJSON } from "./utils";
test("formatDate returns ISO string", () => {
const d = new Date("2024-01-01");
expect(formatDate(d)).toBe("2024-01-01T00:00:00.000Z");
});
test("safeParseJSON parses valid JSON", () => {
expect(safeParseJSON('{"a":1}')).toEqual({ a: 1 });
});
test("safeParseJSON returns null for invalid JSON", () => {
expect(safeParseJSON("not json")).toBeNull();
});
=== src/lib/utils.ts ===
export function formatDate(d: Date): string {
return d.toISOString();
}
export function safeParseJSON(str: string): unknown {
try {
return JSON.parse(str);
} catch {
return null;
}
}
- 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\./
- button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID":
- img
- text: ""
- paragraph: tc=local-agent/persistent-todos
- paragraph: I'll create a task list to track the work.Let me create the utility module first.
- 'button "utils.ts src/lib/utils.ts Edit Summary: Create utility module"':
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- text: ""
- paragraph: Marking the first task as done.I've completed the utility module. I'll continue with the remaining tasks.I see there are remaining tasks. I'll pick these up in the next turn.
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- button "Copy Request ID":
- img
- text: ""
- paragraph: tc=local-agent/persistent-todos-resume
- paragraph: I see there are unfinished todos from last time. Let me continue.Adding error handling to the utility module.
- 'button "utils.ts src/lib/utils.ts Edit Summary: Add error handling utility"':
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- text: ""
- paragraph: Now writing the tests.
- 'button "utils.test.ts src/lib/utils.test.ts Edit Summary: Write tests for utility module"':
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- text: ""
- paragraph: Marking all remaining tasks as done.All tasks from the previous turn are now complete! I created the utility module, added error handling, and wrote the tests.
- button "Copy":
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- text: ""
- button "Undo":
- img
- text: ""
- button "Retry":
- img
- text: ""
\ No newline at end of file
# Local Agent Tool Definitions
Agent tool definitions live in `src/pro/main/ipc/handlers/local_agent/tools/`. Each tool has a `ToolDefinition` with optional flags.
## Read-only / plan-only mode
- **`modifiesState: true`** must be set on any tool that writes to disk or modifies external state (files, database, etc.). This flag controls whether the tool is available in read-only (ask) mode and plan-only mode — see `buildAgentToolSet` in `tool_definitions.ts`.
- Similarly, code in the `handleLocalAgentStream` handler that writes to the workspace (e.g., `ensureDyadGitignored`, injecting synthetic todo reminders) should be guarded with `if (!readOnly && !planModeOnly)` checks. Injecting instructions that reference state-changing tools into non-writable runs will confuse the model since those tools are filtered out.
## Async I/O
- Use `fs.promises` (not sync `fs` methods) in any code running on the Electron main process (e.g., `todo_persistence.ts`) to avoid blocking the event loop.
...@@ -56,8 +56,11 @@ import { ...@@ -56,8 +56,11 @@ import {
prepareStepMessages, prepareStepMessages,
buildTodoReminderMessage, buildTodoReminderMessage,
hasIncompleteTodos, hasIncompleteTodos,
formatTodoSummary,
type InjectedMessage, type InjectedMessage,
} from "./prepare_step_utils"; } from "./prepare_step_utils";
import { loadTodos } from "./todo_persistence";
import { ensureDyadGitignored } from "@/ipc/handlers/planUtils";
import { TOOL_DEFINITIONS } from "./tool_definitions"; import { TOOL_DEFINITIONS } from "./tool_definitions";
import { import {
parseAiMessagesJson, parseAiMessagesJson,
...@@ -421,6 +424,23 @@ export async function handleLocalAgentStream( ...@@ -421,6 +424,23 @@ export async function handleLocalAgentStream(
settings, settings,
); );
// Load persisted todos from a previous turn (if any)
const persistedTodos = await loadTodos(appPath, chat.id);
// Ensure .dyad/ is gitignored (idempotent; also done by compaction/plans)
// Skip in read-only/plan-only mode to avoid modifying the workspace
if (!readOnly && !planModeOnly) {
await ensureDyadGitignored(appPath).catch((err) =>
logger.warn("Failed to ensure .dyad gitignored:", err),
);
}
if (persistedTodos.length > 0) {
// Emit loaded todos to the renderer so the UI shows them immediately
safeSend(event.sender, "agent-tool:todos-update", {
chatId: chat.id,
todos: persistedTodos,
});
}
// Build tool execute context // Build tool execute context
const fileEditTracker: FileEditTracker = Object.create(null); const fileEditTracker: FileEditTracker = Object.create(null);
const ctx: AgentContext = { const ctx: AgentContext = {
...@@ -432,7 +452,7 @@ export async function handleLocalAgentStream( ...@@ -432,7 +452,7 @@ export async function handleLocalAgentStream(
supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug, supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug,
messageId: placeholderMessageId, messageId: placeholderMessageId,
isSharedModulesChanged: false, isSharedModulesChanged: false,
todos: [], todos: persistedTodos,
dyadRequestId, dyadRequestId,
fileEditTracker, fileEditTracker,
isDyadPro: isDyadProEnabled(settings), isDyadPro: isDyadProEnabled(settings),
...@@ -523,6 +543,40 @@ export async function handleLocalAgentStream( ...@@ -523,6 +543,40 @@ export async function handleLocalAgentStream(
let currentMessageHistory = messageHistory; let currentMessageHistory = messageHistory;
const accumulatedAiMessages: ModelMessage[] = []; const accumulatedAiMessages: ModelMessage[] = [];
// If there are persisted todos from a previous turn, inject a synthetic
// user message so the LLM is aware of them. Inserted BEFORE the user's
// current message so the user's actual request is the last thing the LLM
// reads, giving it natural priority over stale todos.
if (
!messageOverride &&
!readOnly &&
!planModeOnly &&
persistedTodos.length > 0 &&
hasIncompleteTodos(persistedTodos)
) {
const incompleteTodos = persistedTodos.filter(
(t) => t.status === "pending" || t.status === "in_progress",
);
const todoSummary = formatTodoSummary(incompleteTodos);
const syntheticMessage: ModelMessage = {
role: "user",
content: [
{
type: "text",
text: `[System] You have unfinished todos from your previous turn:\n${todoSummary}\n\nThe user's next message is their current request. If their request relates to these todos, continue working on them. If their request is about something different, discard these old todos by calling update_todos with merge=false and an empty list, then focus entirely on the user's new request.`,
},
],
};
// Insert before the last message (the user's current message) so the
// user's intent is the final thing the LLM sees.
const insertIndex = Math.max(0, currentMessageHistory.length - 1);
currentMessageHistory = [
...currentMessageHistory.slice(0, insertIndex),
syntheticMessage,
...currentMessageHistory.slice(insertIndex),
];
}
while (!abortController.signal.aborted) { while (!abortController.signal.aborted) {
// Reset mid-turn compaction state at the start of each pass. // Reset mid-turn compaction state at the start of each pass.
// These flags track compaction within a single pass and must not persist // These flags track compaction within a single pass and must not persist
......
...@@ -22,15 +22,20 @@ export function hasIncompleteTodos(todos: Todo[]): boolean { ...@@ -22,15 +22,20 @@ export function hasIncompleteTodos(todos: Todo[]): boolean {
return todos.some(isIncompleteTodo); return todos.some(isIncompleteTodo);
} }
/**
* Format a list of todos as a bullet-point summary string.
*/
export function formatTodoSummary(todos: Todo[]): string {
return todos.map((t) => `- [${t.status}] ${t.content}`).join("\n");
}
/** /**
* Build a reminder message for incomplete todos. * Build a reminder message for incomplete todos.
*/ */
export function buildTodoReminderMessage(todos: Todo[]): string { export function buildTodoReminderMessage(todos: Todo[]): string {
const incompleteTodos = todos.filter(isIncompleteTodo); const incompleteTodos = todos.filter(isIncompleteTodo);
const todoList = incompleteTodos const todoList = formatTodoSummary(incompleteTodos);
.map((t) => `- [${t.status}] ${t.content}`)
.join("\n");
// Note: The "incomplete todo(s)" substring is used as a detection marker by test // Note: The "incomplete todo(s)" substring is used as a detection marker by test
// infrastructure in testing/fake-llm-server/ (chatCompletionHandler.ts and // infrastructure in testing/fake-llm-server/ (chatCompletionHandler.ts and
......
/**
* Todo persistence utilities.
*
* Reads/writes per-chat todo JSON files so that todos survive across turns.
*/
import fs from "node:fs";
import path from "node:path";
import log from "electron-log";
import { AgentTodoSchema } from "@/ipc/types";
import type { Todo } from "./tools/types";
const logger = log.scope("todo_persistence");
/**
* Return the path to the todos JSON file for a given chat.
*
* Layout: `<appPath>/.dyad/todos/<chatId>.json`
*/
export function getTodosFilePath(appPath: string, chatId: number): string {
return path.join(appPath, ".dyad", "todos", `${chatId}.json`);
}
/**
* Persist the current todos list to disk.
*
* Creates the `.dyad/todos/` directory if it does not exist.
*/
export async function saveTodos(
appPath: string,
chatId: number,
todos: Todo[],
): Promise<void> {
const filePath = getTodosFilePath(appPath, chatId);
try {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
const data = JSON.stringify(
{ todos, updatedAt: new Date().toISOString() },
null,
2,
);
await fs.promises.writeFile(filePath, data, "utf-8");
} catch (err) {
logger.warn("Failed to save todos:", err);
}
}
/**
* Load previously persisted todos for a chat.
*
* Returns `[]` if the file does not exist or is corrupted.
*/
export async function loadTodos(
appPath: string,
chatId: number,
): Promise<Todo[]> {
const filePath = getTodosFilePath(appPath, chatId);
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed?.todos)) {
// Validate each todo entry to guard against corrupted/hand-edited files
const validated = parsed.todos.flatMap((t: unknown) => {
const result = AgentTodoSchema.safeParse(t);
return result.success ? [result.data] : [];
});
return validated;
}
logger.warn("Unexpected todos file format, returning empty list");
return [];
} catch (err: any) {
// ENOENT just means no todos have been saved for this chat yet.
if (err?.code === "ENOENT") {
return [];
}
logger.warn("Failed to load todos, returning empty list:", err);
return [];
}
}
/**
* Delete the todos file for a chat (e.g. when all todos are completed).
*/
export async function deleteTodos(
appPath: string,
chatId: number,
): Promise<void> {
const filePath = getTodosFilePath(appPath, chatId);
try {
await fs.promises.unlink(filePath);
} catch (err) {
// ENOENT is fine — the file may not exist if no todos were ever persisted,
// or parallel tool executions in the same step may race on deletion.
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
logger.warn("Failed to delete todos file:", err);
}
}
}
import { z } from "zod"; import { z } from "zod";
import { ToolDefinition, AgentContext, Todo } from "./types"; import { ToolDefinition, AgentContext, Todo } from "./types";
import { saveTodos, deleteTodos } from "../todo_persistence";
const todoSchema = z.object({ const todoSchema = z.object({
id: z.string().describe("Unique identifier for the todo item"), id: z.string().describe("Unique identifier for the todo item"),
...@@ -101,6 +102,7 @@ export const updateTodosTool: ToolDefinition< ...@@ -101,6 +102,7 @@ export const updateTodosTool: ToolDefinition<
description: DESCRIPTION, description: DESCRIPTION,
inputSchema: updateTodosSchema, inputSchema: updateTodosSchema,
defaultConsent: "always", defaultConsent: "always",
modifiesState: true,
getConsentPreview: (args) => { getConsentPreview: (args) => {
const count = args.todos.length; const count = args.todos.length;
...@@ -147,6 +149,15 @@ export const updateTodosTool: ToolDefinition< ...@@ -147,6 +149,15 @@ export const updateTodosTool: ToolDefinition<
// Send todos to renderer for UI display // Send todos to renderer for UI display
ctx.onUpdateTodos(ctx.todos); ctx.onUpdateTodos(ctx.todos);
// Persist todos to disk so they survive across turns
const allCompleted =
ctx.todos.length > 0 && ctx.todos.every((t) => t.status === "completed");
if (allCompleted || ctx.todos.length === 0) {
await deleteTodos(ctx.appPath, ctx.chatId);
} else {
await saveTodos(ctx.appPath, ctx.chatId, ctx.todos);
}
const completed = ctx.todos.filter((t) => t.status === "completed").length; const completed = ctx.todos.filter((t) => t.status === "completed").length;
const inProgressTodos = ctx.todos.filter((t) => t.status === "in_progress"); const inProgressTodos = ctx.todos.filter((t) => t.status === "in_progress");
const pendingTodos = ctx.todos.filter((t) => t.status === "pending"); const pendingTodos = ctx.todos.filter((t) => t.status === "pending");
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论