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 }