@@ -3,11 +3,13 @@ package runtime
33import (
44 "bytes"
55 "errors"
6+ "fmt"
67 "io/fs"
78 "log/slog"
89 "os"
910 "path/filepath"
1011 "strings"
12+ "syscall"
1113 "testing"
1214 "time"
1315
@@ -268,6 +270,8 @@ func TestMaterializeGeneratedMedia_NoWorkspaceRoot(t *testing.T) {
268270 assert .Contains (t , warnings [1 ].Message , "2/2" )
269271 assert .Contains (t , warnings [1 ].Message , "dog.jpg" )
270272 for _ , w := range warnings {
273+ assert .Contains (t , w .Message , "No session workspace is available to save into." ,
274+ "a missing workspace must surface its classified reason" )
271275 assertSafeWarningMessage (t , w .Message , sess .ID , "" )
272276 }
273277
@@ -298,6 +302,8 @@ func TestMaterializeGeneratedMedia_UnwritableRoot(t *testing.T) {
298302 require .Len (t , warnings , 1 )
299303 assert .Contains (t , warnings [0 ].Message , "cat.png" )
300304 assert .Contains (t , warnings [0 ].Message , "1/1" )
305+ assert .Contains (t , warnings [0 ].Message , "The save location no longer exists." ,
306+ "a deleted workspace root must surface its classified reason" )
301307 assertSafeWarningMessage (t , warnings [0 ].Message , sess .ID , root )
302308 assertNoFilesUnder (t , dataDir )
303309}
@@ -334,6 +340,9 @@ func TestMaterializeGeneratedMedia_PartialSuccess_SingleBatchCall(t *testing.T)
334340 assert .Contains (t , warnings [0 ].Message , "2/2" , "the failing item's index/total must reflect its real position in the batch" )
335341 assert .Contains (t , warnings [0 ].Message , "dog.jpg" )
336342 assert .Contains (t , warnings [0 ].Message , "image/jpeg" )
343+ assert .Contains (t , warnings [0 ].Message , retryWithDebugAdvice ,
344+ "an unclassified failure must carry the retry-with-debug advice" )
345+ assert .NotContains (t , warnings [0 ].Message , "injected failure" , "the raw error text must never reach the warning" )
337346 assertSafeWarningMessage (t , warnings [0 ].Message , sess .ID , root )
338347
339348 data , err := os .ReadFile (filepath .Join (root , "cat.png" ))
@@ -346,6 +355,82 @@ func TestMaterializeGeneratedMedia_PartialSuccess_SingleBatchCall(t *testing.T)
346355 require .ErrorIs (t , err , session .ErrGeneratedFileNotFound , "a failed write must never be recorded in the manifest" )
347356}
348357
358+ // TestMaterializeGeneratedMedia_ClassifiedWriteFailureReasons drives every
359+ // classified writer-failure category through the workspacemediaWrite seam
360+ // and proves the per-item warning carries exactly the fixed classified
361+ // sentence — while the raw error (with its embedded secret path) reaches
362+ // only the debug log, never the warning.
363+ func TestMaterializeGeneratedMedia_ClassifiedWriteFailureReasons (t * testing.T ) {
364+ const secretPath = "/secret/root/cat.png"
365+
366+ cases := []struct {
367+ name string
368+ writeErr error
369+ wantReason string
370+ }{
371+ {
372+ name : "not writable" ,
373+ writeErr : fmt .Errorf ("claim %q: %w" , secretPath , fs .ErrPermission ),
374+ wantReason : "The save location is not writable." ,
375+ },
376+ {
377+ name : "read-only filesystem" ,
378+ writeErr : fmt .Errorf ("open workspace root: %w" , & fs.PathError {Op : "open" , Path : secretPath , Err : syscall .EROFS }),
379+ wantReason : "The save location is not writable." ,
380+ },
381+ {
382+ name : "collision exhaustion" ,
383+ writeErr : fmt .Errorf ("%w: %q after 10000 attempts" , workspacemedia .ErrNameExhausted , secretPath ),
384+ wantReason : "Every candidate filename is already taken." ,
385+ },
386+ {
387+ // The provider-named flow retries ErrPathEscape once under the
388+ // generic name; the seam fails both attempts, so the refusal
389+ // itself must reach the user as the classified reason.
390+ name : "requested path refused" ,
391+ writeErr : fmt .Errorf ("%w: %q: absolute path" , workspacemedia .ErrPathEscape , secretPath ),
392+ wantReason : "The requested save path was refused." ,
393+ },
394+ }
395+
396+ for _ , tc := range cases {
397+ t .Run (tc .name , func (t * testing.T ) {
398+ r , _ , dataDir := newMediaTestRuntime (t )
399+ sess , root := workspaceSession (t , "sess-classified-" + tc .name )
400+
401+ var logBuf bytes.Buffer
402+ prevLogger := slog .Default ()
403+ slog .SetDefault (slog .New (slog .NewTextHandler (& logBuf , & slog.HandlerOptions {Level : slog .LevelDebug })))
404+ t .Cleanup (func () { slog .SetDefault (prevLogger ) })
405+
406+ orig := workspacemediaWrite
407+ workspacemediaWrite = func (string , string , []byte , string ) (workspacemedia.Result , error ) {
408+ return workspacemedia.Result {}, tc .writeErr
409+ }
410+ t .Cleanup (func () { workspacemediaWrite = orig })
411+
412+ sink := & collectingSink {}
413+ parts := r .materializeGeneratedMedia (t .Context (), sess , []chat.MediaDelta {
414+ {Data : []byte {0x01 }, MimeType : "image/png" , Name : "cat.png" , Size : 1 },
415+ }, "root" , sink )
416+
417+ assert .Empty (t , parts )
418+ warnings := sink .warnings ()
419+ require .Len (t , warnings , 1 )
420+ msg := warnings [0 ].Message
421+ assert .Contains (t , msg , "1/1" )
422+ assert .Contains (t , msg , "cat.png" )
423+ assert .Contains (t , msg , tc .wantReason )
424+ assert .NotContains (t , msg , retryWithDebugAdvice , "a classified failure must show its reason, not the debug fallback" )
425+ assert .NotContains (t , msg , secretPath , "the warning must never leak a path embedded in the error" )
426+ assertSafeWarningMessage (t , msg , sess .ID , root )
427+
428+ assert .Contains (t , logBuf .String (), secretPath , "the detailed error must still reach the debug log" )
429+ assertNoFilesUnder (t , dataDir )
430+ })
431+ }
432+ }
433+
349434// storeWithoutManifest hides the built-in store's GeneratedMediaManifest
350435// implementation: interface embedding only promotes session.Store's own
351436// method set, so the type assertion in recordGeneratedFile fails.
@@ -372,7 +457,10 @@ func TestMaterializeGeneratedMedia_ManifestFailureKeepsFileAndWarns(t *testing.T
372457 warnings := sink .warnings ()
373458 require .Len (t , warnings , 1 )
374459 assert .Contains (t , warnings [0 ].Message , "cat.png" )
375- assert .Contains (t , warnings [0 ].Message , "record" )
460+ assert .Contains (t , warnings [0 ].Message , "could not record it for display" )
461+ assert .Contains (t , warnings [0 ].Message , retryWithDebugAdvice ,
462+ "the manifest cause is unclassified storage internals, so the warning must carry the retry-with-debug advice" )
463+ assert .NotContains (t , warnings [0 ].Message , "see debug log" )
376464 assertBoundedSingleLineUTF8 (t , warnings [0 ].Message )
377465}
378466
0 commit comments