fix: restore manual context compaction for direct models

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: 修复本地直连文本模型无法手动压缩上下文的问题。现在关闭自动压缩或尚未达到触发阈值时,也可点击“压缩上下文”;摘要会随会话保存并用于后续对话,同时保留最近轮次和完整历史。
This commit is contained in:
mesalogo
2026-09-28 22:21:47 +08:00
parent 1603871f7c
commit 4baf4ec142
14 changed files with 552 additions and 47 deletions
@@ -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 回归通过。未执行构建或跨平台
真机测试,不将全仓验证记为全绿。
## 进度维护要求
- 只有真实生产路径完成并验证后才能勾选实施项。
@@ -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 探测按执行服务实例缓存,不在每轮模型调用重复探测。
@@ -21,6 +21,11 @@
Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由或操作菜单。
本地直连文本会话复用输入区已有的“压缩上下文”按钮,不依赖原生 Runtime 快照,
也不因自动压缩关闭或上下文低于触发阈值而隐藏。运行中或压缩中按钮禁用;图像连接、
远程通道会话和 SSH 项目不显示直连模型压缩入口。完成后沿用应用通知及压缩后上下文估算,
摘要随会话保存。压缩行为见[技术设计](./technical-design.md#101-本地直连手动压缩)。
## 2. 设置中的能力目录
直连模型工具目录使用两个本机开发分组:
+26 -3
View File
@@ -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<typeof fetch>().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()
})
+23 -18
View File
@@ -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 }
+69
View File
@@ -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'))
+16 -4
View File
@@ -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' &&
+62 -16
View File
@@ -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(<App />);
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(<App />);
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 () => {
+12 -5
View File
@@ -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" ||
@@ -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),
@@ -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)
+58
View File
@@ -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', '<html><body><div id="root"></div><script type="module" src="/tests/support/magic-notes-capture-renderer.tsx"></script></body></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)
+93
View File
@@ -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)
})
+117
View File
@@ -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<void>(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 = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
async function wait(check: () => Promise<unknown> | unknown, label: string): Promise<void> {
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<void>(resolve => server.close(() => resolve()))
}
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })