From 1c9819f08ea03ec35a832d09a1a0fded2804caf0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 21 Jul 2026 14:13:11 +0000 Subject: [PATCH] Make D&D prompt asset manifests exact --- .../dnd/chunk/scenes/scriptorium_assets.go | 23 +- .../extract/combatturns/scriptorium_assets.go | 24 +- .../combatturns/scriptorium_assets_test.go | 17 -- .../dnd/extract/npcs/scriptorium_assets.go | 23 +- .../dnd/extract/spells/scriptorium_assets.go | 25 +- internal/modules/dnd/shared/assets.go | 88 ++++--- internal/modules/dnd/shared/assets_test.go | 236 +++++++++++++----- 7 files changed, 292 insertions(+), 144 deletions(-) diff --git a/internal/modules/dnd/chunk/scenes/scriptorium_assets.go b/internal/modules/dnd/chunk/scenes/scriptorium_assets.go index f00cfe5..81f36e4 100644 --- a/internal/modules/dnd/chunk/scenes/scriptorium_assets.go +++ b/internal/modules/dnd/chunk/scenes/scriptorium_assets.go @@ -11,12 +11,22 @@ import ( const scriptoriumPromptRoot = "assets/prompts" -func RegisterPromptAssets(registry *llm.AssetRegistry) error { - promptFS, err := shared.ModulePromptFS("dnd.scenes", embeddedAssets, []promptfs.ModulePromptFile{ +var promptAssetManifest = shared.PromptAssetManifest{ + ModuleDir: "dnd.scenes", + ModuleFiles: []promptfs.ModulePromptFile{ {Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"}, {Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, - }) + }, + SharedFiles: []string{ + "common-dnd-system.md", + "common-dnd-transcript.md", + "common-dnd-references.md", + }, +} + +func RegisterPromptAssets(registry *llm.AssetRegistry) error { + promptFS, err := promptAssetManifest.PromptFS(embeddedAssets) if err != nil { return fmt.Errorf("prepare scene prompt assets: %w", err) } @@ -28,12 +38,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { func scriptoriumPromptMetadata() (string, error) { scriptoriumPromptHashOnce.Do(func() { - parts := append([]llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"}, - {FS: embeddedAssets, Path: "assets/prompts/task.md"}, - {FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, - }, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...) - scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) + scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets) }) return scriptoriumPromptHash, scriptoriumPromptHashErr } diff --git a/internal/modules/dnd/extract/combatturns/scriptorium_assets.go b/internal/modules/dnd/extract/combatturns/scriptorium_assets.go index 14d9ff0..1ca35fd 100644 --- a/internal/modules/dnd/extract/combatturns/scriptorium_assets.go +++ b/internal/modules/dnd/extract/combatturns/scriptorium_assets.go @@ -11,12 +11,23 @@ import ( const scriptoriumPromptRoot = "assets/prompts" -func RegisterPromptAssets(registry *llm.AssetRegistry) error { - promptFS, err := shared.ModulePromptFS("dnd.combat_turns", embeddedAssets, []promptfs.ModulePromptFile{ +var promptAssetManifest = shared.PromptAssetManifest{ + ModuleDir: "dnd.combat_turns", + ModuleFiles: []promptfs.ModulePromptFile{ {Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"}, {Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, - }) + }, + SharedFiles: []string{ + "common-dnd-system.md", + "common-dnd-transcript.md", + "common-dnd-references.md", + "common-dnd-npcs.md", + }, +} + +func RegisterPromptAssets(registry *llm.AssetRegistry) error { + promptFS, err := promptAssetManifest.PromptFS(embeddedAssets) if err != nil { return fmt.Errorf("prepare combat-turn prompt assets: %w", err) } @@ -28,12 +39,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { func scriptoriumPromptMetadata() (string, error) { scriptoriumPromptHashOnce.Do(func() { - parts := append([]llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/prompts/dnd.combat_turns.yaml"}, - {FS: embeddedAssets, Path: "assets/prompts/task.md"}, - {FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, - }, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...) - scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) + scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets) }) return scriptoriumPromptHash, scriptoriumPromptHashErr } diff --git a/internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go b/internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go index a7e1843..e1b3a0f 100644 --- a/internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go +++ b/internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go @@ -16,23 +16,6 @@ func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) { if err := RegisterPromptAssets(registry); err != nil { t.Fatalf("RegisterPromptAssets() error = %v", err) } - promptFS, err := registry.PromptFS() - if err != nil { - t.Fatal(err) - } - for _, path := range []string{ - "dnd.combat_turns/dnd.combat_turns.yaml", - "dnd.combat_turns/task.md", - "dnd.combat_turns/instructions.md", - "dnd.combat_turns/sharedassets/common-dnd-system.md", - "dnd.combat_turns/sharedassets/common-dnd-transcript.md", - "dnd.combat_turns/sharedassets/common-dnd-references.md", - "dnd.combat_turns/sharedassets/common-dnd-npcs.md", - } { - if _, err := fs.ReadFile(promptFS, path); err != nil { - t.Fatalf("prompt asset %q: %v", path, err) - } - } schemaFS, err := registry.SchemaFS() if err != nil { t.Fatal(err) diff --git a/internal/modules/dnd/extract/npcs/scriptorium_assets.go b/internal/modules/dnd/extract/npcs/scriptorium_assets.go index 0c1d78e..4a1fd53 100644 --- a/internal/modules/dnd/extract/npcs/scriptorium_assets.go +++ b/internal/modules/dnd/extract/npcs/scriptorium_assets.go @@ -11,12 +11,22 @@ import ( const scriptoriumPromptRoot = "assets/prompts" -func RegisterPromptAssets(registry *llm.AssetRegistry) error { - promptFS, err := shared.ModulePromptFS("dnd.npcs", embeddedAssets, []promptfs.ModulePromptFile{ +var promptAssetManifest = shared.PromptAssetManifest{ + ModuleDir: "dnd.npcs", + ModuleFiles: []promptfs.ModulePromptFile{ {Name: "dnd.npcs.yaml", Path: "assets/prompts/dnd.npcs.yaml"}, {Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, - }) + }, + SharedFiles: []string{ + "common-dnd-system.md", + "common-dnd-transcript.md", + "common-dnd-references.md", + }, +} + +func RegisterPromptAssets(registry *llm.AssetRegistry) error { + promptFS, err := promptAssetManifest.PromptFS(embeddedAssets) if err != nil { return fmt.Errorf("prepare NPC prompt assets: %w", err) } @@ -28,12 +38,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { func scriptoriumPromptMetadata() (string, error) { scriptoriumPromptHashOnce.Do(func() { - parts := append([]llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/prompts/dnd.npcs.yaml"}, - {FS: embeddedAssets, Path: "assets/prompts/task.md"}, - {FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, - }, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...) - scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) + scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets) }) return scriptoriumPromptHash, scriptoriumPromptHashErr } diff --git a/internal/modules/dnd/extract/spells/scriptorium_assets.go b/internal/modules/dnd/extract/spells/scriptorium_assets.go index b12095b..49f6f37 100644 --- a/internal/modules/dnd/extract/spells/scriptorium_assets.go +++ b/internal/modules/dnd/extract/spells/scriptorium_assets.go @@ -11,13 +11,24 @@ import ( const scriptoriumPromptRoot = "assets/prompts" -func RegisterPromptAssets(registry *llm.AssetRegistry) error { - promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{ +var promptAssetManifest = shared.PromptAssetManifest{ + ModuleDir: "dnd.spells", + ModuleFiles: []promptfs.ModulePromptFile{ {Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"}, {Name: "catalog.md", Path: "assets/prompts/catalog.md"}, {Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, - }) + }, + SharedFiles: []string{ + "common-dnd-system.md", + "common-dnd-transcript.md", + "common-dnd-references.md", + "common-dnd-npcs.md", + }, +} + +func RegisterPromptAssets(registry *llm.AssetRegistry) error { + promptFS, err := promptAssetManifest.PromptFS(embeddedAssets) if err != nil { return fmt.Errorf("prepare spell prompt assets: %w", err) } @@ -29,13 +40,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { func scriptoriumPromptMetadata() (string, error) { scriptoriumPromptHashOnce.Do(func() { - parts := append([]llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"}, - {FS: embeddedAssets, Path: "assets/prompts/catalog.md"}, - {FS: embeddedAssets, Path: "assets/prompts/task.md"}, - {FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, - }, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...) - scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) + scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets) }) return scriptoriumPromptHash, scriptoriumPromptHashErr } diff --git a/internal/modules/dnd/shared/assets.go b/internal/modules/dnd/shared/assets.go index cacc724..7ceb40d 100644 --- a/internal/modules/dnd/shared/assets.go +++ b/internal/modules/dnd/shared/assets.go @@ -2,7 +2,9 @@ package shared import ( "embed" + "fmt" "io/fs" + "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" @@ -11,39 +13,69 @@ import ( //go:embed assets/prompts/*.md var embeddedAssets embed.FS -var sharedPromptFiles = []string{ - "common-dnd-system.md", - "common-dnd-transcript.md", - "common-dnd-references.md", - "common-dnd-npcs.md", +// PromptAssetManifest is the ordered set of assets that make up one prompt. +// Module files are addressed in the owning module filesystem; shared files use +// the names in sharedPromptPaths and are mounted beneath sharedassets. +type PromptAssetManifest struct { + ModuleDir string + ModuleFiles []promptfs.ModulePromptFile + SharedFiles []string } -func SharedPromptFiles() []promptfs.SharedPromptFile { - files := make([]promptfs.SharedPromptFile, 0, len(sharedPromptFiles)) - for _, name := range sharedPromptFiles { +var sharedPromptPaths = map[string]string{ + "common-dnd-system.md": "assets/prompts/common-dnd-system.md", + "common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md", + "common-dnd-references.md": "assets/prompts/common-dnd-references.md", + "common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md", +} + +func (manifest PromptAssetManifest) PromptFS(moduleFS fs.FS) (fs.FS, error) { + sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles) + if err != nil { + return nil, err + } + moduleFiles := append([]promptfs.ModulePromptFile(nil), manifest.ModuleFiles...) + return promptfs.ModulePromptFS(manifest.ModuleDir, moduleFS, moduleFiles, sharedFiles...) +} + +func (manifest PromptAssetManifest) Hash(moduleFS fs.FS) (string, error) { + sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles) + if err != nil { + return "", err + } + parts := make([]llm.AssetHashPart, 0, len(manifest.ModuleFiles)+len(sharedFiles)) + for _, file := range manifest.ModuleFiles { + parts = append(parts, llm.AssetHashPart{FS: moduleFS, Path: file.Path}) + } + for _, file := range sharedFiles { + parts = append(parts, llm.AssetHashPart{FS: file.FS, Path: file.Path}) + } + return llm.HashAssets(parts) +} + +func resolveSharedPromptFiles(names []string) ([]promptfs.SharedPromptFile, error) { + files := make([]promptfs.SharedPromptFile, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + if name == "" { + return nil, fmt.Errorf("shared prompt asset name must not be empty") + } + if strings.ContainsAny(name, `/\\`) { + return nil, fmt.Errorf("shared prompt asset name %q must not contain path separators", name) + } + if _, ok := seen[name]; ok { + return nil, fmt.Errorf("duplicate shared prompt asset name %q", name) + } + path, ok := sharedPromptPaths[name] + if !ok { + return nil, fmt.Errorf("unknown shared prompt asset name %q", name) + } + seen[name] = struct{}{} files = append(files, promptfs.SharedPromptFile{ Name: name, FS: embeddedAssets, - Path: "assets/prompts/" + name, + Path: path, }) } - return files -} - -func CommonHashParts() []llm.AssetHashPart { - return []llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"}, - {FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"}, - } -} - -func ReferenceHashParts() []llm.AssetHashPart { - return []llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/prompts/common-dnd-references.md"}, - {FS: embeddedAssets, Path: "assets/prompts/common-dnd-npcs.md"}, - } -} - -func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []promptfs.ModulePromptFile) (fs.FS, error) { - return promptfs.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...) + return files, nil } diff --git a/internal/modules/dnd/shared/assets_test.go b/internal/modules/dnd/shared/assets_test.go index e3d78f4..5dcfebc 100644 --- a/internal/modules/dnd/shared/assets_test.go +++ b/internal/modules/dnd/shared/assets_test.go @@ -2,6 +2,8 @@ package shared import ( "io/fs" + "reflect" + "strings" "testing" "testing/fstest" @@ -9,81 +11,191 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" ) -func TestSharedPromptFilesReturnsNewSlice(t *testing.T) { - first := SharedPromptFiles() - second := SharedPromptFiles() - - if len(first) != 4 || len(second) != 4 { - t.Fatalf("SharedPromptFiles() lengths = %d and %d, want 4", len(first), len(second)) +func TestPromptAssetManifestPromptFS(t *testing.T) { + manifest := PromptAssetManifest{ + ModuleDir: "dnd.test", + ModuleFiles: []promptfs.ModulePromptFile{ + {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, + }, + SharedFiles: []string{ + "common-dnd-system.md", + "common-dnd-transcript.md", + }, } - first[0].Name = "changed.md" - if second[0].Name != "common-dnd-system.md" { - t.Fatalf("SharedPromptFiles() reused descriptor slice: %#v", second) - } -} -func TestSharedPromptFilesReferenceEmbeddedAssets(t *testing.T) { - for _, file := range SharedPromptFiles() { - if file.FS == nil { - t.Fatalf("SharedPromptFiles() descriptor %q has nil FS", file.Name) - } - if _, err := fs.ReadFile(file.FS, file.Path); err != nil { - t.Fatalf("ReadFile(%q) error = %v, want nil", file.Path, err) - } - } -} - -func TestHashPartsReferenceSharedPrompts(t *testing.T) { - assertHashParts(t, "common", CommonHashParts(), []string{ - "assets/prompts/common-dnd-system.md", - "assets/prompts/common-dnd-transcript.md", - }) - assertHashParts(t, "reference", ReferenceHashParts(), []string{ - "assets/prompts/common-dnd-references.md", - "assets/prompts/common-dnd-npcs.md", - }) - - for _, part := range append(CommonHashParts(), ReferenceHashParts()...) { - if _, err := fs.ReadFile(part.FS, part.Path); err != nil { - t.Fatalf("ReadFile(%q) error = %v, want nil", part.Path, err) - } - } -} - -func assertHashParts(t *testing.T, name string, parts []llm.AssetHashPart, want []string) { - t.Helper() - if len(parts) != len(want) { - t.Fatalf("%s hash parts length = %d, want %d", name, len(parts), len(want)) - } - for i, part := range parts { - if part.Path != want[i] { - t.Fatalf("%s hash part %d path = %q, want %q", name, i, part.Path, want[i]) - } - if part.FS == nil { - t.Fatalf("%s hash part %d has nil FS", name, i) - } - } -} - -func TestModulePromptFSMountsDNDSharedPrompts(t *testing.T) { - fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{ + fsys, err := manifest.PromptFS(fstest.MapFS{ "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, - }, []promptfs.ModulePromptFile{ - {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, + "assets/prompts/task.md": {Data: []byte("task")}, }) if err != nil { - t.Fatalf("ModulePromptFS() error = %v, want nil", err) + t.Fatalf("PromptFS() error = %v, want nil", err) } + wantModule := map[string]string{ + "assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test", + "assets/prompts/dnd.test/task.md": "task", + } + for path, wantContent := range wantModule { + content, err := fs.ReadFile(fsys, path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) + } + if string(content) != wantContent { + t.Fatalf("ReadFile(%q) = %q, want %q", path, content, wantContent) + } + } for _, path := range []string{ - "assets/prompts/dnd.test/dnd.test.yaml", "assets/prompts/dnd.test/sharedassets/common-dnd-system.md", "assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md", + } { + content, err := fs.ReadFile(fsys, path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) + } + if len(content) == 0 { + t.Fatalf("ReadFile(%q) returned empty content, want mounted asset", path) + } + } + for _, path := range []string{ "assets/prompts/dnd.test/sharedassets/common-dnd-references.md", "assets/prompts/dnd.test/sharedassets/common-dnd-npcs.md", } { - if _, err := fs.ReadFile(fsys, path); err != nil { - t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) + if _, err := fs.ReadFile(fsys, path); err == nil { + t.Fatalf("ReadFile(%q) succeeded, want unlisted shared asset to be absent", path) } } } + +func TestPromptAssetManifestRejectsInvalidSharedNames(t *testing.T) { + tests := []struct { + name string + sharedFiles []string + wantError string + }{ + {name: "unknown", sharedFiles: []string{"missing.md"}, wantError: "unknown shared prompt asset name"}, + {name: "duplicate", sharedFiles: []string{"common-dnd-system.md", "common-dnd-system.md"}, wantError: "duplicate shared prompt asset name"}, + {name: "empty", sharedFiles: []string{""}, wantError: "must not be empty"}, + {name: "slash path", sharedFiles: []string{"nested/common-dnd-system.md"}, wantError: "must not contain path separators"}, + {name: "backslash path", sharedFiles: []string{`nested\common-dnd-system.md`}, wantError: "must not contain path separators"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := (PromptAssetManifest{ + ModuleDir: "dnd.test", + ModuleFiles: []promptfs.ModulePromptFile{ + {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, + }, + SharedFiles: test.sharedFiles, + }).PromptFS(fstest.MapFS{ + "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, + }) + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("PromptFS() error = %v, want %q", err, test.wantError) + } + }) + } +} + +func TestPromptAssetManifestRejectsMissingModuleFile(t *testing.T) { + _, err := (PromptAssetManifest{ + ModuleDir: "dnd.test", + ModuleFiles: []promptfs.ModulePromptFile{ + {Name: "task.md", Path: "assets/prompts/task.md"}, + }, + }).PromptFS(fstest.MapFS{}) + if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/task.md") { + t.Fatalf("PromptFS() error = %v, want missing module asset context", err) + } +} + +func TestPromptAssetManifestRejectsMissingSharedFile(t *testing.T) { + const name = "missing-for-test.md" + const path = "assets/prompts/missing-for-test.md" + previous, existed := sharedPromptPaths[name] + sharedPromptPaths[name] = path + t.Cleanup(func() { + if existed { + sharedPromptPaths[name] = previous + } else { + delete(sharedPromptPaths, name) + } + }) + + _, err := (PromptAssetManifest{ + ModuleDir: "dnd.test", + ModuleFiles: []promptfs.ModulePromptFile{ + {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, + }, + SharedFiles: []string{name}, + }).PromptFS(fstest.MapFS{ + "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, + }) + if err == nil || !strings.Contains(err.Error(), "read shared prompt asset "+path) { + t.Fatalf("PromptFS() error = %v, want missing shared asset context", err) + } +} + +func TestPromptAssetManifestHashMatchesManifestParts(t *testing.T) { + moduleFS := fstest.MapFS{ + "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, + "assets/prompts/task.md": {Data: []byte("task")}, + } + manifest := PromptAssetManifest{ + ModuleDir: "dnd.test", + ModuleFiles: []promptfs.ModulePromptFile{ + {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, + }, + SharedFiles: []string{ + "common-dnd-transcript.md", + "common-dnd-system.md", + }, + } + + got, err := manifest.Hash(moduleFS) + if err != nil { + t.Fatalf("Hash() error = %v, want nil", err) + } + want, err := llm.HashAssets([]llm.AssetHashPart{ + {FS: moduleFS, Path: "assets/prompts/dnd.test.yaml"}, + {FS: moduleFS, Path: "assets/prompts/task.md"}, + {FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"}, + {FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"}, + }) + if err != nil { + t.Fatalf("HashAssets() error = %v, want nil", err) + } + if got != want { + t.Fatalf("Hash() = %q, want independently assembled manifest hash %q", got, want) + } + withUnused, err := llm.HashAssets([]llm.AssetHashPart{ + {FS: moduleFS, Path: "assets/prompts/dnd.test.yaml"}, + {FS: moduleFS, Path: "assets/prompts/task.md"}, + {FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"}, + {FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"}, + {FS: embeddedAssets, Path: "assets/prompts/common-dnd-npcs.md"}, + }) + if err != nil { + t.Fatalf("HashAssets() with unused asset error = %v, want nil", err) + } + if got == withUnused { + t.Fatalf("Hash() included an unlisted shared asset") + } +} + +func TestSharedPromptDescriptorsReturnFreshCopies(t *testing.T) { + first, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"}) + if err != nil { + t.Fatalf("resolveSharedPromptFiles() error = %v, want nil", err) + } + second, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"}) + if err != nil { + t.Fatalf("resolveSharedPromptFiles() second error = %v, want nil", err) + } + first[0].Name = "changed.md" + first[0].Path = "changed.md" + if reflect.DeepEqual(first, second) || second[0].Name != "common-dnd-system.md" || second[0].Path != "assets/prompts/common-dnd-system.md" { + t.Fatalf("resolveSharedPromptFiles() reused descriptor state: first=%#v second=%#v", first, second) + } +}