Skip to content

Commit 86cf713

Browse files
authored
refactor(tool): migrate Jobs/LSP/ToolSearch/Toolset/Worktree×2/PowerShell to typed schemas (#327)
* refactor(tool): migrate Jobs/LSP/ToolSearch/Toolset/Worktree×2/PowerShell to typed schemas Single-source Schema()+Parameters() pattern, Execute parses via DecodeInput[T], wire-shape parity tests covering enums, bools, numbers, and required arrays. * style(tool): apply gofumpt formatting and drop stray placeholder
1 parent 9fe0430 commit 86cf713

7 files changed

Lines changed: 267 additions & 132 deletions

File tree

‎internal/tool/jobs_tool.go‎

Lines changed: 26 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -44,49 +44,34 @@ func (JobsTool) Description() string {
4444
"session id: a session sees its own jobs plus every unowned job."
4545
}
4646

47-
func (JobsTool) Parameters() map[string]interface{} {
48-
return map[string]interface{}{
49-
"type": "object",
50-
"properties": map[string]interface{}{
51-
"action": map[string]interface{}{
52-
"type": "string",
53-
"enum": []interface{}{"list", "run", "read", "wait", "kill"},
54-
"description": "Operation to perform",
55-
},
56-
"command": map[string]interface{}{
57-
"type": "string",
58-
"description": "Shell command to run in the background (required for run)",
59-
},
60-
"label": map[string]interface{}{
61-
"type": "string",
62-
"description": "One-line model-facing label for the job (defaults to the command)",
63-
},
64-
"session": map[string]interface{}{
65-
"type": "string",
66-
"description": "Owner session id. Jobs owned by a session are visible only to it; omitted jobs are unowned and visible to any caller",
67-
},
68-
"id": map[string]interface{}{
69-
"type": "string",
70-
"description": "Job id, e.g. bash-3 (required for read/wait/kill)",
71-
},
72-
"reason": map[string]interface{}{
73-
"type": "string",
74-
"description": "Kill reason forwarded verbatim to the job (default: 'user requested')",
75-
},
76-
"timeout_sec": map[string]interface{}{
77-
"type": "integer",
78-
"description": "Max seconds to wait before returning the current snapshot (default: 60)",
79-
},
80-
"output_limit": map[string]interface{}{
81-
"type": "integer",
82-
"description": "UTF-8 byte cap for stored output (default 100000)",
83-
},
47+
// Schema returns the typed input schema. Parameters() delegates to it so the
48+
// two cannot diverge.
49+
func (JobsTool) Schema() ToolSchema {
50+
return ToolSchema{
51+
Type: "object",
52+
Properties: map[string]SchemaProperty{
53+
"action": {Type: "string", Enum: []interface{}{"list", "run", "read", "wait", "kill"}, Description: "Operation to perform"},
54+
"command": {Type: "string", Description: "Shell command to run in the background (required for run)"},
55+
"label": {Type: "string", Description: "One-line model-facing label for the job (defaults to the command)"},
56+
"session": {Type: "string", Description: "Owner session id. Jobs owned by a session are visible only to it; omitted jobs are unowned and visible to any caller"},
57+
"id": {Type: "string", Description: "Job id, e.g. bash-3 (required for read/wait/kill)"},
58+
"reason": {Type: "string", Description: "Kill reason forwarded verbatim to the job (default: 'user requested')"},
59+
"timeout_sec": {Type: "integer", Description: "Max seconds to wait before returning the current snapshot (default: 60)"},
60+
"output_limit": {Type: "integer", Description: "UTF-8 byte cap for stored output (default 100000)"},
8461
},
85-
"required": []string{"action"},
62+
Required: []string{"action"},
8663
}
8764
}
8865

89-
type jobsInput struct {
66+
func (JobsTool) Parameters() map[string]interface{} {
67+
return jobsSchema.ToJSONSchema()
68+
}
69+
70+
// jobsSchema is the single source of truth for Jobs' input schema.
71+
var jobsSchema = JobsTool{}.Schema()
72+
73+
// JobsInput is the typed input for JobsTool.
74+
type JobsInput struct {
9075
Action string `json:"action"`
9176
Command string `json:"command"`
9277
Label string `json:"label"`
@@ -112,8 +97,8 @@ type jobsSnapshotJSON struct {
11297
}
11398

11499
func (JobsTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
115-
var p jobsInput
116-
if err := json.Unmarshal(input, &p); err != nil {
100+
p, err := DecodeInput[JobsInput]("Jobs", input)
101+
if err != nil {
117102
return "", err
118103
}
119104
if p.Action == "" {

‎internal/tool/lsp.go‎

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,37 +18,49 @@ type LSPTool struct {
1818
Manager *lsp.LSPManager
1919
}
2020

21+
// LSPInput is the typed input for LSPTool.
22+
type LSPInput struct {
23+
Action string `json:"action"`
24+
Path string `json:"path"`
25+
Line int `json:"line"`
26+
Column int `json:"column"`
27+
Symbol string `json:"symbol"`
28+
Root string `json:"root"`
29+
}
30+
2131
func (LSPTool) Name() string { return "LSP" }
2232
func (LSPTool) Aliases() []string { return []string{"lsp"} }
2333
func (LSPTool) Description() string {
2434
return "Get code intelligence through configured language servers, with codegraph and local-tool fallbacks."
2535
}
2636

27-
func (LSPTool) Parameters() map[string]interface{} {
28-
return map[string]interface{}{
29-
"type": "object",
30-
"properties": map[string]interface{}{
31-
"action": map[string]interface{}{"type": "string", "enum": []string{"diagnostics", "definition", "references", "implementations"}, "description": "LSP action"},
32-
"path": map[string]interface{}{"type": "string", "description": "File path"},
33-
"line": map[string]interface{}{"type": "integer", "description": "Line number (1-based)"},
34-
"column": map[string]interface{}{"type": "integer", "description": "Column number (1-based)"},
35-
"symbol": map[string]interface{}{"type": "string", "description": "Symbol name to look up (alternative to line/column)"},
36-
"root": map[string]interface{}{"type": "string", "description": "Project root directory (default: current dir)"},
37+
// Schema returns the typed input schema. Parameters() delegates to it so the
38+
// two cannot diverge.
39+
func (LSPTool) Schema() ToolSchema {
40+
return ToolSchema{
41+
Type: "object",
42+
Properties: map[string]SchemaProperty{
43+
"action": {Type: "string", Enum: []interface{}{"diagnostics", "definition", "references", "implementations"}, Description: "LSP action"},
44+
"path": {Type: "string", Description: "File path"},
45+
"line": {Type: "integer", Description: "Line number (1-based)"},
46+
"column": {Type: "integer", Description: "Column number (1-based)"},
47+
"symbol": {Type: "string", Description: "Symbol name to look up (alternative to line/column)"},
48+
"root": {Type: "string", Description: "Project root directory (default: current dir)"},
3749
},
38-
"required": []string{"action", "path"},
50+
Required: []string{"action", "path"},
3951
}
4052
}
4153

54+
func (LSPTool) Parameters() map[string]interface{} {
55+
return lspSchema.ToJSONSchema()
56+
}
57+
58+
// lspSchema is the single source of truth for LSP's input schema.
59+
var lspSchema = LSPTool{}.Schema()
60+
4261
func (t LSPTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
43-
var p struct {
44-
Action string `json:"action"`
45-
Path string `json:"path"`
46-
Line int `json:"line"`
47-
Column int `json:"column"`
48-
Symbol string `json:"symbol"`
49-
Root string `json:"root"`
50-
}
51-
if err := json.Unmarshal(input, &p); err != nil {
62+
p, err := DecodeInput[LSPInput]("LSP", input)
63+
if err != nil {
5264
return "", err
5365
}
5466

‎internal/tool/powershell.go‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,42 @@ import (
1414
// PowerShellTool executes PowerShell commands (Windows/cross-platform pwsh).
1515
type PowerShellTool struct{}
1616

17+
// PowerShellInput is the typed input for PowerShellTool.
18+
type PowerShellInput struct {
19+
Command string `json:"command"`
20+
Timeout int64 `json:"timeout"`
21+
}
22+
1723
func (PowerShellTool) Name() string { return "PowerShell" }
1824
func (PowerShellTool) RiskLevel() string { return "high" }
1925
func (PowerShellTool) Aliases() []string { return []string{"powershell"} }
2026
func (PowerShellTool) Description() string {
2127
return "Execute a PowerShell command. Use this instead of Bash when running on Windows or when PowerShell-specific cmdlets are needed."
2228
}
2329

24-
func (PowerShellTool) Parameters() map[string]interface{} {
25-
return map[string]interface{}{
26-
"type": "object",
27-
"properties": map[string]interface{}{
28-
"command": map[string]interface{}{
29-
"type": "string",
30-
"description": "The PowerShell command to execute",
31-
},
32-
"timeout": map[string]interface{}{
33-
"type": "number",
34-
"description": "Timeout in milliseconds (max 600000, default 120000)",
35-
},
30+
// Schema returns the typed input schema. Parameters() delegates to it so the
31+
// two cannot diverge.
32+
func (PowerShellTool) Schema() ToolSchema {
33+
return ToolSchema{
34+
Type: "object",
35+
Properties: map[string]SchemaProperty{
36+
"command": {Type: "string", Description: "The PowerShell command to execute"},
37+
"timeout": {Type: "number", Description: "Timeout in milliseconds (max 600000, default 120000)"},
3638
},
37-
"required": []string{"command"},
39+
Required: []string{"command"},
3840
}
3941
}
4042

43+
func (PowerShellTool) Parameters() map[string]interface{} {
44+
return powershellSchema.ToJSONSchema()
45+
}
46+
47+
// powershellSchema is the single source of truth for PowerShell's input schema.
48+
var powershellSchema = PowerShellTool{}.Schema()
49+
4150
func (PowerShellTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
42-
var p struct {
43-
Command string `json:"command"`
44-
Timeout int64 `json:"timeout"`
45-
}
46-
if err := json.Unmarshal(input, &p); err != nil {
51+
p, err := DecodeInput[PowerShellInput]("PowerShell", input)
52+
if err != nil {
4753
return "", err
4854
}
4955
if p.Command == "" {
@@ -78,7 +84,7 @@ func (PowerShellTool) Execute(ctx context.Context, input json.RawMessage) (strin
7884
cmd.Stdout = &stdout
7985
cmd.Stderr = &stderr
8086

81-
err := cmd.Run()
87+
err = cmd.Run()
8288
result := stdout.String()
8389
if stderr.Len() > 0 {
8490
if result != "" {

‎internal/tool/schema_batch_test.go‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,3 +576,92 @@ func TestImportOrganizerSchemaProvider(t *testing.T) {
576576
t.Fatalf("required = %v, want [path]", ImportOrganizerTool{}.Parameters()["required"])
577577
}
578578
}
579+
580+
func TestJobsSchemaProvider(t *testing.T) {
581+
var _ SchemaProvider = JobsTool{}
582+
props := schemaProps(t, JobsTool{}.Parameters())
583+
enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{})
584+
if !ok || len(enum) != 5 || enum[0] != "list" || enum[4] != "kill" {
585+
t.Fatalf("action enum = %v, want 5 options", props["action"])
586+
}
587+
if props["command"].(map[string]interface{})["type"] != "string" {
588+
t.Fatal("command type wrong")
589+
}
590+
req, _ := JobsTool{}.Parameters()["required"].([]string)
591+
if len(req) != 1 || req[0] != "action" {
592+
t.Fatalf("required = %v, want [action]", JobsTool{}.Parameters()["required"])
593+
}
594+
}
595+
596+
func TestLSPToolSchemaProvider(t *testing.T) {
597+
var _ SchemaProvider = LSPTool{}
598+
props := schemaProps(t, LSPTool{}.Parameters())
599+
enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{})
600+
if !ok || len(enum) != 4 {
601+
t.Fatalf("action enum = %v, want 4 options", props["action"])
602+
}
603+
req, _ := LSPTool{}.Parameters()["required"].([]string)
604+
if len(req) != 2 || req[0] != "action" || req[1] != "path" {
605+
t.Fatalf("required = %v, want [action path]", LSPTool{}.Parameters()["required"])
606+
}
607+
}
608+
609+
func TestToolSearchSchemaProvider(t *testing.T) {
610+
var _ SchemaProvider = ToolSearchTool{}
611+
props := schemaProps(t, ToolSearchTool{}.Parameters())
612+
if props["query"].(map[string]interface{})["type"] != "string" {
613+
t.Fatal("query type wrong")
614+
}
615+
if props["max_results"].(map[string]interface{})["type"] != "integer" {
616+
t.Fatal("max_results type wrong")
617+
}
618+
req, _ := ToolSearchTool{}.Parameters()["required"].([]string)
619+
if len(req) != 1 || req[0] != "query" {
620+
t.Fatalf("required = %v, want [query]", ToolSearchTool{}.Parameters()["required"])
621+
}
622+
}
623+
624+
func TestToolsetSchemaProvider(t *testing.T) {
625+
var _ SchemaProvider = ToolsetTool{}
626+
props := schemaProps(t, ToolsetTool{}.Parameters())
627+
enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{})
628+
if !ok || len(enum) != 2 || enum[0] != "list" || enum[1] != "resolve" {
629+
t.Fatalf("action enum = %v, want [list resolve]", props["action"])
630+
}
631+
req, _ := ToolsetTool{}.Parameters()["required"].([]string)
632+
if len(req) != 1 || req[0] != "action" {
633+
t.Fatalf("required = %v, want [action]", ToolsetTool{}.Parameters()["required"])
634+
}
635+
}
636+
637+
func TestWorktreeSchemasProvider(t *testing.T) {
638+
var _ SchemaProvider = EnterWorktreeTool{}
639+
var _ SchemaProvider = ExitWorktreeTool{}
640+
props := schemaProps(t, EnterWorktreeTool{}.Parameters())
641+
if props["path"].(map[string]interface{})["type"] != "string" {
642+
t.Fatal("enter path type wrong")
643+
}
644+
req, _ := EnterWorktreeTool{}.Parameters()["required"].([]string)
645+
if len(req) != 1 || req[0] != "path" {
646+
t.Fatalf("enter required = %v, want [path]", EnterWorktreeTool{}.Parameters()["required"])
647+
}
648+
eprops := schemaProps(t, ExitWorktreeTool{}.Parameters())
649+
if eprops["cleanup"].(map[string]interface{})["type"] != "boolean" {
650+
t.Fatal("exit cleanup type wrong")
651+
}
652+
}
653+
654+
func TestPowerShellSchemaProvider(t *testing.T) {
655+
var _ SchemaProvider = PowerShellTool{}
656+
props := schemaProps(t, PowerShellTool{}.Parameters())
657+
if props["command"].(map[string]interface{})["type"] != "string" {
658+
t.Fatal("command type wrong")
659+
}
660+
if props["timeout"].(map[string]interface{})["type"] != "number" {
661+
t.Fatal("timeout type wrong")
662+
}
663+
req, _ := PowerShellTool{}.Parameters()["required"].([]string)
664+
if len(req) != 1 || req[0] != "command" {
665+
t.Fatalf("required = %v, want [command]", PowerShellTool{}.Parameters()["required"])
666+
}
667+
}

‎internal/tool/tool_search.go‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,42 @@ import (
1010

1111
type ToolSearchTool struct{}
1212

13+
// ToolSearchInput is the typed input for ToolSearchTool.
14+
type ToolSearchInput struct {
15+
Query string `json:"query"`
16+
MaxResults int `json:"max_results"`
17+
}
18+
1319
func (ToolSearchTool) Name() string { return "ToolSearch" }
1420
func (ToolSearchTool) RiskLevel() string { return "low" }
1521
func (ToolSearchTool) Aliases() []string { return []string{"tool_search"} }
1622
func (ToolSearchTool) Description() string {
1723
return `Search available tools by name or description. Use query "select:<tool_name>" for direct selection.`
1824
}
1925

20-
func (ToolSearchTool) Parameters() map[string]interface{} {
21-
return map[string]interface{}{
22-
"type": "object",
23-
"properties": map[string]interface{}{
24-
"query": map[string]interface{}{"type": "string", "description": `Search terms, or "select:<tool_name>"`},
25-
"max_results": map[string]interface{}{"type": "integer", "description": "Maximum results to return (default 5)"},
26+
// Schema returns the typed input schema. Parameters() delegates to it so the
27+
// two cannot diverge.
28+
func (ToolSearchTool) Schema() ToolSchema {
29+
return ToolSchema{
30+
Type: "object",
31+
Properties: map[string]SchemaProperty{
32+
"query": {Type: "string", Description: `Search terms, or "select:<tool_name>"`},
33+
"max_results": {Type: "integer", Description: "Maximum results to return (default 5)"},
2634
},
27-
"required": []string{"query"},
35+
Required: []string{"query"},
2836
}
2937
}
3038

39+
func (ToolSearchTool) Parameters() map[string]interface{} {
40+
return toolSearchSchema.ToJSONSchema()
41+
}
42+
43+
// toolSearchSchema is the single source of truth for ToolSearch's input schema.
44+
var toolSearchSchema = ToolSearchTool{}.Schema()
45+
3146
func (ToolSearchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
32-
var p struct {
33-
Query string `json:"query"`
34-
MaxResults int `json:"max_results"`
35-
}
36-
if err := json.Unmarshal(input, &p); err != nil {
47+
p, err := DecodeInput[ToolSearchInput]("ToolSearch", input)
48+
if err != nil {
3749
return "", err
3850
}
3951
p.Query = strings.TrimSpace(p.Query)

0 commit comments

Comments
 (0)