Add immutable conversation exchange storage
This commit is contained in:
@@ -318,6 +318,8 @@ domain representations simply to fan out callbacks.
|
||||
|
||||
## Stage 1 — Implement Immutable Exchange Storage
|
||||
|
||||
**Status:** Complete.
|
||||
|
||||
**Scope:** internal history value and ownership only.
|
||||
|
||||
1. Add the internal exchange representation and construction/flattening helpers
|
||||
|
||||
60
internal/domain/exchange.go
Normal file
60
internal/domain/exchange.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package domain
|
||||
|
||||
// Exchange is an immutable logical conversation ending in an assistant message.
|
||||
type Exchange struct {
|
||||
parent *Exchange
|
||||
runID string
|
||||
parentRunID string
|
||||
messages []RenderedMessage
|
||||
count int
|
||||
}
|
||||
|
||||
// CaptureExchange creates a new exchange without modifying its parent.
|
||||
func CaptureExchange(parent *Exchange, runID string, messages []RenderedMessage, output string) *Exchange {
|
||||
turn := make([]RenderedMessage, len(messages)+1)
|
||||
copyRenderedMessages(turn, messages)
|
||||
turn[len(messages)] = RenderedMessage{Role: RoleAssistant, Content: output}
|
||||
exchange := &Exchange{parent: parent, runID: runID, messages: turn, count: len(turn)}
|
||||
if parent != nil {
|
||||
exchange.parentRunID = parent.runID
|
||||
exchange.count += parent.count
|
||||
}
|
||||
return exchange
|
||||
}
|
||||
|
||||
// RunID returns the identity of the run that produced this exchange.
|
||||
func (e *Exchange) RunID() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.runID
|
||||
}
|
||||
|
||||
// ParentRunID returns the immediate source run identity, if present.
|
||||
func (e *Exchange) ParentRunID() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.parentRunID
|
||||
}
|
||||
|
||||
// MessageCount returns the number of messages in the logical history.
|
||||
func (e *Exchange) MessageCount() int {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return e.count
|
||||
}
|
||||
|
||||
// Messages returns a detached copy of the complete logical history.
|
||||
func (e *Exchange) Messages() []RenderedMessage {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
messages := make([]RenderedMessage, e.count)
|
||||
for current := e; current != nil; current = current.parent {
|
||||
start := current.count - len(current.messages)
|
||||
copyRenderedMessages(messages[start:current.count], current.messages)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
53
internal/domain/exchange_test.go
Normal file
53
internal/domain/exchange_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExchangeHistoryOwnershipAndBranches(t *testing.T) {
|
||||
control := &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}
|
||||
source := []RenderedMessage{{Role: RoleSystem, Content: "", CacheControl: control}, {Role: RoleUser, Content: "question"}}
|
||||
initial := CaptureExchange(nil, "first", source, " answer ")
|
||||
first := CaptureExchange(initial, "second", []RenderedMessage{{Role: RoleUser, Content: "follow-up"}}, "reply")
|
||||
second := CaptureExchange(initial, "third", []RenderedMessage{{Role: RoleDeveloper, Content: "other"}}, "")
|
||||
source[0].Content = "changed"
|
||||
control.TTL = "changed"
|
||||
got := initial.Messages()
|
||||
want := []RenderedMessage{{Role: RoleSystem, Content: "", CacheControl: &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}}, {Role: RoleUser, Content: "question"}, {Role: RoleAssistant, Content: " answer "}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("initial messages = %#v, want %#v", got, want)
|
||||
}
|
||||
got[0].CacheControl.TTL = "mutated"
|
||||
got[1].Content = "mutated"
|
||||
if !reflect.DeepEqual(initial.Messages(), want) {
|
||||
t.Fatal("returned messages changed retained history")
|
||||
}
|
||||
if first.RunID() != "second" || first.ParentRunID() != "first" || first.MessageCount() != 5 {
|
||||
t.Fatal("incorrect branch metadata")
|
||||
}
|
||||
if second.ParentRunID() != "first" || second.Messages()[3].Content != "other" || second.Messages()[4].Content != "" {
|
||||
t.Fatal("incorrect sibling history")
|
||||
}
|
||||
if first.Messages()[3].Content != "follow-up" || !reflect.DeepEqual(initial.Messages(), want) {
|
||||
t.Fatal("branches changed each other or their parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeManyTurns(t *testing.T) {
|
||||
var exchange *Exchange
|
||||
const turns = 2000
|
||||
for i := 0; i < turns; i++ {
|
||||
exchange = CaptureExchange(exchange, fmt.Sprint(i), []RenderedMessage{{Role: RoleUser, Content: fmt.Sprint(i)}}, fmt.Sprint(i))
|
||||
}
|
||||
messages := exchange.Messages()
|
||||
if len(messages) != turns*2 || exchange.MessageCount() != len(messages) {
|
||||
t.Fatalf("unexpected history length: %d", len(messages))
|
||||
}
|
||||
for i := 0; i < turns; i++ {
|
||||
if messages[2*i].Content != fmt.Sprint(i) || messages[2*i+1].Role != RoleAssistant || messages[2*i+1].Content != fmt.Sprint(i) {
|
||||
t.Fatalf("unexpected turn %d: %#v", i, messages[2*i:2*i+2])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user