Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Fixed

- Compaction preserves the previous summary when the history bucket is empty,
including mid-turn cuts that only summarize the current turn prefix. Inherited
file-operation lists are refreshed once instead of accumulating duplicate blocks.
- Picker hints (`/` commands, `@` files, `?` shortcuts) now start in one shared
column instead of trailing each label's own length.

Expand Down
53 changes: 27 additions & 26 deletions internal/session/compaction/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import (
// summarize and the file operations captured from the summarized history.
// Recent messages stay in the session tree via FirstKeptEntryId.
type CompactionPreparation struct {
FirstKeptEntryId string
MessagesToSummarize []llm.Message
TurnPrefixMessages []llm.Message
IsMidTurnCut bool
TokensBefore int
PreviousSummary string
FirstKeptEntryId string
MessagesToSummarize []llm.Message
TurnPrefixMessages []llm.Message
IsMidTurnCut bool
TokensBefore int
PreviousSummary string
PreviousFileOperations string
// ReserveTokens is the headroom compaction keeps from the context window;
// summaries are capped at a fraction of it.
ReserveTokens int
Expand Down Expand Up @@ -98,10 +99,15 @@ func PrepareCompact(
}

previousSummary := ""
previousFileOperations := ""
var previousPreserveData map[string]any
if preCompactionIndex >= 0 {
prevCompaction := pathEntries[preCompactionIndex].(session.CompactionEntry)
previousSummary = prevCompaction.Compaction.Summary
previousFileOperations = formatFileOperations(
prevCompaction.Compaction.Details.ReadFiles,
prevCompaction.Compaction.Details.ModifiedFiles,
)
previousPreserveData = prevCompaction.Compaction.PreserveData
}

Expand All @@ -121,15 +127,16 @@ func PrepareCompact(
tokenBefore := lastUsage.ContextTokens()

return &CompactionPreparation{
FirstKeptEntryId: firstKeptEntryID,
MessagesToSummarize: messagesToSummarize,
TurnPrefixMessages: turnPrefixMessages,
TokensBefore: tokenBefore,
PreviousSummary: previousSummary,
PreviousPreserveData: previousPreserveData,
FileOps: *fileOps,
IsMidTurnCut: cutPoint.isMidTurnCut,
ReserveTokens: settings.reverseTokens,
FirstKeptEntryId: firstKeptEntryID,
MessagesToSummarize: messagesToSummarize,
TurnPrefixMessages: turnPrefixMessages,
TokensBefore: tokenBefore,
PreviousSummary: previousSummary,
PreviousFileOperations: previousFileOperations,
PreviousPreserveData: previousPreserveData,
FileOps: *fileOps,
IsMidTurnCut: cutPoint.isMidTurnCut,
ReserveTokens: settings.reverseTokens,
}, nil
}

Expand Down Expand Up @@ -189,17 +196,7 @@ func summarizeMidTurnCut(

go func() {
defer wg.Done()
if len(preparation.MessagesToSummarize) == 0 {
historySummary = "No prior history."
return
}
historySummary, historySummaryErr = generateSummary(
ctx,
llm,
preparation.MessagesToSummarize,
preparation.PreviousSummary,
summarizationCap(preparation.ReserveTokens, historySummaryRatio),
)
historySummary, historySummaryErr = summarizeHistory(ctx, preparation, llm)
}()

go func() {
Expand Down Expand Up @@ -231,6 +228,10 @@ func summarizeHistory(
llm llm.Compactor,
) (string, error) {
if len(preparation.MessagesToSummarize) == 0 {
// An empty history bucket can still have context from an earlier compaction.
if preparation.PreviousSummary != "" {
return stripFileOperations(preparation.PreviousSummary, preparation.PreviousFileOperations), nil
}
return "No prior history.", nil
}
return generateSummary(
Expand Down
60 changes: 60 additions & 0 deletions internal/session/compaction/compact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,66 @@ func TestCompact_TruncatedSummary_ReturnsError(t *testing.T) {
assert.Equal(t, []int{13107}, c.maxTokens, "0.8 * reserveTokens")
}

func TestCompact_EmptyHistoryRefreshesFileOperations(t *testing.T) {
for _, midTurn := range []bool{false, true} {
name := "history"
if midTurn {
name = "mid-turn"
}
t.Run(name, func(t *testing.T) {
previous := session.Compaction{
Summary: "Keep the public API unchanged.\n\n<read-files>\na.go\nnotes.md\n</read-files>" +
"\n\n<modified-files>\nold.go\n</modified-files>",
Details: session.CompactionDetails{
ReadFiles: []string{"a.go", "notes.md"},
ModifiedFiles: []string{"old.go"},
},
}
c := &captureCompactor{text: "Current turn context."}
for range 3 {
messages := []llm.Message{{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{Function: llm.Function{Name: "edit", Arguments: `{"path":"a.go"}`}},
{Function: llm.Function{Name: "edit", Arguments: `{"path":"b.go"}`}},
}}}
fileOps := extractFileOperations(messages, []session.MessageEntry{
session.CompactionEntry{Compaction: previous},
}, 0)
prep := CompactionPreparation{
PreviousSummary: previous.Summary,
PreviousFileOperations: formatFileOperations(
previous.Details.ReadFiles,
previous.Details.ModifiedFiles,
),
FileOps: *fileOps,
}
if midTurn {
prep.IsMidTurnCut = true
prep.TurnPrefixMessages = messages
}

comp, err := Compact(t.Context(), prep, c)

require.NoError(t, err)
assert.Contains(t, comp.Summary, "Keep the public API unchanged.")
assert.Equal(t, 1, strings.Count(comp.Summary, "<read-files>"))
assert.Equal(t, 1, strings.Count(comp.Summary, "<modified-files>"))
assert.True(t, strings.HasSuffix(
comp.Summary,
"\n\n<read-files>\nnotes.md\n</read-files>\n\n<modified-files>\na.go\nb.go\nold.go\n</modified-files>",
))
assert.Equal(t, []string{"notes.md"}, comp.Details.ReadFiles)
assert.Equal(t, []string{"a.go", "b.go", "old.go"}, comp.Details.ModifiedFiles)
previous = comp
}
wantCalls := 0
if midTurn {
wantCalls = 3
}
assert.Len(t, c.prompts, wantCalls)
})
}
}

// A mid-turn cut drops the turn prefix as well as the history, and both buckets
// are summarized away. Every file touched in either bucket has to reach the
// file-operation lists: missing the prefix listed the edited files of the cut
Expand Down
9 changes: 9 additions & 0 deletions internal/session/compaction/fileops.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ func formatFileOperations(readFiles, modifiedFiles []string) string {
return "\n\n" + strings.Join(sections, "\n\n")
}

// Match the suffix reconstructed from the previous Details so similar text
// in older summaries is not mistaken for an appended file-operation block.
func stripFileOperations(summary, previousFileOperations string) string {
if previousFileOperations != "" && strings.HasSuffix(summary, previousFileOperations) {
return strings.TrimSuffix(summary, previousFileOperations)
}
return summary
}

func computeFileLists(fileOps *FileOperation) ([]string, []string) {
modifiedSet := make(map[string]struct{})
readFiles := []string{}
Expand Down
30 changes: 30 additions & 0 deletions internal/session/compaction/fileops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,33 @@ func TestComputeFileLists(t *testing.T) {
assert.Empty(t, modifiedFiles)
})
}

func TestStripFileOperations(t *testing.T) {
const readBlock = "\n\n<read-files>\na.go\n</read-files>"
const modifiedBlock = "\n\n<modified-files>\nb.go\n</modified-files>"
for _, tt := range []struct {
name string
summary string
previousFileOperations string
want string
}{
{"no files", "summary\n", "", "summary\n"},
{"read only", "summary" + readBlock, readBlock, "summary"},
{"modified only", "summary" + modifiedBlock, modifiedBlock, "summary"},
{"both", "summary" + readBlock + modifiedBlock, readBlock + modifiedBlock, "summary"},
{
"embedded example", "example" + readBlock + "\nmore context" + modifiedBlock, modifiedBlock,
"example" + readBlock + "\nmore context",
},
{"unknown suffix", "summary" + readBlock, modifiedBlock, "summary" + readBlock},
{"no details", "summary" + readBlock, "", "summary" + readBlock},
{
"incomplete block", "summary\n\n<read-files>\na.go", readBlock,
"summary\n\n<read-files>\na.go",
},
} {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, stripFileOperations(tt.summary, tt.previousFileOperations))
})
}
}
46 changes: 46 additions & 0 deletions internal/session/compaction/reload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,49 @@ func TestFindCutIndex_ReloadedEntriesRespectTokenBudget(t *testing.T) {
cutIndex := findCutIndex(entries, 0, len(entries), 25, cutPoints)
assert.NotEqual(t, cutPoints[0], cutIndex)
}

func TestCompact_EmptyHistoryPreservesSummaryAfterReload(t *testing.T) {
dir := t.TempDir()
m, err := session.NewSessionManager(dir, session.WithSessionDir(dir), session.WithShouldFlush(true))
require.NoError(t, err)
const previousSummary = "Keep the public API unchanged."
const fileOperations = "\n\n<read-files>\nnotes.md\n</read-files>\n\n<modified-files>\na.go\n</modified-files>"
_, err = m.AppendCompaction(session.Compaction{
Summary: previousSummary + fileOperations,
Details: session.CompactionDetails{ReadFiles: []string{"notes.md"}, ModifiedFiles: []string{"a.go"}},
})
require.NoError(t, err)
_, err = m.Append(llm.Message{Role: llm.RoleUser, Content: "fix the parser"})
require.NoError(t, err)
keptID, err := m.Append(llm.Message{Role: llm.RoleAssistant, Content: sizedContent(30)})
require.NoError(t, err)
m, err = session.OpenSession(m.File())
require.NoError(t, err)

prep, err := PrepareCompact(m.BuildContext(), Settings{keepRecentTokens: 20})
require.NoError(t, err)
require.True(t, prep.IsMidTurnCut)
require.Empty(t, prep.MessagesToSummarize)
require.NotEmpty(t, prep.TurnPrefixMessages)
require.Equal(t, keptID, prep.FirstKeptEntryId)

c := &captureCompactor{text: "Current turn context."}
comp, err := Compact(t.Context(), *prep, c)
require.NoError(t, err)
want := previousSummary + "\n\n---\n\n**Turn Context (mid-turn cut):**\n\nCurrent turn context." + fileOperations
assert.Equal(t, want, comp.Summary)
assert.Len(t, c.prompts, 1, "only the turn-prefix summary needs a model request")
_, err = m.AppendCompaction(comp)
require.NoError(t, err)

m, err = session.OpenSession(m.File())
require.NoError(t, err)
entries := m.BuildContext()
require.Len(t, entries, 2)
restored, ok := entries[0].(session.CompactionEntry)
require.True(t, ok)
assert.Equal(t, want, restored.Compaction.Summary)
assert.Equal(t, []string{"notes.md"}, restored.Compaction.Details.ReadFiles)
assert.Equal(t, []string{"a.go"}, restored.Compaction.Details.ModifiedFiles)
assert.Equal(t, keptID, entries[1].GetID())
}
36 changes: 36 additions & 0 deletions internal/session/compaction/summary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,39 @@ func TestSummarizeMidTurnCut_CapsEachSummary(t *testing.T) {
assert.Contains(t, summary, "Turn Context (mid-turn cut)")
assert.ElementsMatch(t, []int{13107, 8192}, c.maxTokens)
}

func TestCompact_EmptyHistory(t *testing.T) {
for _, midTurn := range []bool{false, true} {
for _, previousSummary := range []string{"", "Keep the public API unchanged.\nPreserve exact error messages."} {
name := "history"
if midTurn {
name = "mid-turn"
}
if previousSummary != "" {
name += "/previous-summary"
} else {
name += "/no-previous-summary"
}
t.Run(name, func(t *testing.T) {
c := &captureCompactor{text: "Current turn context."}
prep := CompactionPreparation{PreviousSummary: previousSummary, IsMidTurnCut: midTurn}
want := previousSummary
if want == "" {
want = "No prior history."
}
wantCalls := 0
if midTurn {
prep.TurnPrefixMessages = []llm.Message{{Role: llm.RoleUser, Content: "fix the parser"}}
want += "\n\n---\n\n**Turn Context (mid-turn cut):**\n\nCurrent turn context."
wantCalls = 1
}

comp, err := Compact(t.Context(), prep, c)

require.NoError(t, err)
assert.Equal(t, want, comp.Summary)
assert.Len(t, c.prompts, wantCalls, "an empty history must not trigger a model request")
})
}
}
}
Loading