Files
summercms/modules/pier/prompt_ceiling_test.go
Jakub Zych 788d4a49fa fix(19.7): RA-WR-05 keep prompt Render errors out of the client's answer
promptHandler logs a failing Render's error with the prompt name and
answers the fixed JSON-RPC internal error "pier: the prompt could not be
rendered", as dispatch does for tool calls (A-WR-08), so database or
provider text never reaches the agent or its transcript.

TestPromptRenderError renders a prompt that fails with a database error
naming its host and asserts, on both paths, that the client sees only the
fixed text and the cause is in the log.
2026-10-23 20:12:22 +02:00

102 lines
3.7 KiB
Go

package pier_test
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// promptNames lists the prompt names a session sees, sorted.
func promptNames(t *testing.T, ctx context.Context, cs *mcp.ClientSession) []string {
t.Helper()
list, err := cs.ListPrompts(ctx, nil)
if err != nil {
t.Fatalf("prompts/list: %v", err)
}
var out []string
for _, p := range list.Prompts {
out = append(out, p.Name)
}
slices.Sort(out)
return out
}
// TestPromptWriteCeiling pins plan 06's prompt ceiling (C-IN-01, P-28): a
// prompt declaring Writes is listed for a read and write grant and hidden
// from a read-only grant (and cannot be fetched by it), while a prompt
// without Writes is listed for both.
func TestPromptWriteCeiling(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
draft := summarizePrompt()
draft.Name = "draft"
draft.Writes = true
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}}),
acmePlugin{tools: []pact.MCPTool{echoTool()}, prompts: []pact.MCPPrompt{summarizePrompt(), draft}})
t.Cleanup(in.pier().StopReaper)
for _, c := range []struct {
name string
token string
want []string
}{
{"read and write grant", "oas_at_test", []string{"acme_draft", "acme_summarize"}},
{"read-only grant", "oas_at_read", []string{"acme_summarize"}},
} {
t.Run(c.name, func(t *testing.T) {
for _, version := range []string{"", "newest"} {
cs := sdkClient(t, ctx, in.url(), c.token, clientOpts{version: version})
if got := promptNames(t, ctx, cs); !slices.Equal(got, c.want) {
t.Fatalf("version %q: prompts = %v, want %v", version, got, c.want)
}
_, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_draft", Arguments: map[string]string{"topic": "x"}})
if allowed := slices.Contains(c.want, "acme_draft"); (err == nil) != allowed {
t.Fatalf("version %q: get acme_draft err = %v, allowed %v", version, err, allowed)
}
}
})
}
}
// TestPromptRenderError pins RA-WR-05: a prompt whose Render fails answers a
// fixed JSON-RPC error on both paths; the cause (here a database error
// naming its host and user) is logged server-side and never reaches the
// client.
func TestPromptRenderError(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const cause = `failed to connect to host=db.internal user=summer database=app: dial tcp 10.0.0.5:5432`
broken := summarizePrompt()
broken.Name = "broken"
broken.Render = func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) {
return nil, errors.New(cause)
}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}}),
acmePlugin{tools: []pact.MCPTool{echoTool()}, prompts: []pact.MCPPrompt{broken}})
t.Cleanup(in.pier().StopReaper)
logs := captureLogs(t)
for _, version := range []string{"", "newest"} {
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{version: version})
_, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_broken", Arguments: map[string]string{"topic": "x"}})
if err == nil {
t.Fatalf("version %q: a failing Render answered no error", version)
}
if strings.Contains(err.Error(), "db.internal") || strings.Contains(err.Error(), "10.0.0.5") {
t.Fatalf("version %q: the Render cause reached the client: %v", version, err)
}
if !strings.Contains(err.Error(), "pier: the prompt could not be rendered") {
t.Fatalf("version %q: error = %v, want the fixed text", version, err)
}
}
if !strings.Contains(logs.String(), "db.internal") {
t.Fatalf("the Render cause was not logged: %s", logs.String())
}
}