From d52d0a17771fe6a250ef0890a2f9544a64075366 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Mon, 31 Aug 2026 18:00:36 -0400 Subject: [PATCH 1/2] feat: preserve context across multiple turns --- internal/conversation/turn.go | 27 ++++ internal/conversation/turn_test.go | 203 +++++++++++++++++++++++++++++ main.go | 7 +- 3 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 internal/conversation/turn.go create mode 100644 internal/conversation/turn_test.go diff --git a/internal/conversation/turn.go b/internal/conversation/turn.go new file mode 100644 index 0000000..1602909 --- /dev/null +++ b/internal/conversation/turn.go @@ -0,0 +1,27 @@ +package conversation + +import ( + "context" + "fmt" +) + +// ChatFunc requests one assistant response for an ordered message history. +type ChatFunc func(context.Context, string, []Message) (Message, error) + +// RunTurn sends a user prompt with the session history and commits the +// user-assistant exchange only when chat succeeds. Any returned error leaves +// the session unchanged. +func RunTurn(ctx context.Context, session *Session, model string, prompt string, chat ChatFunc) (Message, error) { + messages := session.Messages() + messages = append(messages, Message{Role: RoleUser, Content: prompt}) + + response, err := chat(ctx, model, messages) + if err != nil { + return Message{}, fmt.Errorf("run conversation turn: %w", err) + } + + session.AppendUser(prompt) + session.AppendAssistant(response.Content) + + return response, nil +} diff --git a/internal/conversation/turn_test.go b/internal/conversation/turn_test.go new file mode 100644 index 0000000..bbb6dca --- /dev/null +++ b/internal/conversation/turn_test.go @@ -0,0 +1,203 @@ +package conversation + +import ( + "context" + "errors" + "slices" + "testing" +) + +func TestRunTurn(t *testing.T) { + session := NewSession(Message{Role: RoleSystem, Content: "You are Ghost."}) + + responses := []Message{ + { + Role: RoleAssistant, + Content: "First response.", + }, + { + Role: RoleAssistant, + Content: "Second response.", + }, + } + + var calls [][]Message + + chat := func(_ context.Context, model string, messages []Message) (Message, error) { + if model != "qwen3:8b" { + t.Errorf("model = %q, want %q", model, "qwen3:8b") + } + + callIndex := len(calls) + calls = append(calls, slices.Clone(messages)) + + if callIndex >= len(responses) { + t.Fatalf("Chat() call count exceeded available responses: %d", callIndex+1) + } + + return responses[callIndex], nil + } + + if _, err := RunTurn(context.Background(), session, "qwen3:8b", "First signal.", chat); err != nil { + t.Fatalf("first RunTurn() error = %v, want nil", err) + } + + if _, err := RunTurn(context.Background(), session, "qwen3:8b", "Second signal.", chat); err != nil { + t.Fatalf("second RunTurn() error = %v, want nil", err) + } + + wantCalls := [][]Message{ + { + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "First signal.", + }, + }, + { + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "First signal.", + }, + { + Role: RoleAssistant, + Content: "First response.", + }, + { + Role: RoleUser, + Content: "Second signal.", + }, + }, + } + + if len(calls) != len(wantCalls) { + t.Fatalf("Chat() call count = %d, want %d", len(calls), len(wantCalls)) + } + + for index := range wantCalls { + if !slices.Equal(calls[index], wantCalls[index]) { + t.Errorf("Chat() call %d messages = %#v, want %#v", index+1, calls[index], wantCalls[index]) + } + } + + wantHistory := []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "First signal.", + }, + { + Role: RoleAssistant, + Content: "First response.", + }, + { + Role: RoleUser, + Content: "Second signal.", + }, + { + Role: RoleAssistant, + Content: "Second response.", + }, + } + + if got := session.Messages(); !slices.Equal(got, wantHistory) { + t.Errorf("Messages() = %#v, want %#v", got, wantHistory) + } + + t.Run("leaves session unchanged when chat fails", func(t *testing.T) { + chatErr := errors.New("model unavailable") + + initial := []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "Previous signal", + }, + { + Role: RoleAssistant, + Content: "Previous response.", + }, + } + + session := NewSession(initial...) + + chat := func(_ context.Context, _ string, _ []Message) (Message, error) { + return Message{Role: RoleAssistant, Content: "Fabricated response."}, chatErr + } + + response, err := RunTurn(context.Background(), session, "qwen3:8b", "Failed signal.", chat) + + if err == nil { + t.Fatal("RunTurn() error = nil, want an error") + } + + if !errors.Is(err, chatErr) { + t.Errorf("RunTurn() error = %v, want errors.Is(error, %v)", err, chatErr) + } + + if response != (Message{}) { + t.Errorf("RunTurn() response = %#v, want zero-value Message", response) + } + + if got := session.Messages(); !slices.Equal(got, initial) { + t.Errorf("Messages() = %#v, want unchanged history %#v", got, initial) + } + }) + + t.Run("propagates cancellation without changing session", func(t *testing.T) { + initial := []Message{ + { + Role: RoleSystem, + Content: "You are Ghost.", + }, + { + Role: RoleUser, + Content: "Previous signal.", + }, + { + Role: RoleAssistant, + Content: "Previous response.", + }, + } + + session := NewSession(initial...) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + chat := func(ctx context.Context, _ string, _ []Message) (Message, error) { + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("Chat() context error = %v, want context.Canceled", ctx.Err()) + } + + return Message{Role: RoleAssistant, Content: "Obsolete response."}, ctx.Err() + } + + response, err := RunTurn(ctx, session, "qwen3:8b", "Cancelled signal.", chat) + + if !errors.Is(err, context.Canceled) { + t.Errorf("RunTurn() error = %v, want errors.Is(error, context.Canceled)", err) + } + + if response != (Message{}) { + t.Errorf("RunTurn() response = %#v, want zero-value Messages", response) + } + + if got := session.Messages(); !slices.Equal(got, initial) { + t.Errorf("Messages() = %#v, want unchanged history %#v", got, initial) + } + }) +} diff --git a/main.go b/main.go index 02bc657..623b281 100644 --- a/main.go +++ b/main.go @@ -18,8 +18,6 @@ var ( errPromptRequired = errors.New("prompt is required") ) -type chatFunc func(context.Context, string, []conversation.Message) (conversation.Message, error) - func main() { client, err := ollama.NewClient(ollama.DefaultURL, nil) if err != nil { @@ -33,7 +31,7 @@ func main() { } } -func run(ctx context.Context, args []string, output io.Writer, chat chatFunc) error { +func run(ctx context.Context, args []string, output io.Writer, chat conversation.ChatFunc) error { flags := flag.NewFlagSet("ghost", flag.ContinueOnError) flags.SetOutput(io.Discard) @@ -54,9 +52,8 @@ func run(ctx context.Context, args []string, output io.Writer, chat chatFunc) er } session := conversation.NewSession() - session.AppendUser(prompt) - response, err := chat(ctx, modelName, session.Messages()) + response, err := conversation.RunTurn(ctx, session, modelName, prompt, chat) if err != nil { return fmt.Errorf("request Ollama chat response: %w", err) } From c071aab3e6ab59d42e565e3589b7bca84c6cde29 Mon Sep 17 00:00:00 2001 From: Christopher Lamm Date: Mon, 31 Aug 2026 18:04:22 -0400 Subject: [PATCH 2/2] fix: typo in test assertion --- internal/conversation/turn_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/conversation/turn_test.go b/internal/conversation/turn_test.go index bbb6dca..12a07f3 100644 --- a/internal/conversation/turn_test.go +++ b/internal/conversation/turn_test.go @@ -193,7 +193,7 @@ func TestRunTurn(t *testing.T) { } if response != (Message{}) { - t.Errorf("RunTurn() response = %#v, want zero-value Messages", response) + t.Errorf("RunTurn() response = %#v, want zero-value Message", response) } if got := session.Messages(); !slices.Equal(got, initial) {