From ec8a7a6277f413777a77fc9a348732346e2c87c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 21:11:23 +0200 Subject: [PATCH 1/2] fix(session): use user role for compaction summary to keep conversation valid After compaction the conversation started with a system-role summary message. Providers that require a user-role message reject the request with HTTP 500 'no user query found in messages'. Emit the summary as a user message instead. Adds a regression test asserting the compacted conversation always contains a user-role user message. --- internal/server/session_ops_test.go | 2 +- internal/session/processor_llm.go | 5 ++- internal/session/processor_test.go | 53 +++++++++++++++++++++++++++++ internal/session/session_test.go | 2 +- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/internal/server/session_ops_test.go b/internal/server/session_ops_test.go index 1bbba1b07..d8ccfe900 100644 --- a/internal/server/session_ops_test.go +++ b/internal/server/session_ops_test.go @@ -354,7 +354,7 @@ func TestPersistPromptResult_CompactedReplacesHistory(t *testing.T) { } compacted := []session.Message{ - {ID: "summary", SessionID: info.ID, Role: session.RoleSystem, Parts: []session.Part{session.TextPart("summary")}, CreatedAt: now}, + {ID: "summary", SessionID: info.ID, Role: session.RoleUser, Parts: []session.Part{session.TextPart("summary")}, CreatedAt: now}, {ID: "keep", SessionID: info.ID, Role: session.RoleUser, Parts: []session.Part{session.TextPart("keep")}, CreatedAt: now.Add(time.Second)}, {ID: "reply", SessionID: info.ID, Role: session.RoleAssistant, Parts: []session.Part{session.TextPart("reply")}, CreatedAt: now.Add(2 * time.Second)}, } diff --git a/internal/session/processor_llm.go b/internal/session/processor_llm.go index a2768dd25..8b03d2f7c 100644 --- a/internal/session/processor_llm.go +++ b/internal/session/processor_llm.go @@ -337,10 +337,13 @@ func (p *Processor) compact(ctx context.Context) (bool, error) { summary := strings.Join(summaryParts, "") summaryMsgID, _ := id.Ascending("message") + // Use a user-role message: some providers reject conversations whose + // first non-system message is a system message ("no user query found in + // messages", HTTP 500). summaryMsg := Message{ ID: summaryMsgID, SessionID: p.config.SessionID, - Role: RoleSystem, + Role: RoleUser, Parts: []Part{TextPart(summary)}, CreatedAt: time.Now(), } diff --git a/internal/session/processor_test.go b/internal/session/processor_test.go index 7542d3dc6..bc1223956 100644 --- a/internal/session/processor_test.go +++ b/internal/session/processor_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "time" @@ -650,6 +651,58 @@ func TestProcessor_BelowThreshold_NoCompaction(t *testing.T) { } } +// Issue #23: after compaction the conversation must start with a user-role +// message, otherwise providers reject it with HTTP 500 "no user query found +// in messages". +func TestProcessor_Compact_SummaryIsUserRole(t *testing.T) { + // Build a conversation (18 x ~2000 est. tokens = ~36k) that exceeds the + // 15k preserve budget so compaction actually runs and preserves a tail. + seed := make([]Message, 0, 18) + for i := 0; i < 9; i++ { + seed = append(seed, + Message{ID: fmt.Sprintf("u%d", i), SessionID: "ses_c23", Role: RoleUser, Parts: []Part{TextPart("user turn " + strings.Repeat("x", 8000)+fmt.Sprintf("%d", i))}}, + Message{ID: fmt.Sprintf("a%d", i), SessionID: "ses_c23", Role: RoleAssistant, Parts: []Part{TextPart("assistant turn " + strings.Repeat("y", 8000)+fmt.Sprintf("%d", i))}}, + ) + } + p := NewProcessor(ProcessorConfig{ + SessionID: "ses_c23", + Model: &provider.Model{ID: "test-model"}, + Compaction: DefaultCompactionConfig(), + AutoContinueMax: -1, + }, &mockLLMClient{ + responses: []mockResponse{ + {events: []llm.Event{ // compaction summary call + {Type: llm.EventTextDelta, Text: "summary of earlier work"}, + {Type: llm.EventFinish, FinishReason: "stop", Usage: &llm.Usage{PromptTokens: 100, CompletionTokens: 10}}, + }}, + }, + }, &stubToolExecutor{}, bus.New()) + p.SetMessages(seed) + + compacted, err := p.Compact(context.Background()) + if err != nil { + t.Fatalf("Compact: %v", err) + } + if !compacted { + t.Fatal("expected compaction to run, got no-op") + } + + msgs := p.Messages() + if msgs[0].Role != RoleUser { + t.Fatalf("first message after compaction has role %q, want %q", msgs[0].Role, RoleUser) + } + hasUser := false + for _, m := range msgs { + if m.Role == RoleUser { + hasUser = true + break + } + } + if !hasUser { + t.Fatal("compacted conversation has no user-role message at all") + } +} + // --- Issue #89: statusFromError anchored HTTP matching --- func TestStatusFromError_PositiveMatches(t *testing.T) { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 40f68d991..180d82dec 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -703,7 +703,7 @@ func TestMessageStore_ReplaceAll(t *testing.T) { } compacted := []Message{ - {ID: "summary", SessionID: ses.ID, Role: RoleSystem, Parts: []Part{TextPart("summary")}, CreatedAt: now}, + {ID: "summary", SessionID: ses.ID, Role: RoleUser, Parts: []Part{TextPart("summary")}, CreatedAt: now}, {ID: "keep", SessionID: ses.ID, Role: RoleUser, Parts: []Part{TextPart("keep")}, CreatedAt: now.Add(time.Second)}, {ID: "new", SessionID: ses.ID, Role: RoleAssistant, Parts: []Part{TextPart("new")}, CreatedAt: now.Add(2 * time.Second)}, } From 9236f628ef5e268ec669ce575b3c54d618840955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Fenal?= Date: Fri, 9 Oct 2026 21:36:19 +0200 Subject: [PATCH 2/2] chore: restore go 1.25.0 minimum version Co-Authored-By: Claude Sonnet 4.6 (1M context) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 563a493fb..c42b04447 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bobbyjohnstx/tinycode -go 1.27.1 +go 1.25.0 require ( github.com/charmbracelet/bubbles v1.0.0