-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix: apply custom prompts to new sessions #301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
asdek
merged 3 commits into
vxcontrol:feature/next-release
from
mason5052:codex/issue-300-custom-prompts
May 21, 2026
+306
−12
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package controller | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| "pentagi/pkg/database" | ||
| "pentagi/pkg/templates" | ||
| ) | ||
|
|
||
| // newUserPrompter loads the user's custom prompts from the database and | ||
| // overlays them onto the compiled default templates. Prompt types that | ||
| // the user has not customized continue to use the defaults. A database | ||
| // error is returned to the caller so that session creation fails | ||
| // explicitly instead of silently falling back to defaults. | ||
| func newUserPrompter(ctx context.Context, db database.Querier, userID int64) (templates.Prompter, error) { | ||
| userPrompts, err := db.GetUserPrompts(ctx, userID) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to load user prompts: %w", err) | ||
| } | ||
|
|
||
| defaults, err := templates.LoadDefaultPromptsMap() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to load default templates: %w", err) | ||
| } | ||
|
|
||
| return buildUserPrompter(defaults, userPrompts), nil | ||
| } | ||
|
|
||
| // buildUserPrompter is the pure merge step extracted from newUserPrompter so | ||
| // it can be unit-tested without a database fake or filesystem access. It | ||
| // mutates the supplied defaults map by overlaying each non-empty user | ||
| // override on top, then returns a Prompter backed by that map. Callers must | ||
| // pass a fresh map (e.g., from templates.LoadDefaultPromptsMap) so the | ||
| // embedded defaults are not modified. | ||
| func buildUserPrompter(defaults templates.PromptsMap, userPrompts []database.Prompt) templates.Prompter { | ||
| for _, p := range userPrompts { | ||
| if p.Prompt == "" { | ||
| // The Prompts UI uses delete (or reset, which writes the | ||
| // default body back) to remove a customization, so an empty | ||
| // body is unexpected. Skip it instead of clobbering the | ||
| // default with an empty string that would later surface as | ||
| // ErrTemplateNotFound deep inside agent rendering. | ||
| continue | ||
| } | ||
| defaults[templates.PromptType(p.Type)] = p.Prompt | ||
| } | ||
|
|
||
| return templates.NewFlowPrompter(defaults) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| package controller | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "testing" | ||
|
|
||
| "pentagi/pkg/database" | ||
| "pentagi/pkg/templates" | ||
| ) | ||
|
|
||
| // fakeQuerier satisfies database.Querier by embedding the interface | ||
| // (so the unused methods stay nil) and overrides only GetUserPrompts. | ||
| // Calling any other method would panic, which is exactly what we want | ||
| // for a unit test that should not touch unrelated DB code. | ||
| type fakeQuerier struct { | ||
| database.Querier | ||
| prompts []database.Prompt | ||
| err error | ||
| } | ||
|
|
||
| func (f *fakeQuerier) GetUserPrompts(ctx context.Context, userID int64) ([]database.Prompt, error) { | ||
| if f.err != nil { | ||
| return nil, f.err | ||
| } | ||
| return f.prompts, nil | ||
| } | ||
|
|
||
| // getDefaultTemplate returns the compiled default body for a prompt | ||
| // type, used to compare overridden vs. preserved prompts in tests. | ||
| func getDefaultTemplate(t *testing.T, pt templates.PromptType) string { | ||
| t.Helper() | ||
| body, err := templates.NewDefaultPrompter().GetTemplate(pt) | ||
| if err != nil { | ||
| t.Fatalf("default prompter has no template for %q: %v", pt, err) | ||
| } | ||
| return body | ||
| } | ||
|
|
||
| // loadDefaults returns a fresh PromptsMap of the embedded default templates so | ||
| // each test mutates its own map without leaking state between cases. | ||
| func loadDefaults(t *testing.T) templates.PromptsMap { | ||
| t.Helper() | ||
| defaults, err := templates.LoadDefaultPromptsMap() | ||
| if err != nil { | ||
| t.Fatalf("LoadDefaultPromptsMap error: %v", err) | ||
| } | ||
| return defaults | ||
| } | ||
|
|
||
| func TestBuildUserPrompter_NoUserPrompts(t *testing.T) { | ||
| prompter := buildUserPrompter(loadDefaults(t), nil) | ||
|
|
||
| for _, pt := range []templates.PromptType{ | ||
| templates.PromptTypePrimaryAgent, | ||
| templates.PromptTypeAssistant, | ||
| } { | ||
| got, err := prompter.GetTemplate(pt) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(%q) error: %v", pt, err) | ||
| } | ||
| want := getDefaultTemplate(t, pt) | ||
| if got != want { | ||
| t.Errorf("prompt %q: expected default body when user has no overrides, got divergent body", pt) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestBuildUserPrompter_SingleOverride(t *testing.T) { | ||
| const customBody = "custom primary agent prompt body" | ||
| userPrompts := []database.Prompt{ | ||
| {Type: database.PromptTypePrimaryAgent, Prompt: customBody}, | ||
| } | ||
|
|
||
| prompter := buildUserPrompter(loadDefaults(t), userPrompts) | ||
|
|
||
| got, err := prompter.GetTemplate(templates.PromptTypePrimaryAgent) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(primary_agent) error: %v", err) | ||
| } | ||
| if got != customBody { | ||
| t.Errorf("primary_agent: expected custom body %q, got %q", customBody, got) | ||
| } | ||
|
|
||
| // Spot-check that an unrelated type still resolves to the default. | ||
| for _, pt := range []templates.PromptType{ | ||
| templates.PromptTypeAssistant, | ||
| templates.PromptTypePentester, | ||
| } { | ||
| got, err := prompter.GetTemplate(pt) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(%q) error: %v", pt, err) | ||
| } | ||
| if got != getDefaultTemplate(t, pt) { | ||
| t.Errorf("prompt %q should still match default after a single unrelated override", pt) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestBuildUserPrompter_PartialOverridesPreserveDefaults(t *testing.T) { | ||
| overrides := map[database.PromptType]string{ | ||
| database.PromptTypePrimaryAgent: "custom primary", | ||
| database.PromptTypeCoder: "custom coder", | ||
| database.PromptTypeReporter: "custom reporter", | ||
| } | ||
|
|
||
| userPrompts := make([]database.Prompt, 0, len(overrides)) | ||
| for pt, body := range overrides { | ||
| userPrompts = append(userPrompts, database.Prompt{Type: pt, Prompt: body}) | ||
| } | ||
|
|
||
| prompter := buildUserPrompter(loadDefaults(t), userPrompts) | ||
|
|
||
| for pt, want := range overrides { | ||
| got, err := prompter.GetTemplate(templates.PromptType(pt)) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(%q) error: %v", pt, err) | ||
| } | ||
| if got != want { | ||
| t.Errorf("prompt %q: expected custom body %q, got %q", pt, want, got) | ||
| } | ||
| } | ||
|
|
||
| // Types that were not overridden must still match the defaults. | ||
| for _, pt := range []templates.PromptType{ | ||
| templates.PromptTypeAssistant, | ||
| templates.PromptTypePentester, | ||
| templates.PromptTypeSearcher, | ||
| } { | ||
| got, err := prompter.GetTemplate(pt) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(%q) error: %v", pt, err) | ||
| } | ||
| if got != getDefaultTemplate(t, pt) { | ||
| t.Errorf("prompt %q should still match default after partial overrides", pt) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestBuildUserPrompter_EmptyBodyIsIgnored(t *testing.T) { | ||
| userPrompts := []database.Prompt{ | ||
| {Type: database.PromptTypePrimaryAgent, Prompt: ""}, | ||
| } | ||
|
|
||
| prompter := buildUserPrompter(loadDefaults(t), userPrompts) | ||
|
|
||
| got, err := prompter.GetTemplate(templates.PromptTypePrimaryAgent) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(primary_agent) error: %v", err) | ||
| } | ||
| if got != getDefaultTemplate(t, templates.PromptTypePrimaryAgent) { | ||
| t.Errorf("primary_agent: empty user body must not clobber default") | ||
| } | ||
| } | ||
|
|
||
| func TestNewUserPrompter_HappyPath(t *testing.T) { | ||
| const customAssistant = "custom assistant prompt" | ||
| const customCoder = "custom coder prompt" | ||
|
|
||
| db := &fakeQuerier{ | ||
| prompts: []database.Prompt{ | ||
| {Type: database.PromptTypeAssistant, Prompt: customAssistant}, | ||
| {Type: database.PromptTypeCoder, Prompt: customCoder}, | ||
| }, | ||
| } | ||
|
|
||
| prompter, err := newUserPrompter(context.Background(), db, 42) | ||
| if err != nil { | ||
| t.Fatalf("newUserPrompter error: %v", err) | ||
| } | ||
|
|
||
| for pt, want := range map[templates.PromptType]string{ | ||
| templates.PromptTypeAssistant: customAssistant, | ||
| templates.PromptTypeCoder: customCoder, | ||
| } { | ||
| got, err := prompter.GetTemplate(pt) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(%q) error: %v", pt, err) | ||
| } | ||
| if got != want { | ||
| t.Errorf("prompt %q: expected custom body %q, got %q", pt, want, got) | ||
| } | ||
| } | ||
|
|
||
| // Sanity check that an un-customized type still falls back. | ||
| got, err := prompter.GetTemplate(templates.PromptTypePentester) | ||
| if err != nil { | ||
| t.Fatalf("GetTemplate(pentester) error: %v", err) | ||
| } | ||
| if got != getDefaultTemplate(t, templates.PromptTypePentester) { | ||
| t.Errorf("pentester: expected default body when user has no override") | ||
| } | ||
| } | ||
|
|
||
| func TestNewUserPrompter_DBErrorPropagates(t *testing.T) { | ||
| sentinel := errors.New("db connection lost") | ||
| db := &fakeQuerier{err: sentinel} | ||
|
|
||
| prompter, err := newUserPrompter(context.Background(), db, 42) | ||
| if err == nil { | ||
| t.Fatalf("expected error, got nil prompter=%v", prompter) | ||
| } | ||
| if prompter != nil { | ||
| t.Errorf("expected nil prompter on DB error, got %v", prompter) | ||
| } | ||
| if !errors.Is(err, sentinel) { | ||
| t.Errorf("expected wrapped sentinel error, got %v", err) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.