Files
pad/internal/mcp/dispatch_http_workspace_default_test.go
xarmian 22f6342794 fix(mcp): bundle BUG-1081 + BUG-1082 + TASK-1076 — three small MCP-UX fixes from dogfooding (#387)
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
2026-05-02 21:31:48 -04:00

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"])
}
}