feat: add computer-use provider selection for AI agents (#24772)

Adds a deployment-wide setting to select the computer-use provider
(Anthropic or OpenAI) for AI agents, plus the OpenAI computer-use runner
needed to honor that selection.

The setting is stored in `site_configs` under
`agents_computer_use_provider`, defaults to Anthropic when unset, and is
exposed via experimental GET/PUT endpoints under
`/api/experimental/chats/config/computer-use-provider`. The chatd
computer-use tool now dispatches to either `runAnthropicComputerUse` or
`runOpenAIComputerUse` based on the resolved provider, with
provider-specific result metadata for OpenAI screenshots.

Frontend adds a provider dropdown to the Agents Experiments settings
page nested under the virtual desktop toggle, with disabled state
handling while virtual desktop is off and skeleton loaders while config
queries are in flight.

Hugo and Codex review follow-up:
- Uses shared provider validation and clearer computer-use constant
names.
- Removes stale OpenAI pending-safety-checks commentary.
- Documents why provider result metadata is needed for OpenAI
screenshots.
- Keeps the computer-use subagent visible when provider credentials are
missing, then returns a clear spawn-time configuration error.
- Uses OpenAI's recommended 1600x900 screenshot geometry to preserve the
native 16:9 aspect ratio.
- Moves OpenAI-specific computer-use helpers into
`coderd/x/chatd/chatopenai/computeruse` after rebasing onto the provider
package refactor in `main`.
- Converts OpenAI pixel scroll deltas to Coder desktop wheel-click
amounts.
- Preserves OpenAI pointer modifiers with key down/up desktop actions
and rejects unsupported non-left double-click buttons explicitly.
- Maps OpenAI back/forward side-button clicks to browser navigation key
actions.
- Defaults omitted OpenAI click buttons to left-click.
- Retries mouse release cleanup if the final OpenAI drag release fails.
- Keeps computer-use subagent availability messages stable when provider
config cannot be loaded, while logging the backend error.
- Releases remaining OpenAI modifier keys if a synthetic key-up cleanup
action fails.
- Updates Storybook interaction stories so provider snapshots show the
selected final provider.

> Mux updated this PR description on behalf of Mike.
This commit is contained in:
Michael Suchacz
2026-05-04 20:30:50 +02:00
committed by GitHub
parent da6e708bd2
commit 0bb09935bc
38 changed files with 3181 additions and 141 deletions
+230 -50
View File
@@ -4,28 +4,84 @@ import (
"context"
"encoding/base64"
"fmt"
"slices"
"strings"
"time"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
openaicomputeruse "github.com/coder/coder/v2/coderd/x/chatd/chatopenai/computeruse"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/quartz"
)
const (
// ComputerUseModelProvider is the provider for the computer
// use model.
ComputerUseModelProvider = "anthropic"
// ComputerUseModelName is the model used for computer use
// subagents.
ComputerUseModelName = "claude-opus-4-6"
// ComputerUseProviderAnthropic identifies Anthropic computer use.
ComputerUseProviderAnthropic = "anthropic"
// ComputerUseProviderOpenAI identifies OpenAI computer use.
ComputerUseProviderOpenAI = "openai"
// ComputerUseModelProviderDefault is the default model provider name for
// computer use, equal to ComputerUseProviderAnthropic.
ComputerUseModelProviderDefault = ComputerUseProviderAnthropic
// ComputerUseAnthropicModelName is the default Anthropic model used for
// computer use subagents.
ComputerUseAnthropicModelName = "claude-opus-4-6"
// ComputerUseOpenAIModelName is the default OpenAI model used for computer use.
ComputerUseOpenAIModelName = "gpt-5.5"
)
// computerUseTool implements fantasy.AgentTool and
// chatloop.ToolDefiner for Anthropic computer use.
// SupportedComputerUseProviders returns the providers supported by computer use.
// The returned slice is a fresh copy and safe to mutate.
func SupportedComputerUseProviders() []string {
return []string{
ComputerUseProviderAnthropic,
ComputerUseProviderOpenAI,
}
}
// IsSupportedComputerUseProvider reports whether provider supports computer use.
func IsSupportedComputerUseProvider(provider string) bool {
return slices.Contains(SupportedComputerUseProviders(), provider)
}
// DefaultComputerUseProvider returns the effective computer use provider.
func DefaultComputerUseProvider(provider string) string {
if provider == "" {
return ComputerUseProviderAnthropic
}
return provider
}
// DefaultComputerUseModel returns the default model for a computer use provider.
func DefaultComputerUseModel(provider string) (modelProvider, modelName string, ok bool) {
switch DefaultComputerUseProvider(provider) {
case ComputerUseProviderAnthropic:
return ComputerUseModelProviderDefault, ComputerUseAnthropicModelName, true
case ComputerUseProviderOpenAI:
// Keep OpenAI isolated here because computer-use models may advance.
return ComputerUseProviderOpenAI, ComputerUseOpenAIModelName, true
default:
return "", "", false
}
}
// DefaultComputerUseDesktopGeometry returns provider-specific model-facing
// desktop geometry for computer use.
func DefaultComputerUseDesktopGeometry(provider string) workspacesdk.DesktopGeometry {
switch DefaultComputerUseProvider(provider) {
case ComputerUseProviderOpenAI:
return workspacesdk.DefaultOpenAIComputerUseDesktopGeometry()
default:
return workspacesdk.DefaultDesktopGeometry()
}
}
// computerUseTool implements fantasy.AgentTool and chatloop.ToolDefiner.
type computerUseTool struct {
provider string
declaredWidth int
declaredHeight int
getWorkspaceConn func(ctx context.Context) (workspacesdk.AgentConn, error)
@@ -35,11 +91,12 @@ type computerUseTool struct {
logger slog.Logger
}
// NewComputerUseTool creates a computer use AgentTool that delegates to the
// agent's desktop endpoints. declaredWidth and declaredHeight are the
// model-facing desktop dimensions advertised to Anthropic and requested for
// screenshots.
// NewComputerUseTool creates a provider-aware computer use AgentTool that
// delegates to the agent's desktop endpoints. declaredWidth and declaredHeight
// are the model-facing desktop dimensions advertised to providers and requested
// for screenshots.
func NewComputerUseTool(
provider string,
declaredWidth, declaredHeight int,
getWorkspaceConn func(ctx context.Context) (workspacesdk.AgentConn, error),
storeFile StoreFileFunc,
@@ -47,6 +104,7 @@ func NewComputerUseTool(
logger slog.Logger,
) fantasy.AgentTool {
return &computerUseTool{
provider: DefaultComputerUseProvider(provider),
declaredWidth: declaredWidth,
declaredHeight: declaredHeight,
getWorkspaceConn: getWorkspaceConn,
@@ -67,20 +125,31 @@ func (*computerUseTool) Info() fantasy.ToolInfo {
}
}
// ComputerUseProviderTool creates the provider-defined Anthropic computer-use
// tool definition using the declared model-facing desktop geometry.
func ComputerUseProviderTool(declaredWidth, declaredHeight int) fantasy.Tool {
// The run callback is nil because execution is handled separately
// by the AgentTool runner in the chatloop. We extract just the
// provider-defined tool definition.
return fantasyanthropic.NewComputerUseTool(
fantasyanthropic.ComputerUseToolOptions{
DisplayWidthPx: int64(declaredWidth),
DisplayHeightPx: int64(declaredHeight),
ToolVersion: fantasyanthropic.ComputerUse20251124,
},
nil,
).Definition()
// ComputerUseProviderTool creates the provider-defined computer-use tool
// definition using the declared model-facing desktop geometry.
func ComputerUseProviderTool(provider string, declaredWidth, declaredHeight int) (fantasy.Tool, error) {
switch DefaultComputerUseProvider(provider) {
case ComputerUseProviderAnthropic:
// The run callback is nil because execution is handled separately
// by the AgentTool runner in the chatloop. We extract just the
// provider-defined tool definition.
return fantasyanthropic.NewComputerUseTool(
fantasyanthropic.ComputerUseToolOptions{
DisplayWidthPx: int64(declaredWidth),
DisplayHeightPx: int64(declaredHeight),
ToolVersion: fantasyanthropic.ComputerUse20251124,
},
nil,
).Definition(), nil
case ComputerUseProviderOpenAI:
// OpenAI's GA computer tool schema does not accept display
// dimensions. The declared geometry is applied through screenshot
// sizing and desktop action coordinate scaling.
return openaicomputeruse.Tool(), nil
default:
return nil, xerrors.Errorf("unsupported computer use provider %q, supported providers: %s", provider,
strings.Join(SupportedComputerUseProviders(), ", "))
}
}
func (t *computerUseTool) ProviderOptions() fantasy.ProviderOptions {
@@ -92,6 +161,24 @@ func (t *computerUseTool) SetProviderOptions(opts fantasy.ProviderOptions) {
}
func (t *computerUseTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
switch DefaultComputerUseProvider(t.provider) {
case ComputerUseProviderAnthropic:
return t.runAnthropicComputerUse(ctx, call)
case ComputerUseProviderOpenAI:
return t.runOpenAIComputerUse(ctx, call)
default:
return fantasy.NewTextErrorResponse(fmt.Sprintf(
"unsupported computer use provider %q, supported providers: %s",
t.provider,
strings.Join(SupportedComputerUseProviders(), ", "),
)), nil
}
}
func (t *computerUseTool) runAnthropicComputerUse(
ctx context.Context,
call fantasy.ToolCall,
) (fantasy.ToolResponse, error) {
input, err := fantasyanthropic.ParseComputerUseInput(call.Input)
if err != nil {
return fantasy.NewTextErrorResponse(
@@ -110,16 +197,7 @@ func (t *computerUseTool) Run(ctx context.Context, call fantasy.ToolCall) (fanta
// For wait actions, sleep then return a screenshot.
if input.Action == fantasyanthropic.ActionWait {
d := input.Duration
if d <= 0 {
d = 1000
}
timer := t.clock.NewTimer(time.Duration(d)*time.Millisecond, "computeruse", "wait")
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
t.wait(ctx, input.Duration)
return t.captureScreenshot(ctx, conn, declaredWidth, declaredHeight)
}
@@ -129,17 +207,13 @@ func (t *computerUseTool) Run(ctx context.Context, call fantasy.ToolCall) (fanta
}
// Build the action request.
action := workspacesdk.DesktopAction{
Action: string(input.Action),
ScaledWidth: &declaredWidth,
ScaledHeight: &declaredHeight,
}
action := t.desktopAction(string(input.Action), declaredWidth, declaredHeight)
if input.Coordinate != ([2]int64{}) {
coord := [2]int{int(input.Coordinate[0]), int(input.Coordinate[1])}
coord := coordinateFromInt64(input.Coordinate[0], input.Coordinate[1])
action.Coordinate = &coord
}
if input.StartCoordinate != ([2]int64{}) {
coord := [2]int{int(input.StartCoordinate[0]), int(input.StartCoordinate[1])}
coord := coordinateFromInt64(input.StartCoordinate[0], input.StartCoordinate[1])
action.StartCoordinate = &coord
}
if input.Text != "" {
@@ -157,18 +231,124 @@ func (t *computerUseTool) Run(ctx context.Context, call fantasy.ToolCall) (fanta
action.ScrollDirection = &input.ScrollDirection
}
// Execute the action.
_, err = conn.ExecuteDesktopAction(ctx, action)
if err != nil {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("action %q failed: %v", input.Action, err),
), nil
if resp, done := t.executeDesktopAction(ctx, conn, action); done {
return resp, nil
}
// Take a screenshot after every action (Anthropic pattern).
return t.captureScreenshot(ctx, conn, declaredWidth, declaredHeight)
}
func (t *computerUseTool) runOpenAIComputerUse(
ctx context.Context,
call fantasy.ToolCall,
) (fantasy.ToolResponse, error) {
input, err := openaicomputeruse.ParseInput(call.Input)
if err != nil {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("invalid computer use input: %v", err),
), nil
}
conn, err := t.getWorkspaceConn(ctx)
if err != nil {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("failed to connect to workspace: %v", err),
), nil
}
declaredWidth, declaredHeight := t.declaredActionDimensions()
actions, err := openaicomputeruse.DesktopActions(
input,
declaredWidth,
declaredHeight,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
for _, action := range actions {
if action.WaitDurationMillis > 0 {
t.wait(ctx, action.WaitDurationMillis)
continue
}
if resp, done := t.executeDesktopAction(ctx, conn, action.Action); done {
if action.ReleaseMouseOnFailure {
_, err := conn.ExecuteDesktopAction(
ctx,
t.desktopAction("left_mouse_up", declaredWidth, declaredHeight),
)
if err != nil {
t.logger.Warn(ctx, "failed to release mouse after OpenAI drag error",
slog.Error(err),
)
}
}
t.releaseOpenAIModifierKeys(ctx, conn, action.ReleaseKeysOnFailure)
return resp, nil
}
}
return t.captureSharedScreenshot(ctx, conn, declaredWidth, declaredHeight)
}
func (t *computerUseTool) releaseOpenAIModifierKeys(
ctx context.Context,
conn workspacesdk.AgentConn,
keys []string,
) {
for i := len(keys) - 1; i >= 0; i-- {
key := keys[i]
action := t.desktopAction("key_up", 0, 0)
action.Text = &key
if _, err := conn.ExecuteDesktopAction(ctx, action); err != nil {
t.logger.Warn(ctx, "failed to release OpenAI modifier key",
slog.F("key", key),
slog.Error(err),
)
}
}
}
func (*computerUseTool) executeDesktopAction(
ctx context.Context,
conn workspacesdk.AgentConn,
action workspacesdk.DesktopAction,
) (fantasy.ToolResponse, bool) {
_, err := conn.ExecuteDesktopAction(ctx, action)
if err != nil {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("action %q failed: %v", action.Action, err),
), true
}
return fantasy.ToolResponse{}, false
}
func (*computerUseTool) desktopAction(
action string,
declaredWidth, declaredHeight int,
) workspacesdk.DesktopAction {
return workspacesdk.DesktopAction{
Action: action,
ScaledWidth: &declaredWidth,
ScaledHeight: &declaredHeight,
}
}
func (t *computerUseTool) wait(ctx context.Context, durationMillis int64) {
d := durationMillis
if d <= 0 {
d = 1000
}
timer := t.clock.NewTimer(time.Duration(d)*time.Millisecond, "computeruse", "wait")
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}
func coordinateFromInt64(x, y int64) [2]int {
return [2]int{int(x), int(y)}
}
func (t *computerUseTool) captureScreenshot(
ctx context.Context,
conn workspacesdk.AgentConn,
@@ -256,7 +436,7 @@ func executeScreenshotAction(
func (t *computerUseTool) declaredActionDimensions() (declaredWidth, declaredHeight int) {
if t.declaredWidth <= 0 || t.declaredHeight <= 0 {
geometry := workspacesdk.DefaultDesktopGeometry()
geometry := DefaultComputerUseDesktopGeometry(t.provider)
return geometry.DeclaredWidth, geometry.DeclaredHeight
}
return t.declaredWidth, t.declaredHeight
+684 -9
View File
@@ -5,8 +5,10 @@ import (
"context"
"encoding/base64"
"testing"
"time"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -14,23 +16,137 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3/sloggers/slogtest"
openaicomputeruse "github.com/coder/coder/v2/coderd/x/chatd/chatopenai/computeruse"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestDefaultComputerUseModel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
provider string
wantModelProvider string
wantModelName string
wantOK bool
}{
{
name: "empty defaults to Anthropic",
provider: "",
wantModelProvider: chattool.ComputerUseModelProviderDefault,
wantModelName: chattool.ComputerUseAnthropicModelName,
wantOK: true,
},
{
name: "Anthropic",
provider: chattool.ComputerUseProviderAnthropic,
wantModelProvider: chattool.ComputerUseModelProviderDefault,
wantModelName: chattool.ComputerUseAnthropicModelName,
wantOK: true,
},
{
name: "OpenAI",
provider: chattool.ComputerUseProviderOpenAI,
wantModelProvider: chattool.ComputerUseProviderOpenAI,
wantModelName: chattool.ComputerUseOpenAIModelName,
wantOK: true,
},
{
name: "unsupported",
provider: "unsupported",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
modelProvider, modelName, ok := chattool.DefaultComputerUseModel(tt.provider)
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.wantModelProvider, modelProvider)
assert.Equal(t, tt.wantModelName, modelName)
})
}
}
func TestDefaultComputerUseDesktopGeometry(t *testing.T) {
t.Parallel()
tests := []struct {
name string
provider string
declaredWidth int
declaredHeight int
}{
{
name: "empty defaults to Anthropic geometry",
provider: "",
declaredWidth: 1280,
declaredHeight: 720,
},
{
name: "Anthropic",
provider: chattool.ComputerUseProviderAnthropic,
declaredWidth: 1280,
declaredHeight: 720,
},
{
name: "OpenAI",
provider: chattool.ComputerUseProviderOpenAI,
declaredWidth: 1600,
declaredHeight: 900,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
geometry := chattool.DefaultComputerUseDesktopGeometry(tt.provider)
assert.Equal(t, tt.declaredWidth, geometry.DeclaredWidth)
assert.Equal(t, tt.declaredHeight, geometry.DeclaredHeight)
})
}
}
func TestComputerUseProviderTool(t *testing.T) {
t.Parallel()
geometry := workspacesdk.DefaultDesktopGeometry()
def := chattool.ComputerUseProviderTool(geometry.DeclaredWidth, geometry.DeclaredHeight)
def, err := chattool.ComputerUseProviderTool(
chattool.ComputerUseProviderAnthropic,
geometry.DeclaredWidth,
geometry.DeclaredHeight,
)
require.NoError(t, err)
pdt, ok := def.(fantasy.ProviderDefinedTool)
require.True(t, ok, "ComputerUseProviderTool should return a ProviderDefinedTool")
assert.True(t, fantasyanthropic.IsComputerUseTool(def))
assert.Contains(t, pdt.ID, "computer")
assert.Equal(t, "computer", pdt.Name)
assert.Equal(t, int64(geometry.DeclaredWidth), pdt.Args["display_width_px"])
assert.Equal(t, int64(geometry.DeclaredHeight), pdt.Args["display_height_px"])
openAITool, err := chattool.ComputerUseProviderTool(
chattool.ComputerUseProviderOpenAI,
geometry.DeclaredWidth,
geometry.DeclaredHeight,
)
require.NoError(t, err)
assert.True(t, openaicomputeruse.IsTool(openAITool))
_, err = chattool.ComputerUseProviderTool(
"unsupported",
geometry.DeclaredWidth,
geometry.DeclaredHeight,
)
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported computer use provider")
}
func TestComputerUseTool_Run_Screenshot(t *testing.T) {
@@ -56,7 +172,7 @@ func TestComputerUseTool_Run_Screenshot(t *testing.T) {
}, nil
})
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
}, nil, quartz.NewReal(), slogtest.Make(t, nil))
@@ -100,7 +216,7 @@ func TestComputerUseTool_Run_Screenshot_PersistsAttachment(t *testing.T) {
var storedName string
var storedType string
var storedData []byte
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
}, func(_ context.Context, name string, detectName string, data []byte) (chattool.AttachmentMetadata, error) {
storedName = name
@@ -154,7 +270,7 @@ func TestComputerUseTool_Run_Screenshot_StoreErrorFallsBackToImage(t *testing.T)
ScreenshotHeight: geometry.DeclaredHeight,
}, nil)
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
}, func(_ context.Context, _ string, _ string, _ []byte) (chattool.AttachmentMetadata, error) {
return chattool.AttachmentMetadata{}, xerrors.New("chat already has the maximum of 20 linked files")
@@ -190,7 +306,7 @@ func TestComputerUseTool_Run_Screenshot_OversizedAttachmentFallsBackToImage(t *t
ScreenshotHeight: geometry.DeclaredHeight,
}, nil)
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
}, func(_ context.Context, _ string, _ string, _ []byte) (chattool.AttachmentMetadata, error) {
t.Fatal("storeFile should not be called for oversized screenshots")
@@ -250,7 +366,7 @@ func TestComputerUseTool_Run_LeftClick(t *testing.T) {
}, nil
})
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
}, func(_ context.Context, _ string, _ string, _ []byte) (chattool.AttachmentMetadata, error) {
t.Fatal("storeFile should not be called for left_click follow-up screenshots")
@@ -298,7 +414,7 @@ func TestComputerUseTool_Run_Wait(t *testing.T) {
}, nil
})
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
}, func(_ context.Context, _ string, _ string, _ []byte) (chattool.AttachmentMetadata, error) {
t.Fatal("storeFile should not be called for wait screenshots")
@@ -345,6 +461,7 @@ func TestComputerUseTool_Run_ScreenshotDataIsDecodedBinary(t *testing.T) {
}, nil)
tool := chattool.NewComputerUseTool(
chattool.ComputerUseProviderAnthropic,
geometry.DeclaredWidth,
geometry.DeclaredHeight,
func(_ context.Context) (workspacesdk.AgentConn, error) {
@@ -386,7 +503,7 @@ func TestComputerUseTool_Run_ConnError(t *testing.T) {
t.Parallel()
geometry := workspacesdk.DefaultDesktopGeometry()
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return nil, xerrors.New("workspace not available")
}, nil, quartz.NewReal(), slogtest.Make(t, nil))
@@ -406,7 +523,7 @@ func TestComputerUseTool_Run_InvalidInput(t *testing.T) {
t.Parallel()
geometry := workspacesdk.DefaultDesktopGeometry()
tool := chattool.NewComputerUseTool(geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
tool := chattool.NewComputerUseTool(chattool.ComputerUseProviderAnthropic, geometry.DeclaredWidth, geometry.DeclaredHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
return nil, xerrors.New("should not be called")
}, nil, quartz.NewReal(), slogtest.Make(t, nil))
@@ -421,3 +538,561 @@ func TestComputerUseTool_Run_InvalidInput(t *testing.T) {
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "invalid computer use input")
}
func TestComputerUseTool_Run_OpenAI_BatchedActions(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
const screenshotPNG = "aW1hZ2UtZGF0YQ=="
actions := recordDesktopActions(t, mockConn, geometry, 16, screenshotPNG)
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_batch",
"actions":[
{"type":"screenshot"},
{"type":"move","x":10,"y":20},
{"type":"click","button":"left","x":30,"y":40},
{"type":"click","button":"right","x":31,"y":41},
{"type":"click","button":"middle","x":32,"y":42},
{"type":"double_click","x":50,"y":60},
{"type":"drag","path":[{"x":1,"y":2},{"x":3,"y":4},{"x":5,"y":6}]},
{"type":"keypress","keys":["ctrl","s"]},
{"type":"type","text":"hello"},
{"type":"scroll","x":70,"y":80,"scroll_y":500,"scroll_x":-200}
]
}`))
require.NoError(t, err)
assert.Equal(t, "image", resp.Type)
assert.Equal(t, "image/png", resp.MediaType)
assert.False(t, resp.IsError)
expectedImage, err := base64.StdEncoding.DecodeString(screenshotPNG)
require.NoError(t, err)
assert.Equal(t, expectedImage, resp.Data)
attachments, err := chattool.AttachmentsFromMetadata(resp.Metadata)
require.NoError(t, err)
assert.Empty(t, attachments)
require.Len(t, *actions, 16)
for _, action := range *actions {
assertDesktopActionScaled(t, geometry, action)
}
assertDesktopAction(t, (*actions)[0], "mouse_move", [2]int{10, 20})
assertDesktopAction(t, (*actions)[1], "left_click", [2]int{30, 40})
assertDesktopAction(t, (*actions)[2], "right_click", [2]int{31, 41})
assertDesktopAction(t, (*actions)[3], "middle_click", [2]int{32, 42})
assertDesktopAction(t, (*actions)[4], "double_click", [2]int{50, 60})
assertDesktopAction(t, (*actions)[5], "mouse_move", [2]int{1, 2})
assert.Equal(t, "left_mouse_down", (*actions)[6].Action)
assert.Nil(t, (*actions)[6].Coordinate)
assertDesktopAction(t, (*actions)[7], "mouse_move", [2]int{3, 4})
assertDesktopAction(t, (*actions)[8], "mouse_move", [2]int{5, 6})
assert.Equal(t, "left_mouse_up", (*actions)[9].Action)
assert.Nil(t, (*actions)[9].Coordinate)
assertTextAction(t, (*actions)[10], "key", "ctrl+s")
assertTextAction(t, (*actions)[11], "type", "hello")
assertDesktopAction(t, (*actions)[12], "mouse_move", [2]int{70, 80})
assertScrollAction(t, (*actions)[13], [2]int{70, 80}, "down", 5)
assertScrollAction(t, (*actions)[14], [2]int{70, 80}, "left", 2)
assert.Equal(t, "screenshot", (*actions)[15].Action)
assert.Nil(t, (*actions)[15].Coordinate)
}
func TestComputerUseTool_Run_OpenAI_EmptyActionsCapturesScreenshotAndStoresAttachment(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
const screenshotPNG = "ZmluYWwtc2NyZWVuc2hvdA=="
actions := recordDesktopActions(t, mockConn, geometry, 1, screenshotPNG)
var storedName string
var storedData []byte
tool := newOpenAIComputerUseTool(t, geometry, mockConn, func(_ context.Context, name string, detectName string, data []byte) (chattool.AttachmentMetadata, error) {
storedName = name
require.Equal(t, name, detectName)
storedData = append([]byte(nil), data...)
return chattool.AttachmentMetadata{
FileID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
MediaType: "image/png",
Name: name,
}, nil
}, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_empty",
"actions":[]
}`))
require.NoError(t, err)
assert.Equal(t, "image", resp.Type)
require.Len(t, *actions, 1)
assert.Equal(t, "screenshot", (*actions)[0].Action)
assert.Contains(t, storedName, "screenshot-")
expectedData, err := base64.StdEncoding.DecodeString(screenshotPNG)
require.NoError(t, err)
assert.Equal(t, expectedData, storedData)
attachments, err := chattool.AttachmentsFromMetadata(resp.Metadata)
require.NoError(t, err)
require.Len(t, attachments, 1)
assert.Equal(t, uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), attachments[0].FileID)
assert.Equal(t, "image/png", attachments[0].MediaType)
}
func TestComputerUseTool_Run_OpenAI_FinalScreenshotStoreErrorFallsBackToImage(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
const screenshotPNG = "ZmluYWwtc2NyZWVuc2hvdA=="
recordDesktopActions(t, mockConn, geometry, 1, screenshotPNG)
tool := newOpenAIComputerUseTool(t, geometry, mockConn, func(_ context.Context, _ string, _ string, _ []byte) (chattool.AttachmentMetadata, error) {
return chattool.AttachmentMetadata{}, xerrors.New("chat already has the maximum of 20 linked files")
}, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_store_error",
"actions":[{"type":"screenshot"}]
}`))
require.NoError(t, err)
assert.Equal(t, "image", resp.Type)
assert.Equal(t, "image/png", resp.MediaType)
assert.False(t, resp.IsError)
attachments, err := chattool.AttachmentsFromMetadata(resp.Metadata)
require.NoError(t, err)
assert.Empty(t, attachments)
}
func TestComputerUseTool_Run_OpenAI_DragReleaseFailureRetriesMouseUp(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
gomock.InOrder(
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assertDesktopAction(t, action, "mouse_move", [2]int{1, 2})
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{Output: "mouse_move performed"}, nil
}),
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assert.Equal(t, "left_mouse_down", action.Action)
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{Output: "mouse_down performed"}, nil
}),
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assertDesktopAction(t, action, "mouse_move", [2]int{3, 4})
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{Output: "mouse_move performed"}, nil
}),
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assert.Equal(t, "left_mouse_up", action.Action)
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{}, xerrors.New("release failed")
}),
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assert.Equal(t, "left_mouse_up", action.Action)
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{Output: "mouse_up performed"}, nil
}),
)
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_release_failure",
"actions":[{"type":"drag","path":[{"x":1,"y":2},{"x":3,"y":4}]}]
}`))
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, `action "left_mouse_up" failed`)
}
func TestComputerUseTool_Run_OpenAI_ActionFailureSkipsFinalScreenshot(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
gomock.InOrder(
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assertDesktopAction(t, action, "mouse_move", [2]int{10, 20})
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{Output: "mouse_move performed"}, nil
}),
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assertTextAction(t, action, "type", "fail")
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{}, xerrors.New("desktop failed")
}),
)
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_failure",
"actions":[
{"type":"move","x":10,"y":20},
{"type":"type","text":"fail"}
]
}`))
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, `action "type" failed`)
}
func TestComputerUseTool_Run_OpenAI_UnsupportedClickButtons(t *testing.T) {
t.Parallel()
for _, button := range []string{"extra"} {
t.Run(button, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_unsupported_button",
"actions":[{"type":"click","button":"`+button+`","x":10,"y":20}]
}`))
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "unsupported OpenAI click button")
})
}
}
func TestComputerUseTool_Run_OpenAI_WheelClickIsMiddle(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
actions := recordDesktopActions(t, mockConn, geometry, 2, "d2hlZWwtY2xpY2s=")
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_wheel_click",
"actions":[{"type":"click","button":"wheel","x":10,"y":20}]
}`))
require.NoError(t, err)
assert.False(t, resp.IsError)
require.Len(t, *actions, 2)
assertDesktopAction(t, (*actions)[0], "middle_click", [2]int{10, 20})
assert.Equal(t, "screenshot", (*actions)[1].Action)
}
func TestComputerUseTool_Run_OpenAI_UnsupportedActionType(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_unknown_action",
"actions":[{"type":"hover","x":10,"y":20}]
}`))
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, `unsupported OpenAI computer action type "hover"`)
}
func TestComputerUseTool_Run_OpenAI_InvalidInput(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{invalid json`))
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "invalid")
}
func TestComputerUseTool_Run_OpenAI_DragRequiresTwoPoints(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_short_drag",
"actions":[{"type":"drag","path":[{"x":10,"y":20}]}]
}`))
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "requires at least two path points")
}
func TestComputerUseTool_Run_OpenAI_KeyNormalization(t *testing.T) {
t.Parallel()
tests := []struct {
name string
keysJSON string
wantText string
}{
{name: "ctrl s", keysJSON: `["ctrl","s"]`, wantText: "ctrl+s"},
{name: "modifier aliases", keysJSON: `["control","shift","alt","command","A"]`, wantText: "ctrl+shift+alt+meta+a"},
{name: "special keys", keysJSON: `["enter","escape","tab","space","backspace","delete"]`, wantText: "Return+Escape+Tab+space+BackSpace+Delete"},
{name: "arrows", keysJSON: `["ArrowUp","arrowdown","left","Right"]`, wantText: "Up+Down+Left+Right"},
{name: "function letters digits", keysJSON: `["f1","F12","5","Z"]`, wantText: "F1+F12+5+z"},
{name: "minus key", keysJSON: `["-"]`, wantText: "-"},
{name: "equals key", keysJSON: `["="]`, wantText: "="},
{name: "slash key", keysJSON: `["/"]`, wantText: "/"},
{name: "period key", keysJSON: `["."]`, wantText: "."},
{name: "left bracket key", keysJSON: `["["]`, wantText: "["},
{name: "right bracket key", keysJSON: `["]"]`, wantText: "]"},
{name: "semicolon key", keysJSON: `[";"]`, wantText: ";"},
{name: "apostrophe key", keysJSON: `["'"]`, wantText: "'"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
actions := recordDesktopActions(t, mockConn, geometry, 2, "a2V5LWltYWdl")
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_key",
"actions":[{"type":"keypress","keys":`+tt.keysJSON+`}]
}`))
require.NoError(t, err)
assert.False(t, resp.IsError)
require.Len(t, *actions, 2)
assertTextAction(t, (*actions)[0], "key", tt.wantText)
assert.Equal(t, "screenshot", (*actions)[1].Action)
})
}
}
func TestComputerUseTool_Run_OpenAI_KeyNormalizationErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
keysJSON string
want string
}{
{name: "empty array", keysJSON: `[]`, want: "requires at least one key"},
{name: "empty token", keysJSON: `["ctrl",""]`, want: "contains an empty key"},
{name: "unsupported multi-rune", keysJSON: `["ab"]`, want: `unsupported OpenAI keypress "ab"`},
{name: "unsupported function key", keysJSON: `["f99"]`, want: `unsupported OpenAI keypress "f99"`},
{name: "unsupported named key", keysJSON: `["PageDown"]`, want: `unsupported OpenAI keypress "PageDown"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, quartz.NewReal())
resp, err := tool.Run(context.Background(), openAIComputerUseCall(`{
"call_id":"call_key_error",
"actions":[{"type":"keypress","keys":`+tt.keysJSON+`}]
}`))
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, tt.want)
})
}
}
func TestComputerUseTool_Run_OpenAI_WaitUsesMockClock(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
geometry := workspacesdk.DefaultDesktopGeometry()
mClock := quartz.NewMock(t)
const screenshotPNG = "d2FpdC1zY3JlZW5zaG90"
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
assert.Equal(t, "screenshot", action.Action)
assertDesktopActionScaled(t, geometry, action)
return workspacesdk.DesktopActionResponse{
Output: "screenshot",
ScreenshotData: screenshotPNG,
ScreenshotWidth: geometry.DeclaredWidth,
ScreenshotHeight: geometry.DeclaredHeight,
}, nil
}).Times(1)
trap := mClock.Trap().NewTimer("computeruse", "wait")
tool := newOpenAIComputerUseTool(t, geometry, mockConn, nil, mClock)
type toolResult struct {
resp fantasy.ToolResponse
err error
}
resultCh := make(chan toolResult, 1)
go func() {
resp, err := tool.Run(ctx, openAIComputerUseCall(`{
"call_id":"call_wait",
"actions":[{"type":"wait"}]
}`))
resultCh <- toolResult{resp: resp, err: err}
}()
trap.MustWait(ctx).MustRelease(ctx)
trap.Close()
mClock.Advance(time.Second).MustWait(ctx)
result := testutil.RequireReceive(ctx, t, resultCh)
require.NoError(t, result.err)
assert.Equal(t, "image", result.resp.Type)
assert.Equal(t, "image/png", result.resp.MediaType)
assert.False(t, result.resp.IsError)
}
func newOpenAIComputerUseTool(
t testing.TB,
geometry workspacesdk.DesktopGeometry,
conn workspacesdk.AgentConn,
storeFile chattool.StoreFileFunc,
clock quartz.Clock,
) fantasy.AgentTool {
t.Helper()
return chattool.NewComputerUseTool(
chattool.ComputerUseProviderOpenAI,
geometry.DeclaredWidth,
geometry.DeclaredHeight,
func(_ context.Context) (workspacesdk.AgentConn, error) {
return conn, nil
},
storeFile,
clock,
slogtest.Make(t, nil),
)
}
func openAIComputerUseCall(input string) fantasy.ToolCall {
return fantasy.ToolCall{
ID: "openai-call",
Name: "computer",
Input: input,
}
}
func recordDesktopActions(
t testing.TB,
mockConn *agentconnmock.MockAgentConn,
geometry workspacesdk.DesktopGeometry,
times int,
screenshotPNG string,
) *[]workspacesdk.DesktopAction {
t.Helper()
actions := make([]workspacesdk.DesktopAction, 0, times)
mockConn.EXPECT().ExecuteDesktopAction(
gomock.Any(),
gomock.AssignableToTypeOf(workspacesdk.DesktopAction{}),
).DoAndReturn(func(_ context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
actions = append(actions, action)
if action.Action == "screenshot" {
return workspacesdk.DesktopActionResponse{
Output: "screenshot",
ScreenshotData: screenshotPNG,
ScreenshotWidth: geometry.DeclaredWidth,
ScreenshotHeight: geometry.DeclaredHeight,
}, nil
}
return workspacesdk.DesktopActionResponse{Output: action.Action + " performed"}, nil
}).Times(times)
return &actions
}
func assertDesktopActionScaled(
t testing.TB,
geometry workspacesdk.DesktopGeometry,
action workspacesdk.DesktopAction,
) {
t.Helper()
require.NotNil(t, action.ScaledWidth)
require.NotNil(t, action.ScaledHeight)
assert.Equal(t, geometry.DeclaredWidth, *action.ScaledWidth)
assert.Equal(t, geometry.DeclaredHeight, *action.ScaledHeight)
}
func assertDesktopAction(
t testing.TB,
action workspacesdk.DesktopAction,
actionName string,
coordinate [2]int,
) {
t.Helper()
assert.Equal(t, actionName, action.Action)
require.NotNil(t, action.Coordinate)
assert.Equal(t, coordinate, *action.Coordinate)
}
func assertTextAction(
t testing.TB,
action workspacesdk.DesktopAction,
actionName string,
text string,
) {
t.Helper()
assert.Equal(t, actionName, action.Action)
require.NotNil(t, action.Text)
assert.Equal(t, text, *action.Text)
}
func assertScrollAction(
t testing.TB,
action workspacesdk.DesktopAction,
coordinate [2]int,
direction string,
amount int,
) {
t.Helper()
assertDesktopAction(t, action, "scroll", coordinate)
require.NotNil(t, action.ScrollDirection)
require.NotNil(t, action.ScrollAmount)
assert.Equal(t, direction, *action.ScrollDirection)
assert.Equal(t, amount, *action.ScrollAmount)
}