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) })