feat(sse): emit compaction event after start, before sources/chunk

streaming.WriteCompaction packages a 'compaction' event with the
kept/older turn counts, summary tokens and provider-reported
window/used tokens so the client can hint 'context optimized' to the
user without parsing the stream body.

streamChat runs Compact before BuildMessages and writes the event
right after start, ensuring the client sees it before any chunk is
emitted. Add a runner test that exercises limitRAGContext to keep
the system prompt + RAG block under the configured window.
This commit is contained in:
Victor Hugo Vargas 2026-07-18 00:08:22 -07:00
parent 7393812fba
commit 550ba526c4
3 changed files with 51 additions and 1 deletions

View file

@ -93,6 +93,22 @@ func TestRunnerStreamWithRAG(t *testing.T) {
}
}
func TestLimitRAGContextToWindow(t *testing.T) {
cli := &compactionStub{window: 400}
r := New(cli, llmpersona.Persona{}, "sys", nil, 5)
history := []Message{{Role: RoleUser, Content: "question"}}
first := "### [1] first — section\n" + strings.Repeat("x", 100)
second := "### [2] second — section\n" + strings.Repeat("y", 1600)
got := r.limitRAGContext(first+"\n\n"+second, history)
if !strings.Contains(got, "first") {
t.Errorf("limited RAG context dropped the first result: %q", got)
}
if strings.Contains(got, "second") {
t.Errorf("limited RAG context kept an over-budget result")
}
}
func writeFile(path, content string) error {
if err := mkdirAll(path); err != nil {
return err
@ -109,4 +125,4 @@ func dirOf(p string) string {
}
}
return "."
}
}

View file

@ -158,6 +158,23 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
}
flusher.Flush()
// Auto-compact the older part of the conversation when the previous
// turn's input tokens have crossed the configured threshold. Done
// before RAG so the LLM's compaction summary call is counted in the
// next request's reported usage, not the current one. A failure here
// is logged and swallowed: the request still proceeds with the
// original (uncompacted) history.
history, _ = h.runner.Compact(ctx, history)
if stats := h.runner.LastCompaction(); stats.Happened {
_ = streaming.WriteCompaction(w, map[string]any{
"older_turns": stats.OlderTurns,
"kept_turns": stats.KeptTurns,
"summary_tokens": stats.SummaryTokens,
"window_tokens": stats.WindowTokens,
"used_tokens": stats.UsedTokens,
})
}
// Report which RAG sources were used (search happens in BuildMessages).
_, ragContext, err := h.runner.BuildMessages(ctx, history)
if err != nil {
@ -217,6 +234,11 @@ func (h *Handlers) persistAssistant(ctx context.Context, convID, content string,
func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Apply compaction before streaming — same as streamChat. The
// compaction event isn't surfaced in the non-streaming JSON response
// (would be redundant noise), but LastCompaction is still recorded so
// any future caller can introspect it.
history, _ = h.runner.Compact(ctx, history)
var full strings.Builder
var sources []string
for chunk, err := range h.runner.Stream(ctx, history) {

View file

@ -43,6 +43,18 @@ func WriteSources(w http.ResponseWriter, sources []string) error {
})
}
// WriteCompaction surfaces a context-compaction event so the client can
// render a "summary injected" hint. Always emitted right after the
// `sources` event (before the streamed chunks), and only when compaction
// actually fired for this request.
func WriteCompaction(w http.ResponseWriter, payload map[string]any) error {
out := map[string]any{"type": "compaction"}
for k, v := range payload {
out[k] = v
}
return WriteEvent(w, "compaction", out)
}
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`