Slice 4: graceful project handoff (pending switch + ack)
Add pending-switch coordination to internal/activity (RequestSwitch/PendingSwitch/AckSwitch/CancelSwitch); AckSwitch atomically sets the active project and records switch-completed with Claude's summary. New MCP tools get_pending_switch and ack_switch (request is user-only via HTTP). HTTP GET/POST/DELETE /api/switch. New <handoff-bar> component (ask/waiting/cancel/completed) over SSE; activity-feed reflects switch-completed. Test covers request->ack->clear. Verified live: request/waiting/cancel over SSE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
@@ -44,6 +45,25 @@ type activityOutput struct {
|
||||
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
|
||||
}
|
||||
|
||||
// pendingSwitchOutput reports whether the user has asked Claude to switch projects.
|
||||
type pendingSwitchOutput struct {
|
||||
Pending bool `json:"pending" jsonschema:"true if the user has requested a switch you should complete"`
|
||||
Target string `json:"target,omitempty" jsonschema:"the repository path to switch to"`
|
||||
Note string `json:"note,omitempty" jsonschema:"an optional note from the user"`
|
||||
RequestedAt time.Time `json:"requestedAt,omitempty"`
|
||||
}
|
||||
|
||||
// ackSwitchInput is the argument schema for ack_switch.
|
||||
type ackSwitchInput struct {
|
||||
Summary string `json:"summary" jsonschema:"a short note on where you left the previous project (shown to the user)"`
|
||||
}
|
||||
|
||||
// ackSwitchOutput reports the completed switch.
|
||||
type ackSwitchOutput struct {
|
||||
Switched bool `json:"switched"`
|
||||
Target string `json:"target,omitempty" jsonschema:"the repository now active"`
|
||||
}
|
||||
|
||||
// NewServer builds the MCP server and registers the (currently read-only) tools.
|
||||
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
||||
@@ -100,6 +120,30 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
return nil, activityOutput{Events: svc.Activity(50)}, nil
|
||||
})
|
||||
|
||||
// get_pending_switch — has the user asked you to switch projects? (§8.3)
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "get_pending_switch",
|
||||
Description: "Check whether the user has asked you to switch to a different project. If pending is true, finish your current work to a SAFE stopping point (commit or stash so nothing is lost), then call ack_switch to complete the handoff.",
|
||||
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, pendingSwitchOutput, error) {
|
||||
p, ok := svc.PendingSwitch()
|
||||
if !ok {
|
||||
return nil, pendingSwitchOutput{Pending: false}, nil
|
||||
}
|
||||
return nil, pendingSwitchOutput{Pending: true, Target: p.Target, Note: p.Note, RequestedAt: p.RequestedAt}, nil
|
||||
})
|
||||
|
||||
// ack_switch — complete a pending handoff and tell the user where you left off.
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "ack_switch",
|
||||
Description: "Complete a pending project switch: makes the requested target the active project and clears the request. Call this only after reaching a safe stopping point in the current project. Pass a short summary of where you left it — the user is notified.",
|
||||
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in ackSwitchInput) (*mcpsdk.CallToolResult, ackSwitchOutput, error) {
|
||||
p, ok := svc.AckSwitch(in.Summary)
|
||||
if !ok {
|
||||
return nil, ackSwitchOutput{Switched: false}, fmt.Errorf("no pending switch to acknowledge")
|
||||
}
|
||||
return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,48 @@ func TestMCPRoundTrip(t *testing.T) {
|
||||
if !found {
|
||||
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
|
||||
}
|
||||
|
||||
// --- graceful handoff (§8.3): user requests, Claude acks -----------------
|
||||
svc.RequestSwitch(activity.ActorUser, states[0].Path, "fix a bug")
|
||||
|
||||
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
|
||||
var ps struct {
|
||||
Pending bool `json:"pending"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
decodeResult(t, res, &ps)
|
||||
if !ps.Pending || ps.Target != states[0].Path {
|
||||
t.Fatalf("expected pending switch to %q, got %+v", states[0].Path, ps)
|
||||
}
|
||||
|
||||
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "ack_switch",
|
||||
Arguments: map[string]any{"summary": "left tests green"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ack_switch: %v", err)
|
||||
}
|
||||
if res.IsError {
|
||||
t.Fatalf("ack_switch tool error: %+v", res.Content)
|
||||
}
|
||||
|
||||
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
|
||||
decodeResult(t, res, &ps)
|
||||
if ps.Pending {
|
||||
t.Fatalf("expected no pending switch after ack, got %+v", ps)
|
||||
}
|
||||
|
||||
// ack with nothing pending is a tool error.
|
||||
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "ack_switch",
|
||||
Arguments: map[string]any{"summary": "nothing"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ack_switch(empty) protocol error: %v", err)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatalf("expected IsError acking with no pending switch")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeResult unmarshals the JSON text content of a tool result into v.
|
||||
|
||||
Reference in New Issue
Block a user