Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@ export namespace SessionCompaction {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
// Prefer splitting on finished assistant turns so the summary
// covers all tool interactions (e.g. question-tool responses)
// that happened after the last user message. The parent user
// message is replayed so the main loop can continue.
if (msg.info.role === "assistant" && msg.info.finish && !msg.info.summary) {
const pid = msg.info.parentID
const parent = input.messages.find(
(m) => m.info.role === "user" && m.info.id === pid,
)
if (parent && parent.info.role === "user" && !parent.parts.some((p) => p.type === "compaction")) {
replay = { info: parent.info, parts: parent.parts }
messages = input.messages.slice(0, i + 1)
break
}
}
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
replay = { info: msg.info, parts: msg.parts }
messages = input.messages.slice(0, i)
Expand Down
102 changes: 102 additions & 0 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,108 @@ describe("session.compaction.process", () => {
})
})

test("splits on assistant turn boundary to preserve tool interactions", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))

const session = await Session.create({})
// Simulate a session where the user sends one message and the
// model runs several turns (e.g. via question-tool interactions)
// before overflow. The summary should include completed assistant
// turns so that their tool interactions are preserved.
const first = await user(session.id, "initial request")
await assistant(session.id, first.id, tmp.path)
await assistant(session.id, first.id, tmp.path)
// Compaction trigger — no user messages between the assistant
// turns and here.
const trigger = await user(session.id, "trigger")

const rt = runtime("continue")
try {
const msgs = await Session.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: trigger.id,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
}),
),
)

const all = await Session.messages({ sessionID: session.id })
const last = all.at(-1)

expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
// The replay should be "initial request" (the parent of the
// assistant turn used as the split boundary).
if (last?.parts[0]?.type === "text") {
expect(last.parts[0].text).toContain("initial request")
}
} finally {
await rt.dispose()
}
},
})
})

test("prefers assistant boundary over earlier user message", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))

const session = await Session.create({})
// user → assistant → user → assistant(tool interactions) → overflow
// The summary should include the second assistant's tool work,
// not discard it by splitting at the second user message.
const first = await user(session.id, "root")
await assistant(session.id, first.id, tmp.path)
const second = await user(session.id, "follow-up")
await assistant(session.id, second.id, tmp.path)
await assistant(session.id, second.id, tmp.path)
const trigger = await user(session.id, "trigger")

const rt = runtime("continue")
try {
const msgs = await Session.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: trigger.id,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
}),
),
)

const all = await Session.messages({ sessionID: session.id })
const last = all.at(-1)

expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
// Should replay "follow-up" (parent of the last completed
// assistant turn), preserving assistant tool interactions in
// the summary.
if (last?.parts[0]?.type === "text") {
expect(last.parts[0].text).toContain("follow-up")
}
} finally {
await rt.dispose()
}
},
})
})

test("falls back to overflow guidance when no replayable turn exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
Expand Down
Loading