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:
@@ -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)
|
||||
})
|
||||
@@ -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) })
|
||||
Reference in New Issue
Block a user