Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,270 changes: 3,037 additions & 233 deletions dotnet/src/Generated/Rpc.cs

Large diffs are not rendered by default.

229 changes: 226 additions & 3 deletions dotnet/src/Generated/SessionEvents.cs

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions dotnet/src/SessionFsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.Rpc;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;

namespace GitHub.Copilot;
Expand All @@ -27,6 +28,23 @@
public long? LastInsertRowid { get; set; }
}

/// <summary>
/// One statement in an atomic SQLite transaction passed to
/// <see cref="ISessionFsSqliteProvider.TransactionAsync"/>.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public sealed class SessionFsSqliteStatement
{
/// <summary>How to execute: <c>"exec"</c>, <c>"query"</c>, or <c>"run"</c>.</summary>
public SessionFsSqliteQueryType QueryType { get; set; }

/// <summary>SQL statement to execute.</summary>
public string Query { get; set; } = string.Empty;

/// <summary>Optional named bind parameters.</summary>
public IDictionary<string, object?>? Params { get; set; }
}

/// <summary>
/// Optional interface for <see cref="SessionFsProvider"/> subclasses that support
/// per-session SQLite databases. Implement this interface on your provider to enable
Expand All @@ -48,13 +66,53 @@
IDictionary<string, object?>? bindParams,
CancellationToken cancellationToken);

/// <summary>
/// Executes <paramref name="statements"/> atomically against the per-session database.
/// </summary>
/// <param name="statements">Statements to execute in order, inside a single transaction.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One result per statement, in the same order as <paramref name="statements"/>.</returns>
/// <exception cref="SessionFsSqliteTransactionException">
/// Thrown to tell the runtime how the failure should be classified. Any other exception
/// is reported as <see cref="SessionFsSqliteTransactionErrorClass.Fatal"/>.
/// </exception>
Task<IList<SessionFsSqliteResult>> TransactionAsync(
IList<SessionFsSqliteStatement> statements,
CancellationToken cancellationToken);

/// <summary>
/// Checks whether the per-session SQLite database already exists, without creating it.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<bool> ExistsAsync(CancellationToken cancellationToken);
}

/// <summary>
/// Thrown by an <see cref="ISessionFsSqliteProvider"/> to classify a failed SQLite transaction.
/// <see cref="SessionFsSqliteTransactionErrorClass.BusyOrLocked"/> guarantees the transaction
/// rolled back and is safe to retry; <see cref="SessionFsSqliteTransactionErrorClass.PostCommitAmbiguous"/>
/// must never be retried.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public sealed class SessionFsSqliteTransactionException : Exception
{
/// <summary>Initializes a new instance of the <see cref="SessionFsSqliteTransactionException"/> class.</summary>
/// <param name="message">Human-readable failure description.</param>
/// <param name="errorClass">How the runtime should classify the failure.</param>
/// <param name="innerException">Optional underlying exception.</param>
public SessionFsSqliteTransactionException(
string message,
SessionFsSqliteTransactionErrorClass errorClass,
Exception? innerException = null)
: base(message, innerException)
{
ErrorClass = errorClass;
}

/// <summary>Gets the failure classification reported to the runtime.</summary>
public SessionFsSqliteTransactionErrorClass ErrorClass { get; }
}

/// <summary>
/// Base class for session filesystem providers. Subclasses override the
/// virtual methods and use normal C# patterns (return values, throw exceptions).
Expand Down Expand Up @@ -309,6 +367,64 @@
}
}

async Task<SessionFsSqliteTransactionResult> ISessionFsHandler.SqliteTransactionAsync(SessionFsSqliteTransactionRequest request, CancellationToken cancellationToken)
{
if (this is not ISessionFsSqliteProvider sqliteProvider)
{
return new SessionFsSqliteTransactionResult
{
Error = new SessionFsSqliteTransactionError
{
ErrorClass = SessionFsSqliteTransactionErrorClass.Fatal,
Message = "SQLite is not supported by this provider.",
},
};
}

IList<SessionFsSqliteResult> results;
try
{
var statements = request.Statements.Select(statement => new SessionFsSqliteStatement
{
QueryType = statement.QueryType,
Query = statement.Query,
Params = statement.Params?.ToDictionary(kvp => kvp.Key, kvp => JsonElementToValue(kvp.Value)),
}).ToList();
results = await sqliteProvider.TransactionAsync(statements, cancellationToken).ConfigureAwait(false);
}
catch (SessionFsSqliteTransactionException ex)
{
return new SessionFsSqliteTransactionResult
{
Error = new SessionFsSqliteTransactionError { ErrorClass = ex.ErrorClass, Message = ex.Message },
};
}
catch (Exception ex)
{
return new SessionFsSqliteTransactionResult
{
Error = new SessionFsSqliteTransactionError
{
ErrorClass = SessionFsSqliteTransactionErrorClass.Fatal,
Message = ex.Message,
},
};
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment thread
stephentoub marked this conversation as resolved.

return new SessionFsSqliteTransactionResult
{
Results = results.Select(result => new SessionFsSqliteQueryResult
{
Rows = result.Rows?.Select(row => (IDictionary<string, JsonElement>)row.ToDictionary(
kvp => kvp.Key,
kvp => CopilotClient.ToJsonElementForWire(kvp.Value)!.Value)).ToList() ?? [],
Columns = result.Columns ?? [],
RowsAffected = result.RowsAffected,
LastInsertRowid = result.LastInsertRowid,
}).ToList(),
};
}

async Task<SessionFsSqliteExistsResult> ISessionFsHandler.SqliteExistsAsync(SessionFsSqliteExistsRequest request, CancellationToken cancellationToken)
{
if (this is not ISessionFsSqliteProvider sqliteProvider)
Expand Down
58 changes: 50 additions & 8 deletions dotnet/test/E2E/InMemorySessionFsSqliteHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,68 @@
string query,
IDictionary<string, object?>? bindParams,
CancellationToken cancellationToken)
{
return Task.FromResult(RunStatement(GetOrCreateDb(), null, queryType, query, bindParams));
}

public Task<IList<SessionFsSqliteResult>> TransactionAsync(
IList<SessionFsSqliteStatement> statements,
CancellationToken cancellationToken)
{
var db = GetOrCreateDb();
using var transaction = db.BeginTransaction();
try
{
IList<SessionFsSqliteResult> results = statements
.Select(statement => RunStatement(db, transaction, statement.QueryType, statement.Query, statement.Params)
?? new SessionFsSqliteResult())
.ToList();
transaction.Commit();
return Task.FromResult(results);
}
catch (SqliteException ex)
{
transaction.Rollback();
var errorClass = ex.SqliteErrorCode is 5 or 6
? SessionFsSqliteTransactionErrorClass.BusyOrLocked
: SessionFsSqliteTransactionErrorClass.Fatal;
throw new SessionFsSqliteTransactionException(ex.Message, errorClass, ex);
}
catch (Exception ex)
{
transaction.Rollback();
throw new SessionFsSqliteTransactionException(ex.Message, SessionFsSqliteTransactionErrorClass.Fatal, ex);
}

Check notice

Code scanning / CodeQL

Generic catch clause Note test

Generic catch clause.
Comment thread
stephentoub marked this conversation as resolved.
}

private SessionFsSqliteResult? RunStatement(
SqliteConnection db,
SqliteTransaction? transaction,
SessionFsSqliteQueryType queryType,
string query,
IDictionary<string, object?>? bindParams)
{
sqliteCalls.Add(new SqliteCall(sessionId, queryType.Value, query));

var trimmed = query.Trim();
if (trimmed.Length == 0)
{
return Task.FromResult<SessionFsSqliteResult?>(null);
return null;
}

var db = GetOrCreateDb();

if (queryType == SessionFsSqliteQueryType.Exec)
{
using var cmd = db.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = trimmed;
cmd.ExecuteNonQuery();
return Task.FromResult<SessionFsSqliteResult?>(null);
return null;
}

if (queryType == SessionFsSqliteQueryType.Query)
{
using var cmd = db.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = trimmed;
AddParams(cmd, bindParams);

Expand All @@ -88,33 +128,35 @@
rows.Add(row);
}

return Task.FromResult<SessionFsSqliteResult?>(new SessionFsSqliteResult
return new SessionFsSqliteResult
{
Columns = columns,
Rows = rows,
RowsAffected = 0,
});
};
}

if (queryType == SessionFsSqliteQueryType.Run)
{
using var cmd = db.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = trimmed;
AddParams(cmd, bindParams);

var rowsAffected = cmd.ExecuteNonQuery();

using var rowidCmd = db.CreateCommand();
rowidCmd.Transaction = transaction;
rowidCmd.CommandText = "SELECT last_insert_rowid()";
var lastRowid = rowidCmd.ExecuteScalar();

return Task.FromResult<SessionFsSqliteResult?>(new SessionFsSqliteResult
return new SessionFsSqliteResult
{
Columns = [],
Rows = [],
RowsAffected = rowsAffected,
LastInsertRowid = lastRowid is long l ? l : null,
});
};
}

throw new ArgumentException($"Unknown queryType: {queryType}");
Expand Down
2 changes: 2 additions & 0 deletions dotnet/test/E2E/RpcShellAndFleetE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public async Task Should_Kill_Shell_Process()
var killResult = await session.Rpc.Shell.KillAsync(execResult.ProcessId);

Assert.True(killResult.Killed);

await session.DisposeAsync();
}

[Fact]
Expand Down
3 changes: 3 additions & 0 deletions dotnet/test/E2E/SessionFsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,9 @@ protected override Task RenameAsync(string src, string dest, CancellationToken c
Task<SessionFsSqliteResult?> ISessionFsSqliteProvider.QueryAsync(SessionFsSqliteQueryType queryType, string query, IDictionary<string, object?>? bindParams, CancellationToken cancellationToken) =>
Task.FromException<SessionFsSqliteResult?>(exception);

Task<IList<SessionFsSqliteResult>> ISessionFsSqliteProvider.TransactionAsync(IList<SessionFsSqliteStatement> statements, CancellationToken cancellationToken) =>
Task.FromException<IList<SessionFsSqliteResult>>(exception);

Task<bool> ISessionFsSqliteProvider.ExistsAsync(CancellationToken cancellationToken) =>
Task.FromException<bool>(exception);
}
Expand Down
2 changes: 1 addition & 1 deletion go/internal/e2e/rpc_session_state_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1083,7 +1083,7 @@ func TestRPCSessionStateE2E(t *testing.T) {
t.Errorf("Expected SetApproveAll(true) to succeed, got %+v", approve)
}

reset, err := session.RPC.Permissions.ResetSessionApprovals(t.Context())
reset, err := session.RPC.Permissions.ResetSessionApprovals(t.Context(), &rpc.PermissionsResetSessionApprovalsRequest{})
if err != nil {
t.Fatalf("Failed to call ResetSessionApprovals: %v", err)
}
Expand Down
4 changes: 4 additions & 0 deletions go/internal/e2e/rpc_shell_and_fleet_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ func TestRPCShellAndFleetE2E(t *testing.T) {
if !kill.Killed {
t.Errorf("Expected shell.kill to report Killed=true, got %+v", kill)
}

if err := session.Disconnect(); err != nil {
t.Fatalf("Failed to disconnect session: %v", err)
}
})

t.Run("should start fleet and complete custom tool task", func(t *testing.T) {
Expand Down
53 changes: 43 additions & 10 deletions go/internal/e2e/session_fs_sqlite_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,39 +198,72 @@ func (p *inMemorySqliteProvider) Rename(src string, dest string) error {
func (p *inMemorySqliteProvider) SqliteQuery(queryType rpc.SessionFSSqliteQueryType, query string, params map[string]any) (*copilot.SessionFSSqliteQueryResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.runQueryLocked(queryType, query), nil
}

func (p *inMemorySqliteProvider) SqliteTransaction(statements []rpc.SessionFSSqliteTransactionStatement) ([]copilot.SessionFSSqliteQueryResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
results := make([]copilot.SessionFSSqliteQueryResult, 0, len(statements))
for _, statement := range statements {
results = append(results, *p.runQueryLocked(statement.QueryType, statement.Query))
}
return results, nil
}

// runQueryLocked returns canned results based on query type. The agent doesn't
// know or care whether a real SQLite database is behind this — it just receives
// SQL tool results. These stubs return plausible responses so the agent can
// proceed normally without pulling in a real SQLite dependency.
//
// Callers must hold p.mu.
func (p *inMemorySqliteProvider) runQueryLocked(queryType rpc.SessionFSSqliteQueryType, query string) *copilot.SessionFSSqliteQueryResult {
p.hadQuery = true
*p.sqliteCalls = append(*p.sqliteCalls, sqliteCall{
SessionID: p.sessionID,
QueryType: string(queryType),
Query: query,
})

// Return canned results based on query type. The agent doesn't know or care
// whether a real SQLite database is behind this — it just receives SQL tool
// results. These stubs return plausible responses so the agent can proceed
// normally without pulling in a real SQLite dependency.
upper := strings.ToUpper(strings.TrimSpace(query))
switch queryType {
case rpc.SessionFSSqliteQueryTypeExec:
return &copilot.SessionFSSqliteQueryResult{Columns: []string{}, Rows: []map[string]any{}}, nil
return &copilot.SessionFSSqliteQueryResult{Columns: []string{}, Rows: []map[string]any{}}
case rpc.SessionFSSqliteQueryTypeRun:
lastID := int64(1)
return &copilot.SessionFSSqliteQueryResult{
Columns: []string{},
Rows: []map[string]any{},
RowsAffected: 1,
LastInsertRowid: &lastID,
}, nil
}
case rpc.SessionFSSqliteQueryTypeQuery:
if strings.Contains(upper, "SELECT") {
// Only the "items" table the test asks the agent to create is modelled
// here. The runtime also reads its own bookkeeping tables (for example
// inbox_entries) through this provider and deserializes those rows into
// typed structs, so returning the canned item row for every SELECT would
// make the runtime reject rows it cannot parse.
if strings.Contains(upper, "SELECT") && readsTable(upper, "ITEMS") {
return &copilot.SessionFSSqliteQueryResult{
Columns: []string{"id", "name"},
Rows: []map[string]any{{"id": "a1", "name": "Widget"}},
}, nil
}
}
return &copilot.SessionFSSqliteQueryResult{Columns: []string{}, Rows: []map[string]any{}}
}
return &copilot.SessionFSSqliteQueryResult{Columns: []string{}, Rows: []map[string]any{}}
}

// readsTable reports whether an upper-cased SQL statement selects from the given
// table, tolerating the quoting styles the agent may emit.
func readsTable(upperQuery string, table string) bool {
names := []string{table, `"` + table + `"`, "`" + table + "`", "[" + table + "]", "MAIN." + table}
for _, name := range names {
if strings.Contains(upperQuery, "FROM "+name) {
return true
}
return &copilot.SessionFSSqliteQueryResult{Columns: []string{}, Rows: []map[string]any{}}, nil
}
return &copilot.SessionFSSqliteQueryResult{Columns: []string{}, Rows: []map[string]any{}}, nil
return false
}

func (p *inMemorySqliteProvider) SqliteExists() (bool, error) {
Expand Down
Loading
Loading