From 60c4541fa4b9fd71b020d0fff9724ce31d69118c Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Sat, 15 Aug 2026 13:42:11 +0200 Subject: [PATCH] normalize summary cache keys --- cmd/olliesrv/internal/agent/history.go | 31 +++++++++++--- .../internal/agent/history_summary_test.go | 31 +++++++++++++- .../internal/agent/summary_cache_test.go | 41 +++++++++++++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 cmd/olliesrv/internal/agent/summary_cache_test.go diff --git a/cmd/olliesrv/internal/agent/history.go b/cmd/olliesrv/internal/agent/history.go index 613bb2e..5e26660 100644 --- a/cmd/olliesrv/internal/agent/history.go +++ b/cmd/olliesrv/internal/agent/history.go @@ -101,12 +101,33 @@ func toolMessageID(m backend.Message) string { return "tool:content:" + hex.EncodeToString(h[:]) } +const coldSummaryCacheVersion = "v2" + func toolSummaryHash(m backend.Message) string { - h := sha256.New() - h.Write([]byte(m.ToolCallID)) - h.Write([]byte{0}) - h.Write([]byte(m.Content)) - return hex.EncodeToString(h.Sum(nil)) + h := sha256.Sum256([]byte(coldSummaryCacheVersion + "\x00" + normalizeSummaryCacheContent(m.Content))) + return hex.EncodeToString(h[:]) +} + +func normalizeSummaryCacheContent(content string) string { + if looksStructured(content) { + return content + } + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + normalized := make([]string, 0, len(lines)) + blank := false + for _, line := range lines { + line = strings.TrimRight(line, " \t") + if strings.TrimSpace(line) == "" { + if blank { + continue + } + blank = true + } else { + blank = false + } + normalized = append(normalized, line) + } + return strings.TrimSpace(strings.Join(normalized, "\n")) } func (s *History) history() []backend.Message { diff --git a/cmd/olliesrv/internal/agent/history_summary_test.go b/cmd/olliesrv/internal/agent/history_summary_test.go index a829071..0dd7480 100644 --- a/cmd/olliesrv/internal/agent/history_summary_test.go +++ b/cmd/olliesrv/internal/agent/history_summary_test.go @@ -77,6 +77,27 @@ func TestStripColdReusesSummaryCache(t *testing.T) { t.Fatalf("cached summary was not applied: %#v", h.messages[0]) } } +func TestStripColdReusesSummaryCacheAcrossToolCalls(t *testing.T) { + b := &summaryBackend{} + h := newHistory("test") + for i := 0; i < 9; i++ { + m := backend.Message{Role: "user", Content: "hot"} + if i == 0 { + m = backend.Message{Role: "tool", ToolCallID: "tc-0", Content: strings.Repeat("same result ", 30)} + } + h.messages = append(h.messages, m) + } + if _, _ = h.stripCold(context.Background(), b); b.calls != 1 { + t.Fatalf("first stripCold calls = %d, want 1", b.calls) + } + h.messages[0] = backend.Message{Role: "tool", ToolCallID: "tc-0", Content: strings.Repeat("same result ", 30)} + if _, _ = h.stripCold(context.Background(), b); b.calls != 1 { + t.Fatalf("different-call cache calls = %d, want 1", b.calls) + } + if !h.messages[0].SummaryApplied || h.messages[0].Content != "summary" { + t.Fatalf("cached summary was not applied: %#v", h.messages[0]) + } +} func TestStripColdSkipsAlreadySummarizedAfterRestore(t *testing.T) { b := &summaryBackend{} h := RestoreHistoryFromMessages([]backend.Message{ @@ -99,11 +120,17 @@ type summaryBackend struct{ calls int } func (b *summaryBackend) ChatStream(_ context.Context, messages []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { b.calls++ - if len(messages) != 1 || !strings.Contains(messages[0].Content, "id=tool:tc-0:") { + if len(messages) != 1 || !strings.Contains(messages[0].Content, "id=tool:") { return nil, fmt.Errorf("summary request did not use stable message ID") } + start := strings.Index(messages[0].Content, "id=") + len("id=") + end := strings.Index(messages[0].Content[start:], ":\n") + if end < 0 { + return nil, fmt.Errorf("summary request has malformed message ID") + } + id := messages[0].Content[start : start+end] out := make(chan backend.StreamEvent, 1) - out <- backend.StreamEvent{Content: `{"tool:tc-0":"summary"}`, Done: true} + out <- backend.StreamEvent{Content: fmt.Sprintf(`{"%s":"summary"}`, id), Done: true} close(out) return out, nil } diff --git a/cmd/olliesrv/internal/agent/summary_cache_test.go b/cmd/olliesrv/internal/agent/summary_cache_test.go new file mode 100644 index 0000000..ba04853 --- /dev/null +++ b/cmd/olliesrv/internal/agent/summary_cache_test.go @@ -0,0 +1,41 @@ +package agent + +import ( + "testing" + + "ollie/cmd/olliesrv/internal/backend" +) + +func messageWithContent(callID, content string) backend.Message { + return backend.Message{Role: "tool", ToolCallID: callID, Content: content} +} + +func TestNormalizeSummaryCacheContentPlainText(t *testing.T) { + got := normalizeSummaryCacheContent(" first line \r\n\r\n\nsecond line \t\n\n") + if got != "first line\n\nsecond line" { + t.Fatalf("normalized content = %q", got) + } +} + +func TestNormalizeSummaryCacheContentPreservesStructuredContent(t *testing.T) { + content := "{\r\n \"key\": \"value\"\r\n}\r\n" + if got := normalizeSummaryCacheContent(content); got != content { + t.Fatalf("structured content changed: %q", got) + } +} + +func TestToolSummaryHashIgnoresToolCallID(t *testing.T) { + first := toolSummaryHash(messageWithContent("first-call", "same result\n")) + second := toolSummaryHash(messageWithContent("second-call", "same result")) + if first != second { + t.Fatalf("hashes differ for identical content: %q != %q", first, second) + } +} + +func TestToolSummaryHashSeparatesContent(t *testing.T) { + first := toolSummaryHash(messageWithContent("call", "first result")) + second := toolSummaryHash(messageWithContent("call", "second result")) + if first == second { + t.Fatal("different content produced the same hash") + } +}