diff --git a/copilot-sdk b/copilot-sdk index c4b3b36..388f2f3 160000 --- a/copilot-sdk +++ b/copilot-sdk @@ -1 +1 @@ -Subproject commit c4b3b366c4bd8dfba9ba4aa05e4019825360ad78 +Subproject commit 388f2f36e0db1f35ac9d7bbe8e794d8fd26bc3ef diff --git a/src/Client.php b/src/Client.php index 737fc61..5424259 100644 --- a/src/Client.php +++ b/src/Client.php @@ -20,6 +20,7 @@ use Revolution\Copilot\Exceptions\JsonRpcException; use Revolution\Copilot\JsonRpc\JsonRpcClient; use Revolution\Copilot\Process\ProcessManager; +use Revolution\Copilot\Rpc\ServerRpc; use Revolution\Copilot\Transport\TcpTransport; use Revolution\Copilot\Types\ForegroundSessionInfo; use Revolution\Copilot\Types\GetAuthStatusResponse; @@ -213,6 +214,16 @@ public function stop(): array return $errors; } + /** + * Typed server-scoped RPC methods. + */ + public function rpc(): ServerRpc + { + $this->ensureConnected(); + + return new ServerRpc($this->rpcClient); + } + /** * Check if the client is using TCP mode. */ diff --git a/src/Contracts/CopilotClient.php b/src/Contracts/CopilotClient.php index 532726c..e498ebd 100644 --- a/src/Contracts/CopilotClient.php +++ b/src/Contracts/CopilotClient.php @@ -5,6 +5,7 @@ namespace Revolution\Copilot\Contracts; use Revolution\Copilot\Exceptions\JsonRpcException; +use Revolution\Copilot\Rpc\ServerRpc; use Revolution\Copilot\Types\GetAuthStatusResponse; use Revolution\Copilot\Types\GetStatusResponse; use Revolution\Copilot\Types\ModelInfo; @@ -25,6 +26,11 @@ interface CopilotClient */ public function start(): static; + /** + * Typed server-scoped RPC methods. + */ + public function rpc(): ServerRpc; + /** * Create a new conversation session. * diff --git a/src/Contracts/CopilotSession.php b/src/Contracts/CopilotSession.php index 4f2f486..105c395 100644 --- a/src/Contracts/CopilotSession.php +++ b/src/Contracts/CopilotSession.php @@ -6,6 +6,7 @@ use Closure; use Revolution\Copilot\Enums\SessionEventType; +use Revolution\Copilot\Rpc\SessionRpc; use Revolution\Copilot\Types\SessionEvent; /** @@ -18,6 +19,11 @@ interface CopilotSession */ public function id(): string; + /** + * Typed session-scoped RPC methods. + */ + public function rpc(): SessionRpc; + /** * Send a message to this session. */ diff --git a/src/Enums/SessionEventType.php b/src/Enums/SessionEventType.php index e9af3c1..3a458ef 100644 --- a/src/Enums/SessionEventType.php +++ b/src/Enums/SessionEventType.php @@ -14,8 +14,13 @@ enum SessionEventType: string case SESSION_RESUME = 'session.resume'; case SESSION_ERROR = 'session.error'; case SESSION_IDLE = 'session.idle'; + case SESSION_TITLE_CHANGED = 'session.title_changed'; case SESSION_INFO = 'session.info'; + case SESSION_WARNING = 'session.warning'; case SESSION_MODEL_CHANGE = 'session.model_change'; + case SESSION_MODE_CHANGED = 'session.mode_changed'; + case SESSION_PLAN_CHANGED = 'session.plan_changed'; + case SESSION_WORKSPACE_FILE_CHANGED = 'session.workspace_file_changed'; case SESSION_HANDOFF = 'session.handoff'; case SESSION_TRUNCATION = 'session.truncation'; case SESSION_SNAPSHOT_REWIND = 'session.snapshot_rewind'; @@ -24,6 +29,7 @@ enum SessionEventType: string case SESSION_COMPACTION_START = 'session.compaction_start'; case SESSION_COMPACTION_COMPLETE = 'session.compaction_complete'; case SESSION_CONTEXT_CHANGED = 'session.context_changed'; + case SESSION_TASK_COMPLETE = 'session.task_complete'; // User messages case USER_MESSAGE = 'user.message'; @@ -34,6 +40,7 @@ enum SessionEventType: string case ASSISTANT_INTENT = 'assistant.intent'; case ASSISTANT_REASONING = 'assistant.reasoning'; case ASSISTANT_REASONING_DELTA = 'assistant.reasoning_delta'; + case ASSISTANT_STREAMING_DELTA = 'assistant.streaming_delta'; case ASSISTANT_MESSAGE = 'assistant.message'; case ASSISTANT_MESSAGE_DELTA = 'assistant.message_delta'; case ASSISTANT_TURN_END = 'assistant.turn_end'; diff --git a/src/Rpc/PendingAccount.php b/src/Rpc/PendingAccount.php new file mode 100644 index 0000000..22d7650 --- /dev/null +++ b/src/Rpc/PendingAccount.php @@ -0,0 +1,28 @@ +client->request('account.getQuota', []), + ); + } +} diff --git a/src/Rpc/PendingAgent.php b/src/Rpc/PendingAgent.php new file mode 100644 index 0000000..f5e191c --- /dev/null +++ b/src/Rpc/PendingAgent.php @@ -0,0 +1,69 @@ +client->request('session.agent.list', [ + 'sessionId' => $this->sessionId, + ]), + ); + } + + /** + * Get the current agent. + */ + public function getCurrent(): SessionAgentGetCurrentResult + { + return SessionAgentGetCurrentResult::fromArray( + $this->client->request('session.agent.getCurrent', [ + 'sessionId' => $this->sessionId, + ]), + ); + } + + /** + * Select an agent. + */ + public function select(SessionAgentSelectParams|array $params): SessionAgentSelectResult + { + $paramsArray = $params instanceof SessionAgentSelectParams ? $params->toArray() : $params; + $paramsArray['sessionId'] = $this->sessionId; + + return SessionAgentSelectResult::fromArray( + $this->client->request('session.agent.select', $paramsArray), + ); + } + + /** + * Deselect the current agent. + */ + public function deselect(): array + { + return $this->client->request('session.agent.deselect', [ + 'sessionId' => $this->sessionId, + ]); + } +} diff --git a/src/Rpc/PendingCompaction.php b/src/Rpc/PendingCompaction.php new file mode 100644 index 0000000..e6c6076 --- /dev/null +++ b/src/Rpc/PendingCompaction.php @@ -0,0 +1,31 @@ +client->request('session.compaction.compact', [ + 'sessionId' => $this->sessionId, + ]), + ); + } +} diff --git a/src/Rpc/PendingFleet.php b/src/Rpc/PendingFleet.php new file mode 100644 index 0000000..3e24e7c --- /dev/null +++ b/src/Rpc/PendingFleet.php @@ -0,0 +1,33 @@ +toArray() : $params; + $paramsArray['sessionId'] = $this->sessionId; + + return SessionFleetStartResult::fromArray( + $this->client->request('session.fleet.start', $paramsArray), + ); + } +} diff --git a/src/Rpc/PendingMode.php b/src/Rpc/PendingMode.php new file mode 100644 index 0000000..c0e8943 --- /dev/null +++ b/src/Rpc/PendingMode.php @@ -0,0 +1,46 @@ +client->request('session.mode.get', [ + 'sessionId' => $this->sessionId, + ]), + ); + } + + /** + * Set the mode. + */ + public function set(SessionModeSetParams|array $params): SessionModeSetResult + { + $paramsArray = $params instanceof SessionModeSetParams ? $params->toArray() : $params; + $paramsArray['sessionId'] = $this->sessionId; + + return SessionModeSetResult::fromArray( + $this->client->request('session.mode.set', $paramsArray), + ); + } +} diff --git a/src/Rpc/PendingModel.php b/src/Rpc/PendingModel.php new file mode 100644 index 0000000..e58e659 --- /dev/null +++ b/src/Rpc/PendingModel.php @@ -0,0 +1,46 @@ +client->request('session.model.getCurrent', [ + 'sessionId' => $this->sessionId, + ]), + ); + } + + /** + * Switch to a different model. + */ + public function switchTo(SessionModelSwitchToParams|array $params): SessionModelSwitchToResult + { + $paramsArray = $params instanceof SessionModelSwitchToParams ? $params->toArray() : $params; + $paramsArray['sessionId'] = $this->sessionId; + + return SessionModelSwitchToResult::fromArray( + $this->client->request('session.model.switchTo', $paramsArray), + ); + } +} diff --git a/src/Rpc/PendingModels.php b/src/Rpc/PendingModels.php new file mode 100644 index 0000000..da9e0c5 --- /dev/null +++ b/src/Rpc/PendingModels.php @@ -0,0 +1,28 @@ +client->request('models.list', []), + ); + } +} diff --git a/src/Rpc/PendingPlan.php b/src/Rpc/PendingPlan.php new file mode 100644 index 0000000..b421181 --- /dev/null +++ b/src/Rpc/PendingPlan.php @@ -0,0 +1,53 @@ +client->request('session.plan.read', [ + 'sessionId' => $this->sessionId, + ]), + ); + } + + /** + * Update the plan. + */ + public function update(SessionPlanUpdateParams|array $params): array + { + $paramsArray = $params instanceof SessionPlanUpdateParams ? $params->toArray() : $params; + $paramsArray['sessionId'] = $this->sessionId; + + return $this->client->request('session.plan.update', $paramsArray); + } + + /** + * Delete the plan. + */ + public function delete(): array + { + return $this->client->request('session.plan.delete', [ + 'sessionId' => $this->sessionId, + ]); + } +} diff --git a/src/Rpc/PendingTools.php b/src/Rpc/PendingTools.php new file mode 100644 index 0000000..a01ded5 --- /dev/null +++ b/src/Rpc/PendingTools.php @@ -0,0 +1,31 @@ +toArray() : $params; + + return ToolsListResult::fromArray( + $this->client->request('tools.list', $paramsArray), + ); + } +} diff --git a/src/Rpc/PendingWorkspace.php b/src/Rpc/PendingWorkspace.php new file mode 100644 index 0000000..839a4c9 --- /dev/null +++ b/src/Rpc/PendingWorkspace.php @@ -0,0 +1,58 @@ +client->request('session.workspace.listFiles', [ + 'sessionId' => $this->sessionId, + ]), + ); + } + + /** + * Read a file from the workspace. + */ + public function readFile(SessionWorkspaceReadFileParams|array $params): SessionWorkspaceReadFileResult + { + $paramsArray = $params instanceof SessionWorkspaceReadFileParams ? $params->toArray() : $params; + $paramsArray['sessionId'] = $this->sessionId; + + return SessionWorkspaceReadFileResult::fromArray( + $this->client->request('session.workspace.readFile', $paramsArray), + ); + } + + /** + * Create a file in the workspace. + */ + public function createFile(SessionWorkspaceCreateFileParams|array $params): array + { + $paramsArray = $params instanceof SessionWorkspaceCreateFileParams ? $params->toArray() : $params; + $paramsArray['sessionId'] = $this->sessionId; + + return $this->client->request('session.workspace.createFile', $paramsArray); + } +} diff --git a/src/Rpc/ServerRpc.php b/src/Rpc/ServerRpc.php new file mode 100644 index 0000000..4f793da --- /dev/null +++ b/src/Rpc/ServerRpc.php @@ -0,0 +1,63 @@ +rpc()->ping(new PingParams(message: 'hello')); + * $client->rpc()->models()->list(); + * $client->rpc()->tools()->list(); + * $client->rpc()->account()->getQuota(); + * ``` + */ +class ServerRpc +{ + public function __construct( + protected JsonRpcClient $client, + ) {} + + /** + * Send a ping request. + */ + public function ping(PingParams|array $params = []): PingResult + { + $paramsArray = $params instanceof PingParams ? $params->toArray() : $params; + + return PingResult::fromArray( + $this->client->request('ping', $paramsArray), + ); + } + + /** + * Models RPC operations. + */ + public function models(): PendingModels + { + return new PendingModels($this->client); + } + + /** + * Tools RPC operations. + */ + public function tools(): PendingTools + { + return new PendingTools($this->client); + } + + /** + * Account RPC operations. + */ + public function account(): PendingAccount + { + return new PendingAccount($this->client); + } +} diff --git a/src/Rpc/SessionRpc.php b/src/Rpc/SessionRpc.php new file mode 100644 index 0000000..cf0ce73 --- /dev/null +++ b/src/Rpc/SessionRpc.php @@ -0,0 +1,87 @@ +rpc()->model()->getCurrent(); + * $session->rpc()->model()->switchTo(new SessionModelSwitchToParams(modelId: 'gpt-4')); + * $session->rpc()->mode()->get(); + * $session->rpc()->mode()->set(new SessionModeSetParams(mode: 'plan')); + * $session->rpc()->plan()->read(); + * $session->rpc()->workspace()->listFiles(); + * $session->rpc()->fleet()->start(); + * $session->rpc()->agent()->list(); + * $session->rpc()->compaction()->compact(); + * ``` + */ +class SessionRpc +{ + public function __construct( + protected JsonRpcClient $client, + protected string $sessionId, + ) {} + + /** + * Model RPC operations. + */ + public function model(): PendingModel + { + return new PendingModel($this->client, $this->sessionId); + } + + /** + * Mode RPC operations. + */ + public function mode(): PendingMode + { + return new PendingMode($this->client, $this->sessionId); + } + + /** + * Plan RPC operations. + */ + public function plan(): PendingPlan + { + return new PendingPlan($this->client, $this->sessionId); + } + + /** + * Workspace RPC operations. + */ + public function workspace(): PendingWorkspace + { + return new PendingWorkspace($this->client, $this->sessionId); + } + + /** + * Fleet RPC operations. + */ + public function fleet(): PendingFleet + { + return new PendingFleet($this->client, $this->sessionId); + } + + /** + * Agent RPC operations. + */ + public function agent(): PendingAgent + { + return new PendingAgent($this->client, $this->sessionId); + } + + /** + * Compaction RPC operations. + */ + public function compaction(): PendingCompaction + { + return new PendingCompaction($this->client, $this->sessionId); + } +} diff --git a/src/Session.php b/src/Session.php index 0f6c0b4..d83b847 100644 --- a/src/Session.php +++ b/src/Session.php @@ -18,6 +18,7 @@ use Revolution\Copilot\Exceptions\SessionErrorException; use Revolution\Copilot\Exceptions\SessionTimeoutException; use Revolution\Copilot\JsonRpc\JsonRpcClient; +use Revolution\Copilot\Rpc\SessionRpc; use Revolution\Copilot\Support\PermissionRequestKind; use Revolution\Copilot\Types\SessionEvent; use Revolution\Copilot\Types\SessionHooks; @@ -119,6 +120,14 @@ public function workspacePath(): ?string return $this->workspacePath; } + /** + * Typed session-scoped RPC methods. + */ + public function rpc(): SessionRpc + { + return new SessionRpc($this->client, $this->sessionId); + } + /** * Send a message to this session. * diff --git a/src/Testing/FakeSession.php b/src/Testing/FakeSession.php index e098f69..8ca4089 100644 --- a/src/Testing/FakeSession.php +++ b/src/Testing/FakeSession.php @@ -7,6 +7,7 @@ use Closure; use Revolution\Copilot\Contracts\CopilotSession; use Revolution\Copilot\Enums\SessionEventType; +use Revolution\Copilot\Rpc\SessionRpc; use Revolution\Copilot\Types\SessionEvent; /** @@ -31,6 +32,21 @@ public function id(): string return $this->sessionId; } + public function rpc(): SessionRpc + { + // Return a SessionRpc with a mock client; methods will throw if actually called in tests + // Since SessionRpc requires a real JsonRpcClient, we create a minimal instance + return new SessionRpc( + new \Revolution\Copilot\JsonRpc\JsonRpcClient( + new \Revolution\Copilot\Transport\StdioTransport( + fopen('php://memory', 'r'), + fopen('php://memory', 'w'), + ), + ), + $this->sessionId, + ); + } + public function send(string $prompt, ?array $attachments = null, ?string $mode = null): string { $this->recorded[] = [ diff --git a/src/Types/Rpc/AccountGetQuotaResult.php b/src/Types/Rpc/AccountGetQuotaResult.php new file mode 100644 index 0000000..360d405 --- /dev/null +++ b/src/Types/Rpc/AccountGetQuotaResult.php @@ -0,0 +1,40 @@ + $quotaSnapshots Quota snapshots keyed by type + */ + public function __construct( + public array $quotaSnapshots, + ) {} + + public static function fromArray(array $data): self + { + return new self( + quotaSnapshots: array_map( + fn (array $snapshot) => QuotaSnapshot::fromArray($snapshot), + $data['quotaSnapshots'] ?? [], + ), + ); + } + + public function toArray(): array + { + return [ + 'quotaSnapshots' => array_map( + fn (QuotaSnapshot $snapshot) => $snapshot->toArray(), + $this->quotaSnapshots, + ), + ]; + } +} diff --git a/src/Types/Rpc/AgentInfo.php b/src/Types/Rpc/AgentInfo.php new file mode 100644 index 0000000..4573fdf --- /dev/null +++ b/src/Types/Rpc/AgentInfo.php @@ -0,0 +1,40 @@ + $this->name, + 'displayName' => $this->displayName, + 'description' => $this->description, + ]; + } +} diff --git a/src/Types/Rpc/ModelsListResult.php b/src/Types/Rpc/ModelsListResult.php new file mode 100644 index 0000000..b4abffe --- /dev/null +++ b/src/Types/Rpc/ModelsListResult.php @@ -0,0 +1,38 @@ + $models List of available models with full metadata + */ + public function __construct( + public array $models, + ) {} + + public static function fromArray(array $data): self + { + return new self( + models: array_map( + fn (array $model) => ModelInfo::fromArray($model), + $data['models'] ?? [], + ), + ); + } + + public function toArray(): array + { + return [ + 'models' => array_map(fn (ModelInfo $model) => $model->toArray(), $this->models), + ]; + } +} diff --git a/src/Types/Rpc/PingParams.php b/src/Types/Rpc/PingParams.php new file mode 100644 index 0000000..e793d25 --- /dev/null +++ b/src/Types/Rpc/PingParams.php @@ -0,0 +1,32 @@ + $this->message, + ], fn ($v) => $v !== null); + } +} diff --git a/src/Types/Rpc/PingResult.php b/src/Types/Rpc/PingResult.php new file mode 100644 index 0000000..9be8d40 --- /dev/null +++ b/src/Types/Rpc/PingResult.php @@ -0,0 +1,40 @@ + $this->message, + 'timestamp' => $this->timestamp, + 'protocolVersion' => $this->protocolVersion, + ]; + } +} diff --git a/src/Types/Rpc/QuotaSnapshot.php b/src/Types/Rpc/QuotaSnapshot.php new file mode 100644 index 0000000..0da4b90 --- /dev/null +++ b/src/Types/Rpc/QuotaSnapshot.php @@ -0,0 +1,52 @@ + $this->entitlementRequests, + 'usedRequests' => $this->usedRequests, + 'remainingPercentage' => $this->remainingPercentage, + 'overage' => $this->overage, + 'overageAllowedWithExhaustedQuota' => $this->overageAllowedWithExhaustedQuota, + 'resetDate' => $this->resetDate, + ], fn ($v) => $v !== null); + } +} diff --git a/src/Types/Rpc/SessionAgentGetCurrentResult.php b/src/Types/Rpc/SessionAgentGetCurrentResult.php new file mode 100644 index 0000000..bd09749 --- /dev/null +++ b/src/Types/Rpc/SessionAgentGetCurrentResult.php @@ -0,0 +1,32 @@ + $this->agent?->toArray(), + ]; + } +} diff --git a/src/Types/Rpc/SessionAgentListResult.php b/src/Types/Rpc/SessionAgentListResult.php new file mode 100644 index 0000000..b09f6e8 --- /dev/null +++ b/src/Types/Rpc/SessionAgentListResult.php @@ -0,0 +1,37 @@ + $agents Available custom agents + */ + public function __construct( + public array $agents, + ) {} + + public static function fromArray(array $data): self + { + return new self( + agents: array_map( + fn (array $agent) => AgentInfo::fromArray($agent), + $data['agents'] ?? [], + ), + ); + } + + public function toArray(): array + { + return [ + 'agents' => array_map(fn (AgentInfo $agent) => $agent->toArray(), $this->agents), + ]; + } +} diff --git a/src/Types/Rpc/SessionAgentSelectParams.php b/src/Types/Rpc/SessionAgentSelectParams.php new file mode 100644 index 0000000..a48bcc1 --- /dev/null +++ b/src/Types/Rpc/SessionAgentSelectParams.php @@ -0,0 +1,32 @@ + $this->name, + ]; + } +} diff --git a/src/Types/Rpc/SessionAgentSelectResult.php b/src/Types/Rpc/SessionAgentSelectResult.php new file mode 100644 index 0000000..3b50863 --- /dev/null +++ b/src/Types/Rpc/SessionAgentSelectResult.php @@ -0,0 +1,32 @@ + $this->agent->toArray(), + ]; + } +} diff --git a/src/Types/Rpc/SessionCompactionCompactResult.php b/src/Types/Rpc/SessionCompactionCompactResult.php new file mode 100644 index 0000000..f7d12f7 --- /dev/null +++ b/src/Types/Rpc/SessionCompactionCompactResult.php @@ -0,0 +1,40 @@ + $this->success, + 'tokensRemoved' => $this->tokensRemoved, + 'messagesRemoved' => $this->messagesRemoved, + ]; + } +} diff --git a/src/Types/Rpc/SessionFleetStartParams.php b/src/Types/Rpc/SessionFleetStartParams.php new file mode 100644 index 0000000..7fe20af --- /dev/null +++ b/src/Types/Rpc/SessionFleetStartParams.php @@ -0,0 +1,32 @@ + $this->prompt, + ], fn ($v) => $v !== null); + } +} diff --git a/src/Types/Rpc/SessionFleetStartResult.php b/src/Types/Rpc/SessionFleetStartResult.php new file mode 100644 index 0000000..af30b9f --- /dev/null +++ b/src/Types/Rpc/SessionFleetStartResult.php @@ -0,0 +1,32 @@ + $this->started, + ]; + } +} diff --git a/src/Types/Rpc/SessionModeGetResult.php b/src/Types/Rpc/SessionModeGetResult.php new file mode 100644 index 0000000..1b35d31 --- /dev/null +++ b/src/Types/Rpc/SessionModeGetResult.php @@ -0,0 +1,32 @@ + $this->mode, + ]; + } +} diff --git a/src/Types/Rpc/SessionModeSetParams.php b/src/Types/Rpc/SessionModeSetParams.php new file mode 100644 index 0000000..52fdef9 --- /dev/null +++ b/src/Types/Rpc/SessionModeSetParams.php @@ -0,0 +1,32 @@ + $this->mode, + ]; + } +} diff --git a/src/Types/Rpc/SessionModeSetResult.php b/src/Types/Rpc/SessionModeSetResult.php new file mode 100644 index 0000000..fbe2c31 --- /dev/null +++ b/src/Types/Rpc/SessionModeSetResult.php @@ -0,0 +1,32 @@ + $this->mode, + ]; + } +} diff --git a/src/Types/Rpc/SessionModelGetCurrentResult.php b/src/Types/Rpc/SessionModelGetCurrentResult.php new file mode 100644 index 0000000..4e2d12a --- /dev/null +++ b/src/Types/Rpc/SessionModelGetCurrentResult.php @@ -0,0 +1,31 @@ + $this->modelId, + ], fn ($v) => $v !== null); + } +} diff --git a/src/Types/Rpc/SessionModelSwitchToParams.php b/src/Types/Rpc/SessionModelSwitchToParams.php new file mode 100644 index 0000000..44d6606 --- /dev/null +++ b/src/Types/Rpc/SessionModelSwitchToParams.php @@ -0,0 +1,31 @@ + $this->modelId, + ]; + } +} diff --git a/src/Types/Rpc/SessionModelSwitchToResult.php b/src/Types/Rpc/SessionModelSwitchToResult.php new file mode 100644 index 0000000..8af1877 --- /dev/null +++ b/src/Types/Rpc/SessionModelSwitchToResult.php @@ -0,0 +1,31 @@ + $this->modelId, + ], fn ($v) => $v !== null); + } +} diff --git a/src/Types/Rpc/SessionPlanReadResult.php b/src/Types/Rpc/SessionPlanReadResult.php new file mode 100644 index 0000000..81a336e --- /dev/null +++ b/src/Types/Rpc/SessionPlanReadResult.php @@ -0,0 +1,36 @@ + $this->exists, + 'content' => $this->content, + ]; + } +} diff --git a/src/Types/Rpc/SessionPlanUpdateParams.php b/src/Types/Rpc/SessionPlanUpdateParams.php new file mode 100644 index 0000000..20167ca --- /dev/null +++ b/src/Types/Rpc/SessionPlanUpdateParams.php @@ -0,0 +1,32 @@ + $this->content, + ]; + } +} diff --git a/src/Types/Rpc/SessionWorkspaceCreateFileParams.php b/src/Types/Rpc/SessionWorkspaceCreateFileParams.php new file mode 100644 index 0000000..f5a0df5 --- /dev/null +++ b/src/Types/Rpc/SessionWorkspaceCreateFileParams.php @@ -0,0 +1,36 @@ + $this->path, + 'content' => $this->content, + ]; + } +} diff --git a/src/Types/Rpc/SessionWorkspaceListFilesResult.php b/src/Types/Rpc/SessionWorkspaceListFilesResult.php new file mode 100644 index 0000000..6440ba7 --- /dev/null +++ b/src/Types/Rpc/SessionWorkspaceListFilesResult.php @@ -0,0 +1,34 @@ + $files Relative file paths in the workspace files directory + */ + public function __construct( + public array $files, + ) {} + + public static function fromArray(array $data): self + { + return new self( + files: $data['files'] ?? [], + ); + } + + public function toArray(): array + { + return [ + 'files' => $this->files, + ]; + } +} diff --git a/src/Types/Rpc/SessionWorkspaceReadFileParams.php b/src/Types/Rpc/SessionWorkspaceReadFileParams.php new file mode 100644 index 0000000..0fcdb15 --- /dev/null +++ b/src/Types/Rpc/SessionWorkspaceReadFileParams.php @@ -0,0 +1,32 @@ + $this->path, + ]; + } +} diff --git a/src/Types/Rpc/SessionWorkspaceReadFileResult.php b/src/Types/Rpc/SessionWorkspaceReadFileResult.php new file mode 100644 index 0000000..3c7ea62 --- /dev/null +++ b/src/Types/Rpc/SessionWorkspaceReadFileResult.php @@ -0,0 +1,32 @@ + $this->content, + ]; + } +} diff --git a/src/Types/Rpc/ToolsListParams.php b/src/Types/Rpc/ToolsListParams.php new file mode 100644 index 0000000..9f1a5a4 --- /dev/null +++ b/src/Types/Rpc/ToolsListParams.php @@ -0,0 +1,32 @@ + $this->model, + ], fn ($v) => $v !== null); + } +} diff --git a/src/Types/Rpc/ToolsListResult.php b/src/Types/Rpc/ToolsListResult.php new file mode 100644 index 0000000..2afa8fb --- /dev/null +++ b/src/Types/Rpc/ToolsListResult.php @@ -0,0 +1,35 @@ + $tools + */ + public function __construct( + /** List of available built-in tools with metadata */ + public array $tools, + ) {} + + public static function fromArray(array $data): self + { + return new self( + tools: $data['tools'] ?? [], + ); + } + + public function toArray(): array + { + return [ + 'tools' => $this->tools, + ]; + } +} diff --git a/tests/Unit/Rpc/ServerRpcTest.php b/tests/Unit/Rpc/ServerRpcTest.php new file mode 100644 index 0000000..58c4b93 --- /dev/null +++ b/tests/Unit/Rpc/ServerRpcTest.php @@ -0,0 +1,38 @@ +models())->toBeInstanceOf(PendingModels::class); + }); + + it('returns PendingTools from tools()', function () { + $rpc = new ServerRpc(createMockRpcClient()); + + expect($rpc->tools())->toBeInstanceOf(PendingTools::class); + }); + + it('returns PendingAccount from account()', function () { + $rpc = new ServerRpc(createMockRpcClient()); + + expect($rpc->account())->toBeInstanceOf(PendingAccount::class); + }); +}); + +function createMockRpcClient(): \Revolution\Copilot\JsonRpc\JsonRpcClient +{ + $transport = new \Revolution\Copilot\Transport\StdioTransport( + fopen('php://memory', 'r'), + fopen('php://memory', 'w'), + ); + + return new \Revolution\Copilot\JsonRpc\JsonRpcClient($transport); +} diff --git a/tests/Unit/Rpc/SessionRpcTest.php b/tests/Unit/Rpc/SessionRpcTest.php new file mode 100644 index 0000000..a95daea --- /dev/null +++ b/tests/Unit/Rpc/SessionRpcTest.php @@ -0,0 +1,66 @@ +model())->toBeInstanceOf(PendingModel::class); + }); + + it('returns PendingMode from mode()', function () { + $rpc = new SessionRpc(createMockSessionRpcClient(), 'test-session'); + + expect($rpc->mode())->toBeInstanceOf(PendingMode::class); + }); + + it('returns PendingPlan from plan()', function () { + $rpc = new SessionRpc(createMockSessionRpcClient(), 'test-session'); + + expect($rpc->plan())->toBeInstanceOf(PendingPlan::class); + }); + + it('returns PendingWorkspace from workspace()', function () { + $rpc = new SessionRpc(createMockSessionRpcClient(), 'test-session'); + + expect($rpc->workspace())->toBeInstanceOf(PendingWorkspace::class); + }); + + it('returns PendingFleet from fleet()', function () { + $rpc = new SessionRpc(createMockSessionRpcClient(), 'test-session'); + + expect($rpc->fleet())->toBeInstanceOf(PendingFleet::class); + }); + + it('returns PendingAgent from agent()', function () { + $rpc = new SessionRpc(createMockSessionRpcClient(), 'test-session'); + + expect($rpc->agent())->toBeInstanceOf(PendingAgent::class); + }); + + it('returns PendingCompaction from compaction()', function () { + $rpc = new SessionRpc(createMockSessionRpcClient(), 'test-session'); + + expect($rpc->compaction())->toBeInstanceOf(PendingCompaction::class); + }); +}); + +function createMockSessionRpcClient(): \Revolution\Copilot\JsonRpc\JsonRpcClient +{ + $transport = new \Revolution\Copilot\Transport\StdioTransport( + fopen('php://memory', 'r'), + fopen('php://memory', 'w'), + ); + + return new \Revolution\Copilot\JsonRpc\JsonRpcClient($transport); +} diff --git a/tests/Unit/Types/Rpc/AccountTest.php b/tests/Unit/Types/Rpc/AccountTest.php new file mode 100644 index 0000000..452feca --- /dev/null +++ b/tests/Unit/Types/Rpc/AccountTest.php @@ -0,0 +1,88 @@ + 100, + 'usedRequests' => 50, + 'remainingPercentage' => 50.0, + 'overage' => 0, + 'overageAllowedWithExhaustedQuota' => true, + 'resetDate' => '2026-03-01T00:00:00Z', + ]); + + expect($snapshot->entitlementRequests)->toBe(100) + ->and($snapshot->usedRequests)->toBe(50) + ->and($snapshot->remainingPercentage)->toBe(50.0) + ->and($snapshot->overage)->toBe(0) + ->and($snapshot->overageAllowedWithExhaustedQuota)->toBeTrue() + ->and($snapshot->resetDate)->toBe('2026-03-01T00:00:00Z'); + }); + + it('can be created without optional fields', function () { + $snapshot = QuotaSnapshot::fromArray([ + 'entitlementRequests' => 100, + 'usedRequests' => 50, + 'remainingPercentage' => 50.0, + 'overage' => 0, + 'overageAllowedWithExhaustedQuota' => false, + ]); + + expect($snapshot->resetDate)->toBeNull(); + }); + + it('filters null values in toArray', function () { + $snapshot = new QuotaSnapshot( + entitlementRequests: 100, + usedRequests: 50, + remainingPercentage: 50.0, + overage: 0, + overageAllowedWithExhaustedQuota: false, + ); + + expect($snapshot->toArray())->not->toHaveKey('resetDate'); + }); +}); + +describe('AccountGetQuotaResult', function () { + it('can be created from array', function () { + $result = AccountGetQuotaResult::fromArray([ + 'quotaSnapshots' => [ + 'chat' => [ + 'entitlementRequests' => 500, + 'usedRequests' => 100, + 'remainingPercentage' => 80.0, + 'overage' => 0, + 'overageAllowedWithExhaustedQuota' => true, + ], + ], + ]); + + expect($result->quotaSnapshots)->toHaveKey('chat') + ->and($result->quotaSnapshots['chat'])->toBeInstanceOf(QuotaSnapshot::class) + ->and($result->quotaSnapshots['chat']->entitlementRequests)->toBe(500); + }); + + it('can convert to array', function () { + $result = AccountGetQuotaResult::fromArray([ + 'quotaSnapshots' => [ + 'chat' => [ + 'entitlementRequests' => 500, + 'usedRequests' => 100, + 'remainingPercentage' => 80.0, + 'overage' => 0, + 'overageAllowedWithExhaustedQuota' => true, + ], + ], + ]); + + $array = $result->toArray(); + + expect($array['quotaSnapshots']['chat']['entitlementRequests'])->toBe(500); + }); +}); diff --git a/tests/Unit/Types/Rpc/AgentTest.php b/tests/Unit/Types/Rpc/AgentTest.php new file mode 100644 index 0000000..be3c9ec --- /dev/null +++ b/tests/Unit/Types/Rpc/AgentTest.php @@ -0,0 +1,107 @@ + 'test-agent', + 'displayName' => 'Test Agent', + 'description' => 'A test agent', + ]); + + expect($agent->name)->toBe('test-agent') + ->and($agent->displayName)->toBe('Test Agent') + ->and($agent->description)->toBe('A test agent'); + }); + + it('can convert to array', function () { + $agent = new AgentInfo( + name: 'test', + displayName: 'Test', + description: 'Testing', + ); + + expect($agent->toArray())->toBe([ + 'name' => 'test', + 'displayName' => 'Test', + 'description' => 'Testing', + ]); + }); + + it('implements Arrayable interface', function () { + $agent = new AgentInfo(name: 'a', displayName: 'b', description: 'c'); + expect($agent)->toBeInstanceOf(\Illuminate\Contracts\Support\Arrayable::class); + }); +}); + +describe('SessionAgentListResult', function () { + it('can be created from array', function () { + $result = SessionAgentListResult::fromArray([ + 'agents' => [ + [ + 'name' => 'agent1', + 'displayName' => 'Agent 1', + 'description' => 'First agent', + ], + ], + ]); + + expect($result->agents)->toHaveCount(1) + ->and($result->agents[0])->toBeInstanceOf(AgentInfo::class) + ->and($result->agents[0]->name)->toBe('agent1'); + }); + + it('handles empty agents list', function () { + $result = SessionAgentListResult::fromArray([]); + expect($result->agents)->toBe([]); + }); +}); + +describe('SessionAgentGetCurrentResult', function () { + it('can be created with agent', function () { + $result = SessionAgentGetCurrentResult::fromArray([ + 'agent' => [ + 'name' => 'test', + 'displayName' => 'Test', + 'description' => 'Testing', + ], + ]); + + expect($result->agent)->toBeInstanceOf(AgentInfo::class) + ->and($result->agent->name)->toBe('test'); + }); + + it('can be created with null agent', function () { + $result = SessionAgentGetCurrentResult::fromArray([]); + expect($result->agent)->toBeNull(); + }); +}); + +describe('SessionAgentSelectParams', function () { + it('can be created and converted', function () { + $params = new SessionAgentSelectParams(name: 'my-agent'); + expect($params->toArray())->toBe(['name' => 'my-agent']); + }); +}); + +describe('SessionAgentSelectResult', function () { + it('can be created from array', function () { + $result = SessionAgentSelectResult::fromArray([ + 'agent' => [ + 'name' => 'selected', + 'displayName' => 'Selected Agent', + 'description' => 'The selected agent', + ], + ]); + + expect($result->agent)->toBeInstanceOf(AgentInfo::class) + ->and($result->agent->name)->toBe('selected'); + }); +}); diff --git a/tests/Unit/Types/Rpc/ModelsAndToolsTest.php b/tests/Unit/Types/Rpc/ModelsAndToolsTest.php new file mode 100644 index 0000000..ae8a0e7 --- /dev/null +++ b/tests/Unit/Types/Rpc/ModelsAndToolsTest.php @@ -0,0 +1,92 @@ + [ + [ + 'id' => 'gpt-4', + 'name' => 'GPT-4', + 'capabilities' => [ + 'supports' => ['vision' => true, 'reasoningEffort' => false], + 'limits' => ['max_context_window_tokens' => 128000], + ], + ], + ], + ]); + + expect($result->models)->toHaveCount(1) + ->and($result->models[0])->toBeInstanceOf(ModelInfo::class) + ->and($result->models[0]->id)->toBe('gpt-4'); + }); + + it('handles empty models list', function () { + $result = ModelsListResult::fromArray(['models' => []]); + + expect($result->models)->toBe([]); + }); + + it('can convert to array', function () { + $result = ModelsListResult::fromArray([ + 'models' => [ + [ + 'id' => 'gpt-4', + 'name' => 'GPT-4', + 'capabilities' => [ + 'supports' => ['vision' => true, 'reasoningEffort' => false], + 'limits' => ['max_context_window_tokens' => 128000], + ], + ], + ], + ]); + + $array = $result->toArray(); + + expect($array['models'])->toHaveCount(1) + ->and($array['models'][0]['id'])->toBe('gpt-4'); + }); +}); + +describe('ToolsListResult', function () { + it('can be created from array', function () { + $result = ToolsListResult::fromArray([ + 'tools' => [ + [ + 'name' => 'bash', + 'description' => 'Execute bash commands', + 'parameters' => ['type' => 'object'], + ], + ], + ]); + + expect($result->tools)->toHaveCount(1) + ->and($result->tools[0]['name'])->toBe('bash'); + }); + + it('handles empty tools list', function () { + $result = ToolsListResult::fromArray([]); + + expect($result->tools)->toBe([]); + }); +}); + +describe('ToolsListParams', function () { + it('can be created with model', function () { + $params = new ToolsListParams(model: 'gpt-4'); + + expect($params->toArray())->toBe(['model' => 'gpt-4']); + }); + + it('filters null model', function () { + $params = new ToolsListParams; + + expect($params->toArray())->toBe([]); + }); +}); diff --git a/tests/Unit/Types/Rpc/PingTest.php b/tests/Unit/Types/Rpc/PingTest.php new file mode 100644 index 0000000..9159ef2 --- /dev/null +++ b/tests/Unit/Types/Rpc/PingTest.php @@ -0,0 +1,65 @@ + 'pong', + 'timestamp' => 1234567890.0, + 'protocolVersion' => 1.0, + ]); + + expect($result->message)->toBe('pong') + ->and($result->timestamp)->toBe(1234567890.0) + ->and($result->protocolVersion)->toBe(1.0); + }); + + it('can convert to array', function () { + $result = new PingResult( + message: 'hello', + timestamp: 1234567890.0, + protocolVersion: 2.0, + ); + + expect($result->toArray())->toBe([ + 'message' => 'hello', + 'timestamp' => 1234567890.0, + 'protocolVersion' => 2.0, + ]); + }); + + it('implements Arrayable interface', function () { + $result = new PingResult(message: 'test', timestamp: 0, protocolVersion: 1.0); + expect($result)->toBeInstanceOf(\Illuminate\Contracts\Support\Arrayable::class); + }); +}); + +describe('PingParams', function () { + it('can be created from array with message', function () { + $params = PingParams::fromArray(['message' => 'hello']); + + expect($params->message)->toBe('hello'); + }); + + it('can be created from array without message', function () { + $params = PingParams::fromArray([]); + + expect($params->message)->toBeNull(); + }); + + it('filters null values in toArray', function () { + $params = new PingParams; + + expect($params->toArray())->toBe([]); + }); + + it('includes message in toArray when set', function () { + $params = new PingParams(message: 'hello'); + + expect($params->toArray())->toBe(['message' => 'hello']); + }); +}); diff --git a/tests/Unit/Types/Rpc/SessionRpcTypesTest.php b/tests/Unit/Types/Rpc/SessionRpcTypesTest.php new file mode 100644 index 0000000..835e6ff --- /dev/null +++ b/tests/Unit/Types/Rpc/SessionRpcTypesTest.php @@ -0,0 +1,184 @@ + 'gpt-4']); + expect($result->modelId)->toBe('gpt-4'); + }); + + it('can be created with null modelId', function () { + $result = SessionModelGetCurrentResult::fromArray([]); + expect($result->modelId)->toBeNull(); + }); +}); + +describe('SessionModelSwitchToParams', function () { + it('can be created and converted', function () { + $params = new SessionModelSwitchToParams(modelId: 'gpt-4'); + expect($params->toArray())->toBe(['modelId' => 'gpt-4']); + }); +}); + +describe('SessionModelSwitchToResult', function () { + it('can be created from array', function () { + $result = SessionModelSwitchToResult::fromArray(['modelId' => 'gpt-4']); + expect($result->modelId)->toBe('gpt-4'); + }); +}); + +describe('SessionModeGetResult', function () { + it('can be created from array', function () { + $result = SessionModeGetResult::fromArray(['mode' => 'interactive']); + expect($result->mode)->toBe('interactive'); + }); + + it('can convert to array', function () { + $result = new SessionModeGetResult(mode: 'plan'); + expect($result->toArray())->toBe(['mode' => 'plan']); + }); +}); + +describe('SessionModeSetParams', function () { + it('can be created and converted', function () { + $params = new SessionModeSetParams(mode: 'autopilot'); + expect($params->toArray())->toBe(['mode' => 'autopilot']); + }); +}); + +describe('SessionModeSetResult', function () { + it('can be created from array', function () { + $result = SessionModeSetResult::fromArray(['mode' => 'plan']); + expect($result->mode)->toBe('plan'); + }); +}); + +describe('SessionPlanReadResult', function () { + it('can be created from array with content', function () { + $result = SessionPlanReadResult::fromArray([ + 'exists' => true, + 'content' => '# Plan', + ]); + + expect($result->exists)->toBeTrue() + ->and($result->content)->toBe('# Plan'); + }); + + it('can be created from array without content', function () { + $result = SessionPlanReadResult::fromArray(['exists' => false]); + + expect($result->exists)->toBeFalse() + ->and($result->content)->toBeNull(); + }); +}); + +describe('SessionPlanUpdateParams', function () { + it('can be created and converted', function () { + $params = new SessionPlanUpdateParams(content: '# Updated Plan'); + expect($params->toArray())->toBe(['content' => '# Updated Plan']); + }); +}); + +describe('SessionWorkspaceListFilesResult', function () { + it('can be created from array', function () { + $result = SessionWorkspaceListFilesResult::fromArray([ + 'files' => ['file1.txt', 'file2.txt'], + ]); + + expect($result->files)->toBe(['file1.txt', 'file2.txt']); + }); + + it('handles empty files list', function () { + $result = SessionWorkspaceListFilesResult::fromArray([]); + + expect($result->files)->toBe([]); + }); +}); + +describe('SessionWorkspaceReadFileResult', function () { + it('can be created from array', function () { + $result = SessionWorkspaceReadFileResult::fromArray([ + 'content' => 'file content', + ]); + + expect($result->content)->toBe('file content'); + }); +}); + +describe('SessionWorkspaceReadFileParams', function () { + it('can be created and converted', function () { + $params = new SessionWorkspaceReadFileParams(path: 'test.txt'); + expect($params->toArray())->toBe(['path' => 'test.txt']); + }); +}); + +describe('SessionWorkspaceCreateFileParams', function () { + it('can be created and converted', function () { + $params = new SessionWorkspaceCreateFileParams(path: 'test.txt', content: 'hello'); + expect($params->toArray())->toBe(['path' => 'test.txt', 'content' => 'hello']); + }); +}); + +describe('SessionFleetStartParams', function () { + it('can be created with prompt', function () { + $params = new SessionFleetStartParams(prompt: 'build it'); + expect($params->toArray())->toBe(['prompt' => 'build it']); + }); + + it('filters null prompt', function () { + $params = new SessionFleetStartParams; + expect($params->toArray())->toBe([]); + }); +}); + +describe('SessionFleetStartResult', function () { + it('can be created from array', function () { + $result = SessionFleetStartResult::fromArray(['started' => true]); + expect($result->started)->toBeTrue(); + }); +}); + +describe('SessionCompactionCompactResult', function () { + it('can be created from array', function () { + $result = SessionCompactionCompactResult::fromArray([ + 'success' => true, + 'tokensRemoved' => 1000, + 'messagesRemoved' => 5, + ]); + + expect($result->success)->toBeTrue() + ->and($result->tokensRemoved)->toBe(1000) + ->and($result->messagesRemoved)->toBe(5); + }); + + it('can convert to array', function () { + $result = new SessionCompactionCompactResult( + success: true, + tokensRemoved: 500, + messagesRemoved: 3, + ); + + expect($result->toArray())->toBe([ + 'success' => true, + 'tokensRemoved' => 500, + 'messagesRemoved' => 3, + ]); + }); +}); diff --git a/tests/Unit/Types/SessionEventTest.php b/tests/Unit/Types/SessionEventTest.php index 099f775..accfa58 100644 --- a/tests/Unit/Types/SessionEventTest.php +++ b/tests/Unit/Types/SessionEventTest.php @@ -139,4 +139,14 @@ expect($type)->toBeNull(); }); + + it('has new event types from latest session-events schema', function () { + expect(SessionEventType::SESSION_TITLE_CHANGED->value)->toBe('session.title_changed') + ->and(SessionEventType::SESSION_WARNING->value)->toBe('session.warning') + ->and(SessionEventType::SESSION_MODE_CHANGED->value)->toBe('session.mode_changed') + ->and(SessionEventType::SESSION_PLAN_CHANGED->value)->toBe('session.plan_changed') + ->and(SessionEventType::SESSION_WORKSPACE_FILE_CHANGED->value)->toBe('session.workspace_file_changed') + ->and(SessionEventType::SESSION_TASK_COMPLETE->value)->toBe('session.task_complete') + ->and(SessionEventType::ASSISTANT_STREAMING_DELTA->value)->toBe('assistant.streaming_delta'); + }); });