Files
pad/internal/mcp/bug987_test.go
T
xarmian 55d3a078a8 fix(mcp): standup CLI ref + classifier polish for BUG-987 round 2 (#362)
Round-2 hotfix on top of PR #361 (which shipped to v0.1.0-rc.4).
Claude Desktop's re-review of rc.4 surfaced two fixes that didn't
fully land:

- Bug 8 (round 1 went to wrong layer). My HTTPHandlerDispatcher fix
  populated ref on standup blockers, but Claude Desktop's path is
  ExecDispatcher → CLI subprocess → standupCmd, which has its own
  JSON composition struct. That struct's Attention + SuggestedNext
  anonymous types didn't even define ItemRef as a parseable field.
  Now both define `item_ref` and the JSON-emit loops set Ref from it.
  Verified live: blockers now carry refs (TASK-X), not empty strings.

- Bug 11 part 2. Round 1 stripped the cobra Usage block but two
  artifacts still leaked:
  1. The "pad <verb> failed: <stderr>" prefix on server_error fallback
     messages. The verb name is the OLD CLI verb (e.g. `pad item
     block`) which doesn't match the v0.2 catalog actions agents see,
     and the cmdPath is already implicit from the invoked tool. Drop
     the prefix; emit the cleaned stderr directly.
  2. Self-link / "cannot ..." validation rejections classified as
     server_error instead of validation_failed. Extended the
     validation regex with `cannot ` so server-side rejections like
     "cannot link an item to itself" / "cannot modify archived item"
     route to ErrValidationFailed. Verified live with a self-link
     attempt — now returns code=validation_failed, hint="cannot link
     an item to itself", no prefix.
- New stripErrorPrefix helper trims leading `Error:` / `error:` /
  `ERROR:` from every classified hint+message so the envelope text
  isn't redundant with the envelope's `code` signal.

Bug 13 / Bug 14: my round-1 fixes verified working locally on rc.4
(tested with a fresh Task → convention=None; dashboard by_role shows
"Unassigned"/"unassigned" for the bucket). The reviewer's stale
results almost certainly reflect a pad server process that wasn't
restarted with the rc.4 binary swap.

Tests:
- TestClassifyExecError_CannotPhrasingClassifiesAsValidation —
  three "cannot ..." stderr cases must classify validation_failed.
- TestClassifyExecError_NoLegacyVerbPrefixInMessage — pins the
  prefix-strip behaviour on the server_error fallback path.
- TestStripErrorPrefix — trim-rule round-trip across casing
  variations and empty input.

Parent: BUG-987.
2026-05-01 21:04:26 -04:00

272 lines
8.9 KiB
Go

package mcp
import (
"context"
"errors"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/cmdhelp"
)
// Tests covering BUG-987's MCP-layer fixes (Bugs 11, 12; Bugs 6, 8,
// 13, 14 land in their respective package tests).
// TestStripCobraUsageBlock verifies the helper that scrubs cobra's
// auto-emitted "Usage: ..." block from CLI stderr before it reaches
// MCP error envelopes (BUG-987 bug 11). The Usage block leaks old
// CLI verb names (e.g. `pad item block`) into responses agents see
// — confusing for agents using the v0.2 catalog and fragile against
// future CLI flag changes.
func TestStripCobraUsageBlock(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "strips standard cobra usage block",
in: `Error: cannot link an item to itself
Usage:
pad item block <source-ref> <target-ref> [flags]
Flags:
-h, --help help for block
Global Flags:
--format string output format`,
want: "Error: cannot link an item to itself",
},
{
name: "passes through when no Usage block present",
in: "Error: item TASK-99 not found",
want: "Error: item TASK-99 not found",
},
{
name: "Usage marker as substring of word — not stripped",
in: "Error: this misUsage: case is rare but possible",
want: "Error: this misUsage: case is rare but possible",
},
{
name: "trims trailing whitespace before Usage",
in: "Error: bad input\n\n \nUsage:\n pad item show <ref>",
want: "Error: bad input",
},
{
name: "empty input",
in: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := stripCobraUsageBlock(tc.in); got != tc.want {
t.Errorf("got %q\nwant %q", got, tc.want)
}
})
}
}
// TestClassifyExecError_StripsUsageBlock confirms classifyExecError
// removes Usage text BEFORE pattern matching and BEFORE placing the
// remaining stderr into the envelope. Without this, agents see
// `pad item block ...` references in error responses despite the
// MCP catalog using `pad_item action=link link_type=blocks`.
func TestClassifyExecError_StripsUsageBlock(t *testing.T) {
stderr := `Error: cannot link an item to itself
Usage:
pad item block <source-ref> <target-ref> [flags]
Flags:
-h, --help help for block`
res := classifyExecError(context.Background(),
[]string{"item", "block"},
errors.New("exit 1"),
stderr,
nil,
)
body := textOf(res)
if strings.Contains(body, "Usage:") {
t.Errorf("envelope leaked Usage block: %s", body)
}
if strings.Contains(body, "pad item block <source-ref>") {
t.Errorf("envelope leaked old CLI verb help: %s", body)
}
if !strings.Contains(body, "cannot link an item to itself") {
t.Errorf("envelope dropped the actual error message: %s", body)
}
}
// TestValidationFailedFromBuildErr verifies that BuildCLIArgs error
// strings are wrapped as structured validation_failed envelopes
// instead of bare-text results (BUG-987 bug 12).
func TestValidationFailedFromBuildErr(t *testing.T) {
t.Run("missing required argument extracts field name", func(t *testing.T) {
err := errors.New(`missing required argument "title"`)
res := validationFailedFromBuildErr("item create", err)
if !res.IsError {
t.Errorf("IsError = false, want true")
}
env := decodeEnvelope(t, res)
if env.Error.Code != ErrValidationFailed {
t.Errorf("Code = %q, want %q", env.Error.Code, ErrValidationFailed)
}
if env.Error.Field != "title" {
t.Errorf("Field = %q, want title", env.Error.Field)
}
if !strings.Contains(env.Error.Message, "item create") {
t.Errorf("Message should reference cmdPath; got %q", env.Error.Message)
}
})
t.Run("flag type mismatch extracts field name", func(t *testing.T) {
err := errors.New(`flag "limit": expected number, got string`)
res := validationFailedFromBuildErr("item list", err)
env := decodeEnvelope(t, res)
if env.Error.Code != ErrValidationFailed {
t.Errorf("Code = %q, want %q", env.Error.Code, ErrValidationFailed)
}
if env.Error.Field != "limit" {
t.Errorf("Field = %q, want limit", env.Error.Field)
}
})
t.Run("unrecognized error message still produces validation envelope", func(t *testing.T) {
err := errors.New("totally novel error format")
res := validationFailedFromBuildErr("item show", err)
env := decodeEnvelope(t, res)
if env.Error.Code != ErrValidationFailed {
t.Errorf("Code = %q, want %q", env.Error.Code, ErrValidationFailed)
}
// Field stays empty when the regex misses; the underlying
// message text still carries the detail.
if env.Error.Field != "" {
t.Errorf("Field = %q, want empty", env.Error.Field)
}
if !strings.Contains(env.Error.Message, "totally novel error") {
t.Errorf("Message should preserve underlying text; got %q", env.Error.Message)
}
})
}
// TestClassifyExecError_CannotPhrasingClassifiesAsValidation pins
// the round-2 fix: server-side rejections phrased "cannot link an
// item to itself" / "cannot modify archived item" / etc. were
// falling through to ErrServerError. They're validation-shaped and
// should classify as ErrValidationFailed so agents can branch on
// the right code (BUG-987 bug 11 round 2).
func TestClassifyExecError_CannotPhrasingClassifiesAsValidation(t *testing.T) {
cases := []string{
"Error: cannot link an item to itself",
"Error: cannot modify archived item",
"cannot supersede an item that is already superseded",
}
for _, stderr := range cases {
t.Run(stderr, func(t *testing.T) {
res := classifyExecError(context.Background(),
[]string{"item", "block"},
errors.New("exit 1"),
stderr,
nil,
)
env := decodeEnvelope(t, res)
if env.Error.Code != ErrValidationFailed {
t.Errorf("Code = %q, want %q (stderr=%q)",
env.Error.Code, ErrValidationFailed, stderr)
}
})
}
}
// TestClassifyExecError_NoLegacyVerbPrefixInMessage pins the round-2
// fix that drops the "pad <cmd> failed: " prefix from server_error
// fallback messages. The prefix referenced OLD CLI verb names which
// don't match the v0.2 MCP catalog action names agents see, and was
// noise on top of the cmdPath that's already implicit from the
// invoked tool (BUG-987 bug 11 round 2).
func TestClassifyExecError_NoLegacyVerbPrefixInMessage(t *testing.T) {
// Use a stderr that won't hit any classifier matcher so we
// definitely fall through to the server_error fallback path.
stderr := "Error: completely novel runtime issue"
res := classifyExecError(context.Background(),
[]string{"item", "block"},
errors.New("exit 1"),
stderr,
nil,
)
env := decodeEnvelope(t, res)
if env.Error.Code != ErrServerError {
t.Fatalf("Code = %q, want %q", env.Error.Code, ErrServerError)
}
if strings.Contains(env.Error.Message, "pad item block failed") {
t.Errorf("server_error message should not carry legacy verb prefix; got %q",
env.Error.Message)
}
if strings.Contains(env.Error.Message, "Error:") {
t.Errorf("server_error message should strip leading Error: prefix; got %q",
env.Error.Message)
}
if !strings.Contains(env.Error.Message, "completely novel runtime issue") {
t.Errorf("server_error message dropped the underlying detail; got %q",
env.Error.Message)
}
}
// TestStripErrorPrefix locks the prefix-strip rules so they don't
// accidentally over- or under-trim.
func TestStripErrorPrefix(t *testing.T) {
cases := map[string]string{
"Error: bad input": "bad input",
" Error: spaced input ": "spaced input",
"error: lowercase": "lowercase",
"ERROR: shouty": "shouty",
"no prefix": "no prefix",
"": "",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
if got := stripErrorPrefix(in); got != want {
t.Errorf("got %q, want %q", got, want)
}
})
}
}
// TestEnvDispatch_ValidationErrorIsStructured drives env.Dispatch
// with a missing-arg input and confirms the resulting error result is
// a structured validation_failed envelope, not a bare-text result.
// This is the integration counterpart to TestValidationFailedFromBuildErr.
func TestEnvDispatch_ValidationErrorIsStructured(t *testing.T) {
doc := &cmdhelp.Document{
Binary: "pad",
Commands: map[string]cmdhelp.Command{
"item show": {
Args: []cmdhelp.Arg{{Name: "ref", Required: true}},
},
},
}
env := ActionEnv{
Doc: doc,
Workspace: NewWorkspaceState(""),
Dispatcher: &fakeDispatcher{},
}
// Missing required `ref` — BuildCLIArgs returns an error,
// env.Dispatch must wrap it as validation_failed.
res, err := env.Dispatch(context.Background(), []string{"item", "show"}, map[string]any{})
if err != nil {
t.Fatalf("Dispatch returned protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError, got success: %s", textOf(res))
}
env2 := decodeEnvelope(t, res)
if env2.Error.Code != ErrValidationFailed {
t.Errorf("Code = %q, want %q (full envelope: %+v)",
env2.Error.Code, ErrValidationFailed, env2.Error)
}
if env2.Error.Field != "ref" {
t.Errorf("Field = %q, want ref", env2.Error.Field)
}
}