From e52dbde6b8bea7e31139b25c522d5a8fded6462b Mon Sep 17 00:00:00 2001 From: DarkPhilosophy <19309990+DarkPhilosophy@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:15:15 +0300 Subject: [PATCH] fix(session): place compaction summary at chronological position in collapsed transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 3bcbf1515 (fix #3258) moved the compaction summary to position 0 (top of transcript) as a side-effect of collapsing compacted history for performance. This caused the divider to scroll into native scrollback where Ctrl+O could no longer expand it — users got no visible feedback after /compact. In collapsed transcript mode (display), emit kept messages first, then the compaction summary, then post-compaction messages — restoring the chronological position. Agent context (non-transcript) keeps summary-first ordering unchanged. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/session/session-context.ts | 38 ++++--- packages/coding-agent/test/compaction.test.ts | 8 +- .../session-manager/build-context.test.ts | 99 +++++++++++++++++++ 4 files changed, 130 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e96d200d8..64d654be4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ - Fixed subagents spawned with an output schema (`agent(..., schema=...)`, `task` with structured output) failing with `schema_violation: missing required fields` since the typed-yield rework: a `type: "result"` finalize carrying the full object was assembled as a section named `result`, nesting the payload one level deep. String-typed yields are now treated as terminal finalizers (their data is the complete result), only array-typed yields form accumulating sections, and a data-less finalize keeps accumulated sections instead of collapsing to the last assistant turn. - Fixed the per-provider concurrency cap (e.g. `providers.ollama-cloud.maxConcurrency`) bracketing the whole subagent lifecycle, which deadlocked any spawn tree wider than the cap because parents held every slot while waiting for children queued on the same cap. The semaphore now wraps each provider HTTP request, so a parent's slot frees between turns and child subagents can acquire while their parent's tool calls are running. ([#3749](https://github.com/can1357/oh-my-pi/issues/3749)) - Fixed Ctrl+Q / Ctrl+Enter follow-up submissions sending the literal `[Paste #N]` marker instead of the expanded paste body ([#3737](https://github.com/can1357/oh-my-pi/issues/3737)). +- Fixed `/compact` summary divider landing at the top of the transcript and scrolling into frozen native scrollback where Ctrl+O could not expand it. The collapsed display transcript now emits the summary at the chronological compaction point (after kept messages), keeping it in the live region. ## [16.2.4] - 2026-06-28 diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index 787da464a..0d23e95f0 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -295,21 +295,23 @@ export function buildSessionContext( })(); const remoteReplacementHistory = providerPayload?.items; - if (options?.transcript) handleEntryResetTracking(compaction); - // Emit summary first; re-attach any archived snapcompact frames so the - // model can keep reading the archived history after every context rebuild. + // Re-attach any archived snapcompact frames so the model can keep + // reading the archived history after every context rebuild. const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData); - pushMessage( - createCompactionSummaryMessage( - compaction.summary, - compaction.tokensBefore, - compaction.timestamp, - compaction.shortSummary, - providerPayload, - undefined, - snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, - ), + const compactionSummaryMsg = createCompactionSummaryMessage( + compaction.summary, + compaction.tokensBefore, + compaction.timestamp, + compaction.shortSummary, + providerPayload, + undefined, + snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, ); + // Agent context (non-transcript): summary first so the LLM sees the + // compacted context before recent messages. + if (!options?.transcript) { + pushMessage(compactionSummaryMsg); + } // Find compaction index in path const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id); @@ -333,6 +335,16 @@ export function buildSessionContext( } } + // Display transcript: emit the summary at the chronological compaction + // point (after kept messages, before post-compaction) so it stays in + // the live region where Ctrl+O can expand it. Reset tracking fires + // here so the first post-compaction assistant turn — not a kept + // pre-compaction one — is marked as a cache miss. + if (options?.transcript) handleEntryResetTracking(compaction); + if (options?.transcript) { + pushMessage(compactionSummaryMsg); + } + // Emit messages after compaction for (let i = compactionIdx + 1; i < path.length; i++) { const entry = path[i]; diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index d3131915c..05d3181cb 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -1173,8 +1173,10 @@ describe("buildSessionContext", () => { collapseCompactedHistory: true, }); - expect(transcript.messages.map(m => m.role)).toEqual(["compactionSummary", "user", "user"]); - expect((transcript.messages[0] as { summary: string }).summary).toContain("Second summary"); + expect(transcript.messages.map(m => m.role)).toEqual(["user", "compactionSummary", "user"]); + const summaryMsg = transcript.messages[1]; + if (summaryMsg?.role !== "compactionSummary") throw new Error("Expected compaction summary at index 1"); + expect(summaryMsg.summary).toContain("Second summary"); expect(transcript.cacheMissExplainedAt).toEqual([false, false, false]); }); @@ -1204,7 +1206,7 @@ describe("buildSessionContext", () => { // The provider payload is attached to the summary for LLM replay only; the // collapsed display must still emit the kept SessionEntry rows so a // remotely-compacted session keeps its recent turns visible. - expect(transcript.messages.map(m => m.role)).toEqual(["compactionSummary", "user", "assistant", "user"]); + expect(transcript.messages.map(m => m.role)).toEqual(["user", "assistant", "compactionSummary", "user"]); const dump = JSON.stringify(transcript.messages); expect(dump).toContain("kept-user"); expect(dump).toContain("kept-assistant"); diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index bc3d94ffb..58e7ccd6b 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -248,6 +248,105 @@ describe("buildSessionContext", () => { }); }); + describe("collapsed transcript ordering", () => { + it("display transcript: summary after kept messages, not at top", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "old question"), + msg("2", "1", "assistant", "old response"), + msg("3", "2", "user", "kept question"), + msg("4", "3", "assistant", "kept response"), + compaction("5", "4", "Summary of compacted turns", "3"), + msg("6", "5", "user", "after compact"), + ]; + + // Display transcript: kept messages → summary → post-compaction + const transcript = buildSessionContext(entries, undefined, undefined, { + transcript: true, + collapseCompactedHistory: true, + }); + + expect(transcript.messages).toHaveLength(4); + expect(transcript.messages[0]?.role).toBe("user"); + expect(transcript.messages[1]?.role).toBe("assistant"); + expect(transcript.messages[2]?.role).toBe("compactionSummary"); + expect(transcript.messages[3]?.role).toBe("user"); + }); + + it("agent context: summary stays at top", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "old question"), + msg("2", "1", "assistant", "old response"), + msg("3", "2", "user", "kept question"), + msg("4", "3", "assistant", "kept response"), + compaction("5", "4", "Summary of compacted turns", "3"), + msg("6", "5", "user", "after compact"), + ]; + + // Agent context (no transcript): summary first + const agentCtx = buildSessionContext(entries); + + expect(agentCtx.messages).toHaveLength(4); + expect(agentCtx.messages[0]?.role).toBe("compactionSummary"); + expect(agentCtx.messages[1]?.role).toBe("user"); + expect(agentCtx.messages[2]?.role).toBe("assistant"); + expect(agentCtx.messages[3]?.role).toBe("user"); + }); + + it("display transcript with no post-compaction messages: summary at bottom", () => { + // Simulates the moment right after /compact runs — compaction is the + // last entry, so the summary should be the last (bottom) message. + const entries: SessionEntry[] = [ + msg("1", null, "user", "old question"), + msg("2", "1", "assistant", "old response"), + msg("3", "2", "user", "kept question"), + msg("4", "3", "assistant", "kept response"), + compaction("5", "4", "Freshly compacted", "3"), + ]; + + const transcript = buildSessionContext(entries, undefined, undefined, { + transcript: true, + collapseCompactedHistory: true, + }); + + expect(transcript.messages).toHaveLength(3); + expect(transcript.messages[0]?.role).toBe("user"); + expect(transcript.messages[1]?.role).toBe("assistant"); + expect(transcript.messages[2]?.role).toBe("compactionSummary"); + }); + + it("collapsed transcript marks first post-compaction assistant as cache miss", () => { + // With kept assistant BEFORE compaction and assistant AFTER compaction, + // the cache-miss flag must land on the post-compaction assistant (the + // turn that actually follows /compact), not on the kept pre-compaction one. + const entries: SessionEntry[] = [ + msg("1", null, "user", "old question"), + msg("2", "1", "assistant", "old response"), + msg("3", "2", "user", "kept question"), + msg("4", "3", "assistant", "kept response"), + compaction("5", "4", "Compacted", "3"), + msg("6", "5", "user", "after compact"), + msg("7", "6", "assistant", "after response"), + ]; + + const transcript = buildSessionContext(entries, undefined, undefined, { + transcript: true, + collapseCompactedHistory: true, + }); + + // Roles: user(3), assistant(4), compactionSummary, user(6), assistant(7) + expect(transcript.messages.map(m => m.role)).toEqual([ + "user", + "assistant", + "compactionSummary", + "user", + "assistant", + ]); + // cacheMissExplainedAt: the kept assistant (index 1) should NOT be a miss; + // the post-compaction assistant (index 4) SHOULD be a miss. + expect(transcript.cacheMissExplainedAt).toEqual([false, false, false, false, true]); + }); + }); + describe("with branches", () => { it("follows path to specified leaf", () => { // Tree: