From c319fd422e76c43243501712a45aaab529ab82fd Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Thu, 27 Aug 2026 18:06:46 -0400 Subject: [PATCH 1/8] refactor: move Role to conversation package --- internal/conversation/sessions.go | 10 ++++++++++ internal/ollama/client.go | 14 ++++---------- main.go | 3 ++- main_test.go | 9 +++++---- 4 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 internal/conversation/sessions.go diff --git a/internal/conversation/sessions.go b/internal/conversation/sessions.go new file mode 100644 index 0000000..23f6cc9 --- /dev/null +++ b/internal/conversation/sessions.go @@ -0,0 +1,10 @@ +package conversation + +// Role identifies a participant in an chat conversation. +type Role string + +const ( + RoleSystem Role = "system" + RoleUser Role = "user" + RoleAssistant Role = "assistant" +) diff --git a/internal/ollama/client.go b/internal/ollama/client.go index a7da414..62c79b9 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -9,23 +9,17 @@ import ( "net/http" "net/url" "strings" + + "github.com/theantichris/ghost/v4/internal/conversation" ) const DefaultURL = "http://localhost:11434/api" const maxErrBodySize = 64 << 10 -// Role identifies a participant in an Ollama chat conversation. -type Role string - -const ( - RoleUser Role = "user" - RoleAssistant Role = "assistant" -) - // Message represents one message in an Ollama chat conversation. type Message struct { - Role Role `json:"role"` - Content string `json:"content"` + Role conversation.Role `json:"role"` + Content string `json:"content"` } // Client communicates with the Ollama HTTP API. diff --git a/main.go b/main.go index 1baefd8..9840a1c 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "os" "strings" + "github.com/theantichris/ghost/v4/internal/conversation" "github.com/theantichris/ghost/v4/internal/ollama" ) @@ -52,7 +53,7 @@ func run(ctx context.Context, args []string, output io.Writer, chat chatFunc) er return errPromptRequired } - response, err := chat(ctx, modelName, []ollama.Message{{Role: ollama.RoleUser, Content: prompt}}) + response, err := chat(ctx, modelName, []ollama.Message{{Role: conversation.RoleUser, Content: prompt}}) if err != nil { return fmt.Errorf("request Ollama chat response: %w", err) } diff --git a/main_test.go b/main_test.go index c8bb123..e9dec3f 100644 --- a/main_test.go +++ b/main_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/theantichris/ghost/v4/internal/conversation" "github.com/theantichris/ghost/v4/internal/ollama" ) @@ -25,7 +26,7 @@ func TestRun(t *testing.T) { writeErr := errors.New("output unavailable") promptMessages := []ollama.Message{ - {Role: ollama.RoleUser, Content: "Identify yourself."}, + {Role: conversation.RoleUser, Content: "Identify yourself."}, } tests := []struct { @@ -46,7 +47,7 @@ func TestRun(t *testing.T) { name: "prints assistant response", args: []string{"--model", "qwen3:8b", "Identify", "yourself."}, output: &bytes.Buffer{}, - response: ollama.Message{Role: ollama.RoleAssistant, Content: "Ghost online."}, + response: ollama.Message{Role: conversation.RoleAssistant, Content: "Ghost online."}, wantChat: true, wantModel: "qwen3:8b", wantMessages: promptMessages, @@ -91,7 +92,7 @@ func TestRun(t *testing.T) { name: "wraps output error", args: []string{"--model", "qwen3:8b", "Identify yourself."}, output: errorWriter{err: writeErr}, - response: ollama.Message{Role: ollama.RoleAssistant, Content: "Ghost online."}, + response: ollama.Message{Role: conversation.RoleAssistant, Content: "Ghost online."}, wantChat: true, wantModel: "qwen3:8b", wantMessages: promptMessages, @@ -103,7 +104,7 @@ func TestRun(t *testing.T) { name: "does not duplicate final newline", args: []string{"--model", "qwen3:8b", "Identify yourself."}, output: &bytes.Buffer{}, - response: ollama.Message{Role: ollama.RoleAssistant, Content: "Ghost online.\n"}, + response: ollama.Message{Role: conversation.RoleAssistant, Content: "Ghost online.\n"}, wantChat: true, wantModel: "qwen3:8b", wantMessages: promptMessages, From ddae415beb40ee16b0566ebeeffb1fe6c6ebc616 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Thu, 27 Aug 2026 18:31:42 -0400 Subject: [PATCH 2/8] refactor: start errors with lowercase letter, ignore defer error --- internal/ollama/client.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/ollama/client.go b/internal/ollama/client.go index 62c79b9..2343e6e 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -92,17 +92,20 @@ func (client *Client) Chat(ctx context.Context, model string, messages []Message if err != nil { return Message{}, fmt.Errorf("send Ollama chat request: %w", err) } - defer response.Body.Close() + + defer func() { + _ = response.Body.Close() + }() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { var apiError errorResponse decodeErr := json.NewDecoder(io.LimitReader(response.Body, maxErrBodySize)).Decode(&apiError) if decodeErr != nil || strings.TrimSpace(apiError.Error) == "" { - return Message{}, fmt.Errorf("Ollama chat request failed: %s", response.Status) + return Message{}, fmt.Errorf("ollama chat request failed: %s", response.Status) } - return Message{}, fmt.Errorf("Ollama chat request failed: %s: %s", response.Status, apiError.Error) + return Message{}, fmt.Errorf("ollama chat request failed: %s: %s", response.Status, apiError.Error) } var result chatResponse From 0eb5340664a4bc6a0364d35d1d478bdcb1952d08 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 30 Aug 2026 12:02:46 -0400 Subject: [PATCH 3/8] refactor: move Message to conversation package --- internal/conversation/sessions.go | 6 ++++++ internal/ollama/client.go | 28 +++++++++++----------------- internal/ollama/client_test.go | 8 +++++--- main.go | 4 ++-- main_test.go | 19 +++++++++---------- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/internal/conversation/sessions.go b/internal/conversation/sessions.go index 23f6cc9..e995857 100644 --- a/internal/conversation/sessions.go +++ b/internal/conversation/sessions.go @@ -8,3 +8,9 @@ const ( RoleUser Role = "user" RoleAssistant Role = "assistant" ) + +// Message represents one message in an Ollama chat conversation. +type Message struct { + Role Role `json:"role"` + Content string `json:"content"` +} diff --git a/internal/ollama/client.go b/internal/ollama/client.go index 2343e6e..ad38c42 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -16,12 +16,6 @@ import ( const DefaultURL = "http://localhost:11434/api" const maxErrBodySize = 64 << 10 -// Message represents one message in an Ollama chat conversation. -type Message struct { - Role conversation.Role `json:"role"` - Content string `json:"content"` -} - // Client communicates with the Ollama HTTP API. type Client struct { baseURL *url.URL @@ -29,13 +23,13 @@ type Client struct { } type chatRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Stream bool `json:"stream"` + Model string `json:"model"` + Messages []conversation.Message `json:"messages"` + Stream bool `json:"stream"` } type chatResponse struct { - Message Message `json:"message"` + Message conversation.Message `json:"message"` } type errorResponse struct { @@ -64,14 +58,14 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { } // Chat sends a non-streaming chat request to Ollama. -func (client *Client) Chat(ctx context.Context, model string, messages []Message) (Message, error) { +func (client *Client) Chat(ctx context.Context, model string, messages []conversation.Message) (conversation.Message, error) { payload, err := json.Marshal(chatRequest{ Model: model, Messages: messages, Stream: false, }) if err != nil { - return Message{}, fmt.Errorf("encode Ollama chat request: %w", err) + return conversation.Message{}, fmt.Errorf("encode Ollama chat request: %w", err) } endpoint := client.baseURL.JoinPath("chat") @@ -83,14 +77,14 @@ func (client *Client) Chat(ctx context.Context, model string, messages []Message bytes.NewReader(payload), ) if err != nil { - return Message{}, fmt.Errorf("create Ollama chat request: %w", err) + return conversation.Message{}, fmt.Errorf("create Ollama chat request: %w", err) } request.Header.Set("Content-Type", "application/json") response, err := client.httpClient.Do(request) if err != nil { - return Message{}, fmt.Errorf("send Ollama chat request: %w", err) + return conversation.Message{}, fmt.Errorf("send Ollama chat request: %w", err) } defer func() { @@ -102,16 +96,16 @@ func (client *Client) Chat(ctx context.Context, model string, messages []Message decodeErr := json.NewDecoder(io.LimitReader(response.Body, maxErrBodySize)).Decode(&apiError) if decodeErr != nil || strings.TrimSpace(apiError.Error) == "" { - return Message{}, fmt.Errorf("ollama chat request failed: %s", response.Status) + return conversation.Message{}, fmt.Errorf("ollama chat request failed: %s", response.Status) } - return Message{}, fmt.Errorf("ollama chat request failed: %s: %s", response.Status, apiError.Error) + return conversation.Message{}, fmt.Errorf("ollama chat request failed: %s: %s", response.Status, apiError.Error) } var result chatResponse if err := json.NewDecoder(response.Body).Decode(&result); err != nil { - return Message{}, fmt.Errorf("decode Ollama chat response: %w", err) + return conversation.Message{}, fmt.Errorf("decode Ollama chat response: %w", err) } return result.Message, nil diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go index 04cbb62..3d64a9d 100644 --- a/internal/ollama/client_test.go +++ b/internal/ollama/client_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/theantichris/ghost/v4/internal/conversation" ) func TestNewClient(t *testing.T) { @@ -55,14 +57,14 @@ func TestNewClient(t *testing.T) { } func TestClientChat(t *testing.T) { - messages := []Message{ + messages := []conversation.Message{ { Role: "user", Content: "Identify yourself.", }, } - wantMessage := Message{ + wantMessage := conversation.Message{ Role: "assistant", Content: "Ghost online.", } @@ -70,7 +72,7 @@ func TestClientChat(t *testing.T) { tests := []struct { name string handler http.HandlerFunc - wantMessage Message + wantMessage conversation.Message wantErrContains string }{ { diff --git a/main.go b/main.go index 9840a1c..3e7025e 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ var ( errPromptRequired = errors.New("prompt is required") ) -type chatFunc func(context.Context, string, []ollama.Message) (ollama.Message, error) +type chatFunc func(context.Context, string, []conversation.Message) (conversation.Message, error) func main() { client, err := ollama.NewClient(ollama.DefaultURL, nil) @@ -53,7 +53,7 @@ func run(ctx context.Context, args []string, output io.Writer, chat chatFunc) er return errPromptRequired } - response, err := chat(ctx, modelName, []ollama.Message{{Role: conversation.RoleUser, Content: prompt}}) + response, err := chat(ctx, modelName, []conversation.Message{{Role: conversation.RoleUser, Content: prompt}}) if err != nil { return fmt.Errorf("request Ollama chat response: %w", err) } diff --git a/main_test.go b/main_test.go index e9dec3f..c653e2c 100644 --- a/main_test.go +++ b/main_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/theantichris/ghost/v4/internal/conversation" - "github.com/theantichris/ghost/v4/internal/ollama" ) type errorWriter struct { @@ -25,7 +24,7 @@ func TestRun(t *testing.T) { chatErr := errors.New("Ollama unavailable") writeErr := errors.New("output unavailable") - promptMessages := []ollama.Message{ + promptMessages := []conversation.Message{ {Role: conversation.RoleUser, Content: "Identify yourself."}, } @@ -33,11 +32,11 @@ func TestRun(t *testing.T) { name string args []string output io.Writer - response ollama.Message + response conversation.Message chatErr error wantChat bool wantModel string - wantMessages []ollama.Message + wantMessages []conversation.Message wantOutput string wantErr bool wantErrIs error @@ -47,7 +46,7 @@ func TestRun(t *testing.T) { name: "prints assistant response", args: []string{"--model", "qwen3:8b", "Identify", "yourself."}, output: &bytes.Buffer{}, - response: ollama.Message{Role: conversation.RoleAssistant, Content: "Ghost online."}, + response: conversation.Message{Role: conversation.RoleAssistant, Content: "Ghost online."}, wantChat: true, wantModel: "qwen3:8b", wantMessages: promptMessages, @@ -92,7 +91,7 @@ func TestRun(t *testing.T) { name: "wraps output error", args: []string{"--model", "qwen3:8b", "Identify yourself."}, output: errorWriter{err: writeErr}, - response: ollama.Message{Role: conversation.RoleAssistant, Content: "Ghost online."}, + response: conversation.Message{Role: conversation.RoleAssistant, Content: "Ghost online."}, wantChat: true, wantModel: "qwen3:8b", wantMessages: promptMessages, @@ -104,7 +103,7 @@ func TestRun(t *testing.T) { name: "does not duplicate final newline", args: []string{"--model", "qwen3:8b", "Identify yourself."}, output: &bytes.Buffer{}, - response: ollama.Message{Role: conversation.RoleAssistant, Content: "Ghost online.\n"}, + response: conversation.Message{Role: conversation.RoleAssistant, Content: "Ghost online.\n"}, wantChat: true, wantModel: "qwen3:8b", wantMessages: promptMessages, @@ -133,12 +132,12 @@ func TestRun(t *testing.T) { chatCalled := false var gotModel string - var gotMessages []ollama.Message + var gotMessages []conversation.Message - chat := func(_ context.Context, model string, messages []ollama.Message) (ollama.Message, error) { + chat := func(_ context.Context, model string, messages []conversation.Message) (conversation.Message, error) { chatCalled = true gotModel = model - gotMessages = append([]ollama.Message(nil), messages...) + gotMessages = append([]conversation.Message(nil), messages...) return test.response, test.chatErr } From 4655cdda091e2b7ee0b7dba71b81c3f1952d0779 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 30 Aug 2026 12:13:54 -0400 Subject: [PATCH 4/8] feat: add Session --- internal/conversation/sessions.go | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/conversation/sessions.go b/internal/conversation/sessions.go index e995857..a73db77 100644 --- a/internal/conversation/sessions.go +++ b/internal/conversation/sessions.go @@ -1,5 +1,7 @@ package conversation +import "slices" + // Role identifies a participant in an chat conversation. type Role string @@ -14,3 +16,34 @@ type Message struct { Role Role `json:"role"` Content string `json:"content"` } + +// Session owns the ordered history for one conversation. +type Session struct { + messages []Message +} + +// NewSession constructs a session with a copy of the supplied initial context. +func NewSession(initial ...Message) *Session { + return &Session{messages: slices.Clone(initial)} +} + +// AppendUser appends a user message to the conversation. +func (session *Session) AppendUser(content string) { + session.messages = append(session.messages, Message{ + Role: RoleUser, + Content: content, + }) +} + +// AppendAssistant appends an assistant message to the conversation. +func (session *Session) AppendAssistant(content string) { + session.messages = append(session.messages, Message{ + Role: RoleAssistant, + Content: content, + }) +} + +// Messages returns a copy of the conversation history. +func (session *Session) Messages() []Message { + return slices.Clone(session.messages) +} From 2df2d1ce6ae66114700fd1b0eb2e41868637e078 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Sun, 30 Aug 2026 15:54:38 -0400 Subject: [PATCH 5/8] test: add tests for session --- internal/conversation/session_test.go | 253 ++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 internal/conversation/session_test.go diff --git a/internal/conversation/session_test.go b/internal/conversation/session_test.go new file mode 100644 index 0000000..830783d --- /dev/null +++ b/internal/conversation/session_test.go @@ -0,0 +1,253 @@ +package conversation + +import ( + "slices" + "testing" +) + +func TestNewSession(t *testing.T) { + tests := []struct { + name string + initial []Message + want []Message + }{ + { + name: "starts empty", + }, + { + name: "preserves initial context", + initial: []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "Establish uplink.", + }, + }, + want: []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "Establish uplink.", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + initial := slices.Clone(test.initial) + session := NewSession(initial...) + + if len(initial) > 0 { + initial[0] = Message{ + Role: RoleAssistant, + Content: "mutated outside the session", + } + } + + if got := session.Messages(); !slices.Equal(got, test.want) { + t.Errorf("Messages() = %#v, want %#v", got, test.want) + } + }) + } + + t.Run("sessions do not share history", func(t *testing.T) { + first := NewSession() + second := NewSession() + + first.AppendUser("first session") + + if got := second.Messages(); len(got) != 0 { + t.Errorf("second Messages() = %#v, want empty history", got) + } + }) +} + +func TestSessionAppendUser(t *testing.T) { + tests := []struct { + name string + initial []Message + content string + want []Message + }{ + { + name: "appends to empty history", + content: "Identify yourself.", + want: []Message{ + { + Role: RoleUser, + Content: "Identify yourself.", + }, + }, + }, + { + name: "appends after initial context", + initial: []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + }, + content: "Identify yourself.", + want: []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "Identify yourself.", + }, + }, + }, + { + name: "preserves content verbatim", + content: " signal with surrounding space ", + want: []Message{ + { + Role: RoleUser, + Content: " signal with surrounding space ", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + session := NewSession(test.initial...) + session.AppendUser(test.content) + + if got := session.Messages(); !slices.Equal(got, test.want) { + t.Errorf("Messages() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestSessionAppendAssistant(t *testing.T) { + tests := []struct { + name string + initial []Message + content string + want []Message + }{ + { + name: "appends to empty history", + content: "Ghost online.", + want: []Message{ + { + Role: RoleAssistant, + Content: "Ghost online.", + }, + }, + }, + { + name: "appends after user message", + initial: []Message{ + { + Role: RoleUser, + Content: "Identify yourself.", + }, + }, + content: "Ghost online.", + want: []Message{ + { + Role: RoleUser, + Content: "Identify yourself.", + }, + { + Role: RoleAssistant, + Content: "Ghost online.", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + session := NewSession(test.initial...) + session.AppendAssistant(test.content) + + if got := session.Messages(); !slices.Equal(got, test.want) { + t.Errorf("Messages() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestSessionMessages(t *testing.T) { + tests := []struct { + name string + initial []Message + build func(*Session) + want []Message + }{ + { + name: "returns empty history", + }, + { + name: "returns complete history in order", + initial: []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + }, + build: func(session *Session) { + session.AppendUser("First signal.") + session.AppendAssistant("First response.") + session.AppendUser("Second signal.") + }, + want: []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "First signal.", + }, + { + Role: RoleAssistant, + Content: "First response.", + }, + { + Role: RoleUser, + Content: "Second signal.", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + session := NewSession(test.initial...) + + if test.build != nil { + test.build(session) + } + + got := session.Messages() + if !slices.Equal(got, test.want) { + t.Fatalf("Messages() = %#v, want %#v", got, test.want) + } + + if len(got) > 0 { + got[0] = Message{ + Role: RoleAssistant, + Content: "mutated returned history", + } + } + + if gotAgain := session.Messages(); !slices.Equal(gotAgain, test.want) { + t.Errorf("Messages() after caller mutation = %#v, want %#v", gotAgain, test.want) + } + }) + } +} From 9831843bd83f2ccf2bcd09c8408c77be6763e4d6 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Mon, 31 Aug 2026 08:18:14 -0400 Subject: [PATCH 6/8] refactor: add new session messaging --- .../conversation/{sessions.go => session.go} | 0 internal/ollama/client.go | 31 +++++++++++++++---- internal/ollama/client_test.go | 18 ++++++++--- main.go | 5 ++- 4 files changed, 43 insertions(+), 11 deletions(-) rename internal/conversation/{sessions.go => session.go} (100%) diff --git a/internal/conversation/sessions.go b/internal/conversation/session.go similarity index 100% rename from internal/conversation/sessions.go rename to internal/conversation/session.go diff --git a/internal/ollama/client.go b/internal/ollama/client.go index ad38c42..0eac9ea 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -22,14 +22,19 @@ type Client struct { httpClient *http.Client } +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + type chatRequest struct { - Model string `json:"model"` - Messages []conversation.Message `json:"messages"` - Stream bool `json:"stream"` + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Stream bool `json:"stream"` } type chatResponse struct { - Message conversation.Message `json:"message"` + Message chatMessage `json:"message"` } type errorResponse struct { @@ -61,7 +66,7 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { func (client *Client) Chat(ctx context.Context, model string, messages []conversation.Message) (conversation.Message, error) { payload, err := json.Marshal(chatRequest{ Model: model, - Messages: messages, + Messages: toChatMessage(messages), Stream: false, }) if err != nil { @@ -108,5 +113,19 @@ func (client *Client) Chat(ctx context.Context, model string, messages []convers return conversation.Message{}, fmt.Errorf("decode Ollama chat response: %w", err) } - return result.Message, nil + return fromChatMessage(result.Message), nil +} + +func toChatMessage(messages []conversation.Message) []chatMessage { + result := make([]chatMessage, len(messages)) + + for index, message := range messages { + result[index] = chatMessage{Role: string(message.Role), Content: message.Content} + } + + return result +} + +func fromChatMessage(message chatMessage) conversation.Message { + return conversation.Message{Role: conversation.Role(message.Role), Content: message.Content} } diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go index 3d64a9d..33e25a3 100644 --- a/internal/ollama/client_test.go +++ b/internal/ollama/client_test.go @@ -59,16 +59,26 @@ func TestNewClient(t *testing.T) { func TestClientChat(t *testing.T) { messages := []conversation.Message{ { - Role: "user", + Role: conversation.RoleUser, Content: "Identify yourself.", }, } - wantMessage := conversation.Message{ + wantRequestMessage := chatMessage{ + Role: "user", + Content: "Identify yourself.", + } + + responseMessage := chatMessage{ Role: "assistant", Content: "Ghost online.", } + wantMessage := conversation.Message{ + Role: conversation.RoleAssistant, + Content: "Ghost online.", + } + tests := []struct { name string handler http.HandlerFunc @@ -108,13 +118,13 @@ func TestClientChat(t *testing.T) { t.Error("request stream = true, want false") } - if len(gotRequest.Messages) != 1 || gotRequest.Messages[0] != messages[0] { + if len(gotRequest.Messages) != 1 || gotRequest.Messages[0] != wantRequestMessage { t.Errorf("request messages = %#v, want %#v", gotRequest.Messages, messages) } writer.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(writer).Encode(chatResponse{Message: wantMessage}); err != nil { + if err := json.NewEncoder(writer).Encode(chatResponse{Message: responseMessage}); err != nil { t.Errorf("encode response: %v", err) } }, diff --git a/main.go b/main.go index 3e7025e..02bc657 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,10 @@ func run(ctx context.Context, args []string, output io.Writer, chat chatFunc) er return errPromptRequired } - response, err := chat(ctx, modelName, []conversation.Message{{Role: conversation.RoleUser, Content: prompt}}) + session := conversation.NewSession() + session.AppendUser(prompt) + + response, err := chat(ctx, modelName, session.Messages()) if err != nil { return fmt.Errorf("request Ollama chat response: %w", err) } From 16ccf300541639ed35d8654540ea5ef43b20a509 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Mon, 31 Aug 2026 08:46:45 -0400 Subject: [PATCH 7/8] refactor: remove transport details from messages --- internal/conversation/session.go | 4 ++-- internal/ollama/client.go | 4 ++-- internal/ollama/client_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/conversation/session.go b/internal/conversation/session.go index a73db77..6e2649a 100644 --- a/internal/conversation/session.go +++ b/internal/conversation/session.go @@ -2,7 +2,7 @@ package conversation import "slices" -// Role identifies a participant in an chat conversation. +// Role identifies a participant in a conversation. type Role string const ( @@ -11,7 +11,7 @@ const ( RoleAssistant Role = "assistant" ) -// Message represents one message in an Ollama chat conversation. +// Message represents one entry in a conversationone entry in a conversation type Message struct { Role Role `json:"role"` Content string `json:"content"` diff --git a/internal/ollama/client.go b/internal/ollama/client.go index 0eac9ea..edfbd83 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -66,7 +66,7 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { func (client *Client) Chat(ctx context.Context, model string, messages []conversation.Message) (conversation.Message, error) { payload, err := json.Marshal(chatRequest{ Model: model, - Messages: toChatMessage(messages), + Messages: toChatMessages(messages), Stream: false, }) if err != nil { @@ -116,7 +116,7 @@ func (client *Client) Chat(ctx context.Context, model string, messages []convers return fromChatMessage(result.Message), nil } -func toChatMessage(messages []conversation.Message) []chatMessage { +func toChatMessages(messages []conversation.Message) []chatMessage { result := make([]chatMessage, len(messages)) for index, message := range messages { diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go index 33e25a3..70fe748 100644 --- a/internal/ollama/client_test.go +++ b/internal/ollama/client_test.go @@ -119,7 +119,7 @@ func TestClientChat(t *testing.T) { } if len(gotRequest.Messages) != 1 || gotRequest.Messages[0] != wantRequestMessage { - t.Errorf("request messages = %#v, want %#v", gotRequest.Messages, messages) + t.Errorf("request messages = %#v, want %#v", gotRequest.Messages, []chatMessage{wantRequestMessage}) } writer.Header().Set("Content-Type", "application/json") From aeabef617872832540adb649ba93144746c618f7 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Mon, 31 Aug 2026 08:50:22 -0400 Subject: [PATCH 8/8] fix: remove remaining transport metadata --- internal/conversation/session.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/conversation/session.go b/internal/conversation/session.go index 6e2649a..d07d837 100644 --- a/internal/conversation/session.go +++ b/internal/conversation/session.go @@ -11,10 +11,10 @@ const ( RoleAssistant Role = "assistant" ) -// Message represents one entry in a conversationone entry in a conversation +// Message represents one entry in a conversation. type Message struct { - Role Role `json:"role"` - Content string `json:"content"` + Role Role + Content string } // Session owns the ordered history for one conversation.