Skip to content

Commit a2712a7

Browse files
Copilotedburns
andauthored
Chunk 3: Wire generated RPC wrappers into CopilotClient and CopilotSession
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/77121eb8-79b7-46ee-8734-81054d4e19bf Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
1 parent e4dc6e5 commit a2712a7

File tree

4 files changed

+242
-14
lines changed

4 files changed

+242
-14
lines changed

src/main/java/com/github/copilot/sdk/CopilotClient.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import com.github.copilot.sdk.json.CopilotClientOptions;
2323
import com.github.copilot.sdk.json.CreateSessionResponse;
24+
import com.github.copilot.sdk.generated.rpc.ServerRpc;
2425
import com.github.copilot.sdk.json.DeleteSessionResponse;
2526
import com.github.copilot.sdk.json.GetAuthStatusResponse;
2627
import com.github.copilot.sdk.json.GetLastSessionIdResponse;
@@ -179,7 +180,7 @@ private Connection startCoreBody() {
179180
processInfo.port());
180181
}
181182

182-
Connection connection = new Connection(rpc, process);
183+
Connection connection = new Connection(rpc, process, new ServerRpc(rpc::invoke));
183184

184185
// Register handlers for server-to-client calls
185186
RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch,
@@ -478,6 +479,32 @@ public ConnectionState getState() {
478479
return ConnectionState.CONNECTED;
479480
}
480481

482+
/**
483+
* Returns the typed RPC client for server-level methods.
484+
* <p>
485+
* Provides strongly-typed access to all server-level API namespaces such as
486+
* {@code models}, {@code tools}, {@code account}, and {@code mcp}.
487+
* <p>
488+
* Example usage:
489+
*
490+
* <pre>{@code
491+
* client.start().get();
492+
* var models = client.getRpc().models.list().get();
493+
* }</pre>
494+
*
495+
* @return the server-level typed RPC client
496+
* @throws IllegalStateException
497+
* if the client is not connected; call {@link #start()} first
498+
* @since 1.0.0
499+
*/
500+
public ServerRpc getRpc() {
501+
CompletableFuture<Connection> future = connectionFuture;
502+
if (future == null || !future.isDone() || future.isCompletedExceptionally()) {
503+
throw new IllegalStateException("Client not connected; call start() first");
504+
}
505+
return future.join().serverRpc();
506+
}
507+
481508
/**
482509
* Pings the server to check connectivity.
483510
* <p>
@@ -795,7 +822,7 @@ public void close() {
795822
}
796823
}
797824

798-
private static record Connection(JsonRpcClient rpc, Process process) {
825+
private static record Connection(JsonRpcClient rpc, Process process, ServerRpc serverRpc) {
799826
};
800827

801828
}

src/main/java/com/github/copilot/sdk/CopilotSession.java

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
import com.fasterxml.jackson.databind.JsonNode;
3131
import com.fasterxml.jackson.databind.ObjectMapper;
3232
import com.github.copilot.sdk.generated.AssistantMessageEvent;
33+
import com.github.copilot.sdk.generated.rpc.SessionCommandsHandlePendingCommandParams;
34+
import com.github.copilot.sdk.generated.rpc.SessionPermissionsHandlePendingPermissionRequestParams;
35+
import com.github.copilot.sdk.generated.rpc.SessionRpc;
3336
import com.github.copilot.sdk.generated.CapabilitiesChangedEvent;
3437
import com.github.copilot.sdk.generated.CommandExecuteEvent;
3538
import com.github.copilot.sdk.generated.ElicitationRequestedEvent;
@@ -134,6 +137,7 @@ public final class CopilotSession implements AutoCloseable {
134137
private volatile SessionCapabilities capabilities = new SessionCapabilities();
135138
private final SessionUiApi ui;
136139
private final JsonRpcClient rpc;
140+
private volatile SessionRpc sessionRpc;
137141
private final Set<Consumer<SessionEvent>> eventHandlers = ConcurrentHashMap.newKeySet();
138142
private final Map<String, ToolDefinition> toolHandlers = new ConcurrentHashMap<>();
139143
private final Map<String, CommandHandler> commandHandlers = new ConcurrentHashMap<>();
@@ -183,6 +187,7 @@ public final class CopilotSession implements AutoCloseable {
183187
this.rpc = rpc;
184188
this.workspacePath = workspacePath;
185189
this.ui = new SessionUiApiImpl();
190+
this.sessionRpc = rpc != null ? new SessionRpc(rpc::invoke, sessionId) : null;
186191
var executor = new ScheduledThreadPoolExecutor(1, r -> {
187192
var t = new Thread(r, "sendAndWait-timeout");
188193
t.setDaemon(true);
@@ -219,6 +224,7 @@ public String getSessionId() {
219224
*/
220225
void setActiveSessionId(String sessionId) {
221226
this.sessionId = sessionId;
227+
this.sessionRpc = rpc != null ? new SessionRpc(rpc::invoke, sessionId) : null;
222228
}
223229

224230
/**
@@ -269,6 +275,25 @@ public SessionUiApi getUi() {
269275
return ui;
270276
}
271277

278+
/**
279+
* Returns the typed RPC client for this session.
280+
* <p>
281+
* Provides strongly-typed access to all session-level API namespaces. The
282+
* {@code sessionId} is injected automatically into every call.
283+
* <p>
284+
* Example usage:
285+
*
286+
* <pre>{@code
287+
* var agents = session.getRpc().agent.list().get();
288+
* }</pre>
289+
*
290+
* @return the session-scoped typed RPC client
291+
* @since 1.0.0
292+
*/
293+
public SessionRpc getRpc() {
294+
return sessionRpc;
295+
}
296+
272297
/**
273298
* Sets a custom error handler for exceptions thrown by event handlers.
274299
* <p>
@@ -851,8 +876,9 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques
851876
try {
852877
PermissionRequestResult denied = new PermissionRequestResult();
853878
denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER);
854-
rpc.invoke("session.permissions.handlePendingPermissionRequest",
855-
Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class);
879+
sessionRpc.permissions.handlePendingPermissionRequest(
880+
new SessionPermissionsHandlePendingPermissionRequestParams(sessionId, requestId,
881+
denied));
856882
} catch (Exception e) {
857883
LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, e);
858884
}
@@ -863,8 +889,8 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques
863889
try {
864890
PermissionRequestResult denied = new PermissionRequestResult();
865891
denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER);
866-
rpc.invoke("session.permissions.handlePendingPermissionRequest",
867-
Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class);
892+
sessionRpc.permissions.handlePendingPermissionRequest(
893+
new SessionPermissionsHandlePendingPermissionRequestParams(sessionId, requestId, denied));
868894
} catch (Exception sendEx) {
869895
LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, sendEx);
870896
}
@@ -908,8 +934,8 @@ private void executeCommandAndRespondAsync(String requestId, String commandName,
908934
Runnable task = () -> {
909935
if (handler == null) {
910936
try {
911-
rpc.invoke("session.commands.handlePendingCommand", Map.of("sessionId", sessionId, "requestId",
912-
requestId, "error", "Unknown command: " + commandName), Object.class);
937+
sessionRpc.commands.handlePendingCommand(new SessionCommandsHandlePendingCommandParams(sessionId,
938+
requestId, "Unknown command: " + commandName));
913939
} catch (Exception e) {
914940
LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, e);
915941
}
@@ -920,16 +946,16 @@ private void executeCommandAndRespondAsync(String requestId, String commandName,
920946
.setArgs(args);
921947
handler.handle(ctx).thenRun(() -> {
922948
try {
923-
rpc.invoke("session.commands.handlePendingCommand",
924-
Map.of("sessionId", sessionId, "requestId", requestId), Object.class);
949+
sessionRpc.commands.handlePendingCommand(
950+
new SessionCommandsHandlePendingCommandParams(sessionId, requestId, null));
925951
} catch (Exception e) {
926952
LOG.log(Level.WARNING, "Error sending command result for requestId=" + requestId, e);
927953
}
928954
}).exceptionally(ex -> {
929955
try {
930956
String msg = ex.getMessage() != null ? ex.getMessage() : ex.toString();
931-
rpc.invoke("session.commands.handlePendingCommand",
932-
Map.of("sessionId", sessionId, "requestId", requestId, "error", msg), Object.class);
957+
sessionRpc.commands.handlePendingCommand(
958+
new SessionCommandsHandlePendingCommandParams(sessionId, requestId, msg));
933959
} catch (Exception e) {
934960
LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, e);
935961
}
@@ -939,8 +965,8 @@ private void executeCommandAndRespondAsync(String requestId, String commandName,
939965
LOG.log(Level.WARNING, "Error executing command for requestId=" + requestId, e);
940966
try {
941967
String msg = e.getMessage() != null ? e.getMessage() : e.toString();
942-
rpc.invoke("session.commands.handlePendingCommand",
943-
Map.of("sessionId", sessionId, "requestId", requestId, "error", msg), Object.class);
968+
sessionRpc.commands.handlePendingCommand(
969+
new SessionCommandsHandlePendingCommandParams(sessionId, requestId, msg));
944970
} catch (Exception sendEx) {
945971
LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, sendEx);
946972
}

src/test/java/com/github/copilot/sdk/CopilotSessionTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import com.github.copilot.sdk.generated.SessionStartEvent;
3030
import com.github.copilot.sdk.generated.ToolExecutionStartEvent;
3131
import com.github.copilot.sdk.generated.UserMessageEvent;
32+
import com.github.copilot.sdk.generated.rpc.SessionRpc;
3233
import com.github.copilot.sdk.json.MessageOptions;
3334
import com.github.copilot.sdk.json.PermissionHandler;
3435
import com.github.copilot.sdk.json.ResumeSessionConfig;
@@ -849,4 +850,30 @@ void testShouldGetSessionMetadataById() throws Exception {
849850
session.close();
850851
}
851852
}
853+
854+
/**
855+
* Verifies that {@link CopilotSession#getRpc()} returns a non-null
856+
* {@link SessionRpc} wired to the session's ID and that all namespace fields
857+
* are present.
858+
*/
859+
@Test
860+
void testGetRpcReturnsSessionRpcWithCorrectSessionId() throws Exception {
861+
ctx.configureForTest("session", "should_receive_session_events");
862+
863+
try (CopilotClient client = ctx.createClient()) {
864+
CopilotSession session = client
865+
.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get();
866+
867+
SessionRpc rpc = session.getRpc();
868+
assertNotNull(rpc, "getRpc() must not return null");
869+
assertNotNull(rpc.agent, "SessionRpc.agent must not be null");
870+
assertNotNull(rpc.model, "SessionRpc.model must not be null");
871+
assertNotNull(rpc.tools, "SessionRpc.tools must not be null");
872+
assertNotNull(rpc.permissions, "SessionRpc.permissions must not be null");
873+
assertNotNull(rpc.commands, "SessionRpc.commands must not be null");
874+
assertNotNull(rpc.ui, "SessionRpc.ui must not be null");
875+
876+
session.close();
877+
}
878+
}
852879
}

src/test/java/com/github/copilot/sdk/RpcWrappersTest.java

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import org.junit.jupiter.api.Test;
1616

17+
import com.fasterxml.jackson.databind.ObjectMapper;
1718
import com.github.copilot.sdk.generated.rpc.McpConfigAddParams;
1819
import com.github.copilot.sdk.generated.rpc.McpDiscoverParams;
1920
import com.github.copilot.sdk.generated.rpc.RpcCaller;
@@ -273,4 +274,151 @@ void serverRpc_account_getQuota_invokes_correct_method() {
273274
assertEquals(1, stub.calls.size());
274275
assertEquals("account.getQuota", stub.calls.get(0).method());
275276
}
277+
278+
// ── CopilotSession.getRpc() wiring tests ──────────────────────────────────
279+
// These tests use a socket-pair backed JsonRpcClient (same pattern as
280+
// RpcHandlerDispatcherTest) to construct a real CopilotSession and verify
281+
// that getRpc() returns a correctly wired SessionRpc.
282+
283+
@Test
284+
void copilotSession_getRpc_returns_non_null_session_rpc() throws Exception {
285+
try (var sockets = new SocketPair()) {
286+
var rpc = sockets.client();
287+
var session = new CopilotSession("sess-unit", rpc);
288+
289+
assertNotNull(session.getRpc());
290+
}
291+
}
292+
293+
@Test
294+
void copilotSession_getRpc_sessionId_matches_session() throws Exception {
295+
try (var sockets = new SocketPair()) {
296+
var rpc = sockets.client();
297+
var stub = sockets.stubServer();
298+
var session = new CopilotSession("sess-test-id", rpc);
299+
300+
// Call any no-arg session method via getRpc() to verify sessionId injection
301+
session.getRpc().agent.list();
302+
303+
// Drain the sent message from the stub server
304+
var sent = stub.readOneMessage();
305+
assertEquals("session.agent.list", sent.get("method").asText());
306+
assertEquals("sess-test-id", sent.get("params").get("sessionId").asText());
307+
}
308+
}
309+
310+
@Test
311+
void copilotSession_getRpc_updates_when_sessionId_changes() throws Exception {
312+
try (var sockets = new SocketPair()) {
313+
var rpc = sockets.client();
314+
var stub = sockets.stubServer();
315+
var session = new CopilotSession("old-id", rpc);
316+
317+
// Simulate server returning a different sessionId (v2 CLI behaviour)
318+
session.setActiveSessionId("new-id");
319+
320+
session.getRpc().agent.list();
321+
322+
var sent = stub.readOneMessage();
323+
assertEquals("new-id", sent.get("params").get("sessionId").asText(),
324+
"getRpc() should reflect the updated sessionId");
325+
}
326+
}
327+
328+
@Test
329+
void copilotSession_getRpc_all_namespace_fields_present() throws Exception {
330+
try (var sockets = new SocketPair()) {
331+
var rpc = sockets.client();
332+
var session = new CopilotSession("sess-ns", rpc);
333+
334+
var sessionRpc = session.getRpc();
335+
assertNotNull(sessionRpc.model);
336+
assertNotNull(sessionRpc.agent);
337+
assertNotNull(sessionRpc.skills);
338+
assertNotNull(sessionRpc.tools);
339+
assertNotNull(sessionRpc.permissions);
340+
assertNotNull(sessionRpc.commands);
341+
assertNotNull(sessionRpc.ui);
342+
}
343+
}
344+
345+
/**
346+
* Helper that creates a loopback socket pair. The client side is used by
347+
* {@link JsonRpcClient}; the server side can be read to inspect outbound
348+
* messages.
349+
*/
350+
private static final class SocketPair implements AutoCloseable {
351+
352+
private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper();
353+
354+
private final java.net.Socket clientSocket;
355+
private final java.net.Socket serverSocket;
356+
private final JsonRpcClient rpcClient;
357+
358+
SocketPair() throws Exception {
359+
try (var ss = new java.net.ServerSocket(0)) {
360+
clientSocket = new java.net.Socket("localhost", ss.getLocalPort());
361+
serverSocket = ss.accept();
362+
}
363+
serverSocket.setSoTimeout(3000);
364+
rpcClient = JsonRpcClient.fromSocket(clientSocket);
365+
}
366+
367+
JsonRpcClient client() {
368+
return rpcClient;
369+
}
370+
371+
StubServer stubServer() {
372+
return new StubServer(serverSocket);
373+
}
374+
375+
@Override
376+
public void close() throws Exception {
377+
rpcClient.close();
378+
clientSocket.close();
379+
serverSocket.close();
380+
}
381+
}
382+
383+
/**
384+
* Reads raw JSON-RPC messages written to the server side of the socket.
385+
*/
386+
private static final class StubServer {
387+
388+
private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper();
389+
390+
private final java.io.InputStream in;
391+
392+
StubServer(java.net.Socket socket) {
393+
try {
394+
this.in = socket.getInputStream();
395+
} catch (Exception e) {
396+
throw new RuntimeException(e);
397+
}
398+
}
399+
400+
/**
401+
* Reads one JSON-RPC message (Content-Length framed) from the stream.
402+
*/
403+
com.fasterxml.jackson.databind.JsonNode readOneMessage() throws Exception {
404+
// Read Content-Length header
405+
var header = new StringBuilder();
406+
int b;
407+
while ((b = in.read()) != -1) {
408+
if (b == '\n' && header.toString().endsWith("\r")) {
409+
break;
410+
}
411+
header.append((char) b);
412+
}
413+
// Skip blank line
414+
in.read(); // '\r'
415+
in.read(); // '\n'
416+
417+
String hdr = header.toString().trim();
418+
int colon = hdr.indexOf(':');
419+
int len = Integer.parseInt(hdr.substring(colon + 1).trim());
420+
byte[] body = in.readNBytes(len);
421+
return MAPPER.readTree(body);
422+
}
423+
}
276424
}

0 commit comments

Comments
 (0)