diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index e8ef83c5..5125aef2 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -1303,3 +1303,79 @@ func TestSmartCreateSchemaProvider(t *testing.T) { t.Fatalf("required = %v, want [path]", (&SmartCreateTool{}).Parameters()["required"]) } } + +func TestTerminalCreateSchemaProvider(t *testing.T) { + var _ SchemaProvider = TerminalCreateTool{} + props := schemaProps(t, TerminalCreateTool{}.Parameters()) + if props["command"].(map[string]interface{})["type"] != "string" { + t.Fatal("command type wrong") + } + if props["rows"].(map[string]interface{})["type"] != "integer" { + t.Fatal("rows type wrong") + } + if props["cols"].(map[string]interface{})["type"] != "integer" { + t.Fatal("cols type wrong") + } +} + +func TestTerminalSendSchemaProvider(t *testing.T) { + var _ SchemaProvider = TerminalSendTool{} + props := schemaProps(t, TerminalSendTool{}.Parameters()) + if props["terminal_id"].(map[string]interface{})["type"] != "string" { + t.Fatal("terminal_id type wrong") + } + if props["send_enter"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("send_enter type wrong") + } + req, _ := TerminalSendTool{}.Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "terminal_id" || req[1] != "input" { + t.Fatalf("required = %v, want [terminal_id input]", TerminalSendTool{}.Parameters()["required"]) + } +} + +func TestTerminalReadSchemaProvider(t *testing.T) { + var _ SchemaProvider = TerminalReadTool{} + props := schemaProps(t, TerminalReadTool{}.Parameters()) + if props["max_bytes"].(map[string]interface{})["type"] != "integer" { + t.Fatal("max_bytes type wrong") + } + if props["timeout_ms"].(map[string]interface{})["type"] != "integer" { + t.Fatal("timeout_ms type wrong") + } + req, _ := TerminalReadTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "terminal_id" { + t.Fatalf("required = %v, want [terminal_id]", TerminalReadTool{}.Parameters()["required"]) + } +} + +func TestTerminalListSchemaProvider(t *testing.T) { + var _ SchemaProvider = TerminalListTool{} + props := schemaProps(t, TerminalListTool{}.Parameters()) + if props["session_id"].(map[string]interface{})["type"] != "string" { + t.Fatal("session_id type wrong") + } +} + +func TestTerminalResizeSchemaProvider(t *testing.T) { + var _ SchemaProvider = TerminalResizeTool{} + props := schemaProps(t, TerminalResizeTool{}.Parameters()) + if props["rows"].(map[string]interface{})["type"] != "integer" { + t.Fatal("rows type wrong") + } + req, _ := TerminalResizeTool{}.Parameters()["required"].([]string) + if len(req) != 3 || req[0] != "terminal_id" || req[1] != "rows" || req[2] != "cols" { + t.Fatalf("required = %v, want [terminal_id rows cols]", TerminalResizeTool{}.Parameters()["required"]) + } +} + +func TestTerminalKillSchemaProvider(t *testing.T) { + var _ SchemaProvider = TerminalKillTool{} + props := schemaProps(t, TerminalKillTool{}.Parameters()) + if props["terminal_id"].(map[string]interface{})["type"] != "string" { + t.Fatal("terminal_id type wrong") + } + req, _ := TerminalKillTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "terminal_id" { + t.Fatalf("required = %v, want [terminal_id]", TerminalKillTool{}.Parameters()["required"]) + } +} diff --git a/internal/tool/terminal.go b/internal/tool/terminal.go index 6d849221..1fe4f120 100644 --- a/internal/tool/terminal.go +++ b/internal/tool/terminal.go @@ -22,52 +22,52 @@ type TerminalCreateTool struct { Store *terminal.Store } -func (TerminalCreateTool) Name() string { return "TerminalCreate" } +func (TerminalCreateTool) Name() string { return "TerminalCreate" } + +// TerminalCreateInput is the typed input for TerminalCreateTool. +type TerminalCreateInput struct { + Command string `json:"command"` + CWD string `json:"cwd"` + Rows int `json:"rows"` + Cols int `json:"cols"` + SessionID string `json:"session_id"` +} + func (TerminalCreateTool) Aliases() []string { return []string{"terminal_create", "pty_create"} } func (TerminalCreateTool) Description() string { return "Spawn a persistent interactive PTY terminal session whose state persists across tool calls." } -func (TerminalCreateTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "command": map[string]interface{}{ - "type": "string", - "description": "Shell or command to run (defaults to system shell e.g. /bin/bash or powershell)", - }, - "cwd": map[string]interface{}{ - "type": "string", - "description": "Working directory for the terminal session", - }, - "rows": map[string]interface{}{ - "type": "integer", - "description": "Initial terminal rows (default 24)", - }, - "cols": map[string]interface{}{ - "type": "integer", - "description": "Initial terminal columns (default 80)", - }, - "session_id": map[string]interface{}{ - "type": "string", - "description": "Session ID establishing ownership for this terminal", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TerminalCreateTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "command": {Type: "string", Description: "Shell or command to run (defaults to system shell e.g. /bin/bash or powershell)"}, + "cwd": {Type: "string", Description: "Working directory for the terminal session"}, + "rows": {Type: "integer", Description: "Initial terminal rows (default 24)"}, + "cols": {Type: "integer", Description: "Initial terminal columns (default 80)"}, + "session_id": {Type: "string", Description: "Session ID establishing ownership for this terminal"}, }, } } +func (TerminalCreateTool) Parameters() map[string]interface{} { + return terminalCreateSchema.ToJSONSchema() +} + +// terminalCreateSchema is the single source of truth for TerminalCreate's input schema. +var terminalCreateSchema = TerminalCreateTool{}.Schema() + func (t TerminalCreateTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Command string `json:"command"` - CWD string `json:"cwd"` - Rows int `json:"rows"` - Cols int `json:"cols"` - SessionID string `json:"session_id"` - } + var p TerminalCreateInput if len(input) > 0 { - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + decoded, err := DecodeInput[TerminalCreateInput]("TerminalCreate", input) + if err != nil { + return "", err } + p = decoded } // A non-empty command is handed to a shell, so it must pass the same @@ -111,46 +111,47 @@ type TerminalSendTool struct { Store *terminal.Store } -func (TerminalSendTool) Name() string { return "TerminalSend" } +func (TerminalSendTool) Name() string { return "TerminalSend" } + +// TerminalSendInput is the typed input for TerminalSendTool. +type TerminalSendInput struct { + TerminalID string `json:"terminal_id"` + Input string `json:"input"` + SendEnter *bool `json:"send_enter"` + SessionID string `json:"session_id"` +} + func (TerminalSendTool) Aliases() []string { return []string{"terminal_send", "pty_send"} } func (TerminalSendTool) Description() string { return "Send input characters or commands to an active persistent terminal." } -func (TerminalSendTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "terminal_id": map[string]interface{}{ - "type": "string", - "description": "Branded terminal identifier (e.g. 'terminal-1')", - }, - "input": map[string]interface{}{ - "type": "string", - "description": "Characters, keystrokes, or command string to send to stdin", - }, - "send_enter": map[string]interface{}{ - "type": "boolean", - "description": "Whether to append a newline (Enter) at the end of input (default true)", - }, - "session_id": map[string]interface{}{ - "type": "string", - "description": "Owner session ID for authorization", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TerminalSendTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "terminal_id": {Type: "string", Description: "Branded terminal identifier (e.g. 'terminal-1')"}, + "input": {Type: "string", Description: "Characters, keystrokes, or command string to send to stdin"}, + "send_enter": {Type: "boolean", Description: "Whether to append a newline (Enter) at the end of input (default true)"}, + "session_id": {Type: "string", Description: "Owner session ID for authorization"}, }, - "required": []string{"terminal_id", "input"}, + Required: []string{"terminal_id", "input"}, } } +func (TerminalSendTool) Parameters() map[string]interface{} { + return terminalSendSchema.ToJSONSchema() +} + +// terminalSendSchema is the single source of truth for TerminalSend's input schema. +var terminalSendSchema = TerminalSendTool{}.Schema() + func (t TerminalSendTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - TerminalID string `json:"terminal_id"` - Input string `json:"input"` - SendEnter *bool `json:"send_enter"` - SessionID string `json:"session_id"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + p, err := DecodeInput[TerminalSendInput]("TerminalSend", input) + if err != nil { + return "", err } if p.TerminalID == "" { @@ -194,46 +195,47 @@ type TerminalReadTool struct { Store *terminal.Store } -func (TerminalReadTool) Name() string { return "TerminalRead" } +func (TerminalReadTool) Name() string { return "TerminalRead" } + +// TerminalReadInput is the typed input for TerminalReadTool. +type TerminalReadInput struct { + TerminalID string `json:"terminal_id"` + MaxBytes int `json:"max_bytes"` + TimeoutMS int `json:"timeout_ms"` + SessionID string `json:"session_id"` +} + func (TerminalReadTool) Aliases() []string { return []string{"terminal_read", "pty_read"} } func (TerminalReadTool) Description() string { return "Read newly emitted output from an active persistent terminal with an optional timeout." } -func (TerminalReadTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "terminal_id": map[string]interface{}{ - "type": "string", - "description": "Branded terminal identifier (e.g. 'terminal-1')", - }, - "max_bytes": map[string]interface{}{ - "type": "integer", - "description": "Maximum bytes to read (default 65536)", - }, - "timeout_ms": map[string]interface{}{ - "type": "integer", - "description": "Milliseconds to wait for output if buffer is empty (default 500ms)", - }, - "session_id": map[string]interface{}{ - "type": "string", - "description": "Owner session ID for authorization", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TerminalReadTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "terminal_id": {Type: "string", Description: "Branded terminal identifier (e.g. 'terminal-1')"}, + "max_bytes": {Type: "integer", Description: "Maximum bytes to read (default 65536)"}, + "timeout_ms": {Type: "integer", Description: "Milliseconds to wait for output if buffer is empty (default 500ms)"}, + "session_id": {Type: "string", Description: "Owner session ID for authorization"}, }, - "required": []string{"terminal_id"}, + Required: []string{"terminal_id"}, } } +func (TerminalReadTool) Parameters() map[string]interface{} { + return terminalReadSchema.ToJSONSchema() +} + +// terminalReadSchema is the single source of truth for TerminalRead's input schema. +var terminalReadSchema = TerminalReadTool{}.Schema() + func (t TerminalReadTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - TerminalID string `json:"terminal_id"` - MaxBytes int `json:"max_bytes"` - TimeoutMS int `json:"timeout_ms"` - SessionID string `json:"session_id"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + p, err := DecodeInput[TerminalReadInput]("TerminalRead", input) + if err != nil { + return "", err } if p.TerminalID == "" { @@ -273,30 +275,44 @@ type TerminalListTool struct { Store *terminal.Store } -func (TerminalListTool) Name() string { return "TerminalList" } +func (TerminalListTool) Name() string { return "TerminalList" } + +// TerminalListInput is the typed input for TerminalListTool. +type TerminalListInput struct { + SessionID string `json:"session_id"` +} + func (TerminalListTool) Aliases() []string { return []string{"terminal_list", "pty_list"} } func (TerminalListTool) Description() string { return "List active persistent PTY terminals owned by the current session." } -func (TerminalListTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "session_id": map[string]interface{}{ - "type": "string", - "description": "Session ID to filter terminals by", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TerminalListTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "session_id": {Type: "string", Description: "Session ID to filter terminals by"}, }, } } +func (TerminalListTool) Parameters() map[string]interface{} { + return terminalListSchema.ToJSONSchema() +} + +// terminalListSchema is the single source of truth for TerminalList's input schema. +var terminalListSchema = TerminalListTool{}.Schema() + func (t TerminalListTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - SessionID string `json:"session_id"` - } - if len(input) > 0 { - _ = json.Unmarshal(input, &p) + var p TerminalListInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[TerminalListInput]("TerminalList", input) + if err != nil { + return "", err + } + p = decoded } sessionID := strings.TrimSpace(p.SessionID) @@ -317,46 +333,47 @@ type TerminalResizeTool struct { Store *terminal.Store } -func (TerminalResizeTool) Name() string { return "TerminalResize" } +func (TerminalResizeTool) Name() string { return "TerminalResize" } + +// TerminalResizeInput is the typed input for TerminalResizeTool. +type TerminalResizeInput struct { + TerminalID string `json:"terminal_id"` + Rows int `json:"rows"` + Cols int `json:"cols"` + SessionID string `json:"session_id"` +} + func (TerminalResizeTool) Aliases() []string { return []string{"terminal_resize", "pty_resize"} } func (TerminalResizeTool) Description() string { return "Resize the rows and columns of an active persistent PTY terminal." } -func (TerminalResizeTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "terminal_id": map[string]interface{}{ - "type": "string", - "description": "Branded terminal identifier", - }, - "rows": map[string]interface{}{ - "type": "integer", - "description": "New terminal row count", - }, - "cols": map[string]interface{}{ - "type": "integer", - "description": "New terminal column count", - }, - "session_id": map[string]interface{}{ - "type": "string", - "description": "Owner session ID for authorization", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TerminalResizeTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "terminal_id": {Type: "string", Description: "Branded terminal identifier"}, + "rows": {Type: "integer", Description: "New terminal row count"}, + "cols": {Type: "integer", Description: "New terminal column count"}, + "session_id": {Type: "string", Description: "Owner session ID for authorization"}, }, - "required": []string{"terminal_id", "rows", "cols"}, + Required: []string{"terminal_id", "rows", "cols"}, } } +func (TerminalResizeTool) Parameters() map[string]interface{} { + return terminalResizeSchema.ToJSONSchema() +} + +// terminalResizeSchema is the single source of truth for TerminalResize's input schema. +var terminalResizeSchema = TerminalResizeTool{}.Schema() + func (t TerminalResizeTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - TerminalID string `json:"terminal_id"` - Rows int `json:"rows"` - Cols int `json:"cols"` - SessionID string `json:"session_id"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + p, err := DecodeInput[TerminalResizeInput]("TerminalResize", input) + if err != nil { + return "", err } if p.TerminalID == "" { @@ -391,36 +408,43 @@ type TerminalKillTool struct { Store *terminal.Store } -func (TerminalKillTool) Name() string { return "TerminalKill" } +func (TerminalKillTool) Name() string { return "TerminalKill" } + +// TerminalKillInput is the typed input for TerminalKillTool. +type TerminalKillInput struct { + TerminalID string `json:"terminal_id"` + SessionID string `json:"session_id"` +} + func (TerminalKillTool) Aliases() []string { return []string{"terminal_kill", "pty_kill"} } func (TerminalKillTool) Description() string { return "Terminate an active persistent terminal session." } -func (TerminalKillTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "terminal_id": map[string]interface{}{ - "type": "string", - "description": "Branded terminal identifier to terminate", - }, - "session_id": map[string]interface{}{ - "type": "string", - "description": "Owner session ID for authorization", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TerminalKillTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "terminal_id": {Type: "string", Description: "Branded terminal identifier to terminate"}, + "session_id": {Type: "string", Description: "Owner session ID for authorization"}, }, - "required": []string{"terminal_id"}, + Required: []string{"terminal_id"}, } } +func (TerminalKillTool) Parameters() map[string]interface{} { + return terminalKillSchema.ToJSONSchema() +} + +// terminalKillSchema is the single source of truth for TerminalKill's input schema. +var terminalKillSchema = TerminalKillTool{}.Schema() + func (t TerminalKillTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - TerminalID string `json:"terminal_id"` - SessionID string `json:"session_id"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + p, err := DecodeInput[TerminalKillInput]("TerminalKill", input) + if err != nil { + return "", err } if p.TerminalID == "" {