diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9572817..ad8d07c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,120 +1,19 @@ -# Shared Prompt And Asset Layout Implementation Plan +# Completed Shared Asset Layout Follow-Up -## Summary +The shared prompt and asset layout reorganization is complete. -Implement the asset layout defined in -[Shared Prompt And Asset Layout Roadmap](sharedassets.md). This is a structural -reorganization only: prompt semantics, prompt IDs, prompt versions, response -schema IDs, schema names, runtime module contracts, diagnostics policy, and -manifest provenance shape must remain unchanged. +Implemented behavior is documented in canonical current-behavior docs: -## Stage 1: Introduce Shared Assets Package +- [Internal Overview](../internal/overview.md) +- [Internal LLM Runtime](../internal/llm.md) +- [Internal Modules](../internal/modules.md) -- Create `internal/modules/sharedassets` as the cross-module shared asset - package. -- Move the existing shared D&D prompt fragments from - `internal/modules/dnd/promptassets` into `internal/modules/sharedassets`. -- Rename shared prompt files exactly as: - - `common-dnd-system.md` - - `common-dnd-transcript.md` - - `common-dnd-references.md` -- Implement the shared package with the same responsibilities as the current - shared prompt package: - - embed shared prompt files; - - register them with `llm.AssetRegistry`; - - expose hash-part helpers for common shared prompts and reference prompts. -- Keep Scriptorium-visible prompt paths under the shared prompt root as - `common-dnd-*.md`; do not preserve the old `shared/*.md` Scriptorium paths. -- Update production prompt asset collection in `internal/cli` and D&D module - prompt tests to import `internal/modules/sharedassets`. -- Do not delete the old `internal/modules/dnd/promptassets` package until all - callers have been updated and tests compile. +## Implemented Outcomes -## Stage 2: Flatten Module Asset Trees +- module-owned prompts and schemas use shallow module-local asset trees; +- reusable D&D prompt fragments live under `internal/modules/sharedassets`; +- shared prompt filesystem composition is centralized in `sharedassets`; +- D&D scene and spell modules no longer carry duplicated prompt filesystem + helper implementations. -- Move `dnd/spells` module-owned prompt assets to: - - `internal/modules/extract/dnd/spells/assets/prompts/dnd.spells.yaml` - - `internal/modules/extract/dnd/spells/assets/prompts/task.md` - - `internal/modules/extract/dnd/spells/assets/prompts/instructions.md` -- Move `dnd/scenes` module-owned prompt assets to: - - `internal/modules/chunk/dnd/scenes/assets/prompts/dnd.scenes.yaml` - - `internal/modules/chunk/dnd/scenes/assets/prompts/task.md` - - `internal/modules/chunk/dnd/scenes/assets/prompts/instructions.md` -- Keep module schemas under each module's existing shallow schema directory: - - `assets/schemas/dnd_spells.v1.json` - - `assets/schemas/dnd_scenes.v1.json` -- Update each module's `go:embed` patterns to include only the new shallow - prompt and schema paths. -- Update each module's prompt registration root from the old - `assets/scriptorium/prompts` tree to `assets/prompts`. -- Update prompt hash inputs to point at the new module prompt paths and the new - shared asset hash parts. - -## Stage 3: Update Scriptorium Prompt Definitions - -- Update `dnd.spells.yaml` and `dnd.scenes.yaml` to use the new shallow - `content_file` paths. -- Shared prompt references must point to: - - `./common-dnd-system.md` - - `./common-dnd-transcript.md` - - `./common-dnd-references.md` -- Module prompt references must point to: - - `./task.md` - - `./instructions.md` -- Keep all existing prompt IDs, prompt versions, default profiles, inputs, - cache-control settings, output schema paths, validation modes, and repair - attempts unchanged. -- Confirm the flattened module prompt roots and shared prompt roots combine into - one Scriptorium prompt filesystem without duplicate asset paths. - -## Stage 4: Remove Old Package And Update Docs - -- Delete `internal/modules/dnd/promptassets` after all imports and asset - references are gone. -- Update canonical current-behavior docs to describe the new convention: - - module-owned prompts and schemas live under each module's shallow - `assets/prompts` and `assets/schemas` directories; - - shared reusable prompt fragments live under - `internal/modules/sharedassets`; - - framework and CLI code may collect assets, but modules still own prompt - intent, prompt IDs, schemas, and interpretation. -- Keep roadmap docs as future/target-state material only; do not describe - completed behavior in `docs/roadmap/` until the implementation is complete. - -## Stage 5: Verification - -- Update focused tests that assert asset paths, prepared prompt message content, - prompt hash metadata, or import paths. -- Add or preserve coverage proving: - - production prompt asset registration includes shared assets, `dnd/scenes` - assets, and `dnd/spells` assets; - - Scriptorium can prepare both D&D prompts using the moved assets; - - prompt and schema hashes remain present in module manifest metadata; - - raw source input, references, prompt text, and schema JSON still do not leak - into default diagnostics or manifests. -- Run focused validation: - -```sh -go test ./internal/framework/llm -go test ./internal/modules/chunk/dnd/scenes ./internal/modules/extract/dnd/spells -go test ./internal/cli -``` - -- Run full validation: - -```sh -go test ./... -go vet ./... -go build ./cmd/notarius -``` - -## Assumptions - -- This migration is allowed to change internal asset paths because prompt assets - are embedded implementation details, not public API. -- Prompt IDs, versions, schema IDs, schema names, runtime request contracts, and - output manifest shape are stable and must not change in this pass. -- `internal/modules/sharedassets` may contain D&D-specific reusable prompt - fragments when filenames make the domain scope explicit. -- Shared assets should contain durable operating rules and reusable framing, not - module-specific extraction, chunking, normalization, or validation semantics. +Remaining future work belongs in [Future Work](future.md). diff --git a/docs/roadmap/sharedassets.md b/docs/roadmap/sharedassets.md index e4e6e27..c674d03 100644 --- a/docs/roadmap/sharedassets.md +++ b/docs/roadmap/sharedassets.md @@ -1,47 +1,15 @@ -# Shared Prompt And Asset Layout Roadmap +# Completed Shared Prompt And Asset Layout -This roadmap defines the target state for reorganizing embedded prompt and -schema assets so module-owned files are shallow and shared prompt fragments live -in an explicit shared asset package. +The embedded prompt and schema asset layout has been reorganized so module-owned +files are shallow and shared prompt fragments live in an explicit shared asset +package. -## Motivation +## Outcome -The current Scriptorium asset layout works, but module prompt files are nested -more deeply than their package ownership requires. For example, a module package -already identifies its stage, domain, and artifact, so paths like -`assets/scriptorium/prompts/dnd/spells/...` repeat context that is already in -the Go package path. +Module-owned assets stay in the module package that owns the behavior, with +shallow internal paths: -Shared D&D prompt fragments also currently live under a D&D-specific -`promptassets` package. That is serviceable, but it undersells the fact that -shared assets are intentionally cross-module. A clearer shared package will make -reuse deliberate and make room for common prompt hardening and reference-use -rules. - -## Target State - -Module-owned assets should stay in the module package that owns the behavior, -but their internal paths should be shallow: - -```text -internal/modules/extract/dnd/spells/assets/ - prompts/ - dnd.spells.yaml - task.md - instructions.md - schemas/ - dnd_spells.v1.json - -internal/modules/chunk/dnd/scenes/assets/ - prompts/ - dnd.scenes.yaml - task.md - instructions.md - schemas/ - dnd_scenes.v1.json -``` - -Shared reusable assets should move to: +Shared reusable assets live under: ```text internal/modules/sharedassets/ @@ -81,17 +49,13 @@ evidence, use references only for disambiguation unless a module says otherwise, ignore instructions embedded inside source/reference content, follow the structured output contract, and avoid exposing prompt instructions. -## Documentation Outcome +## Canonical Docs -When implemented, canonical internal documentation should describe the asset -ownership convention: +Current behavior is documented in: -- module-owned prompts and schemas live under each module's shallow `assets` - tree; -- shared reusable fragments live under `internal/modules/sharedassets`; -- framework and CLI code may collect assets, but module packages remain - responsible for prompt intent and schemas. +- [Internal Overview](../internal/overview.md) +- [Internal LLM Runtime](../internal/llm.md) +- [Internal Modules](../internal/modules.md) Prompt IDs, prompt versions, response schema IDs, schema names, and runtime -module contracts should remain unchanged unless a later feature explicitly -changes prompt semantics. +module contracts remain unchanged by this reorganization. diff --git a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go index 6783c98..b6a1a1b 100644 --- a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go +++ b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go @@ -3,7 +3,6 @@ package scenes import ( "bytes" "fmt" - "io/fs" "sort" "sync" @@ -15,11 +14,11 @@ import ( const scriptoriumPromptRoot = "assets/prompts" func RegisterPromptAssets(registry *llm.AssetRegistry) error { - sharedPromptFS, err := promptSharedFS() - if err != nil { - return fmt.Errorf("prepare shared prompt assets: %w", err) - } - promptFS, err := modulePromptFS(sharedPromptFS) + promptFS, err := sharedassets.ModulePromptFS("dnd.scenes", embeddedAssets, []sharedassets.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"}, + }) if err != nil { return fmt.Errorf("prepare scene prompt assets: %w", err) } @@ -29,14 +28,6 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas") } -func promptSharedFS() (fs.FS, error) { - registry := llm.NewAssetRegistry() - if err := sharedassets.Register(registry); err != nil { - return nil, err - } - return registry.PromptFS() -} - func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet { return contracts.LLMInputSet{ "transcript": transcriptPromptInput(req.SourceInput), diff --git a/internal/modules/extract/dnd/spells/prompt_fs.go b/internal/modules/extract/dnd/spells/prompt_fs.go deleted file mode 100644 index a4db50f..0000000 --- a/internal/modules/extract/dnd/spells/prompt_fs.go +++ /dev/null @@ -1,194 +0,0 @@ -package spells - -import ( - "bytes" - "fmt" - "io" - "io/fs" - "path" - "sort" - "strings" - "time" -) - -func modulePromptFS(shared fs.FS) (fs.FS, error) { - assetFS, err := promptMapFSFromEmbedded(map[string]string{ - "assets/prompts/dnd.spells/dnd.spells.yaml": "assets/prompts/dnd.spells.yaml", - "assets/prompts/dnd.spells/task.md": "assets/prompts/task.md", - "assets/prompts/dnd.spells/instructions.md": "assets/prompts/instructions.md", - }) - if err != nil { - return nil, err - } - assets := assetFS.(promptMapFS) - if shared == nil { - return assets, nil - } - for _, name := range []string{ - "common-dnd-system.md", - "common-dnd-transcript.md", - "common-dnd-references.md", - } { - data, err := fs.ReadFile(shared, name) - if err != nil { - return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err) - } - assets["assets/prompts/dnd.spells/"+name] = append([]byte(nil), data...) - } - return assets, nil -} - -func promptMapFSFromEmbedded(files map[string]string) (fs.FS, error) { - assets := make(promptMapFS, len(files)) - for virtualPath, embeddedPath := range files { - data, err := fs.ReadFile(embeddedAssets, embeddedPath) - if err != nil { - return nil, fmt.Errorf("read prompt asset %s: %w", embeddedPath, err) - } - assets[virtualPath] = append([]byte(nil), data...) - } - return assets, nil -} - -type promptMapFS map[string][]byte - -func (m promptMapFS) Open(name string) (fs.File, error) { - cleaned, err := cleanPromptPath(name) - if err != nil { - return nil, &fs.PathError{Op: "open", Path: name, Err: err} - } - if data, ok := m[cleaned]; ok { - return &promptFile{ - reader: bytes.NewReader(data), - info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))}, - }, nil - } - entries := m.dirEntries(cleaned) - if entries != nil { - return &promptDir{name: path.Base(cleaned), entries: entries}, nil - } - return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} -} - -func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) { - cleaned, err := cleanPromptPath(name) - if err != nil { - return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} - } - entries := m.dirEntries(cleaned) - if entries == nil { - return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} - } - return entries, nil -} - -func (m promptMapFS) dirEntries(dir string) []fs.DirEntry { - children := map[string]promptDirEntry{} - prefix := "" - if dir != "." { - prefix = dir + "/" - } - for name, data := range m { - if !strings.HasPrefix(name, prefix) { - continue - } - rest := strings.TrimPrefix(name, prefix) - if rest == "" { - continue - } - childName, _, hasSlash := strings.Cut(rest, "/") - entry := promptDirEntry{name: childName, dir: hasSlash} - if !hasSlash { - entry.size = int64(len(data)) - } - children[childName] = entry - } - if len(children) == 0 { - return nil - } - names := make([]string, 0, len(children)) - for name := range children { - names = append(names, name) - } - sort.Strings(names) - entries := make([]fs.DirEntry, 0, len(names)) - for _, name := range names { - entries = append(entries, children[name]) - } - return entries -} - -func cleanPromptPath(name string) (string, error) { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - return "", fmt.Errorf("path must not be empty") - } - cleaned := path.Clean(strings.TrimPrefix(trimmed, "/")) - if cleaned == "." || !fs.ValidPath(cleaned) { - return "", fmt.Errorf("invalid path %q", name) - } - return cleaned, nil -} - -type promptFile struct { - reader *bytes.Reader - info promptFileInfo -} - -func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil } -func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) } -func (f *promptFile) Close() error { return nil } - -type promptDir struct { - name string - offset int - entries []fs.DirEntry -} - -func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil } -func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF } -func (d *promptDir) Close() error { return nil } -func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) { - if d.offset >= len(d.entries) { - return nil, io.EOF - } - end := len(d.entries) - if n > 0 && d.offset+n < end { - end = d.offset + n - } - out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...) - d.offset = end - return out, nil -} - -type promptDirEntry struct { - name string - dir bool - size int64 -} - -func (e promptDirEntry) Name() string { return e.name } -func (e promptDirEntry) IsDir() bool { return e.dir } -func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() } -func (e promptDirEntry) Info() (fs.FileInfo, error) { - return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil -} -func (e promptDirEntry) fileInfoMode() fs.FileMode { - if e.dir { - return fs.ModeDir | 0o555 - } - return 0o444 -} - -type promptFileInfo struct { - name string - dir bool - size int64 -} - -func (i promptFileInfo) Name() string { return i.name } -func (i promptFileInfo) Size() int64 { return i.size } -func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() } -func (i promptFileInfo) ModTime() time.Time { return time.Time{} } -func (i promptFileInfo) IsDir() bool { return i.dir } -func (i promptFileInfo) Sys() any { return nil } diff --git a/internal/modules/extract/dnd/spells/scriptorium_assets.go b/internal/modules/extract/dnd/spells/scriptorium_assets.go index 1ce4f83..dc26472 100644 --- a/internal/modules/extract/dnd/spells/scriptorium_assets.go +++ b/internal/modules/extract/dnd/spells/scriptorium_assets.go @@ -3,7 +3,6 @@ package spells import ( "bytes" "fmt" - "io/fs" "sort" "sync" @@ -15,11 +14,11 @@ import ( const scriptoriumPromptRoot = "assets/prompts" func RegisterPromptAssets(registry *llm.AssetRegistry) error { - sharedPromptFS, err := promptSharedFS() - if err != nil { - return fmt.Errorf("prepare shared prompt assets: %w", err) - } - promptFS, err := modulePromptFS(sharedPromptFS) + promptFS, err := sharedassets.ModulePromptFS("dnd.spells", embeddedAssets, []sharedassets.ModulePromptFile{ + {Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, + {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, + }) if err != nil { return fmt.Errorf("prepare spell prompt assets: %w", err) } @@ -29,14 +28,6 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas") } -func promptSharedFS() (fs.FS, error) { - registry := llm.NewAssetRegistry() - if err := sharedassets.Register(registry); err != nil { - return nil, err - } - return registry.PromptFS() -} - func promptInputs(req contracts.ExtractionRequest) contracts.LLMInputSet { return contracts.LLMInputSet{ "transcript": transcriptPromptInput(req.SourceInput), diff --git a/internal/modules/chunk/dnd/scenes/prompt_fs.go b/internal/modules/sharedassets/prompt_fs.go similarity index 63% rename from internal/modules/chunk/dnd/scenes/prompt_fs.go rename to internal/modules/sharedassets/prompt_fs.go index d849e6b..1aaa221 100644 --- a/internal/modules/chunk/dnd/scenes/prompt_fs.go +++ b/internal/modules/sharedassets/prompt_fs.go @@ -1,4 +1,4 @@ -package scenes +package sharedassets import ( "bytes" @@ -11,49 +11,70 @@ import ( "time" ) -func modulePromptFS(shared fs.FS) (fs.FS, error) { - assetFS, err := promptMapFSFromEmbedded(map[string]string{ - "assets/prompts/dnd.scenes/dnd.scenes.yaml": "assets/prompts/dnd.scenes.yaml", - "assets/prompts/dnd.scenes/task.md": "assets/prompts/task.md", - "assets/prompts/dnd.scenes/instructions.md": "assets/prompts/instructions.md", - }) +// ModulePromptFile maps a module-owned embedded prompt file into the +// Scriptorium-visible module prompt directory. +type ModulePromptFile struct { + Name string + Path string +} + +// ModulePromptFS builds a prompt filesystem for a module directory from +// module-owned prompt files plus the common D&D shared prompt files. +func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile) (fs.FS, error) { + return modulePromptFS(moduleDir, moduleFS, files, embeddedAssets) +} + +func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, sharedFS fs.FS) (fs.FS, error) { + cleanModuleDir, err := cleanPromptPath(moduleDir) if err != nil { - return nil, err + return nil, fmt.Errorf("module prompt directory: %w", err) } - assets := assetFS.(promptMapFS) - if shared == nil { - return assets, nil + if moduleFS == nil { + return nil, fmt.Errorf("module prompt filesystem must not be nil") } - for _, name := range []string{ - "common-dnd-system.md", - "common-dnd-transcript.md", - "common-dnd-references.md", - } { - data, err := fs.ReadFile(shared, name) + if sharedFS == nil { + return nil, fmt.Errorf("shared prompt filesystem must not be nil") + } + assets := make(promptMapFS, len(files)+len(commonDNDPromptFiles)) + for _, file := range files { + name, err := cleanPromptPath(file.Name) + if err != nil { + return nil, fmt.Errorf("module prompt file name %q: %w", file.Name, err) + } + if strings.Contains(name, "/") { + return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.Name) + } + filePath, err := cleanPromptPath(file.Path) + if err != nil { + return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err) + } + data, err := fs.ReadFile(moduleFS, filePath) + if err != nil { + return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err) + } + assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...) + } + + for _, name := range commonDNDPromptFiles { + data, err := fs.ReadFile(sharedFS, "assets/prompts/"+name) if err != nil { return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err) } - assets["assets/prompts/dnd.scenes/"+name] = append([]byte(nil), data...) + assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...) } return assets, nil } -func promptMapFSFromEmbedded(files map[string]string) (fs.FS, error) { - assets := make(promptMapFS, len(files)) - for virtualPath, embeddedPath := range files { - data, err := fs.ReadFile(embeddedAssets, embeddedPath) - if err != nil { - return nil, fmt.Errorf("read prompt asset %s: %w", embeddedPath, err) - } - assets[virtualPath] = append([]byte(nil), data...) - } - return assets, nil +var commonDNDPromptFiles = []string{ + "common-dnd-system.md", + "common-dnd-transcript.md", + "common-dnd-references.md", } type promptMapFS map[string][]byte func (m promptMapFS) Open(name string) (fs.File, error) { - cleaned, err := cleanPromptPath(name) + cleaned, err := cleanPromptFSPath(name) if err != nil { return nil, &fs.PathError{Op: "open", Path: name, Err: err} } @@ -71,7 +92,7 @@ func (m promptMapFS) Open(name string) (fs.File, error) { } func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) { - cleaned, err := cleanPromptPath(name) + cleaned, err := cleanPromptFSPath(name) if err != nil { return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} } @@ -130,6 +151,21 @@ func cleanPromptPath(name string) (string, error) { return cleaned, nil } +func cleanPromptFSPath(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", fmt.Errorf("path must not be empty") + } + cleaned := path.Clean(strings.TrimPrefix(trimmed, "/")) + if cleaned == "." { + return cleaned, nil + } + if !fs.ValidPath(cleaned) { + return "", fmt.Errorf("invalid path %q", name) + } + return cleaned, nil +} + type promptFile struct { reader *bytes.Reader info promptFileInfo diff --git a/internal/modules/sharedassets/prompt_fs_test.go b/internal/modules/sharedassets/prompt_fs_test.go new file mode 100644 index 0000000..8415758 --- /dev/null +++ b/internal/modules/sharedassets/prompt_fs_test.go @@ -0,0 +1,77 @@ +package sharedassets + +import ( + "io/fs" + "strings" + "testing" + "testing/fstest" +) + +func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) { + fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{ + "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, + "assets/prompts/task.md": {Data: []byte("task")}, + }, []ModulePromptFile{ + {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, + }) + if err != nil { + t.Fatalf("ModulePromptFS() error = %v, want nil", err) + } + + tests := map[string]string{ + "assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test", + "assets/prompts/dnd.test/task.md": "task", + "assets/prompts/dnd.test/common-dnd-system.md": "You work with Dungeons & Dragons gameplay transcripts.", + "assets/prompts/dnd.test/common-dnd-transcript.md": "{{ input \"transcript\" }}", + "assets/prompts/dnd.test/common-dnd-references.md": "Roster reference:", + } + for path, want := range tests { + data, err := fs.ReadFile(fsys, path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) + } + if !strings.Contains(string(data), want) { + t.Fatalf("ReadFile(%q) = %q, want substring %q", path, data, want) + } + } + + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + t.Fatalf("ReadDir(.) error = %v, want nil", err) + } + if len(entries) != 1 || entries[0].Name() != "assets" || !entries[0].IsDir() { + t.Fatalf("ReadDir(.) = %#v, want assets directory", entries) + } +} + +func TestModulePromptFSRejectsMissingModuleFile(t *testing.T) { + _, err := ModulePromptFS("dnd.test", fstest.MapFS{}, []ModulePromptFile{ + {Name: "task.md", Path: "assets/prompts/task.md"}, + }) + if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/task.md") { + t.Fatalf("ModulePromptFS() error = %v, want missing module asset context", err) + } +} + +func TestModulePromptFSRejectsMissingSharedFile(t *testing.T) { + _, err := modulePromptFS("dnd.test", fstest.MapFS{ + "assets/prompts/task.md": {Data: []byte("task")}, + }, []ModulePromptFile{ + {Name: "task.md", Path: "assets/prompts/task.md"}, + }, fstest.MapFS{}) + if err == nil || !strings.Contains(err.Error(), "read shared prompt asset common-dnd-system.md") { + t.Fatalf("modulePromptFS() error = %v, want missing shared asset context", err) + } +} + +func TestModulePromptFSRejectsNestedVirtualFileName(t *testing.T) { + _, err := ModulePromptFS("dnd.test", fstest.MapFS{ + "assets/prompts/task.md": {Data: []byte("task")}, + }, []ModulePromptFile{ + {Name: "nested/task.md", Path: "assets/prompts/task.md"}, + }) + if err == nil || !strings.Contains(err.Error(), "must not contain path separators") { + t.Fatalf("ModulePromptFS() error = %v, want nested file name error", err) + } +}