Skip to content

Commit 8756834

Browse files
ryanwylerRyan Wyler
authored andcommitted
feat: add compaction model override for session compaction operations
Adds a dedicated compaction model selector so users can run one model for chat and a different model for summarization (e.g. Claude Sonnet for interactive coding, Zen Big Pickle free tier for zero-cost compaction). Model resolution priority: TUI selection > agent.compaction.model config > session model. When no TUI selection is set, behavior is identical to upstream. Changes: - DialogModel gains target="compaction" prop — no duplicate component - SessionCompaction.process() accepts optional compactionModel override - CompactionPart schema extended with optional compactionModel field - compaction_model_list keybind added (default: none) - /compaction-models slash command and command menu entry - local.model.compaction context backed by kv.signal('compaction_model') - Prompt footer shows active compaction model when set - SDK regenerated via ./script/generate.ts
1 parent 70d2f8c commit 8756834

14 files changed

Lines changed: 175 additions & 21 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,20 @@ function App() {
445445
local.model.cycleFavorite(-1)
446446
},
447447
},
448+
{
449+
title: "Switch compaction model",
450+
value: "compaction_model.list",
451+
keybind: "compaction_model_list",
452+
category: "Agent",
453+
slash: {
454+
name: "compaction-models",
455+
aliases: ["compaction-model"],
456+
},
457+
onSelect: () => {
458+
dialog.replace(() => <DialogModel target="compaction" />)
459+
},
460+
},
461+
448462
{
449463
title: "Switch agent",
450464
value: "agent.list",

packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function useConnected() {
1515
)
1616
}
1717

