github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/environments/memories_two_pass_test.go (about) 1 package environments 2 3 // The sync's two passes: a content-free listing, then targeted fetches. 4 // 5 // A sync lists shas only (basic view) and fetches a memory's body just 6 // before writing it to disk; only the download takes full pages, since it 7 // needs every memory anyway. These tests pin that fetch discipline: a sync 8 // that writes nothing fetches nothing, a changed path fetches exactly 9 // itself, fetches fan out rather than queue, and a fetch that fails — or 10 // races a server-side delete — leaves the old state for the next sync. 11 12 import ( 13 "context" 14 "net/http" 15 "net/http/httptest" 16 "path/filepath" 17 "strings" 18 "testing" 19 "time" 20 21 anthropic "github.com/anthropics/anthropic-sdk-go" 22 "github.com/anthropics/anthropic-sdk-go/option" 23 "github.com/stretchr/testify/require" 24 ) 25 26 func TestTwoPass_ASyncThatPullsNothingFetchesNoContent(t *testing.T) { 27 local, server, stores := downloadedStores(t, map[string]string{"a.md": "v1", "b.md": "v1", "c.md": "v1"}, "", nil, nil) 28 ctx := context.Background() 29 // The download took full listing pages: no single-memory fetches at all. 30 require.Empty(t, server.ContentFetchesSnapshot()) 31 32 writeLocal(t, local, "a.md", "v2 from agent") // a push needs no remote content 33 runSync(ctx, stores) 34 35 require.Empty(t, server.ContentFetchesSnapshot()) 36 require.Equal(t, "v2 from agent", server.Files["a.md"]) 37 } 38 39 func TestTwoPass_OnlyTheChangedMemoryIsFetched(t *testing.T) { 40 local, server, stores := downloadedStores(t, map[string]string{"a.md": "v1", "b.md": "v1"}, "", nil, nil) 41 ctx := context.Background() 42 server.ClearContentFetches() 43 44 server.Write("a.md", "v2 from server") 45 runSync(ctx, stores) 46 47 require.Equal(t, []string{"a.md"}, server.ContentFetchesSnapshot()) 48 require.Equal(t, "v2 from server", readLocal(t, local, "a.md")) 49 require.Equal(t, "v1", readLocal(t, local, "b.md")) 50 } 51 52 func TestTwoPass_AFileAlreadyHoldingTheRemoteContentIsAdoptedWithoutAFetch(t *testing.T) { 53 local, server, stores := downloadedStores(t, map[string]string{"a.md": "v1"}, "", nil, nil) 54 ctx := context.Background() 55 server.ClearContentFetches() 56 57 // Both sides landed on the same bytes independently. 58 writeLocal(t, local, "a.md", "v2 on both") 59 server.Write("a.md", "v2 on both") 60 runSync(ctx, stores) 61 62 require.Empty(t, server.ContentFetchesSnapshot()) 63 require.Empty(t, server.ReceivedSnapshot()) 64 65 // The baseline advanced to the adopted sha: the next edit pushes guarded by it. 66 writeLocal(t, local, "a.md", "v3 from agent") 67 runSync(ctx, stores) 68 require.Equal(t, []string{updated("a.md", "v3 from agent", "v2 on both")}, server.ReceivedSnapshot()) 69 } 70 71 func TestTwoPass_ContentFetchesFanOutWithinAStore(t *testing.T) { 72 // The first memory's fetch completes only after the second's starts — 73 // only parallel fetches satisfy that; serial ones would time out. 74 server := newMemoryServer(t, map[string]string{"a.md": "v1", "b.md": "v1"}, "") 75 secondStarted := make(chan struct{}) 76 httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 77 isFetch := r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/memories/") 78 if isFetch { 79 if strings.HasSuffix(r.URL.Path, "/"+memID("a.md")) { 80 select { 81 case <-secondStarted: 82 case <-time.After(10 * time.Second): 83 t.Error("the second fetch never started: fetches ran serially") 84 } 85 } else { 86 close(secondStarted) 87 } 88 } 89 server.serveHTTP(w, r) 90 })) 91 t.Cleanup(httpSrv.Close) 92 client := anthropic.NewClient(option.WithBaseURL(httpSrv.URL), option.WithAPIKey("k"), option.WithMaxRetries(0)) 93 workdir := t.TempDir() 94 stores := mustStores(t, client, SessionMemoryStoresOptions{Workdir: workdir, Logger: silentLogger}) 95 ctx := context.Background() 96 require.NoError(t, stores.Download(ctx, fetchSession(t, client))) 97 local := filepath.Join(workdir, "memory", "notes") 98 99 server.Write("a.md", "v2 from server") 100 server.Write("b.md", "v2 from server") 101 runSync(ctx, stores) 102 103 require.Equal(t, "v2 from server", readLocal(t, local, "a.md")) 104 require.Equal(t, "v2 from server", readLocal(t, local, "b.md")) 105 } 106 107 func TestTwoPass_AFailedFetchKeepsTheOldStateAndTheNextSyncRetries(t *testing.T) { 108 local, server, stores := downloadedStores(t, map[string]string{"a.md": "v1"}, "", nil, nil) 109 ctx := context.Background() 110 server.Write("a.md", "v2 from server") 111 112 server.SetOnFetch(func(string) int { return 500 }) 113 runSync(ctx, stores) 114 require.Equal(t, "v1", readLocal(t, local, "a.md")) 115 116 server.SetOnFetch(nil) 117 runSync(ctx, stores) 118 require.Equal(t, "v2 from server", readLocal(t, local, "a.md")) 119 // The stale baseline drove no uploads or deletes along the way. 120 require.Empty(t, server.ReceivedSnapshot()) 121 } 122 123 func TestTwoPass_AMemoryDeletedBetweenListingAndFetchWaitsForTheNextSync(t *testing.T) { 124 local, server, stores := downloadedStores(t, map[string]string{"a.md": "v1", "keep.md": "v1"}, "", nil, nil) 125 ctx := context.Background() 126 server.Write("a.md", "v2 from server") 127 128 // Called with the server mutex held, so Files is safe to touch. 129 server.SetOnFetch(func(path string) int { 130 delete(server.Files, path) 131 return 0 132 }) 133 runSync(ctx, stores) 134 // The fetch 404ed: disk and baseline keep the old version for now. 135 require.Equal(t, "v1", readLocal(t, local, "a.md")) 136 137 server.SetOnFetch(nil) 138 runSync(ctx, stores) 139 // The next sync saw the server-side delete and took the local copy with it. 140 require.NoFileExists(t, filepath.Join(local, "a.md")) 141 require.Empty(t, server.ReceivedSnapshot()) 142 } 143 144 func TestTwoPass_ListingsAskForTheLargestPageTheViewAllows(t *testing.T) { 145 // The download lists full pages at the server's cap for that view; the 146 // sync lists basic pages at the API maximum. 147 server := newMemoryServer(t, map[string]string{"a.md": "v1"}, "") 148 var listQueries []string 149 httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 150 if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/memories") { 151 q := r.URL.Query() 152 listQueries = append(listQueries, "view="+q.Get("view")+" limit="+q.Get("limit")) 153 } 154 server.serveHTTP(w, r) 155 })) 156 t.Cleanup(httpSrv.Close) 157 client := anthropic.NewClient(option.WithBaseURL(httpSrv.URL), option.WithAPIKey("k"), option.WithMaxRetries(0)) 158 stores := mustStores(t, client, SessionMemoryStoresOptions{Workdir: t.TempDir(), Logger: silentLogger}) 159 ctx := context.Background() 160 require.NoError(t, stores.Download(ctx, fetchSession(t, client))) 161 runSync(ctx, stores) 162 163 require.Equal(t, []string{"view=full limit=20", "view=basic limit=100"}, listQueries) 164 }