diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 229dff0c46de..f7484e51d127 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -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) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index a686d7ccffab..0b73eaf13a5f 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -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({