Add task sidebar workflow

This commit is contained in:
2026-06-24 12:28:56 +01:00
parent 45263d59f8
commit d133839194
33 changed files with 1890 additions and 134 deletions
+12 -2
View File
@@ -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
View File
@@ -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 {
+89
View File
@@ -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"))
+38
View File
@@ -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)