github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/environments/worker_memory_test.go (about)

     1  package environments
     2  
     3  // Memory-store sync driven end to end through the real EnvironmentWorker.
     4  //
     5  // Real: the worker, SessionMemoryStores, the filestore, and the filesystem.
     6  // Faked: the network (the memoryServer behind the fake work server) and the
     7  // clock. So the chain worker → gate on the sessions token → download →
     8  // SyncIfDue per tool call → Finish → Dispose runs for real against a fake
     9  // control plane; assertions read the server and the disk.
    10  
    11  import (
    12  	"bytes"
    13  	"context"
    14  	"encoding/json"
    15  	"fmt"
    16  	"net/http"
    17  	"os"
    18  	"path/filepath"
    19  	"slices"
    20  	"strings"
    21  	"sync"
    22  	"sync/atomic"
    23  	"testing"
    24  	"time"
    25  
    26  	anthropic "github.com/anthropics/anthropic-sdk-go"
    27  	"github.com/anthropics/anthropic-sdk-go/tools/agenttoolset"
    28  	"github.com/stretchr/testify/require"
    29  )
    30  
    31  // memoryTestTool is a minimal anthropic.BetaTool whose Execute delegates to
    32  // the test — the agent's stand-in for editing its notes folder.
    33  type memoryTestTool struct {
    34  	exec    func(input json.RawMessage)
    35  	onClose func()
    36  }
    37  
    38  func (m *memoryTestTool) Name() string        { return "note_edit" }
    39  func (m *memoryTestTool) Description() string { return "edit the note" }
    40  func (m *memoryTestTool) InputSchema() anthropic.BetaToolInputSchemaParam {
    41  	return anthropic.BetaToolInputSchemaParam{Properties: map[string]any{}}
    42  }
    43  func (m *memoryTestTool) Execute(_ context.Context, input json.RawMessage) ([]anthropic.BetaToolResultBlockParamContentUnion, error) {
    44  	m.exec(input)
    45  	return []anthropic.BetaToolResultBlockParamContentUnion{{OfText: &anthropic.BetaTextBlockParam{Text: "ok"}}}, nil
    46  }
    47  func (m *memoryTestTool) Close() error {
    48  	if m.onClose != nil {
    49  		m.onClose()
    50  	}
    51  	return nil
    52  }
    53  
    54  func toolUseSSE(i int) string {
    55  	return fmt.Sprintf("event: agent.tool_use\n"+
    56  		`data: {"type":"agent.tool_use","id":"evt_%d","name":"note_edit","input":{"i":%d},"processed_at":"2026-05-11T12:00:00Z"}`+
    57  		"\n\n", i, i)
    58  }
    59  
    60  const terminatedSSE = "event: session.status_terminated\n" +
    61  	`data: {"type":"session.status_terminated","id":"evt_term","processed_at":"2026-05-11T12:00:00Z"}` +
    62  	"\n\n"
    63  
    64  // memoryWorkServer scripts a fakeWorkServer around a memoryServer: session
    65  // lookups return the store-carrying session (and are counted), memory calls
    66  // hit the in-memory store, and the stream dispatches toolCalls note_edit
    67  // calls before ending with terminate (or holding open when terminate is
    68  // false, for the unclean-end test).
    69  //
    70  // The SessionToolRunner executes tool calls eagerly as events arrive, ahead
    71  // of the consumer loop that runs SyncIfDue — so to observe one sync per
    72  // call the stream must not emit the next tool_use until the effects of the
    73  // previous one are visible. gate(i) is polled after tool_use i's result was
    74  // posted; the next event is only sent once it returns true (nil gates on the
    75  // post alone).
    76  func memoryWorkServer(t *testing.T, mem *memoryServer, toolCalls int, terminate bool, gate func(i int) bool) *fakeWorkServer {
    77  	t.Helper()
    78  	server := newFakeWorkServer(t)
    79  	server.HandleSessionGet = func(w http.ResponseWriter, r *http.Request) {
    80  		mem.serveHTTP(w, r) // records the retrieve, returns the session JSON
    81  	}
    82  	server.HandleMemories = mem.handleMemories
    83  	server.HandleHeartbeat = func(w http.ResponseWriter, _ *http.Request) {
    84  		w.Header().Set("Content-Type", "application/json")
    85  		_, _ = w.Write([]byte(`{"last_heartbeat":"2026-05-11T12:00:00Z","lease_extended":true,"state":"active","ttl_seconds":30,"type":"work_heartbeat"}`))
    86  	}
    87  	server.HandleList = func(w http.ResponseWriter, _ *http.Request) {
    88  		w.Header().Set("Content-Type", "application/json")
    89  		_, _ = w.Write([]byte(`{"data":[],"first_id":null,"has_more":false,"last_id":null}`))
    90  	}
    91  	var sends atomic.Int32
    92  	server.HandleSend = func(w http.ResponseWriter, _ *http.Request) {
    93  		sends.Add(1)
    94  		w.Header().Set("Content-Type", "application/json")
    95  		_, _ = w.Write([]byte(`{"type":"send_session_events"}`))
    96  	}
    97  	server.HandleStream = func(w http.ResponseWriter, r *http.Request) {
    98  		w.Header().Set("Content-Type", "text/event-stream")
    99  		flusher, ok := w.(http.Flusher)
   100  		require.True(t, ok)
   101  		w.WriteHeader(http.StatusOK)
   102  		flusher.Flush()
   103  		for i := 0; i < toolCalls; i++ {
   104  			_, _ = w.Write([]byte(toolUseSSE(i)))
   105  			flusher.Flush()
   106  			// Wait for tool_use i's result post, then for gate(i), before
   107  			// emitting the next event.
   108  			for int(sends.Load()) <= i || (gate != nil && !gate(i)) {
   109  				select {
   110  				case <-r.Context().Done():
   111  					return
   112  				case <-time.After(2 * time.Millisecond):
   113  				}
   114  			}
   115  		}
   116  		if terminate {
   117  			_, _ = w.Write([]byte(terminatedSSE))
   118  			flusher.Flush()
   119  			return
   120  		}
   121  		<-r.Context().Done()
   122  	}
   123  	server.HandleStop = func(w http.ResponseWriter, _ *http.Request) {
   124  		w.WriteHeader(http.StatusNoContent)
   125  	}
   126  	return server
   127  }
   128  
   129  // memoryWorker builds an EnvironmentWorker whose one tool writes
   130  // "edit {i}" into the store dir (when writeEdits is set — the gate tests run
   131  // tool calls without touching the never-downloaded folder) and then advances
   132  // the fake clock by advanceBy — so a nonzero advance makes the very next
   133  // SyncIfDue due. opts.Logger is honored when the test wants to read the log.
   134  func memoryWorker(t *testing.T, server *fakeWorkServer, workdir string, writeEdits bool, advanceBy time.Duration, opts EnvironmentWorkerOptions) *EnvironmentWorker {
   135  	t.Helper()
   136  	// The tool advances the clock on the runner's dispatch goroutine while
   137  	// the worker loop reads it, so the fake clock takes a lock.
   138  	var mu sync.Mutex
   139  	now := time.Unix(0, 0)
   140  	local := filepath.Join(workdir, "memory", "notes")
   141  	tool := &memoryTestTool{exec: func(input json.RawMessage) {
   142  		var in struct{ I int }
   143  		require.NoError(t, json.Unmarshal(input, &in))
   144  		if writeEdits {
   145  			require.NoError(t, os.WriteFile(filepath.Join(local, "note.md"), fmt.Appendf(nil, "edit %d", in.I), 0o600))
   146  		}
   147  		mu.Lock()
   148  		now = now.Add(advanceBy)
   149  		mu.Unlock()
   150  	}}
   151  	opts.Workdir = workdir
   152  	if opts.Logger == nil {
   153  		opts.Logger = silentLogger
   154  	}
   155  	opts.ToolsFunc = func(*agenttoolset.AgentToolContext) []anthropic.BetaTool { return []anthropic.BetaTool{tool} }
   156  	worker := NewEnvironmentWorker(server.Client(), opts)
   157  	worker.memoryClock = func() time.Time {
   158  		mu.Lock()
   159  		defer mu.Unlock()
   160  		return now
   161  	}
   162  	return worker
   163  }
   164  
   165  // runMemoryItem runs one work item for session s1 under a 15s bound and
   166  // returns what HandleItem returned.
   167  func runMemoryItem(worker *EnvironmentWorker, secret string) error {
   168  	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
   169  	defer cancel()
   170  	return worker.HandleItem(ctx, HandleItemOptions{
   171  		WorkID:         "work_1",
   172  		EnvironmentID:  "env_1",
   173  		SessionID:      "s1",
   174  		EnvironmentKey: "env_key",
   175  		WorkSecret:     secret,
   176  	})
   177  }
   178  
   179  func handleMemoryItem(t *testing.T, worker *EnvironmentWorker, secret string) {
   180  	t.Helper()
   181  	require.NoError(t, runMemoryItem(worker, secret))
   182  }
   183  
   184  // reportStoppingOnceWritten makes the heartbeat report "stopping" once file
   185  // holds want — the tool call has run — so the session is cancelled
   186  // mid-stream and ends uncleanly.
   187  func reportStoppingOnceWritten(server *fakeWorkServer, file, want string) {
   188  	toolRan := make(chan struct{})
   189  	server.HandleHeartbeat = func(w http.ResponseWriter, _ *http.Request) {
   190  		w.Header().Set("Content-Type", "application/json")
   191  		select {
   192  		case <-toolRan:
   193  			_, _ = w.Write([]byte(`{"last_heartbeat":"2026-05-11T12:00:00Z","lease_extended":true,"state":"stopping","ttl_seconds":2,"type":"work_heartbeat"}`))
   194  		default:
   195  			_, _ = w.Write([]byte(`{"last_heartbeat":"2026-05-11T12:00:00Z","lease_extended":true,"state":"active","ttl_seconds":2,"type":"work_heartbeat"}`))
   196  		}
   197  	}
   198  	go func() {
   199  		for {
   200  			if got, err := os.ReadFile(file); err == nil && string(got) == want {
   201  				close(toolRan)
   202  				return
   203  			}
   204  			time.Sleep(10 * time.Millisecond)
   205  		}
   206  	}()
   207  }
   208  
   209  // shortenFlushTimeout sets [MemoryFlushTimeout] to d for the rest of the test.
   210  func shortenFlushTimeout(t *testing.T, d time.Duration) {
   211  	t.Helper()
   212  	old := MemoryFlushTimeout
   213  	MemoryFlushTimeout = d
   214  	t.Cleanup(func() { MemoryFlushTimeout = old })
   215  }
   216  
   217  func TestEnvironmentWorker_MemorySyncsOnCadenceAndDisposes(t *testing.T) {
   218  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   219  	server := memoryWorkServer(t, mem, 3, true, func(i int) bool {
   220  		// Before dispatching call i+1, the cadence sync for call i (which
   221  		// advanced the clock) must have pushed its edit; call 2 does not
   222  		// advance the clock, so it gates on the result post alone.
   223  		if i >= 2 {
   224  			return true
   225  		}
   226  		return slices.Contains(mem.ReceivedSnapshot(), updated("note.md", fmt.Sprintf("edit %d", i), preimage(i)))
   227  	})
   228  	workdir := t.TempDir()
   229  	local := filepath.Join(workdir, "memory", "notes")
   230  
   231  	// Calls 0 and 1 advance the clock past the interval, so their SyncIfDue
   232  	// polls are due; call 2 does not, so its edit is only pushed by the
   233  	// unconditional final sync at the clean stream end.
   234  	worker := memoryWorker(t, server, workdir, true, 400*time.Second, EnvironmentWorkerOptions{
   235  		MemorySyncInterval: 300 * time.Second,
   236  	})
   237  	handleMemoryItem(t, worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   238  
   239  	// One session fetch, shared by the skills and memory downloads.
   240  	require.Equal(t, []string{"s1"}, mem.Retrieves)
   241  	// Two cadence syncs pushed edits 0/1; the final sync pushed edit 2.
   242  	require.Equal(t, []string{
   243  		updated("note.md", "edit 0", "v1"),
   244  		updated("note.md", "edit 1", "edit 0"),
   245  		updated("note.md", "edit 2", "edit 1"),
   246  	}, mem.ReceivedSnapshot())
   247  	// Dispose removed the store dir the download created.
   248  	require.NoDirExists(t, local)
   249  }
   250  
   251  // The file tools must reach the session's memory folders wherever they are
   252  // mounted, so the worker lists every store's folder in AllowedRoots — the
   253  // default location under the workdir and a mount_path outside it alike.
   254  func TestEnvironmentWorker_GrantsFileToolsAccessToStoreFolders(t *testing.T) {
   255  	outsideMount := filepath.Join(t.TempDir(), "mnt", "notes")
   256  	tests := []struct {
   257  		description string
   258  		mountPath   string
   259  		wantRoot    func(workdir string) string
   260  	}{
   261  		{
   262  			description: "a store with no mount_path lands under the workdir and is listed",
   263  			wantRoot:    func(workdir string) string { return filepath.Join(workdir, "memory", "notes") },
   264  		},
   265  		{
   266  			description: "a store mounted outside the workdir is listed at its mount_path",
   267  			mountPath:   outsideMount,
   268  			wantRoot:    func(string) string { return outsideMount },
   269  		},
   270  	}
   271  	for _, tc := range tests {
   272  		t.Run(tc.description, func(t *testing.T) {
   273  			mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   274  			mem.MountPath = tc.mountPath
   275  			server := memoryWorkServer(t, mem, 0, true, nil)
   276  			workdir := t.TempDir()
   277  			worker := memoryWorker(t, server, workdir, false, 0, EnvironmentWorkerOptions{})
   278  			var got *agenttoolset.AgentToolContext
   279  			toolsFunc := worker.opts.ToolsFunc
   280  			worker.opts.ToolsFunc = func(env *agenttoolset.AgentToolContext) []anthropic.BetaTool {
   281  				got = env
   282  				return toolsFunc(env)
   283  			}
   284  
   285  			handleMemoryItem(t, worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   286  
   287  			require.NotNil(t, got)
   288  			require.Equal(t, []string{tc.wantRoot(workdir)}, got.AllowedRoots)
   289  			require.Empty(t, got.ReadOnlyRoots)
   290  		})
   291  	}
   292  }
   293  
   294  func TestEnvironmentWorker_MissingSessionsTokenFailsTheItem(t *testing.T) {
   295  	// The session has memory stores attached but the work item carried no
   296  	// sessions token, so its memories cannot be mounted. The item fails
   297  	// before a single tool call is served — a hosted sandbox refuses to start
   298  	// in this state, and running here without memories would silently diverge
   299  	// from it.
   300  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   301  	server := memoryWorkServer(t, mem, 2, true, nil)
   302  	// Any memory_stores call would fail the test as unscripted.
   303  	server.HandleMemories = nil
   304  	workdir := t.TempDir()
   305  
   306  	worker := memoryWorker(t, server, workdir, false, 400*time.Second, EnvironmentWorkerOptions{})
   307  	err := runMemoryItem(worker, "")
   308  
   309  	require.ErrorIs(t, err, ErrSessionMemoryNoToken)
   310  	require.ErrorContains(t, err, "carried no sessions token")
   311  
   312  	// No download, no sync, no store dir, and the session was never served...
   313  	require.Empty(t, mem.ReceivedSnapshot())
   314  	require.NoDirExists(t, filepath.Join(workdir, "memory"))
   315  	calls := server.Calls()
   316  	require.False(t, slices.ContainsFunc(calls, func(c recordedCall) bool {
   317  		return strings.HasSuffix(c.path, "/events/stream")
   318  	}), "the session ran")
   319  	// ...but the deferred force-stop still ran.
   320  	require.True(t, slices.ContainsFunc(calls, func(c recordedCall) bool {
   321  		return strings.HasSuffix(c.path, "/stop")
   322  	}), "force-stop was not sent")
   323  }
   324  
   325  func TestEnvironmentWorker_MemorySkippedWhenIntervalNegative(t *testing.T) {
   326  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   327  	server := memoryWorkServer(t, mem, 2, true, nil)
   328  	server.HandleMemories = nil
   329  	workdir := t.TempDir()
   330  
   331  	var logBuf bytes.Buffer
   332  	worker := memoryWorker(t, server, workdir, false, 400*time.Second, EnvironmentWorkerOptions{
   333  		MemorySyncInterval: -1,
   334  		Logger:             captureLog(&logBuf),
   335  	})
   336  	handleMemoryItem(t, worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   337  
   338  	// Even with a sessions token, a negative interval turns memory off
   339  	// entirely.
   340  	require.Empty(t, mem.ReceivedSnapshot())
   341  	require.NoDirExists(t, filepath.Join(workdir, "memory"))
   342  	// Turning memory off is a deliberate opt-out, so it says nothing loud —
   343  	// even though this session does have a store attached.
   344  	require.NotContains(t, logBuf.String(), "level=ERROR")
   345  	require.NotContains(t, logBuf.String(), "has memory stores attached")
   346  }
   347  
   348  func TestEnvironmentWorker_MemoryDownloadFailureFailsTheItem(t *testing.T) {
   349  	// A folder already at the store's path is debris from a run that died. The
   350  	// download refuses it and the work item fails, rather than serving a
   351  	// session whose system prompt names a memory folder holding someone else's
   352  	// files.
   353  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   354  	server := memoryWorkServer(t, mem, 0, true, nil)
   355  	// The refusal precedes any listing, so a memory_stores call would be a bug.
   356  	server.HandleMemories = nil
   357  	workdir := t.TempDir()
   358  	debris := filepath.Join(workdir, "memory", "notes")
   359  	require.NoError(t, os.MkdirAll(debris, 0o755))
   360  	require.NoError(t, os.WriteFile(filepath.Join(debris, "left-behind.md"), []byte("from a dead run"), 0o600))
   361  
   362  	worker := memoryWorker(t, server, workdir, false, 0, EnvironmentWorkerOptions{})
   363  	err := runMemoryItem(worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   364  
   365  	var memErr *SessionMemoryError
   366  	require.ErrorAs(t, err, &memErr)
   367  	require.ErrorContains(t, err, "already exists")
   368  
   369  	// Nothing was pushed, the debris is exactly as it was found...
   370  	require.Empty(t, mem.ReceivedSnapshot())
   371  	require.Equal(t, "from a dead run", readLocal(t, debris, "left-behind.md"))
   372  	// ...and the deferred force-stop still ran.
   373  	require.True(t, slices.ContainsFunc(server.Calls(), func(c recordedCall) bool {
   374  		return strings.HasSuffix(c.path, "/stop")
   375  	}), "force-stop was not sent")
   376  }
   377  
   378  func TestEnvironmentWorker_MemoryFlushesWritesOnUncleanEnd(t *testing.T) {
   379  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   380  	// The stream never terminates; instead the heartbeat reports "stopping"
   381  	// once the tool call has run, cancelling the session mid-run.
   382  	server := memoryWorkServer(t, mem, 1, false, nil)
   383  	workdir := t.TempDir()
   384  	local := filepath.Join(workdir, "memory", "notes")
   385  	reportStoppingOnceWritten(server, filepath.Join(local, "note.md"), "edit 0")
   386  
   387  	// The tool edits the note WITHOUT advancing the clock, so the per-call
   388  	// SyncIfDue is never due — the unclean end skips the final sync, but
   389  	// the shutdown flush pushes the pending edit before Dispose removes
   390  	// the folder.
   391  	worker := memoryWorker(t, server, workdir, true, 0, EnvironmentWorkerOptions{
   392  		MemorySyncInterval: 300 * time.Second,
   393  	})
   394  	_ = runMemoryItem(worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   395  
   396  	// The cancel skipped the final sync, but the shutdown flush pushed
   397  	// the pending edit before Dispose removed the folder.
   398  	require.Equal(t, []string{updated("note.md", "edit 0", "v1")}, mem.ReceivedSnapshot())
   399  	require.NoDirExists(t, local)
   400  }
   401  
   402  func TestEnvironmentWorker_MemoryCleanEndSyncsOnceAndTheFlushAddsNothing(t *testing.T) {
   403  	// A clean end runs the final sync exactly once, and the teardown
   404  	// flush that follows finds nothing dirty and sends nothing more.
   405  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   406  	server := memoryWorkServer(t, mem, 1, true, nil)
   407  	workdir := t.TempDir()
   408  	local := filepath.Join(workdir, "memory", "notes")
   409  
   410  	var logBuf bytes.Buffer
   411  	worker := memoryWorker(t, server, workdir, true, 0, EnvironmentWorkerOptions{
   412  		MemorySyncInterval: 300 * time.Second,
   413  		Logger:             captureLog(&logBuf),
   414  	})
   415  	handleMemoryItem(t, worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   416  
   417  	// The clean-end final sync pushed the edit — exactly once. The
   418  	// teardown flush runs after it but finds nothing dirty, so nothing
   419  	// more is sent.
   420  	require.Equal(t, []string{updated("note.md", "edit 0", "v1")}, mem.ReceivedSnapshot())
   421  	// Neither teardown pass hit its bound.
   422  	require.NotContains(t, logBuf.String(), "cut off")
   423  	require.NoDirExists(t, local)
   424  }
   425  
   426  func TestEnvironmentWorker_MemoryFlushIsBoundedAndTeardownStillRuns(t *testing.T) {
   427  	// A flush that hangs is cut off at MemoryFlushTimeout and says so;
   428  	// Dispose still runs.
   429  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   430  	server := memoryWorkServer(t, mem, 1, false, nil)
   431  	workdir := t.TempDir()
   432  	local := filepath.Join(workdir, "memory", "notes")
   433  	reportStoppingOnceWritten(server, filepath.Join(local, "note.md"), "edit 0")
   434  	shortenFlushTimeout(t, 50*time.Millisecond)
   435  
   436  	var flushStarted atomic.Bool
   437  	// Every sync listing hangs; the download lists with the full view and is
   438  	// unaffected. No cadence sync is due (the clock never advances) and the
   439  	// "stopping" heartbeat makes the end unclean, so the flush is the only
   440  	// sync that runs into it.
   441  	realList := server.HandleMemories
   442  	server.HandleMemories = func(w http.ResponseWriter, r *http.Request) {
   443  		if r.Method == http.MethodGet && r.URL.Query().Get("view") == "basic" {
   444  			flushStarted.Store(true)
   445  			<-r.Context().Done()
   446  			return
   447  		}
   448  		realList(w, r)
   449  	}
   450  
   451  	var logBuf bytes.Buffer
   452  	worker := memoryWorker(t, server, workdir, true, 0, EnvironmentWorkerOptions{
   453  		MemorySyncInterval: 300 * time.Second,
   454  		Logger:             captureLog(&logBuf),
   455  	})
   456  	start := time.Now()
   457  	_ = runMemoryItem(worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   458  
   459  	require.True(t, flushStarted.Load())
   460  	// Without the timeout the hung flush would hold teardown until the
   461  	// test's own 15s bound.
   462  	require.Less(t, time.Since(start), 10*time.Second)
   463  	// The pass says it was cut off, on a line carrying the item's ids; the
   464  	// listing was what hung, so every changed file counts as not uploaded.
   465  	require.Contains(t, logBuf.String(),
   466  		`msg="memory flush cut off after 50ms; changed files it had not uploaded yet are not saved" work_id=work_1 session_id=s1`)
   467  	require.Contains(t, logBuf.String(), "memory flush cut off part-way; 1 of 1 changed files had not finished uploading")
   468  	require.NotContains(t, logBuf.String(), "memory flush failed")
   469  	require.NotContains(t, logBuf.String(), "final memory sync cut off")
   470  	require.NoDirExists(t, local)
   471  }
   472  
   473  func TestEnvironmentWorker_MemoryFinalSyncIsBoundedAndTheFlushStillRuns(t *testing.T) {
   474  	// A final sync that hangs is cut off at MemoryFlushTimeout and says so;
   475  	// the flush after it still uploads the last edit.
   476  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   477  	server := memoryWorkServer(t, mem, 1, true, nil)
   478  	workdir := t.TempDir()
   479  	local := filepath.Join(workdir, "memory", "notes")
   480  	// The same bound covers the real flush that follows, which must finish
   481  	// inside it.
   482  	shortenFlushTimeout(t, 500*time.Millisecond)
   483  
   484  	// Only the final sync's listing hangs: the download lists with the full
   485  	// view, no cadence sync is due, and the flush's listing comes second.
   486  	var finalSyncListed atomic.Bool
   487  	realList := server.HandleMemories
   488  	server.HandleMemories = func(w http.ResponseWriter, r *http.Request) {
   489  		if r.Method == http.MethodGet && r.URL.Query().Get("view") == "basic" && finalSyncListed.CompareAndSwap(false, true) {
   490  			<-r.Context().Done()
   491  			return
   492  		}
   493  		realList(w, r)
   494  	}
   495  
   496  	var logBuf bytes.Buffer
   497  	worker := memoryWorker(t, server, workdir, true, 0, EnvironmentWorkerOptions{
   498  		MemorySyncInterval: 300 * time.Second,
   499  		Logger:             captureLog(&logBuf),
   500  	})
   501  	start := time.Now()
   502  	handleMemoryItem(t, worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   503  
   504  	require.Less(t, time.Since(start), 10*time.Second)
   505  	require.Contains(t, logBuf.String(), "final memory sync cut off after 500ms; the flush that follows still uploads changed files")
   506  	// The flush finished in time, so only the final sync reports a cut-off.
   507  	require.NotContains(t, logBuf.String(), "memory flush cut off")
   508  	require.Equal(t, []string{updated("note.md", "edit 0", "v1")}, mem.ReceivedSnapshot())
   509  	require.Equal(t, "edit 0", mem.Files["note.md"])
   510  	require.NoDirExists(t, local)
   511  }
   512  
   513  func TestEnvironmentWorker_MemoryLogsWhatACutOffFlushLeftBehind(t *testing.T) {
   514  	// One upload never completes: the flush is cut off, the store logs that
   515  	// one of its two changed files did not make it, and the other one did.
   516  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   517  	server := memoryWorkServer(t, mem, 1, false, nil)
   518  	workdir := t.TempDir()
   519  	local := filepath.Join(workdir, "memory", "notes")
   520  	shortenFlushTimeout(t, 500*time.Millisecond)
   521  
   522  	tool := &memoryTestTool{exec: func(json.RawMessage) {
   523  		require.NoError(t, os.WriteFile(filepath.Join(local, "kept.md"), []byte("saved"), 0o600))
   524  		require.NoError(t, os.WriteFile(filepath.Join(local, "stuck.md"), []byte("only copy"), 0o600))
   525  	}}
   526  	reportStoppingOnceWritten(server, filepath.Join(local, "stuck.md"), "only copy")
   527  	mem.SetUploadHook(func(ctx context.Context, path string) {
   528  		if path == "stuck.md" {
   529  			<-ctx.Done()
   530  		}
   531  	})
   532  
   533  	var logBuf bytes.Buffer
   534  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   535  		Workdir:            workdir,
   536  		MemorySyncInterval: 300 * time.Second,
   537  		Logger:             captureLog(&logBuf),
   538  		ToolsFunc:          func(*agenttoolset.AgentToolContext) []anthropic.BetaTool { return []anthropic.BetaTool{tool} },
   539  	})
   540  	start := time.Now()
   541  	_ = runMemoryItem(worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   542  
   543  	require.Less(t, time.Since(start), 10*time.Second)
   544  	require.Equal(t, "saved", mem.Files["kept.md"])
   545  	require.NotContains(t, mem.Files, "stuck.md")
   546  	// The store's line carries the item's ids and then the store id.
   547  	require.Contains(t, logBuf.String(),
   548  		`msg="memory flush cut off part-way; 1 of 2 changed files had not finished uploading" work_id=work_1 session_id=s1 memory_store_id=memstore_notes`)
   549  	require.Contains(t, logBuf.String(), "memory flush cut off after 500ms; changed files it had not uploaded yet are not saved")
   550  	require.NotContains(t, logBuf.String(), "failed to upload memory")
   551  	require.NoDirExists(t, local)
   552  }
   553  
   554  // preimage is the note.md content the cadence sync for edit i guards on.
   555  func preimage(i int) string {
   556  	if i == 0 {
   557  		return "v1"
   558  	}
   559  	return fmt.Sprintf("edit %d", i-1)
   560  }
   561  
   562  func TestEnvironmentWorker_NoMemoryStoreAttachedStaysQuiet(t *testing.T) {
   563  	// Same missing sessions token as above, but this session has no memory to
   564  	// run without. Nothing is misconfigured, so nothing is said loudly.
   565  	mem := newMemoryServer(t, map[string]string{}, "")
   566  	mem.NoStores = true
   567  	server := memoryWorkServer(t, mem, 2, true, nil)
   568  	server.HandleMemories = nil
   569  	workdir := t.TempDir()
   570  
   571  	var logBuf bytes.Buffer
   572  	worker := memoryWorker(t, server, workdir, false, 0, EnvironmentWorkerOptions{
   573  		Logger: captureLog(&logBuf),
   574  	})
   575  	handleMemoryItem(t, worker, "")
   576  
   577  	require.NoDirExists(t, filepath.Join(workdir, "memory"))
   578  	require.NotContains(t, logBuf.String(), "level=ERROR")
   579  	require.Contains(t, logBuf.String(), "memory stores disabled for this item")
   580  }
   581  
   582  func TestEnvironmentWorker_MemoryCanTurnSyncDeletionsOff(t *testing.T) {
   583  	// MemorySyncDeletions = MemorySyncDeletionsDisabled reaches the stores: a
   584  	// local deletion never propagates however many syncs run, while
   585  	// uploads still do.
   586  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   587  	server := memoryWorkServer(t, mem, 3, true, nil)
   588  	workdir := t.TempDir()
   589  	local := filepath.Join(workdir, "memory", "notes")
   590  
   591  	tool := &memoryTestTool{}
   592  	var mu sync.Mutex
   593  	now := time.Unix(0, 0)
   594  	tool.exec = func(input json.RawMessage) {
   595  		var in struct{ I int }
   596  		require.NoError(t, json.Unmarshal(input, &in))
   597  		if in.I == 0 {
   598  			require.NoError(t, os.Remove(filepath.Join(local, "note.md")))
   599  			require.NoError(t, os.WriteFile(filepath.Join(local, "new.md"), []byte("survives"), 0o600))
   600  		}
   601  		// Every gap dwarfs both the sync interval and the corroboration window.
   602  		mu.Lock()
   603  		now = now.Add(400 * time.Second)
   604  		mu.Unlock()
   605  	}
   606  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   607  		Workdir:             workdir,
   608  		MemorySyncInterval:  300 * time.Second,
   609  		MemorySyncDeletions: MemorySyncDeletionsDisabled,
   610  		Logger:              silentLogger,
   611  		ToolsFunc:           func(*agenttoolset.AgentToolContext) []anthropic.BetaTool { return []anthropic.BetaTool{tool} },
   612  	})
   613  	worker.memoryClock = func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
   614  
   615  	handleMemoryItem(t, worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   616  
   617  	require.Equal(t, "v1", mem.Files["note.md"])
   618  	require.Equal(t, []string{created("new.md", "survives")}, mem.ReceivedSnapshot())
   619  }
   620  
   621  func TestEnvironmentWorker_TheLastSyncRunsAfterToolsAreClosed(t *testing.T) {
   622  	// Finish skips the delete waiting window, so it must only run once
   623  	// nothing can still be rewriting files — after CloseAll kills the
   624  	// session's subprocesses. The test tool's Close writes a memory file:
   625  	// were CloseAll to run after the syncs, the sync would not see it and
   626  	// this file would never reach the server.
   627  	mem := newMemoryServer(t, map[string]string{"note.md": "v1"}, "")
   628  	server := memoryWorkServer(t, mem, 1, true, nil)
   629  	workdir := t.TempDir()
   630  	local := filepath.Join(workdir, "memory", "notes")
   631  
   632  	tool := &memoryTestTool{
   633  		exec: func(input json.RawMessage) {
   634  			require.NoError(t, os.WriteFile(filepath.Join(local, "note.md"), []byte("last edit"), 0o600))
   635  		},
   636  		onClose: func() {
   637  			require.NoError(t, os.WriteFile(filepath.Join(local, "on-close.md"), []byte("written on close"), 0o600))
   638  		},
   639  	}
   640  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   641  		Workdir:            workdir,
   642  		MemorySyncInterval: 300 * time.Second,
   643  		Logger:             silentLogger,
   644  		ToolsFunc:          func(*agenttoolset.AgentToolContext) []anthropic.BetaTool { return []anthropic.BetaTool{tool} },
   645  	})
   646  
   647  	handleMemoryItem(t, worker, encodeSecret(t, map[string]any{"sessions_token": "tok"}))
   648  
   649  	// The tool was closed before Finish/Flush scanned the folder: the
   650  	// file Close wrote is on the server.
   651  	require.Equal(t, "written on close", mem.Files["on-close.md"])
   652  	require.Equal(t, "last edit", mem.Files["note.md"])
   653  	require.NoDirExists(t, local)
   654  }