Add native 2+ arg RPC support and wire a concrete TestMultiArgCommand through server, generated clients, and CLI (#2963)

This PR extends WSH RPC command signatures to support `ctx + 2+ typed
args` while preserving existing `ctx` and `ctx + 1 arg` behavior. It
also adds a concrete `TestMultiArgCommand` end-to-end so the generated
Go/TS client surfaces can be inspected and exercised from CLI.

- **RPC wire + dispatch model**
- Added `wshrpc.MultiArg` (`args []any`) as the over-the-wire envelope
for 2+ arg commands.
- Extended RPC metadata to track all command arg types
(`CommandDataTypes`) and exposed a helper for normalized access.
  - Updated server adapter unmarshalling to:
    - decode `MultiArg` for 2+ arg commands,
    - validate arg count,
- re-unmarshal each arg into its declared type before invoking typed
handlers.
  - Kept single-arg commands on the existing non-`MultiArg` path.

- **Code generation (Go + TS)**
- Go codegen now emits multi-parameter wrappers for 2+ arg methods and
packs payload as `wshrpc.MultiArg`.
- TS codegen now emits multi-parameter API methods and packs payload as
`{ args: [...] }`.
  - 0/1-arg generation remains unchanged to avoid wire/API churn.

- **Concrete command added for validation**
  - Added to `WshRpcInterface`:
- `TestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3
bool) (string, error)`
- Implemented in `wshserver` with deterministic formatted return output
including source + all args.
- Updated `wsh test` command to call `TestMultiArgCommand` and print the
returned string.

- **Focused coverage**
- Added/updated targeted tests around RPC metadata and Go/TS multi-arg
codegen behavior, including command declaration for `testmultiarg`.

Example generated call shape:

```go
func TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, opts *wshrpc.RpcOpts) (string, error) {
    return sendRpcRequestCallHelper[string](
        w,
        "testmultiarg",
        wshrpc.MultiArg{Args: []any{arg1, arg2, arg3}},
        opts,
    )
}
```

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Co-authored-by: sawka <mike@commandline.dev>
This commit is contained in:
Copilot 2026-03-02 12:29:04 -08:00 committed by GitHub
parent 9d89f4372c
commit df24959e23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 263 additions and 52 deletions

View file

@ -5,6 +5,7 @@ package cmd
import (
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var testCmd = &cobra.Command{
@ -20,5 +21,10 @@ func init() {
}
func runTestCmd(cmd *cobra.Command, args []string) error {
rtn, err := wshclient.TestMultiArgCommand(RpcClient, "testarg", 42, true, nil)
if err != nil {
return err
}
WriteStdout("%s\n", rtn)
return nil
}

View file

@ -757,6 +757,11 @@ class RpcApiType {
return client.wshRpcCall("test", data, opts);
}
// command "testmultiarg" [call]
TestMultiArgCommand(client: WshClient, arg1: string, arg2: number, arg3: boolean, opts?: RpcOpts): Promise<string> {
return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts);
}
// command "vdomasyncinitiation" [call]
VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("vdomasyncinitiation", data, opts);

View file

@ -75,12 +75,7 @@ func GenerateMetaMapConsts(buf *strings.Builder, constPrefix string, rtype refle
func GenMethod_Call(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) {
fmt.Fprintf(buf, "// command %q, wshserver.%s\n", methodDecl.Command, methodDecl.MethodName)
var dataType string
dataVarName := "nil"
if methodDecl.CommandDataType != nil {
dataType = ", data " + methodDecl.CommandDataType.String()
dataVarName = "data"
}
dataType, dataVarName := getWshMethodDataParamsAndExpr(methodDecl)
returnType := "error"
respName := "_"
tParamVal := "any"
@ -101,12 +96,7 @@ func GenMethod_Call(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) {
func GenMethod_ResponseStream(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) {
fmt.Fprintf(buf, "// command %q, wshserver.%s\n", methodDecl.Command, methodDecl.MethodName)
var dataType string
dataVarName := "nil"
if methodDecl.CommandDataType != nil {
dataType = ", data " + methodDecl.CommandDataType.String()
dataVarName = "data"
}
dataType, dataVarName := getWshMethodDataParamsAndExpr(methodDecl)
respType := "any"
if methodDecl.DefaultResponseDataType != nil {
respType = methodDecl.DefaultResponseDataType.String()
@ -115,3 +105,27 @@ func GenMethod_ResponseStream(buf *strings.Builder, methodDecl *wshrpc.WshRpcMet
fmt.Fprintf(buf, "\treturn sendRpcRequestResponseStreamHelper[%s](w, %q, %s, opts)\n", respType, methodDecl.Command, dataVarName)
fmt.Fprintf(buf, "}\n\n")
}
func getWshMethodDataParamsAndExpr(methodDecl *wshrpc.WshRpcMethodDecl) (string, string) {
dataTypes := methodDecl.GetCommandDataTypes()
if len(dataTypes) == 0 {
return "", "nil"
}
if len(dataTypes) == 1 {
return ", data " + dataTypes[0].String(), "data"
}
var paramBuilder strings.Builder
var argBuilder strings.Builder
for idx, dataType := range dataTypes {
argName := fmt.Sprintf("arg%d", idx+1)
paramBuilder.WriteString(", ")
paramBuilder.WriteString(argName)
paramBuilder.WriteString(" ")
paramBuilder.WriteString(dataType.String())
if idx > 0 {
argBuilder.WriteString(", ")
}
argBuilder.WriteString(argName)
}
return paramBuilder.String(), fmt.Sprintf("wshrpc.MultiArg{Args: []any{%s}}", argBuilder.String())
}

46
pkg/gogen/gogen_test.go Normal file
View file

@ -0,0 +1,46 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package gogen
import (
"reflect"
"strings"
"testing"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
)
func TestGetWshMethodDataParamsAndExpr_MultiArg(t *testing.T) {
methodDecl := &wshrpc.WshRpcMethodDecl{
CommandDataTypes: []reflect.Type{
reflect.TypeOf(""),
reflect.TypeOf(0),
},
}
params, expr := getWshMethodDataParamsAndExpr(methodDecl)
if params != ", arg1 string, arg2 int" {
t.Fatalf("unexpected params: %q", params)
}
if expr != "wshrpc.MultiArg{Args: []any{arg1, arg2}}" {
t.Fatalf("unexpected expr: %q", expr)
}
}
func TestGenMethodCall_MultiArg(t *testing.T) {
methodDecl := &wshrpc.WshRpcMethodDecl{
Command: "test",
CommandType: wshrpc.RpcType_Call,
MethodName: "TestCommand",
CommandDataTypes: []reflect.Type{reflect.TypeOf(""), reflect.TypeOf(0)},
}
var sb strings.Builder
GenMethod_Call(&sb, methodDecl)
out := sb.String()
if !strings.Contains(out, "func TestCommand(w *wshutil.WshRpc, arg1 string, arg2 int, opts *wshrpc.RpcOpts) error {") {
t.Fatalf("generated method missing multi-arg signature:\n%s", out)
}
if !strings.Contains(out, "sendRpcRequestCallHelper[any](w, \"test\", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)") {
t.Fatalf("generated method missing MultiArg payload:\n%s", out)
}
}

View file

@ -464,16 +464,12 @@ func generateWshClientApiMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDe
if methodDecl.DefaultResponseDataType != nil {
respType, _ = TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap)
}
dataName := "null"
if methodDecl.CommandDataType != nil {
dataName = "data"
}
methodSigDataParams, dataName := getTsWshMethodDataParamsAndExpr(methodDecl, tsTypesMap)
genRespType := fmt.Sprintf("AsyncGenerator<%s, void, boolean>", respType)
if methodDecl.CommandDataType != nil {
cmdDataTsName, _ := TypeToTSType(methodDecl.CommandDataType, tsTypesMap)
sb.WriteString(fmt.Sprintf(" %s(client: WshClient, data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, cmdDataTsName, genRespType))
} else {
if methodSigDataParams == "" {
sb.WriteString(fmt.Sprintf(" %s(client: WshClient, opts?: RpcOpts): %s {\n", methodDecl.MethodName, genRespType))
} else {
sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, genRespType))
}
sb.WriteString(fmt.Sprintf(" return client.wshRpcStream(%q, %s, opts);\n", methodDecl.Command, dataName))
sb.WriteString(" }\n")
@ -488,15 +484,11 @@ func generateWshClientApiMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsType
rtnTypeName, _ := TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap)
rtnType = fmt.Sprintf("Promise<%s>", rtnTypeName)
}
dataName := "null"
if methodDecl.CommandDataType != nil {
dataName = "data"
}
if methodDecl.CommandDataType != nil {
cmdDataTsName, _ := TypeToTSType(methodDecl.CommandDataType, tsTypesMap)
sb.WriteString(fmt.Sprintf(" %s(client: WshClient, data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, cmdDataTsName, rtnType))
} else {
methodSigDataParams, dataName := getTsWshMethodDataParamsAndExpr(methodDecl, tsTypesMap)
if methodSigDataParams == "" {
sb.WriteString(fmt.Sprintf(" %s(client: WshClient, opts?: RpcOpts): %s {\n", methodDecl.MethodName, rtnType))
} else {
sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, rtnType))
}
methodBody := fmt.Sprintf(" return client.wshRpcCall(%q, %s, opts);\n", methodDecl.Command, dataName)
sb.WriteString(methodBody)
@ -504,6 +496,30 @@ func generateWshClientApiMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsType
return sb.String()
}
func getTsWshMethodDataParamsAndExpr(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) (string, string) {
dataTypes := methodDecl.GetCommandDataTypes()
if len(dataTypes) == 0 {
return "", "null"
}
if len(dataTypes) == 1 {
cmdDataTsName, _ := TypeToTSType(dataTypes[0], tsTypesMap)
return fmt.Sprintf("data: %s", cmdDataTsName), "data"
}
var methodParamBuilder strings.Builder
var argBuilder strings.Builder
for idx, dataType := range dataTypes {
if idx > 0 {
methodParamBuilder.WriteString(", ")
argBuilder.WriteString(", ")
}
argName := fmt.Sprintf("arg%d", idx+1)
cmdDataTsName, _ := TypeToTSType(dataType, tsTypesMap)
methodParamBuilder.WriteString(fmt.Sprintf("%s: %s", argName, cmdDataTsName))
argBuilder.WriteString(argName)
}
return methodParamBuilder.String(), fmt.Sprintf("{ args: [%s] }", argBuilder.String())
}
func GenerateWaveObjTypes(tsTypesMap map[reflect.Type]string) {
for _, typeUnion := range TypeUnions {
GenerateTSTypeUnion(typeUnion, tsTypesMap)

View file

@ -0,0 +1,28 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package tsgen
import (
"reflect"
"strings"
"testing"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
)
func TestGenerateWshClientApiMethodCall_MultiArg(t *testing.T) {
methodDecl := &wshrpc.WshRpcMethodDecl{
Command: "test",
CommandType: wshrpc.RpcType_Call,
MethodName: "TestCommand",
CommandDataTypes: []reflect.Type{reflect.TypeOf(""), reflect.TypeOf(0)},
}
out := GenerateWshClientApiMethod(methodDecl, map[reflect.Type]string{})
if !strings.Contains(out, "TestCommand(client: WshClient, arg1: string, arg2: number, opts?: RpcOpts): Promise<void> {") {
t.Fatalf("generated method missing multi-arg signature:\n%s", out)
}
if !strings.Contains(out, "return client.wshRpcCall(\"test\", { args: [arg1, arg2] }, opts);") {
t.Fatalf("generated method missing MultiArg payload:\n%s", out)
}
}

View file

@ -908,6 +908,12 @@ func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
return err
}
// command "testmultiarg", wshserver.TestMultiArgCommand
func TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, opts *wshrpc.RpcOpts) (string, error) {
resp, err := sendRpcRequestCallHelper[string](w, "testmultiarg", wshrpc.MultiArg{Args: []any{arg1, arg2, arg3}}, opts)
return resp, err
}
// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand
func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts)

View file

@ -15,10 +15,14 @@ type WshRpcMethodDecl struct {
Command string
CommandType string
MethodName string
CommandDataType reflect.Type
CommandDataTypes []reflect.Type
DefaultResponseDataType reflect.Type
}
func (decl *WshRpcMethodDecl) GetCommandDataTypes() []reflect.Type {
return decl.CommandDataTypes
}
var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem()
var wshRpcInterfaceRType = reflect.TypeOf((*WshRpcInterface)(nil)).Elem()
@ -75,11 +79,11 @@ func generateWshCommandDecl(method reflect.Method) *WshRpcMethodDecl {
decl.Command = strings.ToLower(cmdStr)
decl.CommandType = getWshCommandType(method)
decl.MethodName = method.Name
var cdataType reflect.Type
if method.Type.NumIn() > 1 {
cdataType = method.Type.In(1)
var cdataTypes []reflect.Type
for idx := 1; idx < method.Type.NumIn(); idx++ {
cdataTypes = append(cdataTypes, method.Type.In(idx))
}
decl.CommandDataType = cdataType
decl.CommandDataTypes = cdataTypes
decl.DefaultResponseDataType = getWshMethodResponseType(decl.CommandType, method)
return decl
}

View file

@ -0,0 +1,50 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package wshrpc
import (
"context"
"reflect"
"testing"
)
type testRpcInterfaceForDecls interface {
NoArgCommand(ctx context.Context) error
OneArgCommand(ctx context.Context, data string) error
TwoArgCommand(ctx context.Context, arg1 string, arg2 int) error
}
func TestGenerateWshCommandDecl_MultiArgs(t *testing.T) {
rtype := reflect.TypeOf((*testRpcInterfaceForDecls)(nil)).Elem()
method, ok := rtype.MethodByName("TwoArgCommand")
if !ok {
t.Fatalf("TwoArgCommand method not found")
}
decl := generateWshCommandDecl(method)
if decl.Command != "twoarg" {
t.Fatalf("expected command twoarg, got %q", decl.Command)
}
if len(decl.CommandDataTypes) != 2 {
t.Fatalf("expected 2 command data types, got %d", len(decl.CommandDataTypes))
}
if decl.CommandDataTypes[0].Kind() != reflect.String || decl.CommandDataTypes[1].Kind() != reflect.Int {
t.Fatalf("unexpected command data types: %#v", decl.CommandDataTypes)
}
if len(decl.GetCommandDataTypes()) != 2 {
t.Fatalf("expected helper to return two command data types")
}
}
func TestGenerateWshCommandDeclMap_TestMultiArgCommand(t *testing.T) {
decl := GenerateWshCommandDeclMap()["testmultiarg"]
if decl == nil {
t.Fatalf("expected testmultiarg command declaration")
}
if decl.MethodName != "TestMultiArgCommand" {
t.Fatalf("expected TestMultiArgCommand method name, got %q", decl.MethodName)
}
if len(decl.GetCommandDataTypes()) != 3 {
t.Fatalf("expected 3 command args, got %d", len(decl.GetCommandDataTypes()))
}
}

View file

@ -23,10 +23,14 @@ type RespOrErrorUnion[T any] struct {
Error error
}
type MultiArg struct {
Args []any `json:"args"`
}
// Instructions for adding a new RPC call
// * methods must end with Command
// * methods must take context as their first parameter
// * methods may take up to one parameter, and may return either just an error, or one return value plus an error
// * methods may take additional typed parameters, and may return either just an error, or one return value plus an error
// * after modifying WshRpcInterface, run `task generate` to regnerate bindings
type WshRpcInterface interface {
@ -69,6 +73,7 @@ type WshRpcInterface interface {
StreamWaveAiCommand(ctx context.Context, request WaveAIStreamRequest) chan RespOrErrorUnion[WaveAIPacketType]
StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData]
TestCommand(ctx context.Context, data string) error
TestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3 bool) (string, error)
SetConfigCommand(ctx context.Context, data MetaSettingsType) error
SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error
GetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error)
@ -895,13 +900,13 @@ type BlockJobStatusData struct {
}
type FocusedBlockData struct {
BlockId string `json:"blockid"`
ViewType string `json:"viewtype"`
Controller string `json:"controller"`
ConnName string `json:"connname"`
BlockMeta waveobj.MetaMapType `json:"blockmeta"`
TermJobStatus *BlockJobStatusData `json:"termjobstatus,omitempty"`
ConnStatus *ConnStatus `json:"connstatus,omitempty"`
TermShellIntegrationStatus string `json:"termshellintegrationstatus,omitempty"`
TermLastCommand string `json:"termlastcommand,omitempty"`
BlockId string `json:"blockid"`
ViewType string `json:"viewtype"`
Controller string `json:"controller"`
ConnName string `json:"connname"`
BlockMeta waveobj.MetaMapType `json:"blockmeta"`
TermJobStatus *BlockJobStatusData `json:"termjobstatus,omitempty"`
ConnStatus *ConnStatus `json:"connstatus,omitempty"`
TermShellIntegrationStatus string `json:"termshellintegrationstatus,omitempty"`
TermLastCommand string `json:"termlastcommand,omitempty"`
}

View file

@ -81,6 +81,16 @@ func (ws *WshServer) TestCommand(ctx context.Context, data string) error {
return nil
}
func (ws *WshServer) TestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3 bool) (string, error) {
defer func() {
panichandler.PanicHandler("TestMultiArgCommand", recover())
}()
rpcSource := wshutil.GetRpcSourceFromContext(ctx)
rtn := fmt.Sprintf("src:%s arg1:%q arg2:%d arg3:%t", rpcSource, arg1, arg2, arg3)
log.Printf("TESTMULTI %s\n", rtn)
return rtn, nil
}
// for testing
func (ws *WshServer) MessageCommand(ctx context.Context, data wshrpc.CommandMessageData) error {
log.Printf("MESSAGE: %s\n", data.Message)

View file

@ -14,6 +14,7 @@ import (
)
var WshCommandDeclMap = wshrpc.GenerateWshCommandDeclMap()
var multiArgRType = reflect.TypeOf(wshrpc.MultiArg{})
func findCmdMethod(impl any, cmd string) *reflect.Method {
rtype := reflect.TypeOf(impl)
@ -53,19 +54,15 @@ func noImplHandler(handler *RpcResponseHandler) bool {
return true
}
func recodeCommandData(command string, data any) (any, error) {
// only applies to initial command packet
if command == "" {
func recodeCommandData(command string, data any, commandDataType reflect.Type) (any, error) {
if command == "" || commandDataType == nil {
return data, nil
}
methodDecl := WshCommandDeclMap[command]
if methodDecl == nil {
return data, fmt.Errorf("command %q not found", command)
}
if methodDecl.CommandDataType == nil {
return data, nil
}
commandDataPtr := reflect.New(methodDecl.CommandDataType).Interface()
commandDataPtr := reflect.New(commandDataType).Interface()
if data != nil {
err := utilfn.ReUnmarshal(commandDataPtr, data)
if err != nil {
@ -103,13 +100,37 @@ func serverImplAdapter(impl any) func(*RpcResponseHandler) bool {
implMethod := reflect.ValueOf(impl).MethodByName(rmethod.Name)
var callParams []reflect.Value
callParams = append(callParams, reflect.ValueOf(handler.Context()))
if methodDecl.CommandDataType != nil {
cmdData, err := recodeCommandData(cmd, handler.GetCommandRawData())
commandDataTypes := methodDecl.GetCommandDataTypes()
if len(commandDataTypes) == 1 {
cmdData, err := recodeCommandData(cmd, handler.GetCommandRawData(), commandDataTypes[0])
if err != nil {
handler.SendResponseError(err)
return true
}
callParams = append(callParams, reflect.ValueOf(cmdData))
} else if len(commandDataTypes) > 1 {
multiArgAny, err := recodeCommandData(cmd, handler.GetCommandRawData(), multiArgRType)
if err != nil {
handler.SendResponseError(err)
return true
}
multiArg, ok := multiArgAny.(wshrpc.MultiArg)
if !ok {
handler.SendResponseError(fmt.Errorf("command %q invalid multi arg payload", cmd))
return true
}
if len(multiArg.Args) != len(commandDataTypes) {
handler.SendResponseError(fmt.Errorf("command %q expected %d args, got %d", cmd, len(commandDataTypes), len(multiArg.Args)))
return true
}
for idx, commandDataType := range commandDataTypes {
cmdData, err := recodeCommandData(cmd, multiArg.Args[idx], commandDataType)
if err != nil {
handler.SendResponseError(err)
return true
}
callParams = append(callParams, reflect.ValueOf(cmdData))
}
}
if methodDecl.CommandType == wshrpc.RpcType_Call {
rtnVals := implMethod.Call(callParams)