18-
export function DialogModel(props: { providerID?: string }) {
18+
export function DialogModel(props: { providerID?: string; target?: "session" | "compaction" }) {
1919
const local = useLocal()
2020
const sync = useSync()
2121
const dialog = useDialog()
@@ -25,14 +25,41 @@ export function DialogModel(props: { providerID?: string }) {
2525
const connected = useConnected()
2626
const providers = createDialogProviderOptions()
2727

28+
const isCompaction = props.target === "compaction"
29+
2830
const showExtra = createMemo(() => connected() && !props.providerID)
2931

32+
function onModelSelect(model: { providerID: string; modelID: string }) {
33+
dialog.clear()
34+
if (isCompaction) {
35+
local.model.compaction.set(model)
36+
return
37+
}
38+
local.model.set(model, { recent: true })
39+
}
40+
3041
const options = createMemo(() => {
3142
const needle = query().trim()
3243
const showSections = showExtra() && needle.length === 0
3344
const favorites = connected() ? local.model.favorite() : []
3445
const recents = local.model.recent()
3546

47+
// "Use session model (default)" option only shown in compaction mode
48+
const defaultOption = isCompaction
49+
? [
50+
{
51+
value: { providerID: "", modelID: "" },
52+
title: "Use session model (default)",
53+
description: "Compaction will use the same model as the session",
54+
category: showSections ? "Default" : undefined,
55+
onSelect: () => {
56+
dialog.clear()
57+
local.model.compaction.clear()
58+
},
59+
},
60+
]
61+
: []
62+
3663
function toOptions(items: typeof favorites, category: string) {
3764
if (!showSections) return []
3865
return items.flatMap((item) => {
@@ -49,10 +76,7 @@ export function DialogModel(props: { providerID?: string }) {
4976
category,
5077
disabled: provider.id === "opencode" && model.id.includes("-nano"),
5178
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
52-
onSelect: () => {
53-
dialog.clear()
54-
local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true })
55-
},
79+
onSelect: () => onModelSelect({ providerID: provider.id, modelID: model.id }),
5680
},
5781
]
5882
})
@@ -87,10 +111,7 @@ export function DialogModel(props: { providerID?: string }) {
87111
category: connected() ? provider.name : undefined,
88112
disabled: provider.id === "opencode" && model.includes("-nano"),
89113
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
90-
onSelect() {
91-
dialog.clear()
92-
local.model.set({ providerID: provider.id, modelID: model }, { recent: true })
93-
},
114+
onSelect: () => onModelSelect({ providerID: provider.id, modelID: model }),
94115
})),
95116
filter((x) => {
96117
if (!showSections) return true
@@ -121,19 +142,22 @@ export function DialogModel(props: { providerID?: string }) {
121142

122143
if (needle) {
123144
return [
145+
...defaultOption,
124146
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
125147
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
126148
]
127149
}
128150

129-
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
151+
return [...defaultOption, ...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
130152
})
131153

132154
const provider = createMemo(() =>
133155
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
134156
)
135157

136-
const title = createMemo(() => provider()?.name ?? "Select model")
158+
const title = createMemo(() => (isCompaction ? "Select compaction model" : (provider()?.name ?? "Select model")))
159+
160+
const current = createMemo(() => (isCompaction ? local.model.compaction.current() : local.model.current()))
137161

138162
return (
139163
<DialogSelect<ReturnType<typeof options>[number]["value"]>
@@ -151,15 +175,18 @@ export function DialogModel(props: { providerID?: string }) {
151175
title: "Favorite",
152176
disabled: !connected(),
153177
onTrigger: (option) => {
154-
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
178+
const val = option.value as { providerID: string; modelID: string }
179+
if (val.providerID && val.modelID) {
180+
local.model.toggleFavorite(val)
181+
}
155182
},
156183
},
157184
]}
158185
onFilter={setQuery}
159186
flat={true}
160187
skipFilter={true}
161188
title={title()}
162-
current={local.model.current()}
189+
current={current()}
163190
/>
164191
)
165192
}

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,10 @@ export function Prompt(props: PromptProps) {
10121012
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
10131013
</text>
10141014
</Show>
1015+
<Show when={local.model.compaction.current()}>
1016+
<text fg={theme.textMuted}>·</text>
1017+
<text fg={theme.textMuted}>compact: {local.model.compaction.parsed().model}</text>
1018+
</Show>
10151019
</box>
10161020
</Show>
10171021
</box>

packages/opencode/src/cli/cmd/tui/context/local.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import { useArgs } from "./args"
1313
import { useSDK } from "./sdk"
1414
import { RGBA } from "@opentui/core"
1515
import { Filesystem } from "@/util/filesystem"
16+
import { useKV } from "./kv"
1617

1718
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
1819
name: "Local",
1920
init: () => {
2021
const sync = useSync()
2122
const sdk = useSDK()
2223
const toast = useToast()
24+
const kv = useKV()
2325

2426
function isModelValid(model: { providerID: string; modelID: string }) {
2527
const provider = sync.data.provider.find((x) => x.id === model.providerID)
@@ -320,6 +322,44 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
320322
save()
321323
})
322324
},
325+
compaction: iife(() => {
326+
const key = "compaction_model"
327+
const [get] = kv.signal<{ providerID: string; modelID: string } | undefined>(key, undefined)
328+
return {
329+
current() {
330+
return get() as { providerID: string; modelID: string } | undefined
331+
},
332+
parsed: createMemo(() => {
333+
const value = get() as { providerID: string; modelID: string } | undefined
334+
if (!value) {
335+
return {
336+
provider: undefined,
337+
model: "Using session model",
338+
}
339+
}
340+
const provider = sync.data.provider.find((x) => x.id === value.providerID)
341+
const info = provider?.models[value.modelID]
342+
return {
343+
provider: provider?.name ?? value.providerID,
344+
model: info?.name ?? value.modelID,
345+
}
346+
}),
347+
set(model: { providerID: string; modelID: string }) {
348+
if (!isModelValid(model)) {
349+
toast.show({
350+
message: `Model ${model.providerID}/${model.modelID} is not valid`,
351+
variant: "warning",
352+
duration: 3000,
353+
})
354+
return
355+
}
356+
kv.set(key, { ...model })
357+
},
358+
clear() {
359+
kv.set(key, undefined)
360+
},
361+
}
362+
}),
323363
variant: {
324364
current() {
325365
const m = currentModel()

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,10 +461,12 @@ export function Session() {
461461
})
462462
return
463463
}
464+
const compactionModel = local.model.compaction.current()
464465
sdk.client.session.summarize({
465466
sessionID: route.sessionID,
466467
modelID: selectedModel.modelID,
467468
providerID: selectedModel.providerID,
469+
compactionModel: compactionModel ?? undefined,
468470
})
469471
dialog.clear()
470472
},

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,7 @@ export namespace Config {
809809
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
810810
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
811811
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
812+
compaction_model_list: z.string().optional().default("none").describe("List available compaction models"),
812813
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
813814
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
814815
agent_cycle: z.string().optional().default("tab").describe("Next agent"),

packages/opencode/src/server/routes/session.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { Log } from "../../util/log"
1717
import { PermissionNext } from "@/permission/next"
1818
import { errors } from "../error"
1919
import { lazy } from "../../util/lazy"
20-
import { SessionProxyMiddleware } from "../../control-plane/session-proxy-middleware"
20+
2121
import { Config } from "../../config/config"
2222

2323
const log = Log.create({ service: "server" })
@@ -564,6 +564,12 @@ export const SessionRoutes = lazy(() =>
564564
providerID: z.string(),
565565
modelID: z.string(),
566566
auto: z.boolean().optional().default(false),
567+
compactionModel: z
568+
.object({
569+
providerID: z.string(),
570+
modelID: z.string(),
571+
})
572+
.optional(),
567573
}),
568574
),
569575
async (c) => {
@@ -588,6 +594,7 @@ export const SessionRoutes = lazy(() =>
588594
modelID: body.modelID,
589595
},
590596
auto: body.auto,
597+
compactionModel: body.compactionModel,
591598
})
592599
await SessionPrompt.loop({ sessionID })
593600
return c.json(true)

