From 4baf4ec1425f91d89c45deb2b41667c1b123192d Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 28 Sep 2026 22:21:47 +0800 Subject: [PATCH] fix: restore manual context compaction for direct models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local direct-model text conversations could not use manual context compaction, and disabling automatic compression prevented saved summaries from being reused. Restore the composer action regardless of the automatic setting or threshold, use the selected summary connection, and persist summaries for subsequent requests while retaining recent turns and full conversation history. Image connections, remote channel conversations, and SSH projects remain outside the local direct-model compaction workflow. Add focused regressions, a local Electron end-to-end test, and an opt-in two-request live probe. Validation: staged snapshot passed typecheck, changed-file ESLint, 13 focused tests, and the Electron end-to-end test with two local HTTP fixture requests. No additional real-model calls were made during commit verification. Release note: 修复本地直连文本模型无法手动压缩上下文的问题。现在关闭自动压缩或尚未达到触发阈值时,也可点击“压缩上下文”;摘要会随会话保存并用于后续对话,同时保留最近轮次和完整历史。 --- docs/features/direct-model-agent/progress.md | 23 ++++ .../direct-model-agent/technical-design.md | 12 ++ docs/features/direct-model-agent/ui-design.md | 5 + src/main/agent/model-runtime.test.ts | 29 ++++- src/main/agent/model-runtime.ts | 41 +++--- src/main/ipc.test.ts | 69 +++++++++++ src/main/ipc.ts | 20 ++- src/renderer/src/App.test.tsx | 78 +++++++++--- src/renderer/src/App.tsx | 17 ++- src/shared/runtime-customization-contracts.ts | 2 +- tests/manual-compaction-live.electron.test.ts | 35 ++++++ tests/manual-compaction.electron.test.ts | 58 +++++++++ tests/support/manual-compaction-live.ts | 93 ++++++++++++++ tests/support/manual-compaction-main.ts | 117 ++++++++++++++++++ 14 files changed, 552 insertions(+), 47 deletions(-) create mode 100644 tests/manual-compaction-live.electron.test.ts create mode 100644 tests/manual-compaction.electron.test.ts create mode 100644 tests/support/manual-compaction-live.ts create mode 100644 tests/support/manual-compaction-main.ts diff --git a/docs/features/direct-model-agent/progress.md b/docs/features/direct-model-agent/progress.md index 90c04e7e..9037b8fb 100644 --- a/docs/features/direct-model-agent/progress.md +++ b/docs/features/direct-model-agent/progress.md @@ -261,6 +261,29 @@ DeepSeek Harness 和托管 SSH 路径不注入这些工具。既有功能的 Win MCP 代理复用 Provider 发现快照规则,托管 SSH Runtime 不经过该本机构造路径。本次未 修改远端 Agent、桥接或启动器。 +## 2026-09-28 本地直连手动压缩 + +- 本地直连文本会话复用“压缩上下文”按钮、摘要状态和会话保存路径,支持关闭自动压缩时 + 手动执行。当前连接选择、图像排除、强制压缩和最新轮次保留规则见 + [技术设计](./technical-design.md#101-本地直连手动压缩)。未修改远端 Agent、桥接或启动器; + model 入口排除远程会话与 SSH 项目,本地 IPC 仍拒绝 SSH 执行空间。 +- 三个目标文件 `App.test.tsx`、`ipc.test.ts`、`model-runtime.test.ts`:569 passed、2 skipped。 + 覆盖入口点击、自动关闭和低于阈值、运行中禁用、图像排除、摘要保存与后续请求、当前及指定 + 摘要连接选择。补入远程通道入口排除后,App 手动压缩专项复跑 7 passed;最终类型检查和 + 本次文件 ESLint 再次通过。 +- `manual-compaction.electron.test.ts`:真实 App、preload、IPC、模型工厂和 SQLite 路径通过。 + 2 次本机 HTTP fixture 请求验证较早历史被摘要替换、最新轮次保留、完整历史仍在数据库, + 页面重载后摘要可读,新建 Runtime 实际使用该摘要。 +- `manual-compaction-live.electron.test.ts`:真实配置文本模型最终通过,2 次请求完成摘要及 + 新 Runtime 续问。三轮探针累计 6 次真实文本模型请求;前两轮失败来自探针用未转义摘要 + 匹配 JSON payload,修正断言后通过。原设置文件未改变,未记录凭据。 +- `npm run typecheck` 和本次涉及文件的 ESLint 通过。全仓 `npm run lint` 有 10 个错误, + 位于已有侧栏探针 `sidebar-final-probe.ts`、`sidebar-final-run.mjs`,保留未修改。 +- 全量 `npm test`:5407 passed、85 skipped、2 failed。失败分别为已有侧栏探针的新建按钮 + 溢出断言(`magic-notes-capture.electron.test.ts`)及魔法笔记布局 Electron 超时 + (`magic-notes-layout.electron.test.ts`);手动压缩 Electron 回归通过。未执行构建或跨平台 + 真机测试,不将全仓验证记为全绿。 + ## 进度维护要求 - 只有真实生产路径完成并验证后才能勾选实施项。 diff --git a/docs/features/direct-model-agent/technical-design.md b/docs/features/direct-model-agent/technical-design.md index 1df40574..e88c2056 100644 --- a/docs/features/direct-model-agent/technical-design.md +++ b/docs/features/direct-model-agent/technical-design.md @@ -451,6 +451,18 @@ type SubagentActor = 处理。Continue 接收完整历史,已有有效摘要只替换其覆盖的前缀,剩余消息全部交给原生 Runtime;GoodBuddy 不再额外保留最后 20 条或限制为 128,000 字符。 +### 10.1 本地直连手动压缩 + +`agentCompactConversation` 接受 `model`,结果保留实际 provider,压缩策略为 +`goodbuddy-summary`。摘要来源为 `current` 时,IPC 使用 `applyRuntimeSelection` +解析后的当前直连连接;来源为 `profile` 时,使用既有指定摘要连接配置。 +图像连接不进入文本摘要路径,SSH 执行空间仍由本地 IPC 拒绝。 + +`ModelAgentRuntime.compactConversation` 复用 `prepareCompressedRequest` 的强制压缩, +跳过自动开关和触发阈值,保留最新轮次。返回的 `contextCompressionState` 沿用会话保存, +不删除历史正文。后续请求校验摘要覆盖范围后,用摘要替换已覆盖前缀;即使关闭自动压缩, +已保存的有效摘要仍会使用。 + ## 11. 性能 - Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。 diff --git a/docs/features/direct-model-agent/ui-design.md b/docs/features/direct-model-agent/ui-design.md index b3371847..9573983f 100644 --- a/docs/features/direct-model-agent/ui-design.md +++ b/docs/features/direct-model-agent/ui-design.md @@ -21,6 +21,11 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由或操作菜单。 +本地直连文本会话复用输入区已有的“压缩上下文”按钮,不依赖原生 Runtime 快照, +也不因自动压缩关闭或上下文低于触发阈值而隐藏。运行中或压缩中按钮禁用;图像连接、 +远程通道会话和 SSH 项目不显示直连模型压缩入口。完成后沿用应用通知及压缩后上下文估算, +摘要随会话保存。压缩行为见[技术设计](./technical-design.md#101-本地直连手动压缩)。 + ## 2. 设置中的能力目录 直连模型工具目录使用两个本机开发分组: diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index be8bcbf9..8337f132 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -796,7 +796,7 @@ describe('ModelAgentRuntime', () => { ) }) - it('manually compacts Continue history even when automatic compression is disabled', async () => { + it.each(['continue', 'model'] as const)('manually compacts %s history even when automatic compression is disabled', async (provider) => { const fetcher = vi.fn().mockResolvedValueOnce( new Response(createEventStream('手动压缩摘要'), { status: 200, @@ -831,7 +831,7 @@ describe('ModelAgentRuntime', () => { { requestId: '00000000-0000-4000-8000-000000000081', conversationId: '00000000-0000-4000-8000-000000000082', - runtimeSelection: { provider: 'continue' }, + runtimeSelection: { provider }, history, historyMessageIds: [ '00000000-0000-4000-8000-000000000083', @@ -850,7 +850,7 @@ describe('ModelAgentRuntime', () => { expect(JSON.stringify(body.messages)).toContain('earlier question') expect(JSON.stringify(body.messages)).not.toContain('recent question') expect(outcome.result).toMatchObject({ - provider: 'continue', + provider, strategy: 'goodbuddy-summary', compacted: true, contextCompressionState: { @@ -862,6 +862,29 @@ describe('ModelAgentRuntime', () => { summary: '手动压缩摘要' } }) + fetcher.mockResolvedValueOnce(new Response(createEventStream('next answer'), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + })) + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), + conversationId: crypto.randomUUID(), + prompt: 'continue', + history, + historyMessageIds: [ + '00000000-0000-4000-8000-000000000083', + '00000000-0000-4000-8000-000000000084', + '00000000-0000-4000-8000-000000000085', + '00000000-0000-4000-8000-000000000086' + ], + contextCompressionState: outcome.result.contextCompressionState + }, new AbortController().signal)) { + expect(event.type).not.toBe('error') + } + const nextBody = fetcher.mock.calls[1]![1]!.body as string + expect(nextBody).toContain('手动压缩摘要') + expect(nextBody).toContain('recent question') + expect(nextBody).not.toContain('earlier question') await runtime.dispose() }) diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 3c277864..34cda811 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -2410,23 +2410,23 @@ export class ModelAgentRuntime implements AgentRuntime { buildConversationSummaryHistory(state.summary) ) : 0 - if ( - (!compression.settings.enabled && !options.force) || - history.length === 0 - ) { + if (history.length === 0) { return { request, compressed: false } } - const plan = planContextCompression({ - history: remainingHistory, - prompt: requestPrompt, - summaryTokens: currentSummaryTokens, - settings: compression.settings, - contextWindowTokens: compression.contextWindowTokens, - allowCompressLatestTurn: options.allowCompressLatestTurn, - effectiveTriggerTokens: options.effectiveTriggerTokens, - triggerContextTokens: options.triggerContextTokens - }) + const plan = + !compression.settings.enabled && !options.force + ? undefined + : planContextCompression({ + history: remainingHistory, + prompt: requestPrompt, + summaryTokens: currentSummaryTokens, + settings: compression.settings, + contextWindowTokens: compression.contextWindowTokens, + allowCompressLatestTurn: options.allowCompressLatestTurn, + effectiveTriggerTokens: options.effectiveTriggerTokens, + triggerContextTokens: options.triggerContextTokens + }) if (!plan) { return state ? { @@ -4153,8 +4153,11 @@ export class ModelAgentRuntime implements AgentRuntime { ) { throw new Error('当前模型连接不支持上下文摘要') } - if (request.runtimeSelection.provider !== 'continue') { - throw new Error('GoodBuddy 摘要压缩仅适用于 Continue Runtime') + if ( + request.runtimeSelection.provider !== 'continue' && + request.runtimeSelection.provider !== 'model' + ) { + throw new Error('GoodBuddy 摘要压缩仅适用于直连模型和 Continue Runtime') } if (request.contextCompressionState) { @@ -4210,11 +4213,13 @@ export class ModelAgentRuntime implements AgentRuntime { } return { result: { - provider: 'continue', + provider: request.runtimeSelection.provider, strategy: 'goodbuddy-summary', compacted, detail: compacted - ? '已使用 GoodBuddy 摘要压缩较早的 Continue 对话历史' + ? request.runtimeSelection.provider === 'continue' + ? '已使用 GoodBuddy 摘要压缩较早的 Continue 对话历史' + : '已使用 GoodBuddy 摘要压缩较早的对话历史' : '当前对话没有可继续压缩的较早历史', ...(conversationState ? { contextCompressionState: conversationState } diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 49c2a064..58d2eb80 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -4523,6 +4523,75 @@ describe('registerIpcHandlers local conversation persistence', () => { vi.clearAllMocks() }) + it.each(['current', 'profile', 'image', 'missing'] as const)( + 'routes direct model manual compaction using %s selection', async (source) => { + const profile = { + id: crypto.randomUUID(), name: 'Selected', baseUrl: 'https://models.example', + modelName: 'selected-text', protocol: 'anthropic-messages', authentication: 'none' + } + const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'default-text' } + const summary = { ...profile, id: crypto.randomUUID(), modelName: 'summary-text' } + const selection = { provider: 'model' as const, profileId: source === 'missing' ? crypto.randomUUID() : profile.id } + const settings = { + provider: 'model', workspacePath: 'C:\\Workspace', defaultModelProfileId: fallback.id, + modelProfiles: [fallback, { ...profile, protocol: source === 'image' ? 'openai-images-generations' : profile.protocol }, summary], + continueModelProfile: fallback, + contextCompression: { enabled: false, modelSource: source === 'profile' ? { kind: 'profile', profileId: summary.id } : { kind: 'current' } } + } + const messages = [ + { id: crypto.randomUUID(), role: 'user', content: 'earlier question', state: 'complete' }, + { id: crypto.randomUUID(), role: 'assistant', content: 'earlier answer', state: 'complete' } + ] + const conversationId = crypto.randomUUID() + const database = { + queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), + listPendingConversationQueueIds: vi.fn(() => []), + getConversation: vi.fn(() => ({ id: conversationId, runtimeSelection: selection, messages })), + createTask: vi.fn(), updateTaskStatus: vi.fn() + } + const compactor = { + compactConversation: vi.fn(async () => ({ result: { + provider: 'model', strategy: 'goodbuddy-summary', compacted: true, detail: 'Compacted' + } })), + dispose: vi.fn(async () => {}) + } + runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(compactor) + const webContents = { on: vi.fn(), removeListener: vi.fn(), + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() + } + const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } + const dispose = registerIpcHandlers( + window as never, {} as never, 'CommandOrControl+Shift+Space', + { getResolvedSettings: vi.fn(async () => settings) } as never, + {} as never, { clear: vi.fn() } as never, {} as never, database as never, + { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) + ) + try { + const result = electronMocks.handlers.get(ipcChannels.agentCompactConversation)?.( + { sender: webContents, senderFrame: webContents.mainFrame }, + { requestId: crypto.randomUUID(), conversationId, runtimeSelection: selection, + history: messages.map(({ role, content }) => ({ role, content })), + historyMessageIds: messages.map(({ id }) => id) } + ) + if (source === 'image' || source === 'missing') { + await expect(result).rejects.toThrow() + expect(runtimeFactoryMocks.createModelProfileRuntime).not.toHaveBeenCalled() + } else { + await expect(result).resolves.toMatchObject({ provider: 'model', compacted: true }) + expect(runtimeFactoryMocks.createModelProfileRuntime).toHaveBeenCalledWith( + 'C:\\Workspace', expect.objectContaining({ defaultModelProfileId: profile.id }), + source === 'profile' ? summary : profile + ) + expect(compactor.dispose).toHaveBeenCalledOnce() + expect(database.updateTaskStatus).toHaveBeenCalledWith(expect.any(String), 'completed') + } + } finally { + await dispose() + } + } + ) + it('pins and unpins without attachment reconciliation or import checks', async () => { const root = await mkdtemp(join(tmpdir(), 'goodbuddy-pin-ipc-')) const database = new AssistantDatabase(join(root, 'assistant.sqlite')) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3610d5d1..d2f7195e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4739,7 +4739,8 @@ export function registerIpcHandlers( const request = runtimeConversationCompactInputSchema.parse(input) if ( request.runtimeSelection.provider !== 'opencode' && - request.runtimeSelection.provider !== 'continue' + request.runtimeSelection.provider !== 'continue' && + request.runtimeSelection.provider !== 'model' ) { throw new Error('当前 Runtime 不支持手动压缩') } @@ -4791,6 +4792,12 @@ export function registerIpcHandlers( settings, request.runtimeSelection ) + if ( + request.runtimeSelection.provider === 'model' && + !isAgentRuntimeModelProtocol(selected.settings.modelProtocol) + ) { + throw new Error('当前模型连接不支持上下文摘要') + } if (project?.executionSpace?.kind === 'ssh') { await requireRemoteProjectsEnabled() } @@ -4844,7 +4851,12 @@ export function registerIpcHandlers( (candidate) => candidate.id === compressionSource.profileId ) - : selected.settings.continueModelProfile) ?? + : request.runtimeSelection.provider === 'model' + ? selected.settings.modelProfiles.find( + (candidate) => + candidate.id === selected.settings.defaultModelProfileId + ) + : selected.settings.continueModelProfile) ?? selected.settings.modelProfiles.find( (candidate) => candidate.id === @@ -4854,8 +4866,8 @@ export function registerIpcHandlers( selected.settings.modelProfiles.find((candidate) => isAgentRuntimeModelProtocol(candidate.protocol) ) - if (!profile) { - throw new Error('没有可用于 Continue 上下文摘要的文本模型连接') + if (!profile || !isAgentRuntimeModelProtocol(profile.protocol)) { + throw new Error('没有可用于上下文摘要的文本模型连接') } if ( profile.authentication === 'api-key' && diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 33aad002..515aa14c 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -9883,7 +9883,9 @@ describe("App", () => { expect(await screen.findByRole("tab", { name: /终端 1/ })).toHaveAttribute("aria-selected", "false"); }); - it("manually compacts Continue context and persists the summary state", async () => { + it.each([ + ["continue", false], ["model", false], ["model", true], + ] as const)("manually compacts %s context below threshold with auto compression %s and persists the summary state", async (provider, enabled) => { const conversationId = "00000000-0000-4000-8000-000000000731"; const messages = [ { @@ -9912,7 +9914,7 @@ describe("App", () => { vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, contextCompression: { - enabled: true, + enabled, triggerTokens: 20_000, recentRawTokens: 4_000, modelSource: { kind: "current" }, @@ -9923,15 +9925,15 @@ describe("App", () => { { id: conversationId, projectId, - runtimeSelection: { provider: "continue" }, - title: "Continue 长对话", + runtimeSelection: { provider }, + title: "手动压缩对话", updatedAt: 1_775_000_000_001, messages, }, ]); vi.mocked(api.agent.getStatus).mockResolvedValueOnce({ - id: "continue", - label: "Continue", + id: provider, + label: provider, available: true, supportsToolExecution: true, detail: "Ready", @@ -9962,10 +9964,10 @@ describe("App", () => { }, ); vi.mocked(api.agent.compactConversation).mockResolvedValueOnce({ - provider: "continue", + provider, strategy: "goodbuddy-summary", compacted: true, - detail: "已压缩 Continue 对话历史", + detail: "已压缩对话历史", contextCompressionState: summaryState, }); @@ -9977,16 +9979,20 @@ describe("App", () => { expect(compactContext.parentElement).toHaveClass( "composer-meta__actions", ); - expect(compactContext.parentElement?.firstElementChild).toBe( - screen.getByRole("button", { name: "在终端中打开" }), - ); + if (provider === "continue") { + expect(compactContext.parentElement?.firstElementChild).toBe( + screen.getByRole("button", { name: "在终端中打开" }), + ); + } else { + expect(api.runtimeCustomization.getNativeSnapshot).not.toHaveBeenCalled(); + } fireEvent.click(compactContext); await waitFor(() => expect(api.agent.compactConversation).toHaveBeenCalledWith({ requestId: expect.any(String), conversationId, projectId, - runtimeSelection: { provider: "continue" }, + runtimeSelection: { provider }, history: messages.map(({ role, content }) => ({ role, content, @@ -9996,11 +10002,9 @@ describe("App", () => { }), ); expect( - await screen.findByText("已压缩 Continue 对话历史"), + await screen.findByText("已压缩对话历史"), ).toBeInTheDocument(); - expect(await screen.findByText(/压缩后对话估算/u)).not.toHaveTextContent( - "压缩线", - ); + expect(await screen.findByText(/压缩后对话估算/u)).toBeInTheDocument(); await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect.objectContaining({ @@ -10014,6 +10018,48 @@ describe("App", () => { }), ]), ); + fireEvent.change(screen.getByRole("textbox", { name: "向 GoodBuddy 提问" }), { + target: { value: "继续" }, + }); + fireEvent.click(screen.getByRole("button", { name: "发送" })); + await waitFor(() => expect(api.agent.run).toHaveBeenCalledWith( + expect.objectContaining({ contextCompressionState: summaryState }), + )); + expect(screen.getByRole("button", { name: "压缩上下文" })).toBeDisabled(); + }); + + it("hides manual compaction for the selected image profile even when the default is text", async () => { + const settings = await api.settings.getRuntime(); + const imageProfile = { + ...settings.modelProfiles[0]!, + id: "00000000-0000-4000-8000-000000000739", + name: "Image model", + protocol: "openai-images-generations" as const, + }; + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, modelProfiles: [...settings.modelProfiles, imageProfile], + }); + vi.mocked(api.conversations.list).mockResolvedValueOnce([{ + id: "00000000-0000-4000-8000-000000000738", projectId, + runtimeSelection: { provider: "model", profileId: imageProfile.id }, + title: "Image conversation", updatedAt: Date.now(), messages: [], + }]); + render(); + await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith({ provider: "model", profileId: imageProfile.id })); + expect(screen.queryByRole("button", { name: "压缩上下文" })).not.toBeInTheDocument(); + }); + + it("hides direct model manual compaction for remote channel conversations", async () => { + vi.mocked(api.conversations.list).mockResolvedValueOnce([{ + id: "00000000-0000-4000-8000-000000000740", projectId, + runtimeSelection: { provider: "model", profileId: modelProfileId }, + title: "Remote model conversation", updatedAt: Date.now(), messages: [], + remote: { channel: "weixin", accountDisplay: "Remote account", conversationType: "direct" }, + }]); + render(); + await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith({ provider: "model", profileId: modelProfileId })); + expect(screen.queryByRole("button", { name: "压缩上下文" })).not.toBeInTheDocument(); + expect(api.agent.compactConversation).not.toHaveBeenCalled(); }); it("restores the direct-model mode after leaving an Agent Runtime", async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index de2c108a..d1d3fe7c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7521,8 +7521,7 @@ function App(): React.JSX.Element { if ( !activeConversation || !activeRuntimeSelection || - (activeRuntimeSelection.provider !== "opencode" && - activeRuntimeSelection.provider !== "continue") || + !runtimeContextCompactAvailable || runtimeContextCompacting || isRunning ) { @@ -8500,9 +8499,17 @@ function App(): React.JSX.Element { : undefined, ].filter(Boolean).join(" · "); const runtimeContextCompactAvailable = - (activeRuntimeSelection?.provider === "opencode" || - activeRuntimeSelection?.provider === "continue") && - runtimeNativeSnapshot?.context.manualCompact === true; + activeRuntimeSelection?.provider === "model" + ? !activeConversation?.remote && + activeProject?.executionSpace.kind !== "ssh" && + runtimeSettings?.modelProfiles.some( + (profile) => + profile.id === getRuntimeSelectionProfileId(activeRuntimeSelection, runtimeSettings) && + profile.protocol !== "openai-images-generations", + ) === true + : (activeRuntimeSelection?.provider === "opencode" || + activeRuntimeSelection?.provider === "continue") && + runtimeNativeSnapshot?.context.manualCompact === true; const nativeClientAvailable = activeRuntimeSelection?.provider === "continue" || activeRuntimeSelection?.provider === "opencode" || diff --git a/src/shared/runtime-customization-contracts.ts b/src/shared/runtime-customization-contracts.ts index fef43b0d..c1e993ff 100644 --- a/src/shared/runtime-customization-contracts.ts +++ b/src/shared/runtime-customization-contracts.ts @@ -498,7 +498,7 @@ export type RuntimeConversationCompactInput = z.infer< export const runtimeConversationCompactResultSchema = z .object({ - provider: z.enum(['opencode', 'continue']), + provider: z.enum(['opencode', 'continue', 'model']), strategy: z.enum(['native', 'goodbuddy-summary']), compacted: z.boolean(), detail: z.string().trim().min(1).max(500), diff --git a/tests/manual-compaction-live.electron.test.ts b/tests/manual-compaction-live.electron.test.ts new file mode 100644 index 00000000..15d1ba1a --- /dev/null +++ b/tests/manual-compaction-live.electron.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { expect, it } from 'vitest' + +it.skipIf(!process.env.GB_COMPACT_LIVE_SETTINGS)('uses manual compaction and restores it with two real text requests', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-compact-live-')) + try { + const main = join(directory, 'main.cjs') + await build({ entryPoints: ['tests/support/manual-compaction-live.ts'], outfile: main, bundle: true, + platform: 'node', format: 'cjs', external: ['electron'], plugins: [{ name: 'external-packages', setup(builder) { + builder.onResolve({ filter: /^[^./]/ }, async (args) => { + if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return + const result = await builder.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } }) + return result.errors.length ? { errors: result.errors } : { path: result.path, external: true } + }) + } }] }) + const env: NodeJS.ProcessEnv = { ...process.env, GB_COMPACT_LIVE_DIRECTORY: directory } + delete env.ELECTRON_RUN_AS_NODE + const child = spawn(createRequire(import.meta.url)('electron'), [main], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', data => { output += String(data) }) + child.stderr.on('data', data => { output += String(data) }) + const timeout = setTimeout(() => child.kill(), 100000) + try { + const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }) + process.stdout.write(output) + expect(code, output).toBe(0) + } finally { clearTimeout(timeout) } + } finally { await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } +}, 110000) diff --git a/tests/manual-compaction.electron.test.ts b/tests/manual-compaction.electron.test.ts new file mode 100644 index 00000000..13cb1cba --- /dev/null +++ b/tests/manual-compaction.electron.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { createServer } from 'vite' +import react from '@vitejs/plugin-react' +import { expect, it } from 'vitest' + +it('compacts direct model context from App through preload, IPC, HTTP and SQLite', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-compaction-')) + const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'), + plugins: [react(), { name: 'compaction', configureServer(server) { + server.middlewares.use('/compact.html', async (_request, response) => { + response.setHeader('Content-Type', 'text/html') + response.end(await server.transformIndexHtml('/compact.html', '
')) + }) + } }], server: { host: '127.0.0.1', port: 0, watch: null }, + optimizeDeps: { entries: ['tests/support/magic-notes-capture-renderer.tsx'], include: ['fabric', 'quill', 'quill/blots/block', 'quill-delta', 'jspdf', 'pdfjs-dist', 'react', 'react-dom/client', 'react-i18next'] } + }) + try { + const driver = join(directory, 'main.mjs') + await build({ entryPoints: ['tests/support/manual-compaction-main.ts'], outfile: driver, + bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [ + { name: 'external-packages', setup(builder) { + builder.onResolve({ filter: /^[^./]/ }, async args => { + if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return + const result = await builder.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } }) + return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true } + }) + } } + ] }) + await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] }) + await build({ entryPoints: ['src/main/execution-stats-worker.ts'], outfile: join(directory, 'out/main/execution-stats-worker.js'), bundle: true, platform: 'node', format: 'cjs' }) + await server.listen() + const bootstrap = join(directory, 'bootstrap.cjs') + await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error => { console.error(error); require('electron').app.exit(1) })`) + const env: NodeJS.ProcessEnv = { ...process.env, GB_COMPACT_DIRECTORY: directory, GB_COMPACT_URL: server.resolvedUrls!.local[0] + 'compact.html' } + delete env.ELECTRON_RUN_AS_NODE + const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', data => { output += data }) + child.stderr.on('data', data => { output += data }) + const timeout = setTimeout(() => child.kill(), 100000) + try { + const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }) + expect(code, output).toBe(0) + expect(output).toContain('"summaryRestored":true') + process.stdout.write(output) + } finally { clearTimeout(timeout) } + } finally { + await server.close() + await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } +}, 120000) diff --git a/tests/support/manual-compaction-live.ts b/tests/support/manual-compaction-live.ts new file mode 100644 index 00000000..b71de3fc --- /dev/null +++ b/tests/support/manual-compaction-live.ts @@ -0,0 +1,93 @@ +import { app, safeStorage } from 'electron' +import { readFileSync, writeFileSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import assert from 'node:assert/strict' +import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store' +import { createModelProfileRuntime } from '../../src/main/agent/create-runtime' +import { applyRuntimeSelection } from '../../src/main/agent/runtime-selection' +import { defaultRuntimeSettings } from '../../src/shared/contracts' + +let calls = 0 +let stage = 'settings' +void app.whenReady().then(async () => { + const directory = process.env.GB_COMPACT_LIVE_DIRECTORY! + const source = process.env.GB_COMPACT_LIVE_SETTINGS! + const original = readFileSync(source) + const snapshot = join(directory, 'settings-snapshot.json') + writeFileSync(snapshot, original) + const store = new RuntimeSettingsStore(snapshot, { + isAvailable: () => safeStorage.isEncryptionAvailable(), + encrypt: () => { throw new Error('Read-only probe') }, + decrypt: value => safeStorage.decryptString(value) + }, process.env) + const settings = await store.getResolvedSettings().finally(() => unlinkSync(snapshot)) + stage = 'profile' + const profile = settings.modelProfiles.find(item => item.protocol !== 'openai-images-generations' && + (item.authentication === 'none' || Boolean(item.apiKey))) + assert(profile && profile.protocol !== 'openai-images-generations') + console.log(JSON.stringify({ credentialReadable: Boolean(profile.apiKey), authentication: profile.authentication, + encryptionAvailable: safeStorage.isEncryptionAvailable() })) + const runtimeSelection = { provider: 'model' as const, profileId: profile.id } + const selected = applyRuntimeSelection(settings, runtimeSelection).settings + selected.contextCompression = { ...(selected.contextCompression ?? defaultRuntimeSettings.contextCompression), enabled: false, modelSource: { kind: 'current' } } + selected.workspacePath = directory + const originalFetch = globalThis.fetch + const payloads: string[] = [] + globalThis.fetch = async (input, init) => { + assert(calls < 2, 'Two-call budget exceeded') + const body = JSON.parse(String(init?.body)) + assert.equal(body.tools?.length ?? 0, 0) + payloads.push(JSON.stringify(body.messages ?? body.input)) + calls++ + return originalFetch(input, init) + } + const history = [ + { role: 'user' as const, content: 'Remember the project codename is Cedar. Reply briefly.' }, + { role: 'assistant' as const, content: 'The project codename is Cedar.' }, + { role: 'user' as const, content: 'The next meeting is Tuesday.' }, + { role: 'assistant' as const, content: 'Meeting: Tuesday.' } + ] + const historyMessageIds = history.map(() => crypto.randomUUID()) + const runtime = createModelProfileRuntime(directory, selected, { ...profile, maximumOutputTokens: 1024 }) + stage = 'compact' + const signal = AbortSignal.timeout(90000) + const result = await runtime.compactConversation({ + requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), runtimeSelection, + history, historyMessageIds + }, signal) + assert.equal(result.result.compacted, true) + const state = result.result.contextCompressionState! + assert.equal(state.coveredMessageCount, 2) + await runtime.dispose() + // A new Runtime must consume the serialized state, without relying on an in-memory cache. + const next = createModelProfileRuntime(directory, selected, { ...profile, maximumOutputTokens: 1024 }) + stage = 'restore' + let answer = '' + try { + for await (const event of next.run({ + requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), runtimeSelection, + prompt: 'What is the project codename? Reply with that word only.', history, historyMessageIds, + contextCompressionState: JSON.parse(JSON.stringify(state)) + }, signal)) { + assert.notEqual(event.type, 'error', 'Runtime returned an error') + if (event.type === 'text') answer += event.delta + } + } finally { await next.dispose() } + stage = 'summary-payload' + assert(payloads[1]!.includes(JSON.stringify(state.summary).slice(1, -1))) + stage = 'history-payload' + assert(!payloads[1]!.includes(history[0]!.content)) + assert(payloads[1]!.includes(history[2]!.content)) + stage = 'answer' + assert.match(answer, /Cedar/i) + stage = 'settings-unchanged' + assert(readFileSync(source).equals(original)) + console.log(JSON.stringify({ realCallCount: calls, compacted: true, coveredMessageCount: state.coveredMessageCount, + summaryRestoredInNewRuntime: true, recentHistoryRetained: true, answerCorrect: true, sourceSettingsUnchanged: true })) + app.exit(0) +}).catch((error: unknown) => { + console.error(JSON.stringify({ realCallCount: calls, failed: true, stage, + errorType: error instanceof Error ? error.name : 'unknown', + location: error instanceof Error ? error.stack?.split('\n')[1]?.replace(/\([^)]*\)/g, '(redacted)') : undefined })) + app.exit(1) +}) diff --git a/tests/support/manual-compaction-main.ts b/tests/support/manual-compaction-main.ts new file mode 100644 index 00000000..eda62951 --- /dev/null +++ b/tests/support/manual-compaction-main.ts @@ -0,0 +1,117 @@ +import { app, BrowserWindow } from 'electron' +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { createServer } from 'node:http' +import { join } from 'node:path' +import { registerIpcHandlers } from '../../src/main/ipc' +import { AssistantDatabase } from '../../src/main/assistant/assistant-database' +import { ApplicationSettingsStore } from '../../src/main/application-settings-store' +import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store' +import { createModelProfileRuntime } from '../../src/main/agent/create-runtime' +import { defaultRuntimeSettings } from '../../src/shared/contracts' + +const directory = process.env.GB_COMPACT_DIRECTORY! +app.setPath('userData', join(directory, 'profile')) + +void app.whenReady().then(async () => { + const payloads: string[] = [] + const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) body += chunk + payloads.push(body) + response.setHeader('Content-Type', 'text/event-stream') + const content = payloads.length === 1 ? 'Project codename: Cedar.' : 'Cedar' + response.end(`data: ${JSON.stringify({ choices: [{ delta: { content }, finish_reason: null }] })}\n\ndata: [DONE]\n\n`) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + assert(address && typeof address !== 'string') + const settingsStore = new RuntimeSettingsStore(join(directory, 'runtime.json'), { + isAvailable: () => false, encrypt: () => { throw new Error('No credentials') }, decrypt: () => { throw new Error('No credentials') } + }, {}) + const profileId = randomUUID() + await settingsStore.update({ ...defaultRuntimeSettings, apiKey: { action: 'keep' }, provider: 'model', workspacePath: directory, + deepseekHarnessModelSource: { kind: 'platform' }, + modelProfiles: [{ id: profileId, name: 'Local text fixture', baseUrl: `http://127.0.0.1:${address.port}/v1`, + modelName: 'selected-text', protocol: 'openai-chat-completions', authentication: 'none', + imageGenerationQuality: 'auto', apiKey: { action: 'keep' } }], + defaultModelProfileId: profileId, + contextCompression: { ...defaultRuntimeSettings.contextCompression, enabled: false } + }) + const settings = await settingsStore.getResolvedSettings() + const runtime = createModelProfileRuntime(directory, settings, settings.modelProfiles[0]!) + const win = new BrowserWindow({ show: true, width: 1280, height: 900, webPreferences: { + sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, + preload: join(directory, 'preload.cjs') + } }) + const database = new AssistantDatabase(join(directory, 'assistant.sqlite')) + database.initialize(directory) + const project = database.listProjects()[0]! + const header = { id: randomUUID(), title: 'Manual compaction', projectId: project.id, + runtimeSelection: { provider: 'model' as const, profileId }, updatedAt: Date.now() } + const messages = ['Remember the project codename is Cedar.', 'Codename recorded.', 'Meeting is Tuesday.', 'Tuesday recorded.'] + .map((content, index) => ({ id: randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const, + content, state: 'complete' as const, createdAt: Date.now() + index })) + database.saveLocalConversations([{ header, messages }]) + const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json')) + await applicationSettings.update({ checkUpdatesOnStartup: false }) + // Only unrelated App services are fixtures; compaction, model HTTP and persistence use production code. + const dispose = registerIpcHandlers(win, runtime, 'CommandOrControl+Shift+Space', settingsStore, + {} as never, { clear() {}, cancelImport() {}, getDraft: () => [] } as never, + { snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }), + database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never, database, + { clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + { getPending: async () => undefined } as never) + const run = (script: string): Promise => win.webContents.executeJavaScript(script, true) + async function wait(check: () => Promise | unknown, label: string): Promise { + for (let i = 0; i < 400; i++) { + if (await check()) return + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`Timed out: ${label}`) + } + try { + await win.loadURL(process.env.GB_COMPACT_URL!) + const button = `[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Compact context')` + await wait(() => run(`Boolean(${button}) && !${button}.disabled`), 'manual button with auto disabled') + await run(`${button}.click()`) + await wait(() => database.getConversation(header.id).contextCompressionState, 'summary persisted by App') + const saved = database.getConversation(header.id) + assert.equal(saved.contextCompressionState?.coveredMessageCount, 2) + assert.equal(saved.contextCompressionState?.summary, 'Project codename: Cedar.') + assert.deepEqual(saved.messages.map(message => message.content), messages.map(message => message.content)) + assert.equal(payloads.length, 1) + assert.equal(JSON.parse(payloads[0]!).model, 'selected-text') + assert(payloads[0]!.includes(messages[0]!.content)) + assert(!payloads[0]!.includes(messages[2]!.content)) + await win.reload() + await wait(() => run(`document.body.innerText.includes('Tuesday recorded.')`), 'reload persisted conversation') + const restored = await run<{ contextCompressionState: typeof saved.contextCompressionState }>( + `window.goodbuddy.conversations.get(${JSON.stringify(header.id)})`) + assert.deepEqual(restored.contextCompressionState, saved.contextCompressionState) + // A fresh production Runtime consumes the state returned across the real preload boundary. + const next = createModelProfileRuntime(directory, settings, settings.modelProfiles[0]!) + try { + for await (const event of next.run({ requestId: randomUUID(), conversationId: header.id, + runtimeSelection: header.runtimeSelection, prompt: 'What is the codename?', + history: messages.map(({ role, content }) => ({ role, content })), historyMessageIds: messages.map(({ id }) => id), + contextCompressionState: restored.contextCompressionState + }, AbortSignal.timeout(10000))) { + assert.notEqual(event.type, 'error') + } + } finally { await next.dispose() } + assert.equal(payloads.length, 2) + assert(payloads[1]!.includes(saved.contextCompressionState!.summary)) + assert(!payloads[1]!.includes(messages[0]!.content)) + assert(payloads[1]!.includes(messages[2]!.content)) + console.log(JSON.stringify({ appClick: true, ipc: true, sqliteSaved: true, summaryRestored: true, httpCalls: payloads.length, externalModelCalls: 0 })) + } finally { + await dispose() + await runtime.dispose() + database.close() + win.destroy() + await new Promise(resolve => server.close(() => resolve())) + } + app.exit(0) +}).catch(error => { console.error(error); app.exit(1) })