From fcd0485340f77f009316d40790429b025a698670 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 14 Nov 2026 01:14:08 +0000 Subject: [PATCH] Add immutable conversation exchange storage --- docs/roadmap/implementation.md | 2 ++ internal/domain/exchange.go | 60 ++++++++++++++++++++++++++++++++ internal/domain/exchange_test.go | 53 ++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 internal/domain/exchange.go create mode 100644 internal/domain/exchange_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6667133..de0bd48 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/domain/exchange.go b/internal/domain/exchange.go new file mode 100644 index 0000000..e77a5f4 --- /dev/null +++ b/internal/domain/exchange.go @@ -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 +} diff --git a/internal/domain/exchange_test.go b/internal/domain/exchange_test.go new file mode 100644 index 0000000..f72d15b --- /dev/null +++ b/internal/domain/exchange_test.go @@ -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]) + } + } +}