diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index b6018d1..bdff1dd 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -2363,33 +2363,36 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { if auto { i.spin.StartFixed("condensing history") i.autoCompacting = true - i.statusOK = "condensing history… (esc to cancel)" } else { - i.spin.Start() - i.statusOK = "compacting..." + i.spin.StartFixed("compacting") } i.cancelTurn = cancel i.statusErr = "" - i.streaming.Reset() - i.streamOn = true + i.statusOK = "" + // Do NOT set streamOn: the summary text should not be visible + // in the chat while compacting. The user just sees the spinner + // and can keep typing / queue prompts. i.scrollOffset = 0 i.helpBlock = nil i.mu.Unlock() i.invalidate() go func() { - sink := func(delta string) { - i.mu.Lock() - i.streaming.WriteString(delta) - i.mu.Unlock() - i.invalidate() - } + // Sink discards deltas — we don't stream the summary to the UI. + sink := func(delta string) {} summary, err := i.agent.Compact(ctx, 4, sink) + _ = summary i.mu.Lock() i.busy = false i.resetStreamingStateLocked() i.cancelTurn = nil i.autoCompacting = false + + // Drain the queue: if the user typed a prompt while compacting, + // fire it now that the transcript is clean. + var next string + var hasNext bool + switch { case err != nil && ctx.Err() != nil: i.statusErr = "" @@ -2398,22 +2401,44 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { } else { i.statusOK = "compaction cancelled" } + i.queued = nil // drop queue on cancel case err != nil: i.statusErr = "compaction failed: " + err.Error() i.statusOK = "" + i.queued = nil // drop queue on error default: i.statusErr = "" - i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary)) - i.lastCtxInput = 0 // reset; next turn will get a fresh measurement + // Read token count from the compaction message meta. + tokens := "" + msgs := i.agent.Messages() + if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" { + tokens = msgs[0].Meta["tokens_before"] + } + if tokens != "" { + i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens) + } else { + i.statusOK = "compacted (ctrl+o to expand)" + } + i.lastCtxInput = 0 i.toolCalls = map[string]*tui.ToolCallView{} i.toolOrder = nil - // Transcript was rewritten in place — purge the per-message - // render cache so stale entries keyed on the old messages - // don't linger. i.view.InvalidateRenderCache() + // Pop queued prompt if any. + if len(i.queued) > 0 { + next, i.queued = i.queued[0], i.queued[1:] + hasNext = true + } } i.mu.Unlock() i.invalidate() + + if hasNext { + p := i.runCtx + if p == nil { + p = context.Background() + } + i.startTurn(p, next) + } }() } diff --git a/internal/core/compact.go b/internal/core/compact.go index 91000f6..3302e2e 100644 --- a/internal/core/compact.go +++ b/internal/core/compact.go @@ -3,6 +3,7 @@ package core import ( "context" "fmt" + "strconv" "strings" "time" @@ -82,6 +83,9 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin return "", fmt.Errorf("empty summary from model") } + // Estimate token count before compaction (rough: 1 token ~ 4 chars). + tokensBefore := len(transcript) / 4 + // Replace transcript: one synthetic user message with the summary, // followed by the preserved tail (if any). synthetic := provider.Message{ @@ -90,9 +94,19 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary}, }, Time: time.Now(), + Meta: map[string]string{ + "compaction": "true", + "tokens_before": strconv.Itoa(tokensBefore), + }, } tail := msgs[len(msgs)-keepTail:] + // Repair the tail: remove orphaned tool_result blocks whose + // matching tool_use was in the compacted (now-removed) portion. + // Anthropic rejects transcripts where a tool_result references + // a tool_use ID that doesn't exist. + tail = repairOrphanedToolResults(tail) + next := make([]provider.Message, 0, 1+len(tail)) next = append(next, synthetic) next = append(next, tail...) @@ -104,6 +118,44 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin return summary, nil } +// repairOrphanedToolResults removes tool_result content blocks (and +// entire messages that become empty) when the matching tool_use ID +// does not appear anywhere in the given messages. This happens after +// compaction when the tail preserves a tool_result but the tool_use +// that produced it was summarized away. +func repairOrphanedToolResults(msgs []provider.Message) []provider.Message { + // Collect all tool_use IDs present in the messages. + useIDs := map[string]bool{} + for _, m := range msgs { + for _, c := range m.Content { + if tc, ok := c.(provider.ToolCallBlock); ok { + useIDs[tc.ID] = true + } + } + } + + // Filter out tool_result blocks referencing missing tool_use IDs. + out := make([]provider.Message, 0, len(msgs)) + for _, m := range msgs { + var filtered []provider.Content + for _, c := range m.Content { + if tr, ok := c.(provider.ToolResultBlock); ok { + if !useIDs[tr.CallID] { + continue // orphaned + } + } + filtered = append(filtered, c) + } + if len(filtered) > 0 { + copy := m + copy.Content = filtered + out = append(out, copy) + } + // Drop messages that became empty after filtering. + } + return out +} + // serializeTranscript renders a list of provider.Message into a plain // text transcript the summarization model can read without trying to // continue the conversation. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5b96fd1..fb9702c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -62,9 +62,10 @@ func (ToolResultBlock) isContent() {} // Message is a single turn in the conversation. type Message struct { - Role Role `json:"role"` - Content []Content `json:"content"` - Time time.Time `json:"time"` + Role Role `json:"role"` + Content []Content `json:"content"` + Time time.Time `json:"time"` + Meta map[string]string `json:"meta,omitempty"` } // Tool is a tool definition advertised to the LLM. diff --git a/internal/tui/view.go b/internal/tui/view.go index f25e22a..70f3f7e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -403,6 +403,17 @@ func fnv64aWrite(h uint64, p []byte) uint64 { func (v *View) renderMessage(m provider.Message, width int) []string { var lines []string + + // Compaction summary: render as a single muted line at the end + // of the chat instead of as a user message. + if m.Meta["compaction"] == "true" { + if v.ExpandAll { + return v.renderCompactionBlock(m, width) + } + // Collapsed: skip entirely. The status bar shows the info. + return nil + } + switch m.Role { case provider.RoleUser: header := v.Theme.FG256(v.Theme.User, "▍ you") @@ -1279,6 +1290,44 @@ func truncateLines(s string, n int) string { return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)" } +// renderCompactionBlock renders a compaction summary as a distinct +// visual block in the chat. When collapsed it shows a one-line label +// with the pre-compaction token count; when expanded (ctrl+o) it +// shows the full summary text. +func (v *View) renderCompactionBlock(m provider.Message, width int) []string { + th := v.Theme + const indent = " " + + tokens := m.Meta["tokens_before"] + if tokens == "" { + tokens = "?" + } + + if v.ExpandAll { + var lines []string + header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens)) + lines = append(lines, indent+header) + lines = append(lines, "") + for _, c := range m.Content { + if tb, ok := c.(provider.TextBlock); ok { + text := tb.Text + if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") { + text = text[idx+2:] + } + md := RenderMarkdown(text, th, width-4) + for _, l := range strings.Split(md, "\n") { + lines = append(lines, indent+l) + } + } + } + return lines + } + + // Collapsed: single line, no banner. + line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)) + return []string{indent + line} +} + // StatusBarParams groups the many bits of state the status bar needs. // Grew from a flat argument list once we settled on the layout. type StatusBarParams struct {