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:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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' &&
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user