Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions chat/fragment_messages_test.go
Original file line number Diff line number Diff line change
@@ -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) }
3 changes: 2 additions & 1 deletion codexapp/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"sync"

"github.com/mudler/cogito"
"github.com/mudler/nib/internal/fragmentreply"
openai "github.com/sashabaranov/go-openai"
)

Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions internal/fragmentreply/fragmentreply.go
Original file line number Diff line number Diff line change
@@ -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})
}
30 changes: 30 additions & 0 deletions internal/fragmentreply/fragmentreply_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion llmprovider/anthropic/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"strings"

"github.com/mudler/cogito"
"github.com/mudler/nib/internal/fragmentreply"
openai "github.com/sashabaranov/go-openai"
)

Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions llmprovider/anthropic/ask_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion llmprovider/azureresponses/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion llmprovider/bedrock/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"

"github.com/mudler/cogito"
"github.com/mudler/nib/internal/fragmentreply"
openai "github.com/sashabaranov/go-openai"
)

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion llmprovider/codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion llmprovider/copilot/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion llmprovider/geminicli/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"time"

"github.com/mudler/cogito"
"github.com/mudler/nib/internal/fragmentreply"
openai "github.com/sashabaranov/go-openai"
)

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion llmprovider/google/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"strings"

"github.com/mudler/cogito"
"github.com/mudler/nib/internal/fragmentreply"
openai "github.com/sashabaranov/go-openai"
)

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading