diff --git a/src/Responses/Responses/Output/OutputComputerToolCall.php b/src/Responses/Responses/Output/OutputComputerToolCall.php index cb598e53..6c5733cd 100644 --- a/src/Responses/Responses/Output/OutputComputerToolCall.php +++ b/src/Responses/Responses/Output/OutputComputerToolCall.php @@ -30,7 +30,8 @@ * @phpstan-import-type WaitType from Wait * @phpstan-import-type PendingSafetyCheckType from OutputComputerPendingSafetyCheck * - * @phpstan-type OutputComputerToolCallType array{action: ClickType|DoubleClickType|DragType|KeyPressType|MoveType|ScreenshotType|ScrollType|TypeType|WaitType, call_id: string, id: string, pending_safety_checks: array, status: 'in_progress'|'completed'|'incomplete', type: 'computer_call'} + * @phpstan-type ActionType ClickType|DoubleClickType|DragType|KeyPressType|MoveType|ScreenshotType|ScrollType|TypeType|WaitType + * @phpstan-type OutputComputerToolCallType array{action?: ActionType, actions?: array, call_id: string, id: string, pending_safety_checks?: array, status: 'in_progress'|'completed'|'incomplete', type: 'computer_call'} * * @implements ResponseContract */ @@ -44,12 +45,13 @@ final class OutputComputerToolCall implements ResponseContract use Fakeable; /** + * @param array $actions * @param array $pendingSafetyChecks * @param 'in_progress'|'completed'|'incomplete' $status * @param 'computer_call' $type */ private function __construct( - public readonly Click|DoubleClick|Drag|KeyPress|Move|Screenshot|Scroll|Type|Wait $action, + public readonly array $actions, public readonly string $callId, public readonly string $id, public readonly array $pendingSafetyChecks, @@ -62,25 +64,26 @@ private function __construct( */ public static function from(array $attributes): self { - $action = match ($attributes['action']['type']) { - 'click' => Click::from($attributes['action']), - 'double_click' => DoubleClick::from($attributes['action']), - 'drag' => Drag::from($attributes['action']), - 'keypress' => KeyPress::from($attributes['action']), - 'move' => Move::from($attributes['action']), - 'screenshot' => Screenshot::from($attributes['action']), - 'scroll' => Scroll::from($attributes['action']), - 'type' => Type::from($attributes['action']), - 'wait' => Wait::from($attributes['action']), - }; + /** @var array $actionAttributes */ + $actionAttributes = []; + if (isset($attributes['actions'])) { + $actionAttributes = $attributes['actions']; + } elseif (isset($attributes['action'])) { + $actionAttributes = [$attributes['action']]; + } + + $actions = array_map( + static fn (array $action): Click|DoubleClick|Drag|KeyPress|Move|Screenshot|Scroll|Type|Wait => self::mapAction($action), + $actionAttributes + ); $pendingSafetyChecks = array_map( fn (array $safetyCheck): OutputComputerPendingSafetyCheck => OutputComputerPendingSafetyCheck::from($safetyCheck), - $attributes['pending_safety_checks'] + $attributes['pending_safety_checks'] ?? [] ); return new self( - action: $action, + actions: $actions, callId: $attributes['call_id'], id: $attributes['id'], pendingSafetyChecks: $pendingSafetyChecks, @@ -98,7 +101,10 @@ public function toArray(): array 'type' => $this->type, 'call_id' => $this->callId, 'id' => $this->id, - 'action' => $this->action->toArray(), + 'actions' => array_map( + fn (Click|DoubleClick|Drag|KeyPress|Move|Screenshot|Scroll|Type|Wait $action): array => $action->toArray(), + $this->actions, + ), 'pending_safety_checks' => array_map( fn (OutputComputerPendingSafetyCheck $safetyCheck): array => $safetyCheck->toArray(), $this->pendingSafetyChecks, @@ -106,4 +112,42 @@ public function toArray(): array 'status' => $this->status, ]; } + + /** + * @param array $action + */ + private static function mapAction(array $action): Click|DoubleClick|Drag|KeyPress|Move|Screenshot|Scroll|Type|Wait + { + switch ($action['type'] ?? null) { + case 'click': + /** @var ClickType $action */ + return Click::from($action); + case 'double_click': + /** @var DoubleClickType $action */ + return DoubleClick::from($action); + case 'drag': + /** @var DragType $action */ + return Drag::from($action); + case 'keypress': + /** @var KeyPressType $action */ + return KeyPress::from($action); + case 'move': + /** @var MoveType $action */ + return Move::from($action); + case 'screenshot': + /** @var ScreenshotType $action */ + return Screenshot::from($action); + case 'scroll': + /** @var ScrollType $action */ + return Scroll::from($action); + case 'type': + /** @var TypeType $action */ + return Type::from($action); + case 'wait': + /** @var WaitType $action */ + return Wait::from($action); + default: + throw new \InvalidArgumentException('Invalid or missing action type in computer action payload.'); + } + } } diff --git a/tests/Fixtures/Responses.php b/tests/Fixtures/Responses.php index cdafd18c..4f339087 100644 --- a/tests/Fixtures/Responses.php +++ b/tests/Fixtures/Responses.php @@ -477,11 +477,13 @@ function outputComputerToolCall(): array 'type' => 'computer_call', 'call_id' => 'call_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', 'id' => 'cu_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c', - 'action' => [ - 'button' => 'left', - 'type' => 'click', - 'x' => 117, - 'y' => 123, + 'actions' => [ + [ + 'button' => 'left', + 'type' => 'click', + 'x' => 117, + 'y' => 123, + ], ], 'pending_safety_checks' => [ [ diff --git a/tests/Responses/Responses/Output/OutputComputerToolCall.php b/tests/Responses/Responses/Output/OutputComputerToolCall.php index d4b6ba94..c36cb6ea 100644 --- a/tests/Responses/Responses/Output/OutputComputerToolCall.php +++ b/tests/Responses/Responses/Output/OutputComputerToolCall.php @@ -1,6 +1,8 @@ toBeInstanceOf(OutputComputerToolCall::class) - ->action->toBeInstanceOf(OutputComputerActionClick::class) + ->actions->toBeArray()->toHaveCount(1); + + expect($response->actions[0]) + ->toBeInstanceOf(OutputComputerActionClick::class); + + expect($response) ->callId->toBe('call_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c') ->id->toBe('cu_67ccf18f64008190a39b619f4c8455ef087bb177ab789d5c') ->status->toBe('completed') @@ -28,3 +35,89 @@ ->toBeArray() ->toBe(outputComputerToolCall()); }); + +test('from with actions and without pending safety checks', function () { + $payload = outputComputerToolCall(); + unset($payload['pending_safety_checks']); + $payload['actions'] = [ + ['type' => 'screenshot'], + ]; + + $response = OutputComputerToolCall::from($payload); + + expect($response) + ->toBeInstanceOf(OutputComputerToolCall::class) + ->actions->toBeArray()->toHaveCount(1) + ->pendingSafetyChecks->toBeArray()->toHaveCount(0) + ->status->toBe('completed') + ->type->toBe('computer_call'); + + expect($response->actions[0])->toBeInstanceOf(OutputComputerActionScreenshot::class); + + expect($response->toArray()) + ->toBeArray() + ->toMatchArray([ + 'actions' => [['type' => 'screenshot']], + 'pending_safety_checks' => [], + ]); +}); + +test('from with multiple actions maps all actions', function () { + $payload = outputComputerToolCall(); + $payload['actions'] = [ + [ + 'button' => 'left', + 'type' => 'click', + 'x' => 117, + 'y' => 123, + ], + ['type' => 'wait'], + ]; + + $response = OutputComputerToolCall::from($payload); + + expect($response->actions) + ->toHaveCount(2); + + expect($response->actions[0])->toBeInstanceOf(OutputComputerActionClick::class); + expect($response->actions[1])->toBeInstanceOf(OutputComputerActionWait::class); +}); + +test('from with empty actions returns empty actions array', function () { + $payload = outputComputerToolCall(); + $payload['actions'] = []; + + $response = OutputComputerToolCall::from($payload); + + expect($response->actions)->toBeArray()->toHaveCount(0); + expect($response->toArray()['actions'])->toBeArray()->toHaveCount(0); +}); + +test('from without action and actions keys returns empty actions array', function () { + $payload = outputComputerToolCall(); + unset($payload['action'], $payload['actions']); + + $response = OutputComputerToolCall::from($payload); + + expect($response->actions)->toBeArray()->toHaveCount(0); + expect($response->toArray()['actions'])->toBeArray()->toHaveCount(0); +}); + +test('from with malformed actions payload throws exception', function () { + $payload = outputComputerToolCall(); + $payload['actions'] = [ + ['x' => 1], + ]; + + expect(fn (): OutputComputerToolCall => OutputComputerToolCall::from($payload)) + ->toThrow(InvalidArgumentException::class, 'Invalid or missing action type in computer action payload.'); +}); + +test('from with malformed legacy action payload throws exception', function () { + $payload = outputComputerToolCall(); + unset($payload['actions']); + $payload['action'] = ['x' => 1]; + + expect(fn (): OutputComputerToolCall => OutputComputerToolCall::from($payload)) + ->toThrow(InvalidArgumentException::class, 'Invalid or missing action type in computer action payload.'); +});