diff --git a/chat/fragment_messages_test.go b/chat/fragment_messages_test.go new file mode 100644 index 0000000..3639ffe --- /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 63da79c..f7cc6b1 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 0000000..45bd6fa --- /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 0000000..10c1b02 --- /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 d19cd04..ba349a9 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 0000000..774e28a --- /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 9869e11..4e42703 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 90128f9..1c9926f 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 e24aa26..9c25e86 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 78e6ede..e331a53 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 290477f..76c6d53 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 1a8849c..f32bc03 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 0000000..5639f44 --- /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 41eed76..834bee7 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 30ae6c8..a5d7de6 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 0000000..03a46cd --- /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 cb448b3..96203bc 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 0000000..5e7a33c --- /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) + } +}