diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3ca4f074f9e4..d7b36b17dffa 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -119,39 +119,58 @@ function completedCompactions(messages: MessageV2.WithParts[]) { }) } -function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) { - const source = input.tail - ? "the conversation history above and the serialized recent conversation tail below" - : "the conversation history above" +function buildPrompt(input: { previousSummary?: string; context: string[] }) { const anchor = input.previousSummary ? [ - `Update the anchored summary below using ${source}.`, + "Update the anchored summary below using the conversation history above.", "Preserve still-true details, remove stale details, and merge in the new facts.", "", input.previousSummary, "", ].join("\n") - : `Create a new anchored summary from ${source}.` - const tail = input.tail - ? [ - "Fold this serialized recent conversation tail into the summary; it is not provider message history.", - "", - input.tail, - "", - ].join("\n") - : undefined - return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n") + : "Create a new anchored summary from the conversation history above." + return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") } const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: { messages: MessageV2.WithParts[] - model: Provider.Model }) { - const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + const parts = input.messages.flatMap((msg) => { + if (msg.info.role === "user") { + const content = msg.parts + .flatMap((part) => { + if (part.type === "text" && !part.ignored && part.text !== "") return [part.text] + if (part.type === "file" && MessageV2.isMedia(part.mime)) return [`[Attached ${part.mime}: ${part.filename ?? "file"}]`] + return [] + }) + .join("\n") + return content ? [`[User]: ${content}`] : [] + } + if (msg.info.role === "assistant") { + return msg.parts.flatMap((part) => { + if (part.type === "reasoning") return part.text ? [`[Assistant thinking]: ${part.text}`] : [] + if (part.type === "text") return part.text ? [`[Assistant]: ${part.text}`] : [] + if (part.type !== "tool") return [] + const input = Object.entries(part.state.input) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(", ") + if (part.state.status === "completed") { + const output = part.state.time.compacted + ? "[Old tool result content cleared]" + : part.state.output.length <= TOOL_OUTPUT_MAX_CHARS + ? part.state.output + : `${part.state.output.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[Tool output truncated for compaction: omitted ${part.state.output.length - TOOL_OUTPUT_MAX_CHARS} chars]` + return [`[Assistant tool call]: ${part.tool}(${input})`, `[Tool result]: ${output}`] + } + if (part.state.status === "error") { + return [`[Assistant tool call]: ${part.tool}(${input})`, `[Tool error]: ${part.state.error}`] + } + return [`[Assistant tool call]: ${part.tool}(${input})`] + }) + } + return [] }) - return messages.length ? JSON.stringify(messages, null, 2) : undefined + return parts.length ? parts.join("\n\n") : undefined }) function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { @@ -167,6 +186,7 @@ function turns(messages: MessageV2.WithParts[]) { const msg = messages[i] if (msg.info.role !== "user") continue if (msg.parts.some((part) => part.type === "compaction")) continue + if (msg.parts.some((part) => part.type === "text" && part.metadata?.compaction_tail === true)) continue result.push({ start: i, end: messages.length, @@ -262,7 +282,7 @@ export const layer: Layer.Layer< messages: MessageV2.WithParts[] model: Provider.Model }) { - return Token.estimate((yield* serialize(input)) ?? "") + return Token.estimate((yield* serialize({ messages: input.messages })) ?? "") }) const select = Effect.fn("SessionCompaction.select")(function* (input: { @@ -425,8 +445,8 @@ export const layer: Layer.Layer< ) const tailMessages = structuredClone(selected.tail) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages }) - const tail = yield* serialize({ messages: tailMessages, model }) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail }) + const tail = yield* serialize({ messages: tailMessages }) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -493,6 +513,37 @@ export const layer: Layer.Layer< return "stop" } + if (processor.message.error) return "stop" + + if (tail) { + const tailMessage = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: userMessage.agent, + model: userMessage.model, + format: userMessage.format, + tools: userMessage.tools, + system: userMessage.system, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: tailMessage.id, + sessionID: input.sessionID, + type: "text", + metadata: { compaction_tail: true }, + synthetic: true, + text: [ + "The conversation history before this point was compacted into the summary above.", + "The following messages are the latest conversation turns after that summarized history.", + "", + tail, + "", + ].join("\n\n"), + }) + } + if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -575,19 +626,19 @@ export const layer: Layer.Layer< } } - if (processor.message.error) return "stop" if (result === "continue") { - const summary = summaryText( - (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { - info: msg, - parts: [], - }, - ) + const summaryMessage = (yield* session.messages({ sessionID: input.sessionID })).find( + (item) => item.info.id === msg.id, + ) ?? { + info: msg, + parts: [], + } + const summary = summaryText(summaryMessage) ?? "" if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { yield* sync.run(SessionEvent.Compaction.Ended.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), - text: summary ?? "", + text: summary, }) } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 15246dac394d..f7b13d97df73 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1642,7 +1642,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] - if (!lastUser && msg.info.role === "user") lastUser = msg.info + if ( + !lastUser && + msg.info.role === "user" && + !msg.parts.some((part) => part.type === "text" && part.metadata?.compaction_tail === true) + ) + lastUser = msg.info if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info if (lastUser && lastFinished) break diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c7f349d5cea0..4e16a33540b5 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -282,7 +282,9 @@ function createSummaryCompaction(sessionID: SessionID) { function readCompactionPart(sessionID: SessionID) { return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( Effect.map((messages) => - messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), + messages + .findLast((message) => message.parts.some((item) => item.type === "compaction")) + ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), ), ) } @@ -1005,7 +1007,7 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "serializes retained tail media as text in the summary input", + "serializes retained tail media as text in the saved summary", () => { const stub = llm() let captured = "" @@ -1034,8 +1036,13 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("recent image turn") - expect(captured).toContain("Attached image/png: big.png") + expect(captured).not.toContain("recent image turn") + expect(captured).not.toContain("Attached image/png: big.png") + const tail = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.synthetic), + ) + expect(JSON.stringify(tail?.parts)).toContain("recent image turn") + expect(JSON.stringify(tail?.parts)).toContain("Attached image/png: big.png") }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, @@ -1080,12 +1087,17 @@ describe("session.compaction.process", () => { expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("zzzz") - expect(captured).toContain("keep tail") + expect(captured).not.toContain("keep tail") + const tail = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.synthetic), + ) + expect(JSON.stringify(tail?.parts)).toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)]) + expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String), expect.any(String)]) expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) + expect(filtered[2]?.info.role).toBe("user") expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id) }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) @@ -1112,7 +1124,9 @@ describe("session.compaction.process", () => { const last = all.at(-1) expect(result).toBe("continue") - expect(last?.info.role).toBe("assistant") + expect(last?.info.role).toBe("user") + expect(last?.parts.some((part) => part.type === "text" && part.text.includes("latest-messages"))).toBe(true) + expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) expect( all.some( (msg) => @@ -1354,7 +1368,7 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "summarizes the head while serializing recent tail into summary input", + "summarizes the head while appending serialized recent tail to saved summary", () => { const stub = llm() let captured: LLM.StreamInput["messages"] = [] @@ -1386,10 +1400,16 @@ describe("session.compaction.process", () => { expect(head).toContain("older context") expect(head).not.toContain("keep this turn") expect(head).not.toContain("and this one too") - expect(prompt).toContain("keep this turn") - expect(prompt).toContain("and this one too") - expect(prompt).toContain("recent-conversation-tail") + expect(prompt).not.toContain("keep this turn") + expect(prompt).not.toContain("and this one too") + expect(prompt).not.toContain("latest-messages") expect(prompt).not.toContain("What did we do so far?") + const tail = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.synthetic), + ) + expect(JSON.stringify(tail?.parts)).toContain("keep this turn") + expect(JSON.stringify(tail?.parts)).toContain("and this one too") + expect(JSON.stringify(tail?.parts)).toContain("latest-messages") }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, @@ -1477,6 +1497,44 @@ describe("session.compaction.process", () => { }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) + itCompaction.instance("summarizes previous synthetic tail on repeated compaction", () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push(reply("summary two", (input) => (captured = JSON.stringify(input.messages)))) + + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + yield* createUserMessage(session.id, "previous tail") + yield* createCompactionMarker(session.id) + + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + yield* createUserMessage(session.id, "new tail") + yield* createCompactionMarker(session.id) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const tails = (yield* ssn.messages({ sessionID: session.id })).filter( + (item) => item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.metadata?.compaction_tail === true), + ) + const latestTail = tails.at(-1) + + expect(captured).toContain("previous tail") + expect(captured).toContain("latest-messages") + expect(JSON.stringify(latestTail?.parts)).toContain("new tail") + expect(JSON.stringify(latestTail?.parts)).not.toContain("previous tail") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 10_000 }) })) + }) + itCompaction.instance( "ignores previous summaries when sizing the serialized tail", Effect.gen(function* () {