diff --git a/dotnet/test/E2E/RpcSessionStateE2ETests.cs b/dotnet/test/E2E/RpcSessionStateE2ETests.cs index 41c08cbd1..9701c5ea2 100644 --- a/dotnet/test/E2E/RpcSessionStateE2ETests.cs +++ b/dotnet/test/E2E/RpcSessionStateE2ETests.cs @@ -35,16 +35,31 @@ public async Task Should_Call_Session_Rpc_Model_GetCurrent() [Fact] public async Task Should_Call_Session_Rpc_Model_SwitchTo() { - await using var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + // The runtime caches /models per (auth, base_url) for 30 minutes (see + // capi_client.rs LIST_MODELS_CACHE). Tests in this class share one CLI + // subprocess and proxy URL via E2ETestFixture, so the first snapshot's + // models list is reused by every later test. SwitchTo needs gpt-5.4 in + // the cache; rather than poisoning every other snapshot we spin up an + // isolated context with its own proxy → its own (auth, base_url) cache + // key. + await using var isolatedCtx = await E2ETestContext.CreateAsync(); + await isolatedCtx.ConfigureForTestAsync("rpc_session_state", nameof(Should_Call_Session_Rpc_Model_SwitchTo)); + var isolatedClient = isolatedCtx.CreateClient(); + + await using var session = await isolatedClient.CreateSessionAsync(new SessionConfig + { + Model = "claude-sonnet-4.5", + OnPermissionRequest = PermissionHandler.ApproveAll, + }); var before = await session.Rpc.Model.GetCurrentAsync(); Assert.Equal("claude-sonnet-4.5", before.ModelId); - var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-4.1", reasoningEffort: "high"); - var after = await session.Rpc.Model.GetCurrentAsync(); + var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-5.4", reasoningEffort: "high"); + Assert.Equal("gpt-5.4", result.ModelId); - Assert.Equal("gpt-4.1", result.ModelId); - Assert.True(after.ModelId is "gpt-4.1" || after.ModelId == before.ModelId, $"Unexpected current model after switch: {after.ModelId}"); + var after = await session.Rpc.Model.GetCurrentAsync(); + Assert.Equal("gpt-5.4", after.ModelId); } [Fact] diff --git a/dotnet/test/E2E/SessionTodosChangedE2ETests.cs b/dotnet/test/E2E/SessionTodosChangedE2ETests.cs new file mode 100644 index 000000000..32d4c0121 --- /dev/null +++ b/dotnet/test/E2E/SessionTodosChangedE2ETests.cs @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class SessionTodosChangedE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "session_todos_changed", output) +{ + private static readonly string[] ExpectedTodoIds = ["alpha", "beta"]; + + [Fact] + public async Task Fires_Session_Todos_Changed_And_Exposes_Rows_And_Dependencies() + { + await using var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var todosChangedTask = TestHelper.GetNextEventOfTypeAsync( + session, + TimeSpan.FromSeconds(30)); + + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = + "Use the sql tool to execute exactly these statements, in order, with no extra rows:\n" + + "1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n" + + "2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n" + + "3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n" + + "Then stop. Do not insert any other rows or create any other tables.", + }); + + await todosChangedTask; + + var result = await session.Rpc.Plan.ReadSqlTodosWithDependenciesAsync(); + + var ids = result.Rows + .Select(row => row.Id) + .OfType() + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(ExpectedTodoIds, ids); + + Assert.Contains(result.Dependencies, dependency => + dependency.TodoId == "beta" && + dependency.DependsOn == "alpha"); + } +} diff --git a/go/internal/e2e/rpc_session_state_e2e_test.go b/go/internal/e2e/rpc_session_state_e2e_test.go index f6698f082..9b28471ba 100644 --- a/go/internal/e2e/rpc_session_state_e2e_test.go +++ b/go/internal/e2e/rpc_session_state_e2e_test.go @@ -42,8 +42,22 @@ func TestRPCSessionStateE2E(t *testing.T) { } }) + // The runtime caches /models per (auth, base_url) for 30 minutes (see + // capi_client.rs LIST_MODELS_CACHE). Within this test function all subtests + // share one CLI subprocess and proxy URL, so the first subtest's snapshot + // models list is reused by every later one. SwitchTo needs gpt-5.4 in the + // cache; rather than poison every other snapshot we give this subtest its + // own dedicated client + proxy → its own cache entry. t.Run("should call session rpc model switchTo", func(t *testing.T) { - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + switchCtx := testharness.NewTestContext(t) + switchClient := switchCtx.NewClient() + t.Cleanup(func() { switchClient.ForceStop() }) + if err := switchClient.Start(t.Context()); err != nil { + t.Fatalf("Failed to start switch client: %v", err) + } + switchCtx.ConfigureForTest(t) + + session, err := switchClient.CreateSession(t.Context(), &copilot.SessionConfig{ Model: "claude-sonnet-4.5", OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) @@ -61,21 +75,21 @@ func TestRPCSessionStateE2E(t *testing.T) { reasoningEffort := "high" result, err := session.RPC.Model.SwitchTo(t.Context(), &rpc.ModelSwitchToRequest{ - ModelID: "gpt-4.1", + ModelID: "gpt-5.4", ReasoningEffort: &reasoningEffort, }) if err != nil { t.Fatalf("Model.SwitchTo failed: %v", err) } - if result.ModelID == nil || *result.ModelID != "gpt-4.1" { - t.Fatalf("Expected switch result model gpt-4.1, got %+v", result) + if result.ModelID == nil || *result.ModelID != "gpt-5.4" { + t.Fatalf("Expected switch result model gpt-5.4, got %+v", result) } after, err := session.RPC.Model.GetCurrent(t.Context()) if err != nil { t.Fatalf("Model.GetCurrent after switch failed: %v", err) } - if after.ModelID == nil || (*after.ModelID != "gpt-4.1" && *after.ModelID != *before.ModelID) { - t.Fatalf("Unexpected current model after switch; before=%q after=%+v", *before.ModelID, after) + if after.ModelID == nil || *after.ModelID != "gpt-5.4" { + t.Fatalf("Model.GetCurrent did not reflect SwitchTo; before=%q after=%+v", *before.ModelID, after) } }) diff --git a/go/internal/e2e/session_todos_changed_e2e_test.go b/go/internal/e2e/session_todos_changed_e2e_test.go new file mode 100644 index 000000000..f85d32c12 --- /dev/null +++ b/go/internal/e2e/session_todos_changed_e2e_test.go @@ -0,0 +1,79 @@ +package e2e + +import ( + "context" + "slices" + "sort" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestFiresSessionTodosChangedAndExposesRowsAndDependencies(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("fires session.todos_changed and exposes rows and dependencies", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + defer session.Disconnect() + + awaitTodosChanged := waitForMatchingEvent( + session, + copilot.SessionEventType("session.todos_changed"), + func(copilot.SessionEvent) bool { return true }, + "session.todos_changed event", + ) + + sendCtx, cancel := context.WithTimeout(t.Context(), 120*time.Second) + defer cancel() + _, err = session.SendAndWait(sendCtx, copilot.MessageOptions{ + Prompt: "Use the sql tool to execute exactly these statements, in order, with no extra rows:\n" + + "1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n" + + "2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n" + + "3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n" + + "Then stop. Do not insert any other rows or create any other tables.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + awaitEvent(t, awaitTodosChanged) + + result, err := session.RPC.Plan.ReadSqlTodosWithDependencies(t.Context()) + if err != nil { + t.Fatalf("Plan.ReadSqlTodosWithDependencies failed: %v", err) + } + + var ids []string + for _, row := range result.Rows { + if row.ID != nil && *row.ID != "" { + ids = append(ids, *row.ID) + } + } + sort.Strings(ids) + if !slices.Equal(ids, []string{"alpha", "beta"}) { + t.Fatalf("Expected todo ids [alpha beta], got %v", ids) + } + + foundDependency := false + for _, dependency := range result.Dependencies { + if dependency.TodoID == "beta" && dependency.DependsOn == "alpha" { + foundDependency = true + break + } + } + if !foundDependency { + t.Fatalf("Expected dependency beta -> alpha, got %+v", result.Dependencies) + } + }) +} diff --git a/java/src/test/java/com/github/copilot/SessionTodosChangedTest.java b/java/src/test/java/com/github/copilot/SessionTodosChangedTest.java new file mode 100644 index 000000000..0f3831889 --- /dev/null +++ b/java/src/test/java/com/github/copilot/SessionTodosChangedTest.java @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.SessionTodosChangedEvent; +import com.github.copilot.generated.rpc.PlanSqlTodoDependency; +import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +public class SessionTodosChangedTest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void firesSessionTodosChangedAndExposesRowsAndDependencies() throws Exception { + ctx.configureForTest("session_todos_changed", "fires_session_todos_changed_and_exposes_rows_and_dependencies"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + CompletableFuture todosChanged = new CompletableFuture<>(); + session.on(event -> { + if (event instanceof SessionTodosChangedEvent todosEvent && !todosChanged.isDone()) { + todosChanged.complete(todosEvent); + } + }); + + session.sendAndWait(new MessageOptions() + .setPrompt("Use the sql tool to execute exactly these statements, in order, with no extra rows:\n" + + "1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n" + + "2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n" + + "3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n" + + "Then stop. Do not insert any other rows or create any other tables.")) + .get(120, TimeUnit.SECONDS); + + assertNotNull(todosChanged.get(15, TimeUnit.SECONDS), + "Should have received at least one session.todos_changed event"); + + var result = session.getRpc().plan.readSqlTodosWithDependencies().get(15, TimeUnit.SECONDS); + assertEquals(2, result.rows().size()); + var ids = result.rows().stream().map(row -> row.id()).filter(id -> id != null).sorted().toList(); + + assertEquals(java.util.List.of("alpha", "beta"), ids); + assertTrue(result.dependencies().stream().anyMatch(SessionTodosChangedTest::isBetaDependsOnAlpha), + "Should contain beta -> alpha dependency"); + + session.close(); + } + } + + private static boolean isBetaDependsOnAlpha(PlanSqlTodoDependency dependency) { + return "beta".equals(dependency.todoId()) && "alpha".equals(dependency.dependsOn()); + } +} diff --git a/nodejs/test/e2e/rpc_session_state.e2e.test.ts b/nodejs/test/e2e/rpc_session_state.e2e.test.ts index 295f60340..d1a628a0d 100644 --- a/nodejs/test/e2e/rpc_session_state.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state.e2e.test.ts @@ -49,25 +49,39 @@ describe("Session-scoped RPC", async () => { await session.disconnect(); }); - it("should call session rpc model switchto", async () => { - const session = await client.createSession({ - onPermissionRequest: approveAll, - model: "claude-sonnet-4.5", - }); + // The runtime caches the /models response per (auth, base_url) for 30 + // minutes (see capi_client.rs LIST_MODELS_CACHE), so within a single + // describe — where all tests share one CLI subprocess and proxy URL — + // the cache is primed by whichever test creates a session first. That + // makes any test which calls switchTo to a model not present in the + // first snapshot's models list fail silently (the runtime accepts the + // switch synchronously, then tool revalidation refetches the cached + // list, doesn't see the model, and reverts _selectedModel). Wrapping + // switchTo in its own describe gives it a dedicated subprocess + proxy + // → its own cache entry, so its snapshot's models list is authoritative. + describe("model switchTo (isolated to avoid models cache contamination)", async () => { + const { copilotClient: switchClient } = await createSdkTestContext(); + + it("should call session rpc model switchto", async () => { + const session = await switchClient.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); - const before = await session.rpc.model.getCurrent(); - expect(before.modelId).toBeTruthy(); + const before = await session.rpc.model.getCurrent(); + expect(before.modelId).toBeTruthy(); - const result = await session.rpc.model.switchTo({ - modelId: "gpt-4.1", - reasoningEffort: "high", - }); - const after = await session.rpc.model.getCurrent(); + const result = await session.rpc.model.switchTo({ + modelId: "gpt-5.4", + reasoningEffort: "high", + }); + const after = await session.rpc.model.getCurrent(); - expect(result.modelId).toBe("gpt-4.1"); - expect(after.modelId).toBe(before.modelId); + expect(result.modelId).toBe("gpt-5.4"); + expect(after.modelId).toBe("gpt-5.4"); - await session.disconnect(); + await session.disconnect(); + }); }); it("should shutdown session with routine type", async () => { diff --git a/nodejs/test/e2e/session_todos_changed.e2e.test.ts b/nodejs/test/e2e/session_todos_changed.e2e.test.ts new file mode 100644 index 000000000..ea4973826 --- /dev/null +++ b/nodejs/test/e2e/session_todos_changed.e2e.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import fs, { realpathSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { getNextEventOfType } from "./harness/sdkTestHelper.js"; + +/** + * E2E coverage for the runtime's `session.todos_changed` event and + * `session.plan.readSqlTodosWithDependencies` RPC. We let the agent drive the + * built-in `sql` tool (default mode = "copilot-cli") to insert known rows into + * the prompted `todos` table, then assert both that the lightweight signal + * event fired and that the structured query API returns those rows. + */ +describe("Todos changed event + readSqlTodosWithDependencies", async () => { + const baseDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-todos-e2e-"))); + const { copilotClient: client } = await createSdkTestContext({ + copilotClientOptions: { baseDirectory: baseDir }, + }); + + it( + "fires session.todos_changed and exposes rows and dependencies", + { timeout: 120_000 }, + async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const todosChanged = getNextEventOfType(session, "session.todos_changed"); + + await session.sendAndWait({ + prompt: + "Use the sql tool to execute exactly these statements, in order, with no extra rows:\n" + + "1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n" + + "2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n" + + "3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n" + + "Then stop. Do not insert any other rows or create any other tables.", + }); + + await todosChanged; + + const result = await session.rpc.plan.readSqlTodosWithDependencies(); + const ids = result.rows + .map((r) => r.id) + .filter((x): x is string => !!x) + .sort(); + expect(ids).toEqual(["alpha", "beta"]); + + const edge = result.dependencies.find( + (d) => d.todoId === "beta" && d.dependsOn === "alpha" + ); + expect(edge).toBeDefined(); + + await session.disconnect(); + } + ); +}); diff --git a/python/e2e/test_rpc_session_state_e2e.py b/python/e2e/test_rpc_session_state_e2e.py index 192111684..7bd94679d 100644 --- a/python/e2e/test_rpc_session_state_e2e.py +++ b/python/e2e/test_rpc_session_state_e2e.py @@ -111,26 +111,39 @@ async def test_should_call_session_rpc_model_get_current(self, ctx: E2ETestConte finally: await session.disconnect() - async def test_should_call_session_rpc_model_switch_to(self, ctx: E2ETestContext): - session = await ctx.client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-sonnet-4.5", - ) + async def test_should_call_session_rpc_model_switchto(self, ctx: E2ETestContext): + # The runtime caches /models per (auth, base_url) for 30 minutes (see + # capi_client.rs LIST_MODELS_CACHE). Tests in this class share one CLI + # subprocess and proxy URL via the module-scoped `ctx` fixture, so the + # first snapshot's models list is reused by every later test. switch_to + # needs gpt-5.4 in the cache; rather than poisoning every other snapshot + # we spin up an isolated context with its own subprocess and proxy → its + # own (auth, base_url) cache key. + isolated_ctx = E2ETestContext() + await isolated_ctx.setup() try: - before = await session.rpc.model.get_current() - assert before.model_id - - result = await session.rpc.model.switch_to( - ModelSwitchToRequest(model_id="gpt-4.1", reasoning_effort="high") + await isolated_ctx.configure_for_test( + "rpc_session_state", "should_call_session_rpc_model_switchto" + ) + session = await isolated_ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + model="claude-sonnet-4.5", ) - after = await session.rpc.model.get_current() + try: + before = await session.rpc.model.get_current() + assert before.model_id + + result = await session.rpc.model.switch_to( + ModelSwitchToRequest(model_id="gpt-5.4", reasoning_effort="high") + ) + assert result.model_id == "gpt-5.4" - assert result.model_id == "gpt-4.1" - # Python's current RPC surface resolves the requested override but does - # not mutate the live session model selection. - assert after.model_id == before.model_id + after = await session.rpc.model.get_current() + assert after.model_id == "gpt-5.4" + finally: + await session.disconnect() finally: - await session.disconnect() + await isolated_ctx.teardown() async def test_should_get_and_set_session_mode(self, ctx: E2ETestContext): session = await ctx.client.create_session( diff --git a/python/e2e/test_session_todos_changed_e2e.py b/python/e2e/test_session_todos_changed_e2e.py new file mode 100644 index 000000000..9a76a5f92 --- /dev/null +++ b/python/e2e/test_session_todos_changed_e2e.py @@ -0,0 +1,43 @@ +"""E2E coverage for session.todos_changed and SQL todo dependency reads.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext, get_next_event_of_type + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +PROMPT = """Use the sql tool to execute exactly these statements, in order, with no extra rows: +1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending'); +2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done'); +3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha'); +Then stop. Do not insert any other rows or create any other tables.""" + + +class TestSessionTodosChanged: + async def test_fires_session_todos_changed_and_exposes_rows_and_dependencies( + self, ctx: E2ETestContext + ): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + todos_changed = asyncio.create_task( + get_next_event_of_type(session, "session.todos_changed", timeout=120.0) + ) + await session.send_and_wait(PROMPT, timeout=120.0) + await todos_changed + + result = await session.rpc.plan.read_sql_todos_with_dependencies() + ids = sorted(row.id for row in result.rows if row.id) + assert ids == ["alpha", "beta"] + + assert any( + dependency.todo_id == "beta" and dependency.depends_on == "alpha" + for dependency in result.dependencies + ) diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 40bb5adb7..a1dd36ca6 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -105,6 +105,8 @@ mod session_fs; mod session_fs_sqlite; #[path = "e2e/session_lifecycle.rs"] mod session_lifecycle; +#[path = "e2e/session_todos_changed.rs"] +mod session_todos_changed; #[path = "e2e/skills.rs"] mod skills; #[path = "e2e/streaming_fidelity.rs"] diff --git a/rust/tests/e2e/rpc_session_state.rs b/rust/tests/e2e/rpc_session_state.rs index 865a90744..8b68aeb3a 100644 --- a/rust/tests/e2e/rpc_session_state.rs +++ b/rust/tests/e2e/rpc_session_state.rs @@ -63,23 +63,39 @@ async fn should_call_session_rpc_model_switchto() { ctx.set_default_copilot_user(); let client = ctx.start_client().await; let session = client - .create_session(ctx.approve_all_session_config()) + .create_session(ctx.approve_all_session_config().with_model(MODEL_ID)) .await .expect("create session"); + let before = session + .rpc() + .model() + .get_current() + .await + .expect("get current model before switch"); + assert!(before.model_id.is_some(), "expected a model before switch"); + let switched = session .rpc() .model() .switch_to(ModelSwitchToRequest { - model_id: MODEL_ID.to_string(), - reasoning_effort: Some("none".to_string()), + model_id: "gpt-5.4".to_string(), + reasoning_effort: Some("high".to_string()), model_capabilities: None, reasoning_summary: None, ..Default::default() }) .await .expect("switch model"); - assert_eq!(switched.model_id.as_deref(), Some(MODEL_ID)); + assert_eq!(switched.model_id.as_deref(), Some("gpt-5.4")); + + let after = session + .rpc() + .model() + .get_current() + .await + .expect("get current model after switch"); + assert_eq!(after.model_id.as_deref(), Some("gpt-5.4")); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); diff --git a/rust/tests/e2e/session_todos_changed.rs b/rust/tests/e2e/session_todos_changed.rs new file mode 100644 index 000000000..ef73a92dd --- /dev/null +++ b/rust/tests/e2e/session_todos_changed.rs @@ -0,0 +1,59 @@ +use github_copilot_sdk::session_events::SessionEventType; + +use super::support::{wait_for_event, with_e2e_context}; + +const PROMPT: &str = concat!( + "Use the sql tool to execute exactly these statements, in order, with no extra rows:\n", + "1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending');\n", + "2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done');\n", + "3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha');\n", + "Then stop. Do not insert any other rows or create any other tables." +); + +#[tokio::test] +async fn fires_session_todos_changed_and_exposes_rows_and_dependencies() { + with_e2e_context( + "session_todos_changed", + "fires_session_todos_changed_and_exposes_rows_and_dependencies", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let todos_changed = wait_for_event(session.subscribe(), "todos changed", |event| { + event.parsed_type() == SessionEventType::SessionTodosChanged + }); + + session.send_and_wait(PROMPT).await.expect("send"); + todos_changed.await; + + let result = session + .rpc() + .plan() + .read_sql_todos_with_dependencies() + .await + .expect("read SQL todos with dependencies"); + + let mut ids: Vec = + result.rows.into_iter().filter_map(|row| row.id).collect(); + ids.sort(); + assert_eq!(ids, ["alpha", "beta"]); + assert!( + result + .dependencies + .iter() + .any(|dependency| dependency.todo_id == "beta" + && dependency.depends_on == "alpha") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml b/test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml index 056351ddb..b276b6a39 100644 --- a/test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml +++ b/test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml @@ -1,3 +1,4 @@ models: - claude-sonnet-4.5 + - gpt-5.4 conversations: [] diff --git a/test/snapshots/session_todos_changed/fires_session_todos_changed_and_exposes_rows_and_dependencies.yaml b/test/snapshots/session_todos_changed/fires_session_todos_changed_and_exposes_rows_and_dependencies.yaml new file mode 100644 index 000000000..d57a49bdf --- /dev/null +++ b/test/snapshots/session_todos_changed/fires_session_todos_changed_and_exposes_rows_and_dependencies.yaml @@ -0,0 +1,81 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: |- + Use the sql tool to execute exactly these statements, in order, with no extra rows: + 1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending'); + 2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done'); + 3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha'); + Then stop. Do not insert any other rows or create any other tables. + - role: assistant + content: I'll execute those three SQL statements in order. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: sql + arguments: "{\"description\":\"Insert alpha todo\",\"query\":\"INSERT INTO todos (id, title, status) VALUES ('alpha', + 'First todo', 'pending');\"}" + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: sql + arguments: "{\"description\":\"Insert beta todo\",\"query\":\"INSERT INTO todos (id, title, status) VALUES ('beta', + 'Second todo', 'done');\"}" + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: sql + arguments: "{\"description\":\"Insert dependency\",\"query\":\"INSERT INTO todo_deps (todo_id, depends_on) VALUES + ('beta', 'alpha');\"}" + - messages: + - role: system + content: ${system} + - role: user + content: |- + Use the sql tool to execute exactly these statements, in order, with no extra rows: + 1. INSERT INTO todos (id, title, status) VALUES ('alpha', 'First todo', 'pending'); + 2. INSERT INTO todos (id, title, status) VALUES ('beta', 'Second todo', 'done'); + 3. INSERT INTO todo_deps (todo_id, depends_on) VALUES ('beta', 'alpha'); + Then stop. Do not insert any other rows or create any other tables. + - role: assistant + content: I'll execute those three SQL statements in order. + tool_calls: + - id: toolcall_0 + type: function + function: + name: sql + arguments: "{\"description\":\"Insert alpha todo\",\"query\":\"INSERT INTO todos (id, title, status) VALUES ('alpha', + 'First todo', 'pending');\"}" + - id: toolcall_1 + type: function + function: + name: sql + arguments: "{\"description\":\"Insert beta todo\",\"query\":\"INSERT INTO todos (id, title, status) VALUES ('beta', + 'Second todo', 'done');\"}" + - id: toolcall_2 + type: function + function: + name: sql + arguments: "{\"description\":\"Insert dependency\",\"query\":\"INSERT INTO todo_deps (todo_id, depends_on) VALUES + ('beta', 'alpha');\"}" + - role: tool + tool_call_id: toolcall_0 + content: "1 row(s) inserted. Last inserted row ID: 1." + - role: tool + tool_call_id: toolcall_1 + content: "1 row(s) inserted. Last inserted row ID: 2." + - role: tool + tool_call_id: toolcall_2 + content: "1 row(s) inserted. Last inserted row ID: 1." + - role: assistant + content: Done. All three statements executed successfully.