Skip to content

Commit 9b885f5

Browse files
committed
fix(session): preserve context when compaction model is unavailable
1 parent 437df75 commit 9b885f5

5 files changed

Lines changed: 189 additions & 6 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,6 @@ export const SessionRoutes = lazy(() =>
576576
const sessionID = c.req.valid("param").sessionID
577577
const body = c.req.valid("json")
578578
const session = await Session.get(sessionID)
579-
await SessionRevert.cleanup(session)
580579
const msgs = await Session.messages({ sessionID })
581580
let currentAgent = await Agent.defaultAgent()
582581
for (let i = msgs.length - 1; i >= 0; i--) {
@@ -586,6 +585,13 @@ export const SessionRoutes = lazy(() =>
586585
break
587586
}
588587
}
588+
await SessionCompaction.resolveModel({
589+
model: {
590+
providerID: body.providerID,
591+
modelID: body.modelID,
592+
},
593+
})
594+
await SessionRevert.cleanup(session)
589595
await SessionCompaction.create({
590596
sessionID,
591597
agent: currentAgent,

packages/opencode/src/session/compaction.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ Rules:
204204
}
205205

206206
export interface Interface {
207+
readonly resolveModel: (input: {
208+
model: { providerID: ProviderID; modelID: ModelID }
209+
}) => Effect.Effect<Provider.Model>
207210
readonly isOverflow: (input: {
208211
tokens: MessageV2.Assistant["tokens"]
209212
model: Provider.Model
@@ -255,6 +258,17 @@ Rules:
255258
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
256259
})
257260

261+
const resolveModel = Effect.fn("SessionCompaction.resolveModel")(function* (input: {
262+
model: { providerID: ProviderID; modelID: ModelID }
263+
}) {
264+
const cfg = yield* config.get()
265+
const agent = yield* agents.get("compaction")
266+
return agent.model
267+
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
268+
: ((yield* resolveLocal(provider, cfg, "compaction")) ??
269+
(yield* provider.getModel(input.model.providerID, input.model.modelID)))
270+
})
271+
258272
const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
259273
messages: MessageV2.WithParts[]
260274
model: Provider.Model
@@ -408,10 +422,7 @@ Rules:
408422

409423
const cfg = yield* config.get()
410424
const agent = yield* agents.get("compaction")
411-
const model = agent.model
412-
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
413-
: ((yield* resolveLocal(provider, cfg, "compaction")) ??
414-
(yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)))
425+
const model = yield* resolveModel({ model: userMessage.model })
415426
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
416427
const prior = completedCompactions(history)
417428
const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
@@ -610,6 +621,7 @@ Rules:
610621
})
611622

612623
return Service.of({
624+
resolveModel,
613625
isOverflow,
614626
prune,
615627
process: processCompaction,
@@ -636,6 +648,10 @@ Rules:
636648
return runPromise((svc) => svc.isOverflow(input))
637649
}
638650

651+
export async function resolveModel(input: { model: { providerID: ProviderID; modelID: ModelID } }) {
652+
return runPromise((svc) => svc.resolveModel(input))
653+
}
654+
639655
export async function prune(input: { sessionID: SessionID }): Promise<boolean> {
640656
return runPromise((svc) => svc.prune(input))
641657
}

packages/opencode/test/cli/cmd/tui/clear-commands.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,49 @@ describe("createClearCommands", () => {
9898
message: "No model selected. Please select a model first.",
9999
})
100100
})
101+
102+
test("compact command shows failure and does not clear when summarize fails", async () => {
103+
const messages = [{ info: { id: "m1", role: "user", cost: 0 } }, { info: { id: "m2", role: "assistant", cost: 3 } }]
104+
const show = mock((_opts: unknown) => {})
105+
const syncSession = mock(async (_sessionID: string, _opts: { force: boolean }) => {})
106+
const api: ClearCommandsDeps["sdk"]["client"]["session"] = {
107+
messages: mock(async (_input: { sessionID: string }) => ({ data: messages })),
108+
deleteMessage: mock(async (_input: { sessionID: string; messageID: string }) => undefined),
109+
clearTodo: mock(async (_input: { sessionID: string }) => undefined),
110+
delete: mock(async (_input: { sessionID: string }) => undefined),
111+
children: mock(async (_input: { sessionID: string }) => ({ data: [] })),
112+
summarize: mock(async () => {
113+
throw new Error("model unreachable")
114+
}),
115+
}
116+
117+
const deps: ClearCommandsDeps = {
118+
sdk: { client: { session: api } },
119+
sync: { session: { sync: syncSession } },
120+
kv: {
121+
get: createGet(0),
122+
set<T>(_key: string, _value: T) {},
123+
},
124+
toast: { show },
125+
route: { data: { type: "session", sessionID: "ses_1" } },
126+
local: { model: { current: () => ({ providerID: "openai", modelID: "gpt-4" }) } },
127+
}
128+
const [, compact] = createClearCommands(deps)
129+
130+
await compact.onSelect!(dialog)
131+
132+
expect(api.summarize).toHaveBeenCalledWith({
133+
sessionID: "ses_1",
134+
providerID: "openai",
135+
modelID: "gpt-4",
136+
auto: false,
137+
})
138+
expect(api.deleteMessage).not.toHaveBeenCalled()
139+
expect(api.clearTodo).not.toHaveBeenCalled()
140+
expect(syncSession).not.toHaveBeenCalled()
141+
expect(show).toHaveBeenCalledWith({
142+
variant: "error",
143+
message: "Failed to compact: model unreachable",
144+
})
145+
})
101146
})

