mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
22f6342794
All three caught in Claude Desktop's second-round review against the
deployed cloud build. Independent file surfaces, but they all polish
the same MCP-tool-call user experience so they ride together.
## BUG-1081: star/unstar return structured JSON instead of 204
internal/server/handlers_stars.go — `handleStarItem` and
`handleUnstarItem` previously returned 204 No Content. RESTfully
fine, but the MCP HTTPHandlerDispatcher passes through whatever the
handler wrote — empty body + 204 → empty MCP tool result. Agents
had no signal whether the operation landed. BUG-989's earlier fix
touched the CLI's text output via the JSON branch but missed the
API endpoint itself.
Fix: both endpoints now return 200 OK with `{ref, starred: bool}`.
Mirrors the shape Claude's review requested + the broader "return
enough info to be the next source of truth" pattern note/decide
adopted.
New test pins the wire shape including content-type. Negative
control verified — reverting the handler fails the test with
"expected 200, got 204" on the first assertion.
## BUG-1082: suggested_next surfaces orphans, not just plan-children
internal/server/handlers_dashboard.go — the candidate loop only
walked items that are children of an active plan. Workspaces
without active plans (or with in-progress / high-priority items
outside their active plans) got an empty suggested_next, even
when the obvious answer was "continue your one in-progress task."
BUG-990's earlier fix added in-progress to the active-plan scope
but kept the orphan branch in scope-creep territory. Real
dogfooding showed it's the common case for new workspaces.
Fix: add a second pass that scans all items for in-progress (any
priority — continuation always beats priority) and high/critical-
priority open items not already in the active-plan candidates.
Orphans rank lower than plan-children so existing plan-driven
behavior is preserved when both are present. Reason text drops
the plan-name reference for orphans.
Two existing tests pinned the OLD "no suggestions when no active
plans" behavior — that was pinning the bug. Updated to the new
correct behavior. Added two new tests pinning the in-progress-
beats-priority gating and the rank-below-active-plan ordering.
## TASK-1076: workspace auto-default from OAuth allow-list
internal/mcp/dispatch_http.go + internal/mcp/dispatch_http_advanced.go
— the dispatcher's preprocess flow now calls `maybeInjectWorkspace`
after the existing --assign / --role resolution. When:
- input["workspace"] is set → caller wins (no override)
- d.Lister is nil → no-op (tests + non-OAuth paths)
- lister returns 1 workspace → inject input["workspace"] = slug
- lister returns 0 or N → no-op (caller must pass explicitly;
route mapper's existing "missing
required input" error surfaces if
the route needs workspace)
The lister already encodes the right policy (PAT auth → all the
user's workspaces; wildcard token → same; specific allow-list →
intersection with memberships) so we reuse it instead of building
a parallel resolver. Auto-defaulting only when the resolved set
collapses to one is the unambiguous case; multi-workspace tokens
still require explicit choice (silently picking one would be a
real audience-confusion hazard for write operations).
Caller-passed workspace ALWAYS wins — agents that pass an explicit
slug never see it silently overridden by the default. Lister error
falls through to no-op (don't poison input on transient store hiccup).
Tests pin all four matrix cases from the task spec plus three
operational corner cases (nil lister, lister error, copy-on-write
non-mutation).
## Combined CI surface
`make check` clean across lint + tests + web. The test surface
gained:
- TestStarUnstar_ReturnsStructuredJSON (server)
- TestDashboardSuggestedNextOrphan_InProgressBeatsPriority (server)
- TestDashboardSuggestedNextOrphan_RanksBelowActivePlan (server)
- TestMaybeInjectWorkspace_* (mcp, 7 cases)
- TestDashboardSuggestedNextNoPlans + TestDashboardSuggestedNextFromPlannedPlan
reframed from pin-the-old-bug to pin-the-new-correct-behavior
120 lines
4.3 KiB
Go
120 lines
4.3 KiB
Go
package mcp
|
|
|
|
// Tests for TASK-1076's workspace auto-default. Pins the four-case
|
|
// matrix the task spec called out:
|
|
//
|
|
// 1. Single resolved workspace, no caller param → inject
|
|
// 2. Single resolved workspace, caller passed workspace=other →
|
|
// caller wins (no silent override)
|
|
// 3. Multiple resolved workspaces, no caller param → leave alone
|
|
// 4. Zero resolved workspaces, no caller param → leave alone
|
|
//
|
|
// Plus two operational cases that aren't in the formal matrix but
|
|
// would silently break behavior if regressed:
|
|
//
|
|
// - No Lister wired → leave alone (tests / non-OAuth paths)
|
|
// - Lister returns error → leave alone (don't poison input on
|
|
// transient store hiccup)
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// fakeLister satisfies WorkspaceLister with a fixed return value.
|
|
// Used to exercise each matrix case without spinning up a real store.
|
|
type fakeLister struct {
|
|
workspaces []WorkspaceHint
|
|
err error
|
|
}
|
|
|
|
func (f *fakeLister) ListWorkspaces(ctx context.Context) ([]WorkspaceHint, error) {
|
|
return f.workspaces, f.err
|
|
}
|
|
|
|
func TestMaybeInjectWorkspace_SingleWorkspace_Injects(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{
|
|
Lister: &fakeLister{workspaces: []WorkspaceHint{{Slug: "only", Name: "Only Workspace"}}},
|
|
}
|
|
got := d.maybeInjectWorkspace(context.Background(), map[string]any{})
|
|
if got["workspace"] != "only" {
|
|
t.Errorf("expected workspace='only' injected, got %v", got["workspace"])
|
|
}
|
|
}
|
|
|
|
func TestMaybeInjectWorkspace_CallerWins_OverSingleWorkspace(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{
|
|
Lister: &fakeLister{workspaces: []WorkspaceHint{{Slug: "default-one"}}},
|
|
}
|
|
got := d.maybeInjectWorkspace(context.Background(), map[string]any{"workspace": "explicit"})
|
|
if got["workspace"] != "explicit" {
|
|
t.Errorf("explicit workspace= must win over default; got %v", got["workspace"])
|
|
}
|
|
}
|
|
|
|
func TestMaybeInjectWorkspace_MultipleWorkspaces_NoInject(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{
|
|
Lister: &fakeLister{workspaces: []WorkspaceHint{{Slug: "a"}, {Slug: "b"}}},
|
|
}
|
|
got := d.maybeInjectWorkspace(context.Background(), map[string]any{})
|
|
if _, present := got["workspace"]; present {
|
|
t.Errorf("multiple workspaces must NOT auto-inject (ambiguous); got %v", got["workspace"])
|
|
}
|
|
}
|
|
|
|
func TestMaybeInjectWorkspace_ZeroWorkspaces_NoInject(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{
|
|
Lister: &fakeLister{workspaces: nil},
|
|
}
|
|
got := d.maybeInjectWorkspace(context.Background(), map[string]any{})
|
|
if _, present := got["workspace"]; present {
|
|
t.Errorf("zero workspaces must NOT inject; got %v", got["workspace"])
|
|
}
|
|
}
|
|
|
|
func TestMaybeInjectWorkspace_NilLister_NoInject(t *testing.T) {
|
|
// Tests + non-OAuth transports leave Lister nil. The injection
|
|
// must be a no-op so existing behavior is preserved.
|
|
d := &HTTPHandlerDispatcher{Lister: nil}
|
|
in := map[string]any{"foo": "bar"}
|
|
got := d.maybeInjectWorkspace(context.Background(), in)
|
|
if _, present := got["workspace"]; present {
|
|
t.Errorf("nil Lister must NOT inject; got %v", got["workspace"])
|
|
}
|
|
if got["foo"] != "bar" {
|
|
t.Errorf("nil Lister must preserve other input keys; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMaybeInjectWorkspace_ListerError_NoInject(t *testing.T) {
|
|
// Transient store error from the lister must fall through to
|
|
// "no inject" — don't fail the whole tool call on a hiccup, let
|
|
// the route mapper's eventual error speak for itself if the
|
|
// route needs workspace.
|
|
d := &HTTPHandlerDispatcher{
|
|
Lister: &fakeLister{err: errors.New("simulated store failure")},
|
|
}
|
|
got := d.maybeInjectWorkspace(context.Background(), map[string]any{})
|
|
if _, present := got["workspace"]; present {
|
|
t.Errorf("lister error must NOT inject; got %v", got["workspace"])
|
|
}
|
|
}
|
|
|
|
func TestMaybeInjectWorkspace_CopyOnWrite(t *testing.T) {
|
|
// The helper must NOT mutate the caller's input map in place —
|
|
// callers (the dispatch flow) hold the original reference and
|
|
// surface it in error logs / tool results.
|
|
d := &HTTPHandlerDispatcher{
|
|
Lister: &fakeLister{workspaces: []WorkspaceHint{{Slug: "only"}}},
|
|
}
|
|
in := map[string]any{}
|
|
out := d.maybeInjectWorkspace(context.Background(), in)
|
|
if _, present := in["workspace"]; present {
|
|
t.Error("input map must not be mutated in place")
|
|
}
|
|
if out["workspace"] != "only" {
|
|
t.Errorf("returned map must carry the injected value; got %v", out["workspace"])
|
|
}
|
|
}
|