packages/opencode/src/session/compaction-extension.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ Critical rules:
184184
sessionID: string
185185
abort: AbortSignal
186186
auto: boolean
187+
compactionModel?: { providerID: string; modelID: string }
188+
overflow?: boolean
187189
}): Promise<"continue" | "stop"> {
188190
const config = await Config.get()
189191
const extractRatio = config.compaction?.extractRatio ?? DEFAULTS.extractRatio
@@ -206,9 +208,12 @@ Critical rules:
206208
// Get the user message to determine which model we'll use
207209
const originalUserMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
208210
const agent = await Agent.get("compaction")
209-
const model = agent.model
210-
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
211-
: await Provider.getModel(originalUserMessage.model.providerID, originalUserMessage.model.modelID)
211+
// Model resolution priority: TUI compactionModel override > agent.compaction.model config > session model
212+
const model = input.compactionModel
213+
? await Provider.getModel(input.compactionModel.providerID, input.compactionModel.modelID)
214+
: agent.model
215+
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
216+
: await Provider.getModel(originalUserMessage.model.providerID, originalUserMessage.model.modelID)
212217

213218
// Calculate token counts and role counts
214219
let messageTokens: number[] = []

packages/opencode/src/session/compaction.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export namespace SessionCompaction {
108108

109109
export async function process(input: {
110110
parentID: string
111+
compactionModel?: { providerID: string; modelID: string }
111112
messages: MessageV2.WithParts[]
112113
sessionID: string
113114
abort: AbortSignal
@@ -175,9 +176,11 @@ export namespace SessionCompaction {
175176
}
176177

177178
const agent = await Agent.get("compaction")
178-
const model = agent.model
179-
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
180-
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
179+
const model = input.compactionModel
180+
? await Provider.getModel(input.compactionModel.providerID, input.compactionModel.modelID)
181+
: agent.model
182+
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
183+
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
181184
const msg = (await Session.updateMessage({
182185
id: Identifier.ascending("message"),
183186
role: "assistant",
@@ -349,6 +352,12 @@ When constructing the summary, try to stick to this template:
349352
}),
350353
auto: z.boolean(),
351354
overflow: z.boolean().optional(),
355+
compactionModel: z
356+
.object({
357+
providerID: z.string(),
358+
modelID: z.string(),
359+
})
360+
.optional(),
352361
}),
353362
async (input) => {
354363
const msg = await Session.updateMessage({
@@ -368,6 +377,7 @@ When constructing the summary, try to stick to this template:
368377
type: "compaction",
369378
auto: input.auto,
370379
overflow: input.overflow,
380+
compactionModel: input.compactionModel,
371381
})
372382
},
373383
)

packages/opencode/src/session/message-v2.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ export namespace MessageV2 {
204204
type: z.literal("compaction"),
205205
auto: z.boolean(),
206206
overflow: z.boolean().optional(),
207+
compactionModel: z
208+
.object({
209+
providerID: z.string(),
210+
modelID: z.string(),
211+
})
212+
.optional(),
207213
}).meta({
208214
ref: "CompactionPart",
209215
})
@@ -875,7 +881,7 @@ export namespace MessageV2 {
875881
// Upstream guard: do not mark errored summaries as completed breakpoints.
876882
// Collapse compaction may not set finish, but summary: true is sufficient;
877883
// however an errored summary must not be treated as a valid breakpoint.
878-
if (isAssistantSummary && !msg.info.error) {
884+
if (isAssistantSummary && !(msg.info as Assistant).error) {
879885
const parentID = (msg.info as Assistant).parentID
880886
log.debug("COLLAPSE filterCompacted found summary", {
881887
msgId: msg.info.id,

0 commit comments

Comments
 (0)