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:
@@ -31,10 +31,21 @@ type Event struct {
|
||||
Detail string `json:"detail,omitempty"` // human-readable extra context
|
||||
}
|
||||
|
||||
// PendingSwitch is a user's request for Claude to switch to another project.
|
||||
// It is the request half of the graceful handoff (§8.3) — Claude fulfils it at a
|
||||
// safe stopping point via AckSwitch.
|
||||
type PendingSwitch struct {
|
||||
Target string `json:"target"` // requested repo path
|
||||
Note string `json:"note,omitempty"`
|
||||
RequestedBy Actor `json:"requestedBy"` // normally the user
|
||||
RequestedAt time.Time `json:"requestedAt"`
|
||||
}
|
||||
|
||||
// Feed is the concurrency-safe active-project + activity store with fan-out.
|
||||
type Feed struct {
|
||||
mu sync.RWMutex
|
||||
active string // active project path ("" = none)
|
||||
active string // active project path ("" = none)
|
||||
pending *PendingSwitch // an outstanding switch request, if any
|
||||
events []Event
|
||||
maxEvents int
|
||||
nextID int64
|
||||
@@ -79,6 +90,65 @@ func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
|
||||
return ev, true
|
||||
}
|
||||
|
||||
// RequestSwitch records a user's request for Claude to switch to target. The
|
||||
// latest request wins (it overwrites any outstanding one).
|
||||
func (f *Feed) RequestSwitch(actor Actor, target, note string) PendingSwitch {
|
||||
f.mu.Lock()
|
||||
p := PendingSwitch{Target: target, Note: note, RequestedBy: actor, RequestedAt: time.Now()}
|
||||
f.pending = &p
|
||||
ev, subs := f.appendLocked(actor, "switch-requested", target, note)
|
||||
f.mu.Unlock()
|
||||
|
||||
publish(subs, ev)
|
||||
return p
|
||||
}
|
||||
|
||||
// PendingSwitch returns the outstanding switch request, if any.
|
||||
func (f *Feed) PendingSwitch() (PendingSwitch, bool) {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
if f.pending == nil {
|
||||
return PendingSwitch{}, false
|
||||
}
|
||||
return *f.pending, true
|
||||
}
|
||||
|
||||
// AckSwitch completes a pending switch: it makes the requested target the active
|
||||
// project, clears the request, and records a "switch-completed" event carrying
|
||||
// Claude's summary of where it left the previous project. Returns false if there
|
||||
// was nothing pending.
|
||||
func (f *Feed) AckSwitch(actor Actor, summary string) (PendingSwitch, bool) {
|
||||
f.mu.Lock()
|
||||
if f.pending == nil {
|
||||
f.mu.Unlock()
|
||||
return PendingSwitch{}, false
|
||||
}
|
||||
p := *f.pending
|
||||
f.pending = nil
|
||||
f.active = p.Target
|
||||
ev, subs := f.appendLocked(actor, "switch-completed", p.Target, summary)
|
||||
f.mu.Unlock()
|
||||
|
||||
publish(subs, ev)
|
||||
return p, true
|
||||
}
|
||||
|
||||
// CancelSwitch clears a pending switch (e.g. the user changed their mind).
|
||||
func (f *Feed) CancelSwitch(actor Actor) (PendingSwitch, bool) {
|
||||
f.mu.Lock()
|
||||
if f.pending == nil {
|
||||
f.mu.Unlock()
|
||||
return PendingSwitch{}, false
|
||||
}
|
||||
p := *f.pending
|
||||
f.pending = nil
|
||||
ev, subs := f.appendLocked(actor, "switch-cancelled", p.Target, "")
|
||||
f.mu.Unlock()
|
||||
|
||||
publish(subs, ev)
|
||||
return p, true
|
||||
}
|
||||
|
||||
// Record adds an arbitrary event to the feed.
|
||||
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
|
||||
f.mu.Lock()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -86,3 +86,32 @@ func (s *Service) Activity(limit int) []activity.Event {
|
||||
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
|
||||
return s.feed.Subscribe()
|
||||
}
|
||||
|
||||
// --- Graceful project handoff (§8.3) ---------------------------------------
|
||||
|
||||
// RequestSwitch records a user's request for Claude to switch to target. The
|
||||
// target must be an indexed repository.
|
||||
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
|
||||
target = filepath.Clean(target)
|
||||
if _, ok := s.index.Get(target); !ok {
|
||||
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
|
||||
}
|
||||
return s.feed.RequestSwitch(actor, target, note), nil
|
||||
}
|
||||
|
||||
// PendingSwitch returns the outstanding switch request, if any.
|
||||
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
|
||||
return s.feed.PendingSwitch()
|
||||
}
|
||||
|
||||
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
|
||||
// project to the requested target and records Claude's summary. Returns false if
|
||||
// nothing was pending.
|
||||
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
|
||||
return s.feed.AckSwitch(activity.ActorClaude, summary)
|
||||
}
|
||||
|
||||
// CancelSwitch clears a pending switch request.
|
||||
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
|
||||
return s.feed.CancelSwitch(actor)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user