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

feat: add context compaction system for local-agent mode (#2515)

Automatically compact long conversations when approaching context window limits (80% of model context or 180k tokens, whichever is first). Changes: - Add enableContextCompaction setting (default: enabled) in Settings > AI - Add database fields to track compaction state and backup paths - Create compaction handler that generates structured summaries via LLM - Store original messages in app data directory before compaction - Show visual indicator using dyad-status tag when compaction occurs - Integrate compaction checks into local_agent_handler <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2515" 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 --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core `local-agent` chat streaming and adds new persistence/migration paths plus LLM-driven summarization, so regressions could affect message ordering/history sent to models. Scoped behind a setting (default on) with added unit/E2E tests, reducing risk. > > **Overview** > Adds **automatic context compaction** for `local-agent` chats: when token usage crosses a threshold, the chat is marked for compaction and the next turn generates an LLM summary, stores an XML transcript backup under `.dyad/chats/<id>/compaction-*.md`, and inserts a `is_compaction_summary` assistant message with a new `<dyad-compaction>` indicator. > > Introduces a user-facing toggle `enableContextCompaction` (default on) wired into settings schema/defaults/search and Settings UI, plus new DB fields on `chats` (`pending_compaction`, `compacted_at`, `compaction_backup_path`) and `messages` (`is_compaction_summary`). Updates local-agent streaming to (a) run pending compaction before processing, (b) send only post-compaction history to the LLM, and (c) update the correct placeholder message by ID to avoid overwriting the compaction summary; includes new unit/E2E coverage and test-fixture support for streaming usage data/snapshot scrubbing. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit df1cdaacbe3e9f3fb47473a3e6965e302a75fe7a. 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 Adds automatic context compaction for local-agent mode to summarize long conversations when token usage reaches 80% of the model’s context window or 180k tokens. Original messages are preserved and backed up, a structured summary is inserted, and a dyad-compaction indicator is shown. - **New Features** - New toggle in Settings > AI: enableContextCompaction (default on) with UI switch and searchable setting. - DB fields to track compaction and backups (chats: compacted_at, compaction_backup_path, pending_compaction; messages: is_compaction_summary). - Compaction handler backs up LLM-visible messages to .dyad/chats/<chatId>/compaction-*.md, generates structured summaries with a system prompt, inserts a summary message, and emits chat:compaction:complete. - Local-agent flow: pending compaction runs at the start of the next turn; LLM history includes only post-compaction messages; streaming updates target the placeholder message by ID to avoid overwriting the summary. - Token threshold checks integrated into local_agent_handler (min(80% of context window, 180k)). <sup>Written for commit df1cdaacbe3e9f3fb47473a3e6965e302a75fe7a. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 70a02101
......@@ -302,3 +302,25 @@ OpenAI's Responses API requires reasoning items to always be followed by an outp
- Only reasoning was generated in a turn
The fix in `src/ipc/utils/ai_messages_utils.ts` filters orphaned reasoning parts via `filterOrphanedReasoningParts()` before sending conversation history back to OpenAI.
### Adding a new user setting
When adding a new toggle/setting to the Settings page:
1. Add the field to `UserSettingsSchema` in `src/lib/schemas.ts`
2. Add the default value in `DEFAULT_SETTINGS` in `src/main/settings.ts`
3. Add a `SETTING_IDS` entry and search index entry in `src/lib/settingsSearchIndex.ts`
4. Create a switch component (e.g., `src/components/MySwitch.tsx`) - follow `AutoApproveSwitch.tsx` as a template
5. Import and add the switch to the relevant section in `src/pages/settings.tsx`
### Custom chat message indicators
The `<dyad-status>` tag in chat messages renders as a collapsible status indicator box. Use it for system messages like compaction notifications:
```
<dyad-status title="My Title" state="finished">
Content here
</dyad-status>
```
Valid states: `"finished"`, `"in-progress"`, `"aborted"`
ALTER TABLE `chats` ADD `compacted_at` integer;--> statement-breakpoint
ALTER TABLE `chats` ADD `compaction_backup_path` text;--> statement-breakpoint
ALTER TABLE `chats` ADD `pending_compaction` integer;--> statement-breakpoint
ALTER TABLE `messages` ADD `is_compaction_summary` integer;
\ No newline at end of file
差异被折叠。
......@@ -176,6 +176,13 @@
"when": 1769582904159,
"tag": "0024_useful_skin",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1770256089560,
"tag": "0025_lush_stark_industries",
"breakpoints": true
}
]
}
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test";
/**
* E2E tests for context compaction feature.
* Tests that long conversations are automatically compacted when token usage
* exceeds the threshold, and that the compaction summary is displayed.
*/
testSkipIfWindows(
"local-agent - context compaction triggers and shows summary",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
// Send first message with a fixture that returns 200k token usage.
// This exceeds the compaction threshold (min(80% of context window, 180k))
// and marks the chat for compaction on the next message.
await po.sendPrompt("tc=local-agent/compaction-trigger");
// Send a second message. The local agent handler detects pending compaction,
// performs it (generates a summary, replaces old messages), then processes
// the second message normally.
await po.sendPrompt("tc=local-agent/simple-response");
// Verify the compaction status indicator is visible
await expect(po.page.getByText("Conversation compacted")).toBeVisible({
timeout: Timeout.MEDIUM,
});
await po.sendPrompt("[dump] hi");
await po.snapshotServerDump("all-messages");
// Snapshot the messages to capture the compaction summary + second response
await po.snapshotMessages();
},
);
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
/**
* Fixture that returns a response with very high token usage (200k tokens)
* to trigger context compaction marking. On the next message, the app will
* perform compaction before processing.
*/
export const fixture: LocalAgentFixture = {
description:
"Response with high token usage to trigger compaction on next message",
turns: [
{
text: "I've completed the initial analysis of the codebase. Here are the findings.",
usage: {
prompt_tokens: 199_900,
completion_tokens: 100,
total_tokens: 200_000,
},
},
],
};
......@@ -592,22 +592,32 @@ export class PageObject {
replaceDumpPath = false,
timeout,
}: { replaceDumpPath?: boolean; timeout?: number } = {}) {
if (replaceDumpPath) {
// Update page so that "[[dyad-dump-path=*]]" is replaced with a placeholder path
// which is stable across runs.
await this.page.evaluate(() => {
// Always scrub compaction backup paths — they contain system-specific
// temp directories and timestamps that change between runs.
// Also conditionally scrub dyad-dump-path placeholders.
await this.page.evaluate(
({ replaceDumpPath }) => {
const messagesList = document.querySelector(
"[data-testid=messages-list]",
);
if (!messagesList) {
throw new Error("Messages list not found");
}
// Scrub compaction backup paths embedded in message text
// e.g. .dyad/chats/1/compaction-2026-02-05T21-25-24-285Z.md
messagesList.innerHTML = messagesList.innerHTML.replace(
/\.dyad\/chats\/\d+\/compaction-[^\s<"]+\.md/g,
"[[compaction-backup-path]]",
);
if (replaceDumpPath) {
messagesList.innerHTML = messagesList.innerHTML.replace(
/\[\[dyad-dump-path=([^\]]+)\]\]/g,
"[[dyad-dump-path=*]]",
);
});
}
},
{ replaceDumpPath },
);
await expect(this.page.getByTestId("messages-list")).toMatchAriaSnapshot({
timeout,
});
......@@ -880,9 +890,14 @@ export class PageObject {
}
// Read the JSON file
const dumpContent: string = (
fs.readFileSync(dumpFilePath, "utf-8") as any
).replaceAll(/\[\[dyad-dump-path=([^\]]+)\]\]/g, "[[dyad-dump-path=*]]");
const dumpContent: string = (fs.readFileSync(dumpFilePath, "utf-8") as any)
.replaceAll(/\[\[dyad-dump-path=([^\]]+)\]\]/g, "[[dyad-dump-path=*]]")
// Stabilize compaction backup file paths embedded in message text
// e.g. .dyad/chats/1/compaction-2026-02-05T21-25-24-285Z.md
.replaceAll(
/\.dyad\/chats\/\d+\/compaction-[^\s"\\]+\.md/g,
"[[compaction-backup-path]]",
);
// Perform snapshot comparison
const parsedDump = JSON.parse(dumpContent);
if (type === "request") {
......
- 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 "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- paragraph: tc=local-agent/compaction-trigger
- paragraph: I've completed the initial analysis of the codebase. Here are the findings.
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- button "Conversation compacted" [expanded]:
- img
- img
- heading "Key Decisions Made" [level=2]
- list:
- listitem: Completed initial task as requested
- heading "Current Task State" [level=2]
- paragraph: Conversation was compacted to save context space.
- paragraph: "If you need to retrieve earlier parts of the conversation history, you can read the backup file at: [[compaction-backup-path]] Note: This file may be large. Read only the sections you need or use grep to search for specific content rather than reading the entire file."
- button "Copy":
- img
- img
- text: less than a minute ago
- paragraph: tc=local-agent/simple-response
- paragraph: Hello! I understand your request. This is a simple response from the Basic Agent mode.
- 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
- paragraph: "[dump] hi"
- paragraph: /\[\[dyad-dump-path=\/Users\/will\/Documents\/GitHub\/dyad-zero\/testing\/fake-llm-server\/dist\/generated\/\d+\.json\]\]/
- button "Copy":
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- button "Undo":
- img
- button "Retry":
- img
\ 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>
- **Read before writing**: Use `read_file` and `list_files` to understand the codebase before making changes
- **Use `edit_file` for edits**: For modifying existing files, prefer `edit_file` over `write_file`
- **Be surgical**: Only change what's necessary to accomplish the task
- **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices>
<file_editing_tool_selection>
You have three tools for editing files. Choose based on the scope of your change:
| Scope | Tool | Examples |
|-------|------|----------|
| **Small** (a few lines) | `search_replace` or `edit_file` | Fix a typo, rename a variable, update a value, change an import |
| **Medium** (one function or section) | `edit_file` | Rewrite a function, add a new component, modify multiple related lines |
| **Large** (most of the file) | `write_file` | Major refactor, rewrite a module, create a new file |
**Tips:**
- `edit_file` supports `// ... existing code ...` markers to skip unchanged sections
- When in doubt, prefer `search_replace` for precision or `write_file` for simplicity
**Post-edit verification (REQUIRED):**
After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again.
</file_editing_tool_selection>
<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use `grep` and `code_search` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use `read_file` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to `read_file`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the `update_todos` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
3. **Implement:** Use the available tools (e.g., `edit_file`, `write_file`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
4. **Verify:** After making code changes, use `run_type_checks` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow>
# 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: assistant
message: <dyad-compaction title="Conversation compacted" state="finished">
## Key Decisions Made
- Completed initial task as requested
## Current Task State
Conversation was compacted to save context space.
</dyad-compaction>
If you need to retrieve earlier parts of the conversation history, you can read the backup file at: [[compaction-backup-path]]
Note: This file may be large. Read only the sections you need or use grep to search for specific content rather than reading the entire file.
===
role: user
message: tc=local-agent/simple-response
===
role: assistant
message: Hello! I understand your request. This is a simple response from the Basic Agent mode.
===
role: user
message: [dump] hi
\ No newline at end of file
差异被折叠。
import { describe, it, expect } from "vitest";
import { getPostCompactionMessages } from "../ipc/handlers/compaction/compaction_utils";
type Msg = { id: number; role: string; isCompactionSummary: boolean | null };
function msg(
id: number,
role: string,
isCompactionSummary: boolean | null = null,
): Msg {
return { id, role, isCompactionSummary };
}
describe("getPostCompactionMessages", () => {
it("returns all messages when there is no compaction summary", () => {
const messages = [
msg(1, "user"),
msg(2, "assistant"),
msg(3, "user"),
msg(4, "assistant"),
];
expect(getPostCompactionMessages(messages)).toEqual(messages);
});
it("returns empty array when given empty input", () => {
expect(getPostCompactionMessages([])).toEqual([]);
});
it("filters pre-compaction messages and keeps summary + triggering user message + subsequent", () => {
// Scenario: messages 1-4 are pre-compaction, 5 is the triggering user msg,
// 6 is the placeholder assistant, 7 is the compaction summary (highest ID)
const messages = [
msg(1, "user"),
msg(2, "assistant"),
msg(3, "user"),
msg(4, "assistant"),
msg(5, "user"), // triggering user message
msg(6, "assistant"), // placeholder
msg(7, "assistant", true), // compaction summary
];
const result = getPostCompactionMessages(messages);
expect(result).toEqual([
msg(5, "user"),
msg(6, "assistant"),
msg(7, "assistant", true),
]);
});
it("includes messages after the compaction summary", () => {
const messages = [
msg(1, "user"),
msg(2, "assistant"),
msg(3, "user"), // triggering user message
msg(4, "assistant"), // placeholder
msg(5, "assistant", true), // compaction summary
msg(6, "user"), // new message after compaction
msg(7, "assistant"), // response after compaction
];
const result = getPostCompactionMessages(messages);
expect(result).toEqual([
msg(3, "user"),
msg(4, "assistant"),
msg(5, "assistant", true),
msg(6, "user"),
msg(7, "assistant"),
]);
});
it("handles re-compaction: uses latest summary and excludes older summaries", () => {
// First compaction produced summary at id=5, second compaction at id=10
const messages = [
msg(1, "user"),
msg(2, "assistant"),
msg(3, "user"),
msg(4, "assistant"),
msg(5, "assistant", true), // first compaction summary
msg(6, "user"),
msg(7, "assistant"),
msg(8, "user"), // triggering user message for second compaction
msg(9, "assistant"), // placeholder
msg(10, "assistant", true), // second compaction summary (latest)
];
const result = getPostCompactionMessages(messages);
// Should use id=10 as latest summary, id=8 as triggering user msg
// Excludes id=5 (older compaction summary)
expect(result).toEqual([
msg(8, "user"),
msg(9, "assistant"),
msg(10, "assistant", true),
]);
});
it("handles compaction summary with no preceding user message", () => {
// Edge case: compaction summary is the first message (shouldn't happen in
// practice, but the function handles it gracefully)
const messages = [
msg(1, "assistant", true), // compaction summary
msg(2, "user"),
msg(3, "assistant"),
];
const result = getPostCompactionMessages(messages);
expect(result).toEqual([
msg(1, "assistant", true),
msg(2, "user"),
msg(3, "assistant"),
]);
});
it("handles compaction summary as the only message", () => {
const messages = [msg(1, "assistant", true)];
const result = getPostCompactionMessages(messages);
expect(result).toEqual([msg(1, "assistant", true)]);
});
it("treats isCompactionSummary: null and false the same (not a summary)", () => {
const messages = [
msg(1, "user"),
msg(2, "assistant"),
msg(3, "user"),
{ id: 4, role: "assistant", isCompactionSummary: false },
msg(5, "assistant", true), // compaction summary
];
const result = getPostCompactionMessages(messages);
// id=3 is the triggering user message
expect(result).toEqual([
msg(3, "user"),
{ id: 4, role: "assistant", isCompactionSummary: false },
msg(5, "assistant", true),
]);
});
it("excludes all older compaction summaries in multi-compaction scenario", () => {
// Three compactions have occurred
const messages = [
msg(1, "user"),
msg(2, "assistant", true), // 1st compaction
msg(3, "user"),
msg(4, "assistant"),
msg(5, "assistant", true), // 2nd compaction
msg(6, "user"),
msg(7, "assistant"),
msg(8, "user"), // triggering user message
msg(9, "assistant"), // placeholder
msg(10, "assistant", true), // 3rd compaction (latest)
msg(11, "user"),
msg(12, "assistant"),
];
const result = getPostCompactionMessages(messages);
expect(result).toEqual([
msg(8, "user"),
msg(9, "assistant"),
msg(10, "assistant", true),
msg(11, "user"),
msg(12, "assistant"),
]);
// Verify older summaries are excluded
expect(result.find((m) => m.id === 2)).toBeUndefined();
expect(result.find((m) => m.id === 5)).toBeUndefined();
});
it("handles non-contiguous IDs", () => {
const messages = [
msg(10, "user"),
msg(20, "assistant"),
msg(30, "user"),
msg(40, "assistant"),
msg(50, "user"), // triggering user message
msg(60, "assistant"), // placeholder
msg(70, "assistant", true), // compaction summary
msg(80, "user"),
];
const result = getPostCompactionMessages(messages);
expect(result).toEqual([
msg(50, "user"),
msg(60, "assistant"),
msg(70, "assistant", true),
msg(80, "user"),
]);
});
});
......@@ -90,10 +90,12 @@ function buildTestSettings(
enableDyadPro?: boolean;
hasApiKey?: boolean;
selectedModel?: string;
enableContextCompaction?: boolean;
} = {},
) {
const baseSettings = {
selectedModel: overrides.selectedModel ?? "gpt-4",
enableContextCompaction: overrides.enableContextCompaction ?? true,
};
if (overrides.enableDyadPro && overrides.hasApiKey !== false) {
......@@ -255,6 +257,22 @@ vi.mock(
}),
);
const {
mockIsChatPendingCompaction,
mockPerformCompaction,
mockCheckAndMarkForCompaction,
} = vi.hoisted(() => ({
mockIsChatPendingCompaction: vi.fn(async () => false),
mockPerformCompaction: vi.fn(async () => ({ success: true })),
mockCheckAndMarkForCompaction: vi.fn(async () => false),
}));
vi.mock("@/ipc/handlers/compaction/compaction_handler", () => ({
isChatPendingCompaction: mockIsChatPendingCompaction,
performCompaction: mockPerformCompaction,
checkAndMarkForCompaction: mockCheckAndMarkForCompaction,
}));
// ============================================================================
// Import the function under test AFTER mocks are set up
// ============================================================================
......@@ -274,6 +292,9 @@ describe("handleLocalAgentStream", () => {
mockChatData = null;
mockSettings = buildTestSettings();
mockStreamResult = null;
mockIsChatPendingCompaction.mockResolvedValue(false);
mockPerformCompaction.mockResolvedValue({ success: true });
mockCheckAndMarkForCompaction.mockResolvedValue(false);
});
describe("Pro status validation", () => {
......@@ -373,6 +394,35 @@ describe("handleLocalAgentStream", () => {
});
});
describe("Context compaction setting", () => {
it("should not run pending compaction when context compaction is disabled", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({
enableDyadPro: true,
enableContextCompaction: false,
});
mockChatData = buildTestChat();
mockStreamResult = createFakeStream([{ type: "text-delta", text: "ok" }]);
mockIsChatPendingCompaction.mockResolvedValue(true);
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{
placeholderMessageId: 10,
systemPrompt: "You are helpful",
dyadRequestId,
},
);
// Assert
expect(mockPerformCompaction).not.toHaveBeenCalled();
});
});
describe("Stream processing - text content", () => {
it("should accumulate text-delta parts and update database", async () => {
// Arrange
......
......@@ -56,6 +56,7 @@ describe("readSettings", () => {
"autoExpandPreviewPanel": true,
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"enableContextCompaction": true,
"enableNativeGit": true,
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
......@@ -312,6 +313,7 @@ describe("readSettings", () => {
"autoExpandPreviewPanel": true,
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"enableContextCompaction": true,
"enableNativeGit": true,
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
......
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export function ContextCompactionSwitch() {
const { settings, updateSettings } = useSettings();
return (
<div className="flex items-center space-x-2">
<Switch
id="context-compaction"
aria-label="Context Compaction"
checked={settings?.enableContextCompaction !== false}
onCheckedChange={(checked) => {
updateSettings({ enableContextCompaction: checked });
}}
/>
<Label htmlFor="context-compaction">Context Compaction</Label>
</div>
);
}
import React, { useState, useEffect } from "react";
import { Layers, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import { VanillaMarkdownParser } from "./DyadMarkdownParser";
import { CustomTagState } from "./stateTypes";
interface DyadCompactionProps {
node: {
properties: {
title?: string;
state?: CustomTagState;
};
};
children?: React.ReactNode;
}
export const DyadCompaction: React.FC<DyadCompactionProps> = ({
children,
node,
}) => {
const { title = "Compacting conversation", state } = node.properties;
const inProgress = state === "pending";
const [isExpanded, setIsExpanded] = useState(true);
// Auto-collapse when compaction finishes
useEffect(() => {
if (!inProgress && isExpanded) {
// Small delay so the user can see the final state before collapsing
const timer = setTimeout(() => setIsExpanded(false), 600);
return () => clearTimeout(timer);
}
}, [inProgress]);
const content = typeof children === "string" ? children : "";
return (
<div
className={`relative rounded-lg border my-2 overflow-hidden transition-colors duration-300 ${
inProgress
? "border-blue-400/60 dark:border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/30"
: "border-border bg-(--background-lightest) dark:bg-zinc-900"
}`}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-2.5 cursor-pointer hover:bg-blue-50/30 dark:hover:bg-blue-950/20 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
<div className="flex items-center gap-2">
{inProgress ? (
<Loader2 className="size-4 animate-spin text-blue-500" />
) : (
<Layers className="size-4 text-blue-500 dark:text-blue-400" />
)}
<span
className={`font-medium text-sm ${
inProgress
? "bg-gradient-to-r from-blue-600 via-blue-400 to-blue-600 dark:from-blue-400 dark:via-blue-300 dark:to-blue-400 bg-[length:200%_100%] animate-[shimmer_2s_ease-in-out_infinite] bg-clip-text text-transparent"
: "text-gray-700 dark:text-gray-300"
}`}
>
{title}
</span>
</div>
<div className="text-gray-400 dark:text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</div>
{/* Content area with smooth transition */}
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "400px" : "0px",
opacity: isExpanded ? 1 : 0,
}}
>
<div className="px-4 pb-3 text-sm text-gray-600 dark:text-gray-300 max-h-[360px] overflow-y-auto">
{content ? (
<VanillaMarkdownParser content={content} />
) : inProgress ? (
<span className="text-xs text-gray-400 italic">
Generating summary...
</span>
) : null}
</div>
</div>
</div>
);
};
......@@ -34,6 +34,7 @@ import { DyadDatabaseSchema } from "./DyadDatabaseSchema";
import { DyadSupabaseTableSchema } from "./DyadSupabaseTableSchema";
import { DyadSupabaseProjectInfo } from "./DyadSupabaseProjectInfo";
import { DyadStatus } from "./DyadStatus";
import { DyadCompaction } from "./DyadCompaction";
import { DyadWritePlan } from "./DyadWritePlan";
import { DyadExitPlan } from "./DyadExitPlan";
import { mapActionToButton } from "./ChatInput";
......@@ -71,6 +72,7 @@ const DYAD_CUSTOM_TAGS = [
"dyad-supabase-table-schema",
"dyad-supabase-project-info",
"dyad-status",
"dyad-compaction",
// Plan mode tags
"dyad-write-plan",
"dyad-exit-plan",
......@@ -716,6 +718,20 @@ function renderCustomTag(
</DyadStatus>
);
case "dyad-compaction":
return (
<DyadCompaction
node={{
properties: {
title: attributes.title || "Compacting conversation",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadCompaction>
);
case "dyad-write-plan":
return (
<DyadWritePlan
......
......@@ -72,6 +72,10 @@ export const chats = sqliteTable("chats", {
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
// Context compaction fields
compactedAt: integer("compacted_at", { mode: "timestamp" }),
compactionBackupPath: text("compaction_backup_path"),
pendingCompaction: integer("pending_compaction", { mode: "boolean" }),
});
export const messages = sqliteTable("messages", {
......@@ -101,6 +105,8 @@ export const messages = sqliteTable("messages", {
usingFreeAgentModeQuota: integer("using_free_agent_mode_quota", {
mode: "boolean",
}),
// Indicates this message is a compaction summary
isCompactionSummary: integer("is_compaction_summary", { mode: "boolean" }),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
......
/**
* Context Compaction Handler
* Orchestrates the compaction of long conversations to stay within context limits.
*/
import { IpcMainInvokeEvent } from "electron";
import { streamText, ModelMessage } from "ai";
import log from "electron-log";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { chats, messages } from "@/db/schema";
import { readSettings } from "@/main/settings";
import { getModelClient } from "@/ipc/utils/get_model_client";
import {
getContextWindow,
shouldTriggerCompaction,
} from "@/ipc/utils/token_utils";
import { safeSend } from "@/ipc/utils/safe_sender";
import { COMPACTION_SYSTEM_PROMPT } from "@/prompts/compaction_system_prompt";
import {
storePreCompactionMessages,
formatAsTranscript,
type CompactionMessage,
} from "./compaction_storage";
import { getPostCompactionMessages } from "./compaction_utils";
import { getProviderOptions, getAiHeaders } from "@/ipc/utils/provider_options";
const logger = log.scope("compaction_handler");
export interface CompactionResult {
success: boolean;
summary?: string;
backupPath?: string;
error?: string;
}
/**
* Mark a chat as needing compaction before the next message.
*/
export async function markChatForCompaction(chatId: number): Promise<void> {
try {
await db
.update(chats)
.set({ pendingCompaction: true })
.where(eq(chats.id, chatId));
logger.info(`Marked chat ${chatId} for compaction`);
} catch (error) {
logger.error(`Failed to mark chat ${chatId} for compaction:`, error);
}
}
/**
* Check if a chat has pending compaction.
*/
export async function isChatPendingCompaction(
chatId: number,
): Promise<boolean> {
try {
const chat = await db.query.chats.findFirst({
where: eq(chats.id, chatId),
columns: { pendingCompaction: true },
});
return chat?.pendingCompaction === true;
} catch (error) {
logger.error(
`Failed to check compaction status for chat ${chatId}:`,
error,
);
return false;
}
}
/**
* Check if compaction should be triggered based on token usage.
*/
export async function checkAndMarkForCompaction(
chatId: number,
totalTokens: number,
): Promise<boolean> {
const settings = readSettings();
// Skip if compaction is disabled
if (settings.enableContextCompaction === false) {
return false;
}
const contextWindow = await getContextWindow();
const shouldCompact = shouldTriggerCompaction(totalTokens, contextWindow);
if (shouldCompact) {
await markChatForCompaction(chatId);
logger.info(
`Compaction triggered for chat ${chatId}: ${totalTokens} tokens (threshold: ${Math.min(Math.floor(contextWindow * 0.8), 180_000)})`,
);
return true;
}
return false;
}
/**
* Perform compaction on a chat.
* This will:
* 1. Load all messages from the chat
* 2. Find the latest compaction boundary (if re-compacting)
* 3. Store LLM-visible messages to a readable backup file
* 4. Generate a summary using the LLM
* 5. Insert summary message (original messages are preserved in DB)
* 6. Update chat record
*/
export async function performCompaction(
event: IpcMainInvokeEvent,
chatId: number,
appPath: string,
dyadRequestId: string,
onSummaryChunk?: (accumulatedText: string) => void,
): Promise<CompactionResult> {
const settings = readSettings();
try {
logger.info(`Starting compaction for chat ${chatId}`);
// Load all messages for the chat
const chatMessages = await db.query.messages.findMany({
where: eq(messages.chatId, chatId),
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
});
if (chatMessages.length === 0) {
logger.warn(`No messages found for chat ${chatId}, skipping compaction`);
await clearPendingCompaction(chatId);
return { success: true };
}
// Only operate on messages the LLM can currently see.
const llmVisibleMessages = getPostCompactionMessages(chatMessages);
// Prepare messages for backup
const messagesToBackup: CompactionMessage[] = llmVisibleMessages.map(
(m) => ({
role: m.role as "user" | "assistant",
content: m.content,
}),
);
// Store readable transcript backup in the app's .dyad/chats/ directory
const backupPath = await storePreCompactionMessages(
appPath,
chatId,
messagesToBackup,
);
// Prepare conversation for summarization using the same XML format as the backup
const conversationText = formatAsTranscript(messagesToBackup, chatId);
// Get model client
const { modelClient } = await getModelClient(
settings.selectedModel,
settings,
);
// Generate summary
const summaryMessages: ModelMessage[] = [
{
role: "user",
content: `Please summarize the following conversation:\n\n${conversationText}`,
},
];
const summaryResult = streamText({
model: modelClient.model,
headers: getAiHeaders({
builtinProviderId: modelClient.builtinProviderId,
}),
providerOptions: getProviderOptions({
dyadAppId: 0,
dyadRequestId,
dyadDisableFiles: true,
files: [],
mentionedAppsCodebases: [],
builtinProviderId: modelClient.builtinProviderId,
settings,
}),
system: COMPACTION_SYSTEM_PROMPT,
messages: summaryMessages,
maxRetries: 2,
});
// Stream summary text to the frontend as it generates
let summary = "";
for await (const chunk of summaryResult.textStream) {
summary += chunk;
onSummaryChunk?.(summary);
}
// Create the compaction indicator message
// Include relative backup path so the AI can read the full original conversation later
const compactionMessageContent = `<dyad-compaction title="Conversation compacted" state="finished">
${summary}
</dyad-compaction>
If you need to retrieve earlier parts of the conversation history, you can read the backup file at: ${backupPath}
Note: This file may be large. Read only the sections you need or use grep to search for specific content rather than reading the entire file.`;
// Insert summary message as a new assistant message
// Original messages are preserved in the DB for the user to see
//
// The createdAt timestamp must be set BEFORE the latest user message
// (the one that triggered compaction). This is critical because:
// 1. Messages are ordered by createdAt, and the compaction summary must
// appear before the new user message in the message array.
// 2. The local_agent_handler slices from the last compaction summary onward
// to build the LLM's message history — if the summary comes after the
// user message, the user's prompt is excluded from the LLM context.
// 3. sendResponseChunk updates the last assistant message, so the summary
// must not be the last assistant message (the placeholder should be).
const latestUserMessage = [...chatMessages]
.reverse()
.find((m) => m.role === "user");
const compactionCreatedAt = latestUserMessage
? new Date(latestUserMessage.createdAt.getTime() - 1)
: new Date();
await db.insert(messages).values({
chatId,
role: "assistant",
content: compactionMessageContent,
isCompactionSummary: true,
createdAt: compactionCreatedAt,
});
// Update chat record
await db
.update(chats)
.set({
compactedAt: new Date(),
compactionBackupPath: backupPath,
pendingCompaction: false,
})
.where(eq(chats.id, chatId));
// Notify the frontend about the compaction
safeSend(event.sender, "chat:compaction:complete", {
chatId,
backupPath,
});
logger.info(
`Compaction completed for chat ${chatId}: ${messagesToBackup.length} messages -> 1 summary (originals preserved)`,
);
return {
success: true,
summary,
backupPath,
};
} catch (error) {
logger.error(`Compaction failed for chat ${chatId}:`, error);
// Clear pending flag to prevent infinite retry loops
await clearPendingCompaction(chatId);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Clear the pending compaction flag for a chat.
*/
async function clearPendingCompaction(chatId: number): Promise<void> {
try {
await db
.update(chats)
.set({ pendingCompaction: false })
.where(eq(chats.id, chatId));
} catch (error) {
logger.error(
`Failed to clear pending compaction for chat ${chatId}:`,
error,
);
}
}
/**
* Compaction Storage Module
* Stores human/LLM-readable conversation transcripts before compaction.
* Uses XML-structured format with truncated tool results for token efficiency.
*/
import fs from "node:fs";
import path from "node:path";
import log from "electron-log";
import { ensureDyadGitignored } from "@/ipc/handlers/planUtils";
const logger = log.scope("compaction_storage");
/**
* Maximum characters to keep from tool results before truncating.
*/
export const TOOL_RESULT_TRUNCATION_LIMIT = 1000;
/**
* Message structure passed to the storage module.
*/
export interface CompactionMessage {
role: "user" | "assistant";
content: string;
}
/**
* Get the backup directory for a specific chat within the app's .dyad/chats/ directory.
*/
function getChatBackupDir(appPath: string, chatId: number): string {
return path.join(appPath, ".dyad", "chats", String(chatId));
}
/**
* Transform dyad-specific tool XML tags to shorter, LLM-friendly equivalents
* and truncate large tool results for token efficiency.
*/
export function transformToolTags(content: string): string {
// Transform <dyad-mcp-tool-call> to <tool-use>
let result = content.replace(
/<dyad-mcp-tool-call server="([^"]*)" tool="([^"]*)">\n([\s\S]*?)\n<\/dyad-mcp-tool-call>/g,
'<tool-use name="$2" server="$1">\n$3\n</tool-use>',
);
// Transform <dyad-mcp-tool-result> to <tool-result> with truncation
result = result.replace(
/<dyad-mcp-tool-result server="([^"]*)" tool="([^"]*)">\n([\s\S]*?)\n<\/dyad-mcp-tool-result>/g,
(_match, server, tool, resultContent: string) => {
const chars = resultContent.length;
const truncated = chars > TOOL_RESULT_TRUNCATION_LIMIT;
const attrs = [
`name="${tool}"`,
`server="${server}"`,
`chars="${chars}"`,
...(truncated ? ['truncated="true"'] : []),
].join(" ");
const body = truncated
? resultContent.slice(0, TOOL_RESULT_TRUNCATION_LIMIT) + "\n..."
: resultContent;
return `<tool-result ${attrs}>\n${body}\n</tool-result>`;
},
);
return result;
}
/**
* Format messages as an XML-structured conversation transcript
* that is easy for a future LLM to read.
*/
export function formatAsTranscript(
messages: CompactionMessage[],
chatId: number,
): string {
const timestamp = new Date().toISOString();
const header = `<transcript chatId="${chatId}" messageCount="${messages.length}" compactedAt="${timestamp}">`;
const body = messages
.map(
(m) => `<msg role="${m.role}">\n${transformToolTags(m.content)}\n</msg>`,
)
.join("\n\n");
return `${header}\n\n${body}\n\n</transcript>`;
}
/**
* Store pre-compaction messages as a readable transcript.
*
* @param appPath - The absolute app directory path
* @param chatId - The chat ID
* @param messages - The messages to backup
* @returns The relative path to the backup file (relative to appPath)
*/
export async function storePreCompactionMessages(
appPath: string,
chatId: number,
messages: CompactionMessage[],
): Promise<string> {
const chatBackupDir = getChatBackupDir(appPath, chatId);
// Ensure directory exists and .dyad is gitignored
if (!fs.existsSync(chatBackupDir)) {
fs.mkdirSync(chatBackupDir, { recursive: true });
}
await ensureDyadGitignored(appPath);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFileName = `compaction-${timestamp}.md`;
const backupPath = path.join(chatBackupDir, backupFileName);
const transcript = formatAsTranscript(messages, chatId);
try {
fs.writeFileSync(backupPath, transcript);
logger.info(
`Stored compaction backup for chat ${chatId}: ${messages.length} messages`,
);
// Return the relative path from the app directory
return path.relative(appPath, backupPath);
} catch (error) {
logger.error(
`Failed to store compaction backup for chat ${chatId}:`,
error,
);
throw error;
}
}
/**
* Shared utilities for context compaction.
*/
/**
* Filter messages to only include those after the latest compaction boundary.
*
* Uses ID-based filtering instead of position-based slicing because the
* createdAt column has second precision (stored as Unix seconds). When
* the compaction summary's timestamp rounds to a full second earlier,
* it can sort before pre-compaction messages in the createdAt-ordered array,
* causing slice() to include everything.
*
* Since message IDs are auto-incrementing, the compaction summary always has
* a higher ID than all pre-compaction messages. The user message that triggered
* compaction processing (and its placeholder) were inserted before the compaction
* summary, so they have lower IDs — but they should be included.
*
* Strategy: find the last user message (by ID) inserted before the compaction
* summary. This is the message whose processing triggered compaction. Include it,
* all subsequent non-summary messages, and the compaction summary itself.
*/
export function getPostCompactionMessages<
T extends { id: number; role: string; isCompactionSummary: boolean | null },
>(messages: T[]): T[] {
// Find the latest compaction summary by highest ID
const latestSummary = messages
.filter((m) => m.isCompactionSummary)
.sort((a, b) => b.id - a.id)[0];
if (!latestSummary) {
return messages;
}
// Find the last user message (by ID) before the compaction summary.
// This is the message that triggered compaction processing.
const triggeringUserMsg = messages
.filter((m) => m.role === "user" && m.id < latestSummary.id)
.sort((a, b) => b.id - a.id)[0];
if (triggeringUserMsg) {
// Include: the compaction summary + all messages with id >= triggering user message
// (excluding older compaction summaries from prior compactions)
return messages.filter(
(m) =>
m.id === latestSummary.id ||
(m.id >= triggeringUserMsg.id && !m.isCompactionSummary),
);
}
// No user message before compaction — include everything from summary onward by ID
return messages.filter((m) => m.id >= latestSummary.id);
}
......@@ -37,3 +37,21 @@ export async function getTemperature(
const modelOption = await findLanguageModel(model);
return modelOption?.temperature ?? 0;
}
/**
* Calculate the token threshold for triggering context compaction.
* Returns the minimum of 80% of context window or 180k tokens.
*/
export function getCompactionThreshold(contextWindow: number): number {
return Math.min(Math.floor(contextWindow * 0.8), 180_000);
}
/**
* Check if compaction should be triggered based on total tokens used.
*/
export function shouldTriggerCompaction(
totalTokens: number,
contextWindow: number,
): boolean {
return totalTokens >= getCompactionThreshold(contextWindow);
}
......@@ -327,6 +327,7 @@ export const UserSettingsSchema = z
})
.optional(),
hideLocalAgentNewChatToast: z.boolean().optional(),
enableContextCompaction: z.boolean().optional(),
})
// Allow unknown properties to pass through (e.g. future settings
// that should be preserved if user downgrades to an older version)
......
......@@ -25,6 +25,7 @@ export const SETTING_IDS = {
chatCompletionNotification: "setting-chat-completion-notification",
thinkingBudget: "setting-thinking-budget",
maxChatTurns: "setting-max-chat-turns",
contextCompaction: "setting-context-compaction",
telemetry: "setting-telemetry",
github: "setting-github",
vercel: "setting-vercel",
......@@ -155,6 +156,23 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [
sectionId: SECTION_IDS.ai,
sectionLabel: "AI",
},
{
id: SETTING_IDS.contextCompaction,
label: "Context Compaction",
description:
"Automatically compact long conversations to stay within context limits",
keywords: [
"context",
"compaction",
"compact",
"summarize",
"tokens",
"window",
"memory",
],
sectionId: SECTION_IDS.ai,
sectionLabel: "AI",
},
// Provider Settings
{
......
......@@ -41,6 +41,7 @@ const DEFAULT_SETTINGS: UserSettings = {
// Enabled by default in 0.33.0-beta.1
enableNativeGit: true,
autoExpandPreviewPanel: true,
enableContextCompaction: true,
};
const SETTINGS_FILE = "user-settings.json";
......
......@@ -31,6 +31,7 @@ import { ToolsMcpSettings } from "@/components/settings/ToolsMcpSettings";
import { AgentToolsSettings } from "@/components/settings/AgentToolsSettings";
import { ZoomSelector } from "@/components/ZoomSelector";
import { DefaultChatModeSelector } from "@/components/DefaultChatModeSelector";
import { ContextCompactionSwitch } from "@/components/ContextCompactionSwitch";
import { useSetAtom } from "jotai";
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
import { SECTION_IDS, SETTING_IDS } from "@/lib/settingsSearchIndex";
......@@ -382,6 +383,14 @@ export function AISettings() {
<div id={SETTING_IDS.maxChatTurns} className="mt-4">
<MaxChatTurnsSelector />
</div>
<div id={SETTING_IDS.contextCompaction} className="space-y-1 mt-4">
<ContextCompactionSwitch />
<div className="text-sm text-gray-500 dark:text-gray-400">
Automatically compact long conversations to stay within context
limits. Original messages are preserved in the app data directory.
</div>
</div>
</div>
);
}
......@@ -62,6 +62,12 @@ import { addIntegrationTool } from "./tools/add_integration";
import { planningQuestionnaireTool } from "./tools/planning_questionnaire";
import { writePlanTool } from "./tools/write_plan";
import { exitPlanTool } from "./tools/exit_plan";
import {
isChatPendingCompaction,
performCompaction,
checkAndMarkForCompaction,
} from "@/ipc/handlers/compaction/compaction_handler";
import { getPostCompactionMessages } from "@/ipc/handlers/compaction/compaction_utils";
const logger = log.scope("local_agent_handler");
......@@ -150,8 +156,8 @@ export async function handleLocalAgentStream(
return false;
}
// Get the chat and app
const chat = await db.query.chats.findFirst({
// Get the chat and app — may be re-queried after compaction
let chat = await db.query.chats.findFirst({
where: eq(chats.id, req.chatId),
with: {
messages: {
......@@ -167,6 +173,48 @@ export async function handleLocalAgentStream(
const appPath = getDyadAppPath(chat.app.path);
// Check if compaction is pending and enabled before processing the message
if (
settings.enableContextCompaction !== false &&
(await isChatPendingCompaction(req.chatId))
) {
logger.info(`Performing pending compaction for chat ${req.chatId}`);
const compactionResult = await performCompaction(
event,
req.chatId,
appPath,
dyadRequestId,
(accumulatedSummary: string) => {
// Stream compaction summary to the frontend in real-time
// We temporarily set the placeholder content to show compaction progress;
// after compaction, the chat is re-queried and the placeholder is reset.
sendResponseChunk(
event,
req.chatId,
chat,
`<dyad-compaction title="Compacting conversation">\n${accumulatedSummary}\n</dyad-compaction>`,
placeholderMessageId,
);
},
);
if (!compactionResult.success) {
logger.warn(
`Compaction failed for chat ${req.chatId}: ${compactionResult.error}`,
);
// Continue anyway - compaction failure shouldn't block the conversation
}
// Re-query to pick up the newly inserted compaction summary message
chat = (await db.query.chats.findFirst({
where: eq(chats.id, req.chatId),
with: {
messages: {
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
},
app: true,
},
}))!;
}
// Send initial message update
safeSend(event.sender, "chat:response:chunk", {
chatId: req.chatId,
......@@ -211,6 +259,7 @@ export async function handleLocalAgentStream(
req.chatId,
chat,
fullResponse + streamingPreview,
placeholderMessageId,
);
},
onXmlComplete: (finalXml: string) => {
......@@ -218,7 +267,13 @@ export async function handleLocalAgentStream(
fullResponse += finalXml + "\n";
streamingPreview = ""; // Clear preview
updateResponseInDb(placeholderMessageId, fullResponse);
sendResponseChunk(event, req.chatId, chat, fullResponse);
sendResponseChunk(
event,
req.chatId,
chat,
fullResponse,
placeholderMessageId,
);
},
requireConsent: async (params: {
toolName: string;
......@@ -254,9 +309,13 @@ export async function handleLocalAgentStream(
// Prepare message history with graceful fallback
// Use messageOverride if provided (e.g., for summarization)
// If a compaction summary exists, only include messages from that point onward
// (pre-compaction messages are preserved in DB for the user but not sent to LLM)
const relevantMessages = getPostCompactionMessages(chat.messages);
const messageHistory: ModelMessage[] = messageOverride
? messageOverride
: chat.messages
: relevantMessages
.filter((msg) => msg.content || msg.aiMessagesJson)
.flatMap((msg) => parseAiMessagesJson(msg));
......@@ -324,6 +383,9 @@ export async function handleLocalAgentStream(
.set({ maxTokensUsed: totalTokens })
.where(eq(messages.id, placeholderMessageId))
.catch((err) => logger.error("Failed to save token count", err));
// Check if compaction should be triggered for the next message
await checkAndMarkForCompaction(req.chatId, totalTokens);
}
},
onError: (error: any) => {
......@@ -439,7 +501,13 @@ export async function handleLocalAgentStream(
if (chunk) {
fullResponse += chunk;
await updateResponseInDb(placeholderMessageId, fullResponse);
sendResponseChunk(event, req.chatId, chat, fullResponse);
sendResponseChunk(
event,
req.chatId,
chat,
fullResponse,
placeholderMessageId,
);
}
}
......@@ -542,13 +610,17 @@ function sendResponseChunk(
chatId: number,
chat: any,
fullResponse: string,
placeholderMessageId: number,
) {
const currentMessages = [...chat.messages];
if (currentMessages.length > 0) {
const lastMsg = currentMessages[currentMessages.length - 1];
if (lastMsg.role === "assistant") {
lastMsg.content = fullResponse;
}
// Find the placeholder message by ID rather than assuming it's the last
// assistant message. After compaction, a compaction summary message may
// exist after the placeholder and we must not overwrite it.
const placeholderMsg = currentMessages.find(
(m) => m.id === placeholderMessageId,
);
if (placeholderMsg) {
placeholderMsg.content = fullResponse;
}
safeSend(event.sender, "chat:response:chunk", {
chatId,
......
/**
* System prompt for generating context compaction summaries.
* Used when the conversation exceeds token limits and needs to be summarized.
*/
export const COMPACTION_SYSTEM_PROMPT = `You are summarizing a coding conversation to preserve the most important context while staying concise.
Your task is to analyze the conversation and generate a structured summary that enables the conversation to continue effectively.
## Output Format
Generate your summary in this EXACT format:
## Key Decisions Made
- [Decision 1: Brief description with rationale]
- [Decision 2: Brief description with rationale]
## Code Changes Completed
- \`path/to/file1.ts\` - [What was changed and why]
- \`path/to/file2.ts\` - [What was changed and why]
## Current Task State
[1-2 sentences describing what the user is currently working on or asking about]
## Active Plan
[If an implementation plan was created or discussed (via write_plan / <dyad-write-plan>), include:
- The plan title and a brief summary of what it covers
- Current status: was it accepted, still being refined, or partially implemented?
- Key implementation steps remaining
If no plan was discussed, omit this section entirely.]
## Important Context
[Any critical context needed to continue, such as:
- Error messages being debugged
- Specific requirements mentioned
- Technical constraints discussed
- Files that need further modification]
## Guidelines
1. **Be concise**: Aim for the minimum content needed to continue effectively
2. **Prioritize recent changes**: Focus more on the latter part of the conversation
3. **Include file paths**: Always use exact file paths when referencing code
4. **Capture intent**: Include the "why" behind decisions, not just the "what"
5. **Preserve errors**: If debugging, include the exact error message being addressed
6. **Preserve plan references**: If an implementation plan was created or updated, always include the plan title, status, and remaining steps so work can continue seamlessly
7. **Skip empty sections**: If there are no code changes or no active plan, omit those sections entirely`;
......@@ -64,6 +64,14 @@ export const createChatCompletionHandler =
let messageContent = CANNED_MESSAGE;
// Handle compaction summary requests (from generateText() in compaction_handler)
if (
userTextContent.startsWith("Please summarize the following conversation:")
) {
messageContent =
"## Key Decisions Made\n- Completed initial task as requested\n\n## Current Task State\nConversation was compacted to save context space.";
}
// Check for upload image to codebase using lastUserMessage (which already handles both string and array content)
if (userTextContent.includes("[[UPLOAD_IMAGE_TO_CODEBASE]]")) {
messageContent = `Uploading image to codebase
......
......@@ -121,6 +121,11 @@ function createStreamChunk(
role: string = "assistant",
isLast: boolean = false,
finishReason: string | null = null,
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
},
) {
const chunk: any = {
id: `chatcmpl-${Date.now()}`,
......@@ -135,13 +140,20 @@ function createStreamChunk(
},
],
};
if (isLast && usage) {
chunk.usage = usage;
}
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) {
async function streamTextResponse(
res: Response,
text: string,
usage?: Turn["usage"],
) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
......@@ -158,7 +170,7 @@ async function streamTextResponse(res: Response, text: string) {
}
// Send final chunk
res.write(createStreamChunk("", "assistant", true, "stop"));
res.write(createStreamChunk("", "assistant", true, "stop", usage));
res.end();
}
......@@ -239,10 +251,26 @@ async function streamToolCallResponse(res: Response, turn: Turn) {
}
}
// 4) Send finish
// 4) Send finish (with optional usage data)
const finishReason =
turn.toolCalls && turn.toolCalls.length > 0 ? "tool_calls" : "stop";
res.write(mkChunk({}, finishReason));
const finishChunk: any = {
id: `chatcmpl-${now}`,
object: "chat.completion.chunk",
created: Math.floor(now / 1000),
model: "fake-local-agent-model",
choices: [
{
index: 0,
delta: {},
finish_reason: finishReason,
},
],
};
if (turn.usage) {
finishChunk.usage = turn.usage;
}
res.write(`data: ${JSON.stringify(finishChunk)}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}
......@@ -290,7 +318,7 @@ export async function handleLocalAgentFixture(
await streamToolCallResponse(res, turn);
} else {
// Text-only turn
await streamTextResponse(res, turn.text || "Done.");
await streamTextResponse(res, turn.text || "Done.", turn.usage);
}
} catch (error) {
console.error(`[local-agent] Error handling fixture:`, error);
......
......@@ -16,6 +16,12 @@ export type Turn = {
toolCalls?: ToolCall[];
/** Text to output after tool results are received (final turn only) */
textAfterTools?: string;
/** Optional usage data to include in the final streaming chunk (for testing token-based features like compaction) */
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
export type LocalAgentFixture = {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论