okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5b3f8be
main
1//go:build unix 2 3package e2e 4 5import ( 6"bufio" 7"context" 8"encoding/json" 9"errors" 10"fmt" 11"net/http" 12"net/http/httptest" 13"net/url" 14"os" 15"path/filepath" 16"reflect" 17"strings" 18"sync/atomic" 19"testing" 20"time" 21 22"albedo/cli/internal/config" 23"albedo/cli/internal/daemon" 24) 25 26// A raw protocol reader and the Go client attach to the same paused real 27// daemon turn. The reset snapshot must describe current activity and arrive 28// through the Go callback as live normalized progress. Finalized file changes 29// must also survive both the live result and durable history projection. 30func TestLateChatClientRestoresNormalizedToolProgress (t * testing.T ) { 31const code = "from pathlib import Path\nimport time\nPath('progress-ready').write_text('ready')\nwhile not Path('finish-progress').exists(): time.sleep(0.01)\nfiles.write('trace-display.txt', 'finished trace\\n')\nprint('finished')" 32ready ,release := make (chan struct {}),make (chan struct {}) 33var calls atomic.Int32 34provider := httptest .NewServer (http .HandlerFunc (func (writer http.ResponseWriter ,request * http.Request ) { 35if request .Method == http .MethodGet { 36writer .Header ().Set ("Content-Type" ,"application/json" ) 37_ ,_ = writer .Write ([]byte (`{"data":[]}` )) 38return 39 } 40call := calls .Add (1 ) 41writer .Header ().Set ("Content-Type" ,"text/event-stream" ) 42flusher ,_ := writer .(http.Flusher ) 43writeChunk := func (delta map [string ]any ,finish string ) { 44choice := map [string ]any {"index" :0 ,"delta" :delta ,"finish_reason" :nil } 45if finish != "" { 46choice ["finish_reason" ]= finish 47 } 48chunk ,_ := json .Marshal (map [string ]any {"id" :"go-progress-e2e" ,"choices" : []any {choice }}) 49_ ,_ = fmt .Fprintf (writer ,"data: %s\n\n" ,chunk ) 50if flusher != nil { 51flusher .Flush () 52 } 53 } 54if call == 1 { 55args ,_ := json .Marshal (map [string ]string {"code" :code }) 56for offset := 0 ;offset < len (args );offset += 9 { 57fragment := string (args [offset :min (len (args ),offset + 9 )]) 58delta := map [string ]any {"tool_calls" : []any {map [string ]any { 59"index" :0 ,"function" :map [string ]any {"arguments" :fragment }, 60 }}} 61if offset == 0 { 62delta ["tool_calls" ]= []any {map [string ]any { 63"index" :0 ,"id" :"native-go-progress" ,"type" :"function" , 64"function" :map [string ]any {"name" :"python" ,"arguments" :fragment }, 65 }} 66 } 67writeChunk (delta ,"" ) 68 } 69close (ready ) 70select { 71case <- release : 72case <- request .Context ().Done (): 73return 74case <- time .After (30 * time .Second ): 75http .Error (writer ,"test release timed out" ,http .StatusGatewayTimeout ) 76return 77 } 78writeChunk (map [string ]any {},"tool_calls" ) 79 }else { 80writeChunk (map [string ]any {"content" :"done" },"" ) 81writeChunk (map [string ]any {},"stop" ) 82 } 83_ ,_ = fmt .Fprint (writer ,"data: [DONE]\n\n" ) 84 })) 85t .Cleanup (func () { 86select { 87case <- release : 88default : 89close (release ) 90 } 91provider .Close () 92 }) 93 94profile := t .Name () 95if err := saveAndSelectProvider (context .Background (),conn (t ),profile , config.Settings { 96Extension :"openai" ,BaseURL :provider .URL ,APIKey :"fixture-key" , 97Model :"fixture-model" ,Protocol :"chat_completions" , 98 });err != nil { 99t .Fatal (err ) 100 } 101t .Parallel () 102workspace := t .TempDir () 103finishPath := filepath .Join (workspace ,"finish-progress" ) 104t .Cleanup (func () {_ = os .WriteFile (finishPath , []byte ("finish" ),0o600 ) }) 105session := newSession (t ,workspace ) 106client := daemon .NewChatClient (conn (t ),session ) 107if _ ,err := client .Send (t .Context (),"pause for Go progress attachment" ,nil );err != nil { 108t .Fatal (err ) 109 } 110select { 111case <- ready : 112case <- time .After (30 * time .Second ): 113t .Fatal ("provider did not reach the tool argument barrier" ) 114 } 115 116compareReset := func (phase string ) []daemon.ToolProgress { 117t .Helper () 118// A flushed provider fragment may still be waiting for the actor. 119// Wait for its full short preview before comparing two attachments. 120wireProgress := waitCurrentProgress (t ,conn (t ),session ,phase ,code ) 121if len (wireProgress )!= 1 || wireProgress [0 ].Phase != phase || wireProgress [0 ].Code == nil || wireProgress [0 ].Code .Text == "" { 122t .Fatalf ("raw reset did not expose the active call: %+v" ,wireProgress ) 123 } 124stop := errors .New ("received live reset snapshot" ) 125var restored * daemon.ToolProgress 126err := daemon .NewChatClient (conn (t ),session ).Stream (t .Context (),0 ,func (event daemon.StreamEvent )error { 127if event .Type == daemon .EventToolProgress { 128if event .Replayed { 129t .Error ("current progress was marked as replayed history" ) 130 } 131restored = event .Progress 132return stop 133 } 134return nil 135 }) 136if ! errors .Is (err ,stop ) { 137t .Fatalf ("Go stream did not deliver current progress: %v" ,err ) 138 } 139if restored == nil || ! reflect .DeepEqual (* restored ,wireProgress [0 ]) { 140t .Fatalf ("Go callback differs from independently decoded reset snapshot: raw=%+v callback=%+v" ,wireProgress [0 ],restored ) 141 } 142return wireProgress 143 } 144generating := compareReset ("generating" ) 145if generating [0 ].Phase != "generating" { 146t .Fatalf ("initial progress phase = %q, want generating" ,generating [0 ].Phase ) 147 } 148 149close (release ) 150deadline := time .Now ().Add (30 * time .Second ) 151for { 152if _ ,err := os .Stat (filepath .Join (workspace ,"progress-ready" ));err == nil { 153break 154 } 155if time .Now ().After (deadline ) { 156t .Fatal ("Python tool did not reach its running barrier" ) 157 } 158time .Sleep (25 * time .Millisecond ) 159 } 160running := compareReset ("running" ) 161if running [0 ].Phase != "running" || running [0 ].CallID != generating [0 ].CallID { 162t .Fatalf ("running reset lost call identity or phase: generating=%+v running=%+v" ,generating [0 ],running [0 ]) 163 } 164finishedTrace := errors .New ("received finalized tool trace" ) 165ctx ,cancel := context .WithTimeout (t .Context (),30 * time .Second ) 166defer cancel () 167var liveTrace * daemon.ToolTrace 168err := daemon .NewChatClient (conn (t ),session ).Stream (ctx ,0 ,func (event daemon.StreamEvent )error { 169if event .Type == daemon .EventToolProgress && event .Progress != nil && event .Progress .Phase == "running" { 170return os .WriteFile (finishPath , []byte ("finish" ),0o600 ) 171 } 172if event .Type == daemon .EventTool && event .ToolName == "python" { 173if event .Replayed { 174t .Error ("live tool result was marked as replayed history" ) 175 } 176liveTrace = event .ToolTrace 177return finishedTrace 178 } 179return nil 180 }) 181if ! errors .Is (err ,finishedTrace ) { 182t .Fatalf ("finalized tool trace did not reach the Go callback: %v" ,err ) 183 } 184assertTrace := func (trace * daemon.ToolTrace ) { 185t .Helper () 186if trace == nil { 187t .Fatal ("tool result lost its finalized trace" ) 188 } 189for _ ,change := range trace .Changes { 190if filepath .Base (change .Path )== "trace-display.txt" && change .Kind == "diff" && strings .Contains (change .Diff ,"+finished trace" ) { 191return 192 } 193 } 194t .Fatalf ("tool trace lost the actual file change: %+v" ,trace ) 195 } 196assertTrace (liveTrace ) 197deadline = time .Now ().Add (30 * time .Second ) 198for { 199status ,err := client .GetStatus (t .Context ()) 200if err != nil { 201t .Fatal (err ) 202 } 203if calls .Load ()>= 2 && ! status .Running { 204break 205 } 206if time .Now ().After (deadline ) { 207t .Fatalf ("tool turn did not finish: provider calls=%d status=%+v" ,calls .Load (),status ) 208 } 209time .Sleep (25 * time .Millisecond ) 210 } 211history ,err := client .History (t .Context (),0 ,120 ) 212if err != nil { 213t .Fatal (err ) 214 } 215var replayedTrace * daemon.ToolTrace 216for _ ,event := range history .Events { 217if event .Type == daemon .EventTool && event .ToolName == "python" { 218replayedTrace = event .ToolTrace 219 } 220 } 221assertTrace (replayedTrace ) 222if ! reflect .DeepEqual (liveTrace ,replayedTrace ) { 223t .Fatalf ("durable history changed the finalized trace: live=%+v history=%+v" ,liveTrace ,replayedTrace ) 224 } 225} 226 227func readCurrentProgress (t * testing.T ,connection * daemon.Connection ,session string ) []daemon.ToolProgress { 228t .Helper () 229ctx ,cancel := context .WithTimeout (t .Context (),15 * time .Second ) 230defer cancel () 231endpoint := connection .BaseURL ()+ "/sessions/" + url .PathEscape (session ) 232request ,err := http .NewRequestWithContext (ctx ,http .MethodGet ,endpoint ,nil ) 233if err != nil { 234t .Fatal (err ) 235 } 236request .Header .Set ("Accept" ,"text/event-stream" ) 237request .Header .Set ("Authorization" ,"Bearer " + connection .Snapshot ().Token ) 238response ,err := connection .HTTPClient ().Do (request ) 239if err != nil { 240t .Fatal (err ) 241 } 242defer response .Body .Close () 243if response .StatusCode != http .StatusOK { 244t .Fatalf ("raw session stream: %s" ,response .Status ) 245 } 246scanner := bufio .NewScanner (response .Body ) 247scanner .Buffer (make ([]byte ,4096 ),10 * 1024 * 1024 ) 248for scanner .Scan () { 249line := scanner .Text () 250if ! strings .HasPrefix (line ,"data: " ) { 251continue 252 } 253var batch struct { 254Events []map [string ]any `json:"events"` 255Snapshot struct { 256CurrentProgress []struct { 257CallID string `json:"call_id"` 258ToolCallID * string `json:"tool_call_id"` 259Name string `json:"name"` 260Phase string `json:"phase"` 261Preview * struct { 262Text string `json:"text"` 263Offset int `json:"offset_scalars"` 264 }`json:"preview"` 265 }`json:"current_progress"` 266 }`json:"snapshot"` 267 } 268if err := json .Unmarshal ([]byte (strings .TrimPrefix (line ,"data: " )),& batch );err != nil { 269t .Fatal (err ) 270 } 271if len (batch .Events )== 0 || batch .Events [0 ]["type" ]!= "reset" { 272t .Fatalf ("late raw attachment did not begin with a reset: %+v" ,batch .Events ) 273 } 274if batch .Snapshot .CurrentProgress == nil { 275t .Fatal ("reset snapshot omitted current_progress" ) 276 } 277result := []daemon.ToolProgress {} 278for _ ,item := range batch .Snapshot .CurrentProgress { 279progress := daemon.ToolProgress {CallID :item .CallID ,Name :item .Name ,Phase :item .Phase } 280if item .ToolCallID != nil { 281progress .ToolCallID = * item .ToolCallID 282 } 283if item .Preview != nil { 284progress .Code = & daemon.ToolCodePreview {Text :item .Preview .Text ,Offset :item .Preview .Offset } 285 } 286result = append (result ,progress ) 287 } 288return result 289 } 290if err := scanner .Err ();err != nil { 291t .Fatal (err ) 292 } 293t .Fatal ("raw session stream ended before a reset batch" ) 294return nil 295} 296 297func waitCurrentProgress (t * testing.T ,connection * daemon.Connection ,session ,phase ,code string ) []daemon.ToolProgress { 298t .Helper () 299deadline := time .Now ().Add (15 * time .Second ) 300for { 301progress := readCurrentProgress (t ,connection ,session ) 302if len (progress )== 1 && progress [0 ].Phase == phase && progress [0 ].Code != nil && progress [0 ].Code .Offset == 0 && progress [0 ].Code .Text == code { 303return progress 304 } 305if time .Now ().After (deadline ) { 306t .Fatal ("reset snapshots never exposed the provider's live progress" ) 307 } 308time .Sleep (10 * time .Millisecond ) 309 } 310}