packages/opencode/test/session/compaction.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,63 @@ describe("session.compaction.prune", () => {
623623
})
624624

625625
describe("session.compaction.process", () => {
626+
test("resolveModel falls back to requested model when local hybrid model is unavailable", async () => {
627+
await using tmp = await tmpdir()
628+
await Instance.provide({
629+
directory: tmp.path,
630+
fn: async () => {
631+
const mdl = ProviderTest.model({
632+
id: ModelID.make("test-model"),
633+
providerID: ProviderID.make("test"),
634+
limit: { context: 100_000, output: 32_000 },
635+
})
636+
const provider = ProviderTest.fake({
637+
model: mdl,
638+
getModel: Effect.fn("TestProvider.getModel")((providerID, modelID) => {
639+
if (providerID === ProviderID.make("local") && modelID === ModelID.make("offline")) {
640+
return Effect.die(new Error("local unreachable"))
641+
}
642+
if (providerID === ProviderID.make("test") && modelID === ModelID.make("test-model")) {
643+
return Effect.succeed(mdl)
644+
}
645+
return Effect.die(new Error(`Unknown test model: ${providerID}/${modelID}`))
646+
}),
647+
})
648+
const rt = runtime(
649+
"continue",
650+
Plugin.defaultLayer,
651+
provider,
652+
Layer.mock(Config.Service)({
653+
get: () =>
654+
Effect.succeed({
655+
...Config.Info.parse({}),
656+
hybrid: {
657+
enabled: true,
658+
local_model: { providerID: "local", modelID: "offline" },
659+
log_routing: false,
660+
compression_threshold: 10,
661+
compression_timeout_ms: 5000,
662+
},
663+
}),
664+
}),
665+
)
666+
try {
667+
const resolved = await rt.runPromise(
668+
SessionCompaction.Service.use((svc) =>
669+
svc.resolveModel({
670+
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") },
671+
}),
672+
),
673+
)
674+
expect(resolved.providerID).toBe(ProviderID.make("test"))
675+
expect(resolved.id).toBe(ModelID.make("test-model"))
676+
} finally {
677+
await rt.dispose()
678+
}
679+
},
680+
})
681+
})
682+
626683
test("throws when parent is not a user message", async () => {
627684
await using tmp = await tmpdir()
628685
await Instance.provide({

packages/opencode/test/session/revert-compact.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
1+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
22
import fs from "fs/promises"
33
import path from "path"
44
import { Session } from "../../src/session"
@@ -11,9 +11,14 @@ import { Log } from "../../src/util/log"
1111
import { Instance } from "../../src/project/instance"
1212
import { MessageID, PartID } from "../../src/session/schema"
1313
import { tmpdir } from "../fixture/fixture"
14+
import { Server } from "../../src/server/server"
1415

1516
Log.init({ print: false })
1617

18+
afterEach(() => {
19+
mock.restore()
20+
})
21+
1722
function user(sessionID: string, agent = "default") {
1823
return Session.updateMessage({
1924
id: MessageID.ascending(),
@@ -350,6 +355,60 @@ describe("revert + compact workflow", () => {
350355
})
351356
})
352357

358+
test("preserves revert state when summarize fails before compaction starts", async () => {
359+
await using tmp = await tmpdir({ git: true })
360+
await Instance.provide({
361+
directory: tmp.path,
362+
fn: async () => {
363+
const session = await Session.create({})
364+
const sessionID = session.id
365+
366+
const u1 = await user(sessionID)
367+
await text(sessionID, u1.id, "hello")
368+
const a1 = await assistant(sessionID, u1.id, tmp.path)
369+
await text(sessionID, a1.id, "hi")
370+
const u2 = await user(sessionID)
371+
await text(sessionID, u2.id, "second")
372+
const a2 = await assistant(sessionID, u2.id, tmp.path)
373+
await text(sessionID, a2.id, "later")
374+
375+
await SessionRevert.revert({
376+
sessionID,
377+
messageID: u2.id,
378+
})
379+
380+
const app = Server.Default().app
381+
const before = await Session.get(sessionID)
382+
const msgs = await Session.messages({ sessionID })
383+
const fail = spyOn(SessionCompaction, "resolveModel").mockRejectedValue(new Error("model unreachable"))
384+
const cleanup = spyOn(SessionRevert, "cleanup")
385+
386+
const res = await app.request(`/session/${sessionID}/summarize`, {
387+
method: "POST",
388+
headers: {
389+
"content-type": "application/json",
390+
},
391+
body: JSON.stringify({
392+
providerID: "openai",
393+
modelID: "gpt-4",
394+
auto: false,
395+
}),
396+
})
397+
398+
const after = await Session.get(sessionID)
399+
const next = await Session.messages({ sessionID })
400+
401+
expect(res.status).toBe(500)
402+
expect(fail).toHaveBeenCalledTimes(1)
403+
expect(cleanup).not.toHaveBeenCalled()
404+
expect(after.revert).toEqual(before.revert)
405+
expect(next.map((msg) => msg.info.id)).toEqual(msgs.map((msg) => msg.info.id))
406+
expect(next).toHaveLength(4)
407+
expect(next.some((msg) => msg.parts.some((part) => part.type === "compaction"))).toBe(false)
408+
},
409+
})
410+
})
411+
353412
test("cleanup with partID removes parts from the revert point onward", async () => {
354413
await using tmp = await tmpdir({ git: true })
355414
await Instance.provide({

0 commit comments

Comments
 (0)