Add task sidebar workflow
This commit is contained in:
@@ -108,10 +108,17 @@ type blockingToolHost struct {
|
||||
waitEntered chan struct{}
|
||||
waitRelease chan struct{}
|
||||
waitOnce sync.Once
|
||||
task TaskInfo
|
||||
}
|
||||
|
||||
func (h *blockingToolHost) ResolveCallerIdentity(identity string) string { return "caller-" + identity }
|
||||
func (h *blockingToolHost) CallerRole(string) CallerRole { return RoleOrchestrator }
|
||||
func (h *blockingToolHost) CallerTask(string) (TaskInfo, bool) {
|
||||
if h.task.ID == "" {
|
||||
return TaskInfo{}, false
|
||||
}
|
||||
return h.task, true
|
||||
}
|
||||
func (h *blockingToolHost) SpawnAgent(string, SpawnAgentArgs) (ProcessInfo, error) {
|
||||
return ProcessInfo{}, nil
|
||||
}
|
||||
@@ -186,5 +193,8 @@ func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, erro
|
||||
}
|
||||
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil }
|
||||
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
|
||||
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} }
|
||||
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }
|
||||
func (h *blockingToolHost) RegisterTaskWorktree(string, TaskRegisterWorktreeArgs) (TaskInfo, error) {
|
||||
return TaskInfo{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} }
|
||||
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }
|
||||
|
||||
+35
-10
@@ -29,7 +29,7 @@ var serverInfo = map[string]any{
|
||||
"version": "0.1.0",
|
||||
}
|
||||
|
||||
// serverInstructions is returned in the MCP `initialize` response. MCP
|
||||
// baseServerInstructions is returned in the MCP `initialize` response. MCP
|
||||
// clients show this to the underlying LLM as context for how to use
|
||||
// the server. Failure modes we've seen and want to head off:
|
||||
// - The agent assumes patterm is something it has to launch (running
|
||||
@@ -45,7 +45,14 @@ var serverInfo = map[string]any{
|
||||
// up as sub-agents and won't be tied into the patterm lifecycle.
|
||||
//
|
||||
// Keep this short — clients vary in how much they surface to the LLM.
|
||||
const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
|
||||
const baseServerInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
|
||||
|
||||
func serverInstructions(taskBound bool) string {
|
||||
if !taskBound {
|
||||
return baseServerInstructions
|
||||
}
|
||||
return baseServerInstructions + " This MCP connection is task-bound; call whoami for the task, and register git worktrees you create or use with task_register_worktree."
|
||||
}
|
||||
|
||||
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
|
||||
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
|
||||
@@ -108,7 +115,7 @@ func arrayOfStringsProp(desc string) map[string]any {
|
||||
// toolCatalog is the full list advertised via tools/list. Descriptions
|
||||
// are intentionally short — clients are expected to fetch help() for
|
||||
// detail. Schemas mirror the param structs in tools.go.
|
||||
func toolCatalog(role CallerRole) []toolDescriptor {
|
||||
func toolCatalog(role CallerRole, taskBound bool) []toolDescriptor {
|
||||
tools := []toolDescriptor{
|
||||
{
|
||||
Name: "spawn_agent",
|
||||
@@ -382,6 +389,14 @@ func toolCatalog(role CallerRole) []toolDescriptor {
|
||||
"name": stringProp("Scratchpad name."),
|
||||
}, []string{"name"}),
|
||||
},
|
||||
{
|
||||
Name: "task_register_worktree",
|
||||
Description: "Register a git worktree path for the current task.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"path": stringProp("Absolute path, or path relative to this process working directory."),
|
||||
"branch": stringProp("Git branch name, if known."),
|
||||
}, []string{"path"}),
|
||||
},
|
||||
{
|
||||
Name: "whoami",
|
||||
Description: "Return caller identity, role, parent, and project metadata.",
|
||||
@@ -397,14 +412,15 @@ func toolCatalog(role CallerRole) []toolDescriptor {
|
||||
}, nil),
|
||||
},
|
||||
}
|
||||
if role != RoleSubAgent {
|
||||
return tools
|
||||
}
|
||||
filtered := tools[:0]
|
||||
for _, tool := range tools {
|
||||
if tool.Name != "spawn_agent" {
|
||||
filtered = append(filtered, tool)
|
||||
if role == RoleSubAgent && tool.Name == "spawn_agent" {
|
||||
continue
|
||||
}
|
||||
if !taskBound && tool.Name == "task_register_worktree" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -426,13 +442,20 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
if protoVersion == "" {
|
||||
protoVersion = supportedProtocolVersion
|
||||
}
|
||||
taskBound := false
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host != nil {
|
||||
_, taskBound = host.CallerTask(callerID)
|
||||
}
|
||||
result := map[string]any{
|
||||
"protocolVersion": protoVersion,
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{"listChanged": false},
|
||||
},
|
||||
"serverInfo": serverInfo,
|
||||
"instructions": serverInstructions,
|
||||
"instructions": serverInstructions(taskBound),
|
||||
}
|
||||
return result, true, 0, "", nil
|
||||
|
||||
@@ -446,13 +469,15 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
|
||||
case "tools/list":
|
||||
role := RoleOrchestrator
|
||||
taskBound := false
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host != nil {
|
||||
role = host.CallerRole(callerID)
|
||||
_, taskBound = host.CallerTask(callerID)
|
||||
}
|
||||
return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil
|
||||
return map[string]any{"tools": toolCatalog(role, taskBound)}, true, 0, "", nil
|
||||
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
|
||||
@@ -119,6 +119,32 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsListTaskRegisterWorktreeIsTaskBound(t *testing.T) {
|
||||
unbound := toolsListNames(t, &Server{})
|
||||
if containsString(unbound, "task_register_worktree") {
|
||||
t.Fatalf("unbound tools leaked task_register_worktree: %v", unbound)
|
||||
}
|
||||
s := &Server{}
|
||||
s.SetHost(&blockingToolHost{task: TaskInfo{ID: "task_1", Title: "Fix sidebar"}})
|
||||
bound := toolsListNames(t, s)
|
||||
if !containsString(bound, "task_register_worktree") {
|
||||
t.Fatalf("task-bound tools missing task_register_worktree: %v", bound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeTaskInstructionsAreTaskBound(t *testing.T) {
|
||||
unbound := initializeInstructions(t, &Server{})
|
||||
if strings.Contains(unbound, "task_register_worktree") {
|
||||
t.Fatalf("unbound initialize leaked task instruction: %q", unbound)
|
||||
}
|
||||
s := &Server{}
|
||||
s.SetHost(&blockingToolHost{task: TaskInfo{ID: "task_1", Title: "Fix sidebar"}})
|
||||
bound := initializeInstructions(t, s)
|
||||
if !strings.Contains(bound, "task_register_worktree") {
|
||||
t.Fatalf("task-bound initialize missing worktree instruction: %q", bound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) {
|
||||
result := ProcessOutput{
|
||||
Content: strings.Repeat("x", 1024),
|
||||
@@ -162,6 +188,69 @@ func TestPingReturnsEmptyObject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func toolsListNames(t *testing.T, s *Server) []string {
|
||||
t.Helper()
|
||||
req := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)
|
||||
resp := s.dispatch("caller", req)
|
||||
if resp == nil {
|
||||
t.Fatal("expected response for tools/list")
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &parsed); err != nil {
|
||||
t.Fatalf("parse tools/list: %v\n%s", err, resp)
|
||||
}
|
||||
if parsed.Error != nil {
|
||||
t.Fatalf("tools/list error: %+v", parsed.Error)
|
||||
}
|
||||
out := make([]string, 0, len(parsed.Result.Tools))
|
||||
for _, tool := range parsed.Result.Tools {
|
||||
out = append(out, tool.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func initializeInstructions(t *testing.T, s *Server) string {
|
||||
t.Helper()
|
||||
req := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
|
||||
resp := s.dispatch("caller", req)
|
||||
if resp == nil {
|
||||
t.Fatal("expected response for initialize")
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Instructions string `json:"instructions"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &parsed); err != nil {
|
||||
t.Fatalf("parse initialize: %v\n%s", err, resp)
|
||||
}
|
||||
if parsed.Error != nil {
|
||||
t.Fatalf("initialize error: %+v", parsed.Error)
|
||||
}
|
||||
return parsed.Result.Instructions
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestTypedInvalidArgsMapToInvalidParams(t *testing.T) {
|
||||
for _, errKind := range []string{ErrorKindInvalidArgs, ErrorKindInvalidKind} {
|
||||
_, code, msg, data := mapToolError(Errorf(errKind, "bad args"))
|
||||
|
||||
@@ -60,6 +60,7 @@ type ToolHost interface {
|
||||
// callers default to RoleOrchestrator (treated as a top-level peer)
|
||||
// so they don't get silently denied.
|
||||
CallerRole(processID string) CallerRole
|
||||
CallerTask(processID string) (TaskInfo, bool)
|
||||
|
||||
// Lifecycle (SPEC §7).
|
||||
SpawnAgent(callerID string, args SpawnAgentArgs) (ProcessInfo, error)
|
||||
@@ -102,6 +103,7 @@ type ToolHost interface {
|
||||
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
|
||||
ScratchpadAppend(name, content string) error
|
||||
ScratchpadDelete(name string) error
|
||||
RegisterTaskWorktree(callerID string, args TaskRegisterWorktreeArgs) (TaskInfo, error)
|
||||
|
||||
// Meta.
|
||||
WhoAmI(callerID string, includeTools bool) WhoAmI
|
||||
@@ -153,6 +155,7 @@ type Cursor struct {
|
||||
type ProjectStatus struct {
|
||||
Project ProjectMeta `json:"project"`
|
||||
Caller WhoAmI `json:"caller"`
|
||||
Task *TaskInfo `json:"task,omitempty"`
|
||||
Processes []ProcessInfo `json:"processes"`
|
||||
Scratchpads []scratchpad.Entry `json:"scratchpads"`
|
||||
}
|
||||
@@ -167,6 +170,26 @@ type ProjectMeta struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type TaskInfo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Worktrees []TaskWorktree `json:"worktrees,omitempty"`
|
||||
}
|
||||
|
||||
type TaskWorktree struct {
|
||||
Path string `json:"path"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
CreatedByProcessID string `json:"created_by_process_id,omitempty"`
|
||||
RegisteredAt string `json:"registered_at,omitempty"`
|
||||
}
|
||||
|
||||
type TaskRegisterWorktreeArgs struct {
|
||||
Path string `json:"path"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessOutput is the get_process_output payload. By default it is
|
||||
// canonical text with light metadata; include_meta restores screen
|
||||
// geometry + version, and raw requests return stream bytes.
|
||||
@@ -341,6 +364,7 @@ type WhoAmI struct {
|
||||
Role CallerRole `json:"role"`
|
||||
ParentProcessID string `json:"parent_process_id,omitempty"`
|
||||
Project ProjectMeta `json:"project"`
|
||||
Task *TaskInfo `json:"task,omitempty"`
|
||||
AvailableTools []string `json:"available_tools"`
|
||||
}
|
||||
|
||||
@@ -836,6 +860,20 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
}
|
||||
return map[string]any{"ok": true}, 0, "", nil
|
||||
|
||||
case "task_register_worktree":
|
||||
if _, ok := h.CallerTask(callerID); !ok {
|
||||
return nil, codeRoleForbidden, "task_register_worktree: caller is not attached to a task", structuredKind(ErrorKindRoleForbidden)
|
||||
}
|
||||
var p TaskRegisterWorktreeArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Path == "" {
|
||||
return nil, codeInvalidParams, "task_register_worktree: path required", nil
|
||||
}
|
||||
info, err := h.RegisterTaskWorktree(callerID, p)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "whoami":
|
||||
var p WhoAmIArgs
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
|
||||
Reference in New Issue
Block a user