From f77f18f4d28dda3d08ed1dc51007dfe73a14fa65 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 18 Oct 2026 22:36:50 +0000 Subject: [PATCH] fix(llmprovider): extend authoritative history in legacy Ask Every legacy adapter appended its reply to Fragment.Messages directly. On a fragment built with WithConversation this leaves the legacy projection one message longer than the authoritative history, and cogito then fails the next Conversation() call with ErrConversationProjectionMismatch. Route the reply through a small helper that uses AppendMessages when the fragment has authoritative history and keeps the plain append for legacy fragments, so they stay legacy. Add Ask tests for the Anthropic, Ollama, OpenAI Responses and Google adapters, tests for the helper, and the first unit tests for replaceMessages (prefix and suffix retention, legacy and stale fallbacks). Co-Authored-By: Claude Haiku 5.5 --- chat/fragment_messages_test.go | 108 +++++++++++++++++++ codexapp/llm.go | 3 +- internal/fragmentreply/fragmentreply.go | 22 ++++ internal/fragmentreply/fragmentreply_test.go | 30 ++++++ llmprovider/anthropic/adapter.go | 3 +- llmprovider/anthropic/ask_test.go | 55 ++++++++++ llmprovider/azureresponses/adapter.go | 3 +- llmprovider/bedrock/adapter.go | 3 +- llmprovider/codex/adapter.go | 3 +- llmprovider/copilot/adapter.go | 3 +- llmprovider/geminicli/adapter.go | 3 +- llmprovider/google/adapter.go | 3 +- llmprovider/google/ask_test.go | 55 ++++++++++ llmprovider/googlevertex/adapter.go | 3 +- llmprovider/ollama/adapter.go | 3 +- llmprovider/ollama/ask_test.go | 55 ++++++++++ llmprovider/openairesponses/adapter.go | 3 +- llmprovider/openairesponses/ask_test.go | 55 ++++++++++ 18 files changed, 402 insertions(+), 11 deletions(-) create mode 100644 chat/fragment_messages_test.go create mode 100644 internal/fragmentreply/fragmentreply.go create mode 100644 internal/fragmentreply/fragmentreply_test.go create mode 100644 llmprovider/anthropic/ask_test.go create mode 100644 llmprovider/google/ask_test.go create mode 100644 llmprovider/ollama/ask_test.go create mode 100644 llmprovider/openairesponses/ask_test.go diff --git a/chat/fragment_messages_test.go b/chat/fragment_messages_test.go new file mode 100644 index 00000000..3639ffe1 --- /dev/null +++ b/chat/fragment_messages_test.go @@ -0,0 +1,108 @@ +package chat + +import ( + "reflect" + "testing" + + "github.com/mudler/cogito" + "github.com/sashabaranov/go-openai" +) + +func msg(role, content string) openai.ChatCompletionMessage { + return openai.ChatCompletionMessage{Role: role, Content: content} +} + +func richHistory() []cogito.Message { + return []cogito.Message{ + {Portable: msg("system", "sys")}, + {Portable: msg("user", "a"), Provider: &cogito.ProviderMessage{Scope: cogito.ProviderScope{Provider: "p"}}}, + {Portable: msg("assistant", "b"), Provider: &cogito.ProviderMessage{Scope: cogito.ProviderScope{Provider: "p"}}}, + {Portable: msg("user", "c")}, + } +} + +func TestReplaceMessagesLegacyFallback(t *testing.T) { + f := cogito.NewFragment(msg("user", "old")) + want := []openai.ChatCompletionMessage{msg("user", "new")} + out := replaceMessages(f, want) + if out.ConversationMessages != nil { + t.Fatalf("legacy fragment gained authoritative history: %+v", out.ConversationMessages) + } + if !reflect.DeepEqual(out.Messages, want) { + t.Fatalf("Messages = %+v, want %+v", out.Messages, want) + } +} + +func TestReplaceMessagesStaleFallback(t *testing.T) { + f := cogito.NewEmptyFragment().WithConversation(richHistory()) + f.Messages = append(f.Messages, msg("assistant", "direct edit")) + if _, err := f.Conversation(); err == nil { + t.Fatal("setup: fragment should be stale") + } + want := []openai.ChatCompletionMessage{msg("user", "fresh")} + out := replaceMessages(f, want) + if out.ConversationMessages != nil { + t.Fatalf("stale fragment kept authoritative history: %+v", out.ConversationMessages) + } + if !reflect.DeepEqual(out.Messages, want) { + t.Fatalf("Messages = %+v, want %+v", out.Messages, want) + } +} + +func TestReplaceMessagesKeepsProviderDataOnPrefixAndSuffix(t *testing.T) { + hist := richHistory() + f := cogito.NewEmptyFragment().WithConversation(hist) + // Replace the middle two messages with one new message. + msgs := []openai.ChatCompletionMessage{hist[0].Portable, msg("user", "edited"), hist[3].Portable} + out := replaceMessages(f, msgs) + + got, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() = %v", err) + } + if len(got) != 3 { + t.Fatalf("len = %d, want 3: %+v", len(got), got) + } + if !reflect.DeepEqual(got[0], hist[0]) || !reflect.DeepEqual(got[2], hist[3]) { + t.Fatalf("prefix or suffix changed: %+v", got) + } + if got[1].Provider != nil || got[1].Portable.Content != "edited" { + t.Fatalf("middle = %+v, want new portable message without provider data", got[1]) + } +} + +func TestReplaceMessagesRetainsEnvelopeOfUnchangedPrefix(t *testing.T) { + hist := richHistory() + f := cogito.NewEmptyFragment().WithConversation(hist) + // Append a message: whole old history is the prefix. + msgs := append(portableOf(hist), msg("assistant", "d")) + out := replaceMessages(f, msgs) + got, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() = %v", err) + } + if len(got) != 5 || got[1].Provider == nil || got[2].Provider == nil || got[4].Provider != nil { + t.Fatalf("got = %+v", got) + } +} + +func TestReplaceMessagesDropsProviderDataOfRemovedMessages(t *testing.T) { + hist := richHistory() + f := cogito.NewEmptyFragment().WithConversation(hist) + // Compaction: keep only the system message and the last user message. + out := replaceMessages(f, []openai.ChatCompletionMessage{hist[0].Portable, hist[3].Portable}) + got, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() = %v", err) + } + if len(got) != 2 || got[0].Portable.Content != "sys" || got[1].Portable.Content != "c" { + t.Fatalf("got = %+v", got) + } + for _, m := range got { + if m.Provider != nil { + t.Fatalf("provider data of a removed message leaked: %+v", got) + } + } +} + +func portableOf(h []cogito.Message) []openai.ChatCompletionMessage { return cogito.PortableMessages(h) } diff --git a/codexapp/llm.go b/codexapp/llm.go index 63da79ca..f7cc6b17 100644 --- a/codexapp/llm.go +++ b/codexapp/llm.go @@ -15,6 +15,7 @@ import ( "sync" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" openai "github.com/sashabaranov/go-openai" ) @@ -47,7 +48,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/internal/fragmentreply/fragmentreply.go b/internal/fragmentreply/fragmentreply.go new file mode 100644 index 00000000..45bd6fa5 --- /dev/null +++ b/internal/fragmentreply/fragmentreply.go @@ -0,0 +1,22 @@ +// Package fragmentreply appends a legacy adapter's reply to a cogito fragment. +package fragmentreply + +import ( + "github.com/mudler/cogito" + "github.com/sashabaranov/go-openai" +) + +// Append returns f with msg added at the end of its history. +// +// A fragment with authoritative history (built with WithConversation) must be +// extended through AppendMessages. Writing Messages alone leaves the two views +// out of step, and cogito then fails with ErrConversationProjectionMismatch. +// A legacy fragment has no authoritative history and keeps the plain Messages +// append, so it stays legacy. +func Append(f cogito.Fragment, msg openai.ChatCompletionMessage) cogito.Fragment { + if f.ConversationMessages == nil { + f.Messages = append(f.Messages, msg) + return f + } + return f.AppendMessages(cogito.Message{Portable: msg}) +} diff --git a/internal/fragmentreply/fragmentreply_test.go b/internal/fragmentreply/fragmentreply_test.go new file mode 100644 index 00000000..10c1b023 --- /dev/null +++ b/internal/fragmentreply/fragmentreply_test.go @@ -0,0 +1,30 @@ +package fragmentreply + +import ( + "testing" + + "github.com/mudler/cogito" + "github.com/sashabaranov/go-openai" +) + +var reply = openai.ChatCompletionMessage{Role: openai.ChatMessageRoleAssistant, Content: "ok"} + +func TestAppendAuthoritativeFragmentStaysConsistent(t *testing.T) { + f := cogito.NewEmptyFragment().WithConversation(cogito.WrapMessages( + []openai.ChatCompletionMessage{{Role: openai.ChatMessageRoleUser, Content: "hi"}})) + out := Append(f, reply) + history, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() = %v", err) + } + if len(history) != 2 || history[1].Portable.Content != "ok" || len(out.Messages) != 2 { + t.Fatalf("history = %+v, messages = %+v", history, out.Messages) + } +} + +func TestAppendLegacyFragmentStaysLegacy(t *testing.T) { + out := Append(cogito.NewFragment(openai.ChatCompletionMessage{Role: openai.ChatMessageRoleUser, Content: "hi"}), reply) + if out.ConversationMessages != nil || len(out.Messages) != 2 || out.Messages[1].Content != "ok" { + t.Fatalf("legacy fragment changed shape: %+v", out) + } +} diff --git a/llmprovider/anthropic/adapter.go b/llmprovider/anthropic/adapter.go index d19cd043..ba349a90 100644 --- a/llmprovider/anthropic/adapter.go +++ b/llmprovider/anthropic/adapter.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" openai "github.com/sashabaranov/go-openai" ) @@ -73,7 +74,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/anthropic/ask_test.go b/llmprovider/anthropic/ask_test.go new file mode 100644 index 00000000..774e28ab --- /dev/null +++ b/llmprovider/anthropic/ask_test.go @@ -0,0 +1,55 @@ +package anthropic + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mudler/cogito" + openai "github.com/sashabaranov/go-openai" +) + +func TestAskKeepsAuthoritativeFragmentConsistent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"m1","type":"message","role":"assistant","model":"claude","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer srv.Close() + + l := New(Config{Model: "claude", BaseURL: srv.URL, APIKey: "k"}) + in := cogito.NewEmptyFragment().WithConversation(cogito.WrapMessages([]openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleUser, Content: "hi"}, + })) + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + history, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() after Ask: %v", err) + } + if len(history) != 2 || history[1].Portable.Content != "hello" { + t.Fatalf("history = %+v, want user message then reply", history) + } +} + +func TestAskLegacyFragmentStaysLegacy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"m1","type":"message","role":"assistant","model":"claude","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer srv.Close() + + l := New(Config{Model: "claude", BaseURL: srv.URL, APIKey: "k"}) + in := cogito.NewEmptyFragment().AddMessage(openai.ChatMessageRoleUser, "hi") + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + if out.ConversationMessages != nil { + t.Fatalf("legacy fragment gained authoritative history: %+v", out.ConversationMessages) + } + if len(out.Messages) != 2 || out.Messages[1].Content != "hello" { + t.Fatalf("Messages = %+v, want user message then reply", out.Messages) + } +} diff --git a/llmprovider/azureresponses/adapter.go b/llmprovider/azureresponses/adapter.go index 9869e115..4e42703f 100644 --- a/llmprovider/azureresponses/adapter.go +++ b/llmprovider/azureresponses/adapter.go @@ -20,6 +20,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" "github.com/mudler/nib/llmprovider/openairesponses" openai "github.com/sashabaranov/go-openai" ) @@ -63,7 +64,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, openairesponses.ErrNoResponseSentinel } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/bedrock/adapter.go b/llmprovider/bedrock/adapter.go index 90128f97..1c9926f2 100644 --- a/llmprovider/bedrock/adapter.go +++ b/llmprovider/bedrock/adapter.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" openai "github.com/sashabaranov/go-openai" ) @@ -61,7 +62,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/codex/adapter.go b/llmprovider/codex/adapter.go index e24aa260..9c25e862 100644 --- a/llmprovider/codex/adapter.go +++ b/llmprovider/codex/adapter.go @@ -28,6 +28,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" "github.com/mudler/nib/llmprovider/openairesponses" openai "github.com/sashabaranov/go-openai" ) @@ -80,7 +81,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/copilot/adapter.go b/llmprovider/copilot/adapter.go index 78e6eded..e331a53f 100644 --- a/llmprovider/copilot/adapter.go +++ b/llmprovider/copilot/adapter.go @@ -23,6 +23,7 @@ import ( "time" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" "github.com/mudler/nib/llmprovider/anthropic" "github.com/mudler/nib/llmprovider/openairesponses" openai "github.com/sashabaranov/go-openai" @@ -75,7 +76,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/geminicli/adapter.go b/llmprovider/geminicli/adapter.go index 290477f9..76c6d536 100644 --- a/llmprovider/geminicli/adapter.go +++ b/llmprovider/geminicli/adapter.go @@ -24,6 +24,7 @@ import ( "time" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" openai "github.com/sashabaranov/go-openai" ) @@ -75,7 +76,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/google/adapter.go b/llmprovider/google/adapter.go index 1a8849c4..f32bc032 100644 --- a/llmprovider/google/adapter.go +++ b/llmprovider/google/adapter.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" openai "github.com/sashabaranov/go-openai" ) @@ -68,7 +69,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/google/ask_test.go b/llmprovider/google/ask_test.go new file mode 100644 index 00000000..5639f444 --- /dev/null +++ b/llmprovider/google/ask_test.go @@ -0,0 +1,55 @@ +package google + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mudler/cogito" + openai "github.com/sashabaranov/go-openai" +) + +func TestAskKeepsAuthoritativeFragmentConsistent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"hello"}]},"finishReason":"STOP"}]}`)) + })) + defer srv.Close() + + l := New(Config{Model: "gemini", BaseURL: srv.URL, APIKey: "k"}) + in := cogito.NewEmptyFragment().WithConversation(cogito.WrapMessages([]openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleUser, Content: "hi"}, + })) + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + history, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() after Ask: %v", err) + } + if len(history) != 2 || history[1].Portable.Content != "hello" { + t.Fatalf("history = %+v, want user message then reply", history) + } +} + +func TestAskLegacyFragmentStaysLegacy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"hello"}]},"finishReason":"STOP"}]}`)) + })) + defer srv.Close() + + l := New(Config{Model: "gemini", BaseURL: srv.URL, APIKey: "k"}) + in := cogito.NewEmptyFragment().AddMessage(openai.ChatMessageRoleUser, "hi") + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + if out.ConversationMessages != nil { + t.Fatalf("legacy fragment gained authoritative history: %+v", out.ConversationMessages) + } + if len(out.Messages) != 2 || out.Messages[1].Content != "hello" { + t.Fatalf("Messages = %+v, want user message then reply", out.Messages) + } +} diff --git a/llmprovider/googlevertex/adapter.go b/llmprovider/googlevertex/adapter.go index 41eed76d..834bee73 100644 --- a/llmprovider/googlevertex/adapter.go +++ b/llmprovider/googlevertex/adapter.go @@ -20,6 +20,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" "github.com/mudler/nib/llmprovider/google" openai "github.com/sashabaranov/go-openai" ) @@ -69,7 +70,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/ollama/adapter.go b/llmprovider/ollama/adapter.go index 30ae6c89..a5d7de66 100644 --- a/llmprovider/ollama/adapter.go +++ b/llmprovider/ollama/adapter.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" openai "github.com/sashabaranov/go-openai" ) @@ -61,7 +62,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/ollama/ask_test.go b/llmprovider/ollama/ask_test.go new file mode 100644 index 00000000..03a46cd7 --- /dev/null +++ b/llmprovider/ollama/ask_test.go @@ -0,0 +1,55 @@ +package ollama + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mudler/cogito" + openai "github.com/sashabaranov/go-openai" +) + +func TestAskKeepsAuthoritativeFragmentConsistent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"model":"m","message":{"role":"assistant","content":"hello"},"done":true,"done_reason":"stop"}`)) + })) + defer srv.Close() + + l := New(Config{Model: "m", BaseURL: srv.URL}) + in := cogito.NewEmptyFragment().WithConversation(cogito.WrapMessages([]openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleUser, Content: "hi"}, + })) + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + history, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() after Ask: %v", err) + } + if len(history) != 2 || history[1].Portable.Content != "hello" { + t.Fatalf("history = %+v, want user message then reply", history) + } +} + +func TestAskLegacyFragmentStaysLegacy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"model":"m","message":{"role":"assistant","content":"hello"},"done":true,"done_reason":"stop"}`)) + })) + defer srv.Close() + + l := New(Config{Model: "m", BaseURL: srv.URL}) + in := cogito.NewEmptyFragment().AddMessage(openai.ChatMessageRoleUser, "hi") + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + if out.ConversationMessages != nil { + t.Fatalf("legacy fragment gained authoritative history: %+v", out.ConversationMessages) + } + if len(out.Messages) != 2 || out.Messages[1].Content != "hello" { + t.Fatalf("Messages = %+v, want user message then reply", out.Messages) + } +} diff --git a/llmprovider/openairesponses/adapter.go b/llmprovider/openairesponses/adapter.go index cb448b39..96203bc7 100644 --- a/llmprovider/openairesponses/adapter.go +++ b/llmprovider/openairesponses/adapter.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/mudler/cogito" + "github.com/mudler/nib/internal/fragmentreply" openai "github.com/sashabaranov/go-openai" ) @@ -67,7 +68,7 @@ func (l *LLM) Ask(ctx context.Context, fragment cogito.Fragment) (cogito.Fragmen if len(reply.ChatCompletionResponse.Choices) == 0 { return fragment, ErrNoResponse } - fragment.Messages = append(fragment.Messages, reply.ChatCompletionResponse.Choices[0].Message) + fragment = fragmentreply.Append(fragment, reply.ChatCompletionResponse.Choices[0].Message) if fragment.Status != nil { fragment.Status.LastUsage = usage fragment.Status.CumulativeUsage.PromptTokens += usage.PromptTokens diff --git a/llmprovider/openairesponses/ask_test.go b/llmprovider/openairesponses/ask_test.go new file mode 100644 index 00000000..5e7a33cd --- /dev/null +++ b/llmprovider/openairesponses/ask_test.go @@ -0,0 +1,55 @@ +package openairesponses + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mudler/cogito" + openai "github.com/sashabaranov/go-openai" +) + +func TestAskKeepsAuthoritativeFragmentConsistent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"r1","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer srv.Close() + + l := New(Config{Model: "gpt-5", BaseURL: srv.URL, APIKey: "k"}) + in := cogito.NewEmptyFragment().WithConversation(cogito.WrapMessages([]openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleUser, Content: "hi"}, + })) + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + history, err := out.Conversation() + if err != nil { + t.Fatalf("Conversation() after Ask: %v", err) + } + if len(history) != 2 || history[1].Portable.Content != "hello" { + t.Fatalf("history = %+v, want user message then reply", history) + } +} + +func TestAskLegacyFragmentStaysLegacy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"r1","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer srv.Close() + + l := New(Config{Model: "gpt-5", BaseURL: srv.URL, APIKey: "k"}) + in := cogito.NewEmptyFragment().AddMessage(openai.ChatMessageRoleUser, "hi") + out, err := l.Ask(t.Context(), in) + if err != nil { + t.Fatal(err) + } + if out.ConversationMessages != nil { + t.Fatalf("legacy fragment gained authoritative history: %+v", out.ConversationMessages) + } + if len(out.Messages) != 2 || out.Messages[1].Content != "hello" { + t.Fatalf("Messages = %+v, want user message then reply", out.Messages) + } +}