mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
78729db1a9
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
155 lines
6.0 KiB
Go
155 lines
6.0 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/cli"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// PLAN-3578 step 1 at the MCP doors.
|
|
|
|
func TestPLAN3578_MCPPatchLinesAndRefusals(t *testing.T) {
|
|
f := newParentFixture(t)
|
|
ws, ref := f.workspace.Slug, f.child.Slug
|
|
f.dispatch(t, []string{"item", "update"}, map[string]any{"workspace": ws, "ref": ref, "content": "alpha\nbeta\ngamma beta\n", "replace": true})
|
|
|
|
// get with lines: numbered lines and the seq, on the catalog path both
|
|
// transports share.
|
|
res, err := actionItemGet(t.Context(), map[string]any{"workspace": ws, "ref": ref, "lines": "2-3"}, f.env(t))
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("get lines: %v %s", err, textOf(res))
|
|
}
|
|
var view struct {
|
|
Seq int64 `json:"seq"`
|
|
Lines []struct {
|
|
N int `json:"n"`
|
|
Text string `json:"text"`
|
|
} `json:"lines"`
|
|
TotalLines int `json:"total_lines"`
|
|
}
|
|
b, _ := json.Marshal(res.StructuredContent)
|
|
_ = json.Unmarshal(b, &view)
|
|
if len(view.Lines) != 2 || view.Lines[0].N != 2 || view.Lines[0].Text != "beta" || view.TotalLines != 3 || view.Seq == 0 {
|
|
t.Fatalf("lines view = %+v", view)
|
|
}
|
|
|
|
// A patch lands and answers with the diff.
|
|
res = f.dispatchResult2(t, []string{"item", "patch"}, map[string]any{"workspace": ws, "ref": ref,
|
|
"edits": []any{map[string]any{"old": "alpha", "new": "ALPHA"}}})
|
|
if res.IsError {
|
|
t.Fatalf("patch: %s", textOf(res))
|
|
}
|
|
m, _ := res.StructuredContent.(map[string]any)
|
|
if d, _ := m["diff"].(string); !strings.Contains(d, "+ALPHA") {
|
|
t.Fatalf("patch answer = %v", m)
|
|
}
|
|
|
|
// An ambiguous anchor: its own code, the lines, the shared hint.
|
|
res = f.dispatchResult2(t, []string{"item", "patch"}, map[string]any{"workspace": ws, "ref": ref,
|
|
"edits": []any{map[string]any{"old": "beta", "new": "B"}}})
|
|
env, ok := res.StructuredContent.(ErrorEnvelope)
|
|
if !res.IsError || !ok || env.Error.Code != ErrPatchAnchorAmbiguous || env.Error.Hint != PatchAnchorHint {
|
|
t.Fatalf("ambiguous: %s", textOf(res))
|
|
}
|
|
var d map[string]any
|
|
_ = json.Unmarshal(env.Error.Details, &d)
|
|
if lines, _ := d["lines"].([]any); len(lines) != 2 {
|
|
t.Fatalf("details = %v", d)
|
|
}
|
|
}
|
|
|
|
// Stdio: the CLI's marker carries the code, details and hint.
|
|
func TestPLAN3578_StdioPatchRefusal(t *testing.T) {
|
|
for code, hint := range map[string]string{
|
|
"patch_anchor_missing": PatchAnchorHint,
|
|
"patch_anchor_ambiguous": PatchAnchorHint,
|
|
"patch_room_open": PatchRoomOpenHint,
|
|
"patch_unstable_roundtrip": PatchUnstableHint,
|
|
} {
|
|
var stderr bytes.Buffer
|
|
cli.WritePatchRefusalError(&stderr, &cli.APIError{Code: code, Message: "m", Details: json.RawMessage(`{"ref":"TASK-1","edit":0}`)})
|
|
res := extractStructuredCLIError(stderr.String())
|
|
if res == nil {
|
|
t.Fatalf("%s: marker not recognised (not in the allow-list?)", code)
|
|
}
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if string(env.Error.Code) != code || env.Error.Hint != hint {
|
|
t.Fatalf("%s: got %s %q", code, env.Error.Code, env.Error.Hint)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPLAN3578_CLIAndMCPAgreeOnPatchHints(t *testing.T) {
|
|
if cli.PatchAnchorHint != PatchAnchorHint || cli.PatchRoomOpenHint != PatchRoomOpenHint || cli.PatchUnstableHint != PatchUnstableHint {
|
|
t.Fatal("patch hints differ between internal/cli and internal/mcp")
|
|
}
|
|
for _, c := range []string{cli.PatchAnchorMissingCode, cli.PatchAnchorAmbiguousCode, cli.PatchRoomOpenCode, cli.PatchUnstableCode} {
|
|
if _, ok := allowedStructuredErrorCodes[c]; !ok {
|
|
t.Fatalf("%s is not in allowedStructuredErrorCodes", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *parentFixture) httpDispatcher() *HTTPHandlerDispatcher {
|
|
return &HTTPHandlerDispatcher{
|
|
Handler: f.srv,
|
|
UserResolver: func(context.Context) *models.User { return f.owner },
|
|
}
|
|
}
|
|
|
|
func (f *parentFixture) env(t *testing.T) ActionEnv {
|
|
t.Helper()
|
|
return ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState(f.workspace.Slug), Dispatcher: f.httpDispatcher()}
|
|
}
|
|
|
|
func (f *parentFixture) dispatchResult2(t *testing.T, cmd []string, input map[string]any) *CallToolResult {
|
|
t.Helper()
|
|
res, err := f.httpDispatcher().Dispatch(WithDispatchInput(context.Background(), input), cmd, nil)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch(%v): %v", cmd, err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
// PLAN-3578 step 3 at the MCP doors: undo round-trips over HTTP, and a
|
|
// patch_not_found carries its own code and hint on both transports.
|
|
func TestPLAN3578_MCPUndo(t *testing.T) {
|
|
f := newParentFixture(t)
|
|
ws, ref := f.workspace.Slug, f.child.Slug
|
|
f.dispatch(t, []string{"item", "update"}, map[string]any{"workspace": ws, "ref": ref, "content": "alpha beta\n", "replace": true})
|
|
res := f.dispatchResult2(t, []string{"item", "patch"}, map[string]any{"workspace": ws, "ref": ref,
|
|
"edits": []any{map[string]any{"old": "beta", "new": "BETA"}}})
|
|
m, _ := res.StructuredContent.(map[string]any)
|
|
pid, _ := m["patch_id"].(string)
|
|
if res.IsError || pid == "" {
|
|
t.Fatalf("patch: %s", textOf(res))
|
|
}
|
|
res = f.dispatchResult2(t, []string{"item", "patch"}, map[string]any{"workspace": ws, "ref": ref, "undo": pid})
|
|
if m, _ := res.StructuredContent.(map[string]any); res.IsError || m["undo_of"] != pid {
|
|
t.Fatalf("undo: %s", textOf(res))
|
|
}
|
|
got, err := f.store.GetItem(f.child.ID)
|
|
if err != nil || got.Content != "alpha beta\n" {
|
|
t.Fatalf("after undo: %q %v", got.Content, err)
|
|
}
|
|
res = f.dispatchResult2(t, []string{"item", "patch"}, map[string]any{"workspace": ws, "ref": ref, "undo": "no-such-patch"})
|
|
env, ok := res.StructuredContent.(ErrorEnvelope)
|
|
if !res.IsError || !ok || env.Error.Code != ErrPatchNotFound || env.Error.Hint != PatchNotFoundHint {
|
|
t.Fatalf("unknown patch id: %s", textOf(res))
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
cli.WritePatchRefusalError(&stderr, &cli.APIError{Code: cli.PatchNotFoundCode, Message: "m"})
|
|
if r := extractStructuredCLIError(stderr.String()); r == nil || r.StructuredContent.(ErrorEnvelope).Error.Hint != PatchNotFoundHint {
|
|
t.Fatalf("stdio patch_not_found: %s", stderr.String())
|
|
}
|
|
if cli.PatchNotFoundHint != PatchNotFoundHint {
|
|
t.Fatal("patch_not_found hints differ between internal/cli and internal/mcp")
|
|
}
|
|
}
|