Skip to content

Commit ee5fa17

Browse files
[E2E] session.todos_changed event + readSqlTodosWithDependencies (6 languages) (#1622)
1 parent bbe5c76 commit ee5fa17

14 files changed

Lines changed: 577 additions & 46 deletions

dotnet/test/E2E/RpcSessionStateE2ETests.cs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,31 @@ public async Task Should_Call_Session_Rpc_Model_GetCurrent()
3535
[Fact]
3636
public async Task Should_Call_Session_Rpc_Model_SwitchTo()
3737
{
38-
await using var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" });
38+
// The runtime caches /models per (auth, base_url) for 30 minutes (see
39+
// capi_client.rs LIST_MODELS_CACHE). Tests in this class share one CLI
40+
// subprocess and proxy URL via E2ETestFixture, so the first snapshot's
41+
// models list is reused by every later test. SwitchTo needs gpt-5.4 in
42+
// the cache; rather than poisoning every other snapshot we spin up an
43+
// isolated context with its own proxy → its own (auth, base_url) cache
44+
// key.
45+
await using var isolatedCtx = await E2ETestContext.CreateAsync();
46+
await isolatedCtx.ConfigureForTestAsync("rpc_session_state", nameof(Should_Call_Session_Rpc_Model_SwitchTo));
47+
var isolatedClient = isolatedCtx.CreateClient();
48+
49+
await using var session = await isolatedClient.CreateSessionAsync(new SessionConfig
50+
{
51+
Model = "claude-sonnet-4.5",
52+
OnPermissionRequest = PermissionHandler.ApproveAll,
53+
});
3954

4055
var before = await session.Rpc.Model.GetCurrentAsync();
4156
Assert.Equal("claude-sonnet-4.5", before.ModelId);
4257

43-
var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-4.1", reasoningEffort: "high");
44-
var after = await session.Rpc.Model.GetCurrentAsync();
58+
var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-5.4", reasoningEffort: "high");
59+
Assert.Equal("gpt-5.4", result.ModelId);
4560

46-
Assert.Equal("gpt-4.1", result.ModelId);
47-
Assert.True(after.ModelId is "gpt-4.1" || after.ModelId == before.ModelId, $"Unexpected current model after switch: {after.ModelId}");
61+
var after = await session.Rpc.Model.GetCurrentAsync();
62+
Assert.Equal("gpt-5.4", after.ModelId);
4863
}
4964

5065
[Fact]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using GitHub.Copilot.Rpc;
6+
using GitHub.Copilot.Test.Harness;
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
10+
namespace GitHub.Copilot.Test.E2E;
11+
12+
public class SessionTodosChangedE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
13+
: E2ETestBase(fixture, "session_todos_changed", output)
14+
{
15+
private static readonly string[] ExpectedTodoIds = ["alpha", "beta"];
16+
17+
[Fact]
18+
public async Task Fires_Session_Todos_Changed_And_Exposes_Rows_And_Dependencies()
19+
{
20+
await using var session = await CreateSessionAsync(new SessionConfig
21+
{
22+
OnPermissionRequest = PermissionHandler.ApproveAll,
23+
});
24+
25+
var todosChangedTask = TestHelper.GetNextEventOfTypeAsync<SessionTodosChangedEvent>(
26+
session,
27+
TimeSpan.FromSeconds(30));
28+
29+
await session.SendAndWaitAsync(new MessageOptions
30+
{
31+
Prompt =
32+
"Use the sql tool to execute exactly these statements, in order, with no extra rows:\n" +
33+
"1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n" +
34+
"2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n" +
35+
"3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n" +
36+
"Then stop. Do not insert any other rows or create any other tables.",
37+
});
38+
39+
await todosChangedTask;
40+
41+
var result = await session.Rpc.Plan.ReadSqlTodosWithDependenciesAsync();
42+
43+
var ids = result.Rows
44+
.Select(row => row.Id)
45+
.OfType<string>()
46+
.OrderBy(id => id, StringComparer.Ordinal)
47+
.ToArray();
48+
49+
Assert.Equal(ExpectedTodoIds, ids);
50+
51+
Assert.Contains(result.Dependencies, dependency =>
52+
dependency.TodoId == "beta" &&
53+
dependency.DependsOn == "alpha");
54+
}
55+
}

go/internal/e2e/rpc_session_state_e2e_test.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,22 @@ func TestRPCSessionStateE2E(t *testing.T) {
4242
}
4343
})
4444

