From bb4690b17a4e47b08d4d7bbd7b941103befa53eb Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Fri, 31 Jul 2026 18:54:07 -0700 Subject: [PATCH 1/2] fix(go/ai): make ModelResponse.History nil-safe and stop aliasing History dereferenced its receiver inside the guard meant to protect against a nil receiver, so the nil check guaranteed the panic it was written to prevent. Request could also be nil independently, since not every model plugin assigns it, which panicked on the append path. History also appended the response message directly onto Request.Messages. When that slice had spare capacity the append wrote into the caller's backing array, so the result aliased anything else pointing at it and successive calls overwrote each other. Return early on a nil receiver, treat a nil Request as no prior messages, and build the result from a clone so callers own the slice they get back. --- go/ai/generate.go | 17 ++++++-- go/ai/generate_test.go | 96 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/go/ai/generate.go b/go/ai/generate.go index 1fe11a8f2e..1fa362c9d5 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -1131,12 +1131,21 @@ func (mr *ModelResponse) Text() string { } // History returns messages from the request combined with the response message -// to represent the conversation history. +// to represent the conversation history. The result is always freshly +// allocated, so callers may retain or append to it without disturbing +// Request.Messages. func (mr *ModelResponse) History() []*Message { - if mr == nil || mr.Message == nil { - return mr.Request.Messages + if mr == nil { + return nil + } + var reqMsgs []*Message + if mr.Request != nil { + reqMsgs = mr.Request.Messages + } + if mr.Message == nil { + return slices.Clone(reqMsgs) } - return append(mr.Request.Messages, mr.Message) + return append(slices.Clone(reqMsgs), mr.Message) } // Reasoning concatenates all reasoning parts present in the message diff --git a/go/ai/generate_test.go b/go/ai/generate_test.go index 2351389a9e..e08a271e8b 100644 --- a/go/ai/generate_test.go +++ b/go/ai/generate_test.go @@ -2376,6 +2376,102 @@ func TestModelResponseReasoning(t *testing.T) { }) } +func TestModelResponseHistory(t *testing.T) { + userMsg := NewUserTextMessage("question") + modelMsg := NewModelTextMessage("answer") + + t.Run("combines request messages with the response message", func(t *testing.T) { + resp := &ModelResponse{ + Request: &ModelRequest{Messages: []*Message{userMsg}}, + Message: modelMsg, + } + + history := resp.History() + + if len(history) != 2 || history[0] != userMsg || history[1] != modelMsg { + t.Errorf("History() = %v, want [userMsg modelMsg]", history) + } + }) + + t.Run("returns nil for a nil response", func(t *testing.T) { + var resp *ModelResponse + + if history := resp.History(); history != nil { + t.Errorf("History() = %v, want nil", history) + } + }) + + t.Run("returns the response message when request is nil", func(t *testing.T) { + resp := &ModelResponse{Message: modelMsg} + + history := resp.History() + + if len(history) != 1 || history[0] != modelMsg { + t.Errorf("History() = %v, want [modelMsg]", history) + } + }) + + t.Run("returns request messages when response message is nil", func(t *testing.T) { + resp := &ModelResponse{Request: &ModelRequest{Messages: []*Message{userMsg}}} + + history := resp.History() + + if len(history) != 1 || history[0] != userMsg { + t.Errorf("History() = %v, want [userMsg]", history) + } + }) + + t.Run("returns nil when request and response message are both nil", func(t *testing.T) { + resp := &ModelResponse{} + + if history := resp.History(); history != nil { + t.Errorf("History() = %v, want nil", history) + } + }) + + // Request.Messages with spare capacity used to let History() append into the + // caller's backing array, so the result aliased whatever else pointed at it. + t.Run("does not write into spare capacity of request messages", func(t *testing.T) { + backing := make([]*Message, 3, 8) + backing[0], backing[1], backing[2] = userMsg, modelMsg, userMsg + sentinel := NewUserTextMessage("caller owns this slot") + extended := backing[:4] + extended[3] = sentinel + + resp := &ModelResponse{ + Request: &ModelRequest{Messages: backing}, + Message: modelMsg, + } + + resp.History() + + if extended[3] != sentinel { + t.Errorf("History() overwrote the caller's backing array at index 3: got %v, want the sentinel", extended[3]) + } + }) + + t.Run("successive calls return independent slices", func(t *testing.T) { + backing := make([]*Message, 3, 8) + backing[0], backing[1], backing[2] = userMsg, modelMsg, userMsg + resp := &ModelResponse{ + Request: &ModelRequest{Messages: backing}, + Message: modelMsg, + } + + first := resp.History() + replacement := NewModelTextMessage("second answer") + resp.Message = replacement + second := resp.History() + + if first[3] != modelMsg { + t.Errorf("first History() result was mutated by the second call: got %v, want the original model message", first[3]) + } + if second[3] != replacement { + t.Errorf("second History() = %v at index 3, want the replacement message", second[3]) + } + }) +} + func TestModelResponseInterrupts(t *testing.T) { t.Run("returns interrupt tool requests", func(t *testing.T) { interruptPart := NewToolRequestPart(&ToolRequest{ From 9b481cd4c0ccbd88fb8c9c1f88aa533602905c9a Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Fri, 31 Jul 2026 19:28:22 -0700 Subject: [PATCH 2/2] perf(go/ai): build History with a single exact-sized allocation slices.Clone allocates at capacity equal to length for most sizes, so appending the response message reallocated and recopied. Size the slice once and copy into it. --- go/ai/generate.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/go/ai/generate.go b/go/ai/generate.go index 1fa362c9d5..8db90fbfea 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -1145,7 +1145,10 @@ func (mr *ModelResponse) History() []*Message { if mr.Message == nil { return slices.Clone(reqMsgs) } - return append(slices.Clone(reqMsgs), mr.Message) + history := make([]*Message, len(reqMsgs)+1) + copy(history, reqMsgs) + history[len(reqMsgs)] = mr.Message + return history } // Reasoning concatenates all reasoning parts present in the message