45+
// The runtime caches /models per (auth, base_url) for 30 minutes (see
46+
// capi_client.rs LIST_MODELS_CACHE). Within this test function all subtests
47+
// share one CLI subprocess and proxy URL, so the first subtest's snapshot
48+
// models list is reused by every later one. SwitchTo needs gpt-5.4 in the
49+
// cache; rather than poison every other snapshot we give this subtest its
50+
// own dedicated client + proxy → its own cache entry.
4551
t.Run("should call session rpc model switchTo", func(t *testing.T) {
46-
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
52+
switchCtx := testharness.NewTestContext(t)
53+
switchClient := switchCtx.NewClient()
54+
t.Cleanup(func() { switchClient.ForceStop() })
55+
if err := switchClient.Start(t.Context()); err != nil {
56+
t.Fatalf("Failed to start switch client: %v", err)
57+
}
58+
switchCtx.ConfigureForTest(t)
59+
60+
session, err := switchClient.CreateSession(t.Context(), &copilot.SessionConfig{
4761
Model: "claude-sonnet-4.5",
4862
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
4963
})
@@ -61,21 +75,21 @@ func TestRPCSessionStateE2E(t *testing.T) {
6175

6276
reasoningEffort := "high"
6377
result, err := session.RPC.Model.SwitchTo(t.Context(), &rpc.ModelSwitchToRequest{
64-
ModelID: "gpt-4.1",
78+
ModelID: "gpt-5.4",
6579
ReasoningEffort: &reasoningEffort,
6680
})
6781
if err != nil {
6882
t.Fatalf("Model.SwitchTo failed: %v", err)
6983
}
70-
if result.ModelID == nil || *result.ModelID != "gpt-4.1" {
71-
t.Fatalf("Expected switch result model gpt-4.1, got %+v", result)
84+
if result.ModelID == nil || *result.ModelID != "gpt-5.4" {
85+
t.Fatalf("Expected switch result model gpt-5.4, got %+v", result)
7286
}
7387
after, err := session.RPC.Model.GetCurrent(t.Context())
7488
if err != nil {
7589
t.Fatalf("Model.GetCurrent after switch failed: %v", err)
7690
}
77-
if after.ModelID == nil || (*after.ModelID != "gpt-4.1" && *after.ModelID != *before.ModelID) {
78-
t.Fatalf("Unexpected current model after switch; before=%q after=%+v", *before.ModelID, after)
91+
if after.ModelID == nil || *after.ModelID != "gpt-5.4" {
92+
t.Fatalf("Model.GetCurrent did not reflect SwitchTo; before=%q after=%+v", *before.ModelID, after)
7993
}
8094
})
8195

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
"slices"
6+
"sort"
7+
"testing"
8+
"time"
9+
10+
copilot "github.com/github/copilot-sdk/go"
11+
"github.com/github/copilot-sdk/go/internal/e2e/testharness"
12+
)
13+
14+
func TestFiresSessionTodosChangedAndExposesRowsAndDependencies(t *testing.T) {
15+
ctx := testharness.NewTestContext(t)
16+
client := ctx.NewClient()
17+
t.Cleanup(func() { client.ForceStop() })
18+
19+
t.Run("fires session.todos_changed and exposes rows and dependencies", func(t *testing.T) {
20+
ctx.ConfigureForTest(t)
21+
22+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
23+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
24+
})
25+
if err != nil {
26+
t.Fatalf("Failed to create session: %v", err)
27+
}
28+
defer session.Disconnect()
29+
30+
awaitTodosChanged := waitForMatchingEvent(
31+
session,
32+
copilot.SessionEventType("session.todos_changed"),
33+
func(copilot.SessionEvent) bool { return true },
34+
"session.todos_changed event",
35+
)
36+
37+
sendCtx, cancel := context.WithTimeout(t.Context(), 120*time.Second)
38+
defer cancel()
39+
_, err = session.SendAndWait(sendCtx, copilot.MessageOptions{
40+
Prompt: "Use the sql tool to execute exactly these statements, in order, with no extra rows:\n" +
41+
"1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n" +
42+
"2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n" +
43+
"3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n" +
44+
"Then stop. Do not insert any other rows or create any other tables.",
45+
})
46+
if err != nil {
47+
t.Fatalf("Failed to send message: %v", err)
48+
}
49+
50+
awaitEvent(t, awaitTodosChanged)
51+
52+
result, err := session.RPC.Plan.ReadSqlTodosWithDependencies(t.Context())
53+
if err != nil {
54+
t.Fatalf("Plan.ReadSqlTodosWithDependencies failed: %v", err)
55+
}
56+
57+
var ids []string
58+
for _, row := range result.Rows {
59+
if row.ID != nil && *row.ID != "" {
60+
ids = append(ids, *row.ID)
61+
}
62+
}
63+
sort.Strings(ids)
64+
if !slices.Equal(ids, []string{"alpha", "beta"}) {
65+
t.Fatalf("Expected todo ids [alpha beta], got %v", ids)
66+
}
67+
68+
foundDependency := false
69+
for _, dependency := range result.Dependencies {
70+
if dependency.TodoID == "beta" && dependency.DependsOn == "alpha" {
71+
foundDependency = true
72+
break
73+
}
74+
}
75+
if !foundDependency {
76+
t.Fatalf("Expected dependency beta -> alpha, got %+v", result.Dependencies)
77+
}
78+
})
79+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot;
6+
7+
import static org.junit.jupiter.api.Assertions.*;
8+
9+
import java.util.concurrent.CompletableFuture;
10+
import java.util.concurrent.TimeUnit;
11+
12+
import org.junit.jupiter.api.AfterAll;
13+
import org.junit.jupiter.api.BeforeAll;
14+
import org.junit.jupiter.api.Test;
15+
16+
import com.github.copilot.generated.SessionTodosChangedEvent;
17+
import com.github.copilot.generated.rpc.PlanSqlTodoDependency;
18+
import com.github.copilot.rpc.MessageOptions;
19+
import com.github.copilot.rpc.PermissionHandler;
20+
import com.github.copilot.rpc.SessionConfig;
21+
22+
public class SessionTodosChangedTest {
23+
24+
private static E2ETestContext ctx;
25+
26+
@BeforeAll
27+
static void setup() throws Exception {
28+
ctx = E2ETestContext.create();
29+
}
30+
31+
@AfterAll
32+
static void teardown() throws Exception {
33+
if (ctx != null) {
34+
ctx.close();
35+
}
36+
}
37+
38+
@Test
39+
void firesSessionTodosChangedAndExposesRowsAndDependencies() throws Exception {
40+
ctx.configureForTest("session_todos_changed", "fires_session_todos_changed_and_exposes_rows_and_dependencies");
41+
42+
try (CopilotClient client = ctx.createClient()) {
43+
CopilotSession session = client
44+
.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get();
45+
46+
CompletableFuture<SessionTodosChangedEvent> todosChanged = new CompletableFuture<>();
47+
session.on(event -> {
48+
if (event instanceof SessionTodosChangedEvent todosEvent && !todosChanged.isDone()) {
49+
todosChanged.complete(todosEvent);
50+
}
51+
});
52+
53+
session.sendAndWait(new MessageOptions()
54+
.setPrompt("Use the sql tool to execute exactly these statements, in order, with no extra rows:\n"
55+
+ "1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n"
56+
+ "2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n"
57+
+ "3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n"
58+
+ "Then stop. Do not insert any other rows or create any other tables."))
59+
.get(120, TimeUnit.SECONDS);
60+
61+
assertNotNull(todosChanged.get(15, TimeUnit.SECONDS),
62+
"Should have received at least one session.todos_changed event");
63+
64+
var result = session.getRpc().plan.readSqlTodosWithDependencies().get(15, TimeUnit.SECONDS);
65+
assertEquals(2, result.rows().size());
66+
var ids = result.rows().stream().map(row -> row.id()).filter(id -> id != null).sorted().toList();
67+
68+
assertEquals(java.util.List.of("alpha", "beta"), ids);
69+
assertTrue(result.dependencies().stream().anyMatch(SessionTodosChangedTest::isBetaDependsOnAlpha),
70+
"Should contain beta -> alpha dependency");
71+
72+
session.close();
73+
}
74+
}
75+
76+
private static boolean isBetaDependsOnAlpha(PlanSqlTodoDependency dependency) {
77+
return "beta".equals(dependency.todoId()) && "alpha".equals(dependency.dependsOn());
78+
}
79+
}

nodejs/test/e2e/rpc_session_state.e2e.test.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,39 @@ describe("Session-scoped RPC", async () => {
4949
await session.disconnect();
5050
});
5151

52-
it("should call session rpc model switchto", async () => {
53-
const session = await client.createSession({
54-
onPermissionRequest: approveAll,
55-
model: "claude-sonnet-4.5",
56-
});
52+
// The runtime caches the /models response per (auth, base_url) for 30
53+
// minutes (see capi_client.rs LIST_MODELS_CACHE), so within a single
54+
// describe — where all tests share one CLI subprocess and proxy URL —
55+
// the cache is primed by whichever test creates a session first. That
56+
// makes any test which calls switchTo to a model not present in the
57+
// first snapshot's models list fail silently (the runtime accepts the
58+
// switch synchronously, then tool revalidation refetches the cached
59+
// list, doesn't see the model, and reverts _selectedModel). Wrapping
60+
// switchTo in its own describe gives it a dedicated subprocess + proxy
61+
// → its own cache entry, so its snapshot's models list is authoritative.
62+
describe("model switchTo (isolated to avoid models cache contamination)", async () => {
63+
const { copilotClient: switchClient } = await createSdkTestContext();
64+
65+
it("should call session rpc model switchto", async () => {
66+
const session = await switchClient.createSession({
67+
onPermissionRequest: approveAll,
68+
model: "claude-sonnet-4.5",
69+
});
5770

58-
const before = await session.rpc.model.getCurrent();
59-
expect(before.modelId).toBeTruthy();
71+
const before = await session.rpc.model.getCurrent();
72+
expect(before.modelId).toBeTruthy();
6073

61-
const result = await session.rpc.model.switchTo({
62-
modelId: "gpt-4.1",
63-
reasoningEffort: "high",
64-
});
65-
const after = await session.rpc.model.getCurrent();
74+
const result = await session.rpc.model.switchTo({
75+
modelId: "gpt-5.4",
76+
reasoningEffort: "high",
77+
});
78+
const after = await session.rpc.model.getCurrent();
6679

67-
expect(result.modelId).toBe("gpt-4.1");
68-
expect(after.modelId).toBe(before.modelId);
80+
expect(result.modelId).toBe("gpt-5.4");
81+
expect(after.modelId).toBe("gpt-5.4");
6982

70-
await session.disconnect();
83+
await session.disconnect();
84+
});
7185
});
7286

7387
it("should shutdown session with routine type", async () => {

0 commit comments

Comments
 (0)