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
187 changes: 187 additions & 0 deletions test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,100 @@ await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResult
call.ResolvedAt.Should().NotBeNull();
}

[Fact]
public async Task HandleResolveForwardedToolResultAsync_WhenCallIsAlreadyResolved_ShouldPreserveFirstResolvedAt()
{
var actor = CreateActor("resp_1");
var firstResolvedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:02:00+00:00"));
var secondResolvedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:03:00+00:00"));

await actor.HandleRegisterAsync(new RegisterResponseSessionRequested
{
Record = BuildRecord("resp_1"),
});
await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested
{
ResponseId = "resp_1",
Call = BuildToolCall("call_1"),
});
await actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
SchemaHash = "schema-1",
Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""),
});

await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
ResolvedAt = firstResolvedAt,
});
var versionAfterFirstResolve = actor.State.LastAppliedEventVersion;

await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
ResolvedAt = secondResolvedAt,
});

actor.State.LastAppliedEventVersion.Should().Be(versionAfterFirstResolve);
var call = actor.State.ForwardedToolCalls.Should().ContainSingle().Which;
call.Status.Should().Be(LlmSessionForwardedToolCallStatus.Resolved);
call.ResolvedAt.Should().Be(firstResolvedAt);
ResponsesJsonValues.ToBoundaryJson(call.Result).Should().Be("""{"temperature":28}""");
}

[Fact]
public async Task ForwardedToolCallLifecycle_ShouldDurablyAdvanceFromPendingToReceivedToResolved()
{
var actor = CreateActor("resp_1");
var emittedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00"));
var receivedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:01:00+00:00"));
var resolvedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:02:00+00:00"));

await actor.HandleRegisterAsync(new RegisterResponseSessionRequested
{
Record = BuildRecord("resp_1"),
});

var call = BuildToolCall("call_1");
call.EmittedAt = emittedAt.Clone();
await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested
{
ResponseId = "resp_1",
Call = call,
});

await actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
SchemaHash = "schema-1",
Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""),
ReceivedAt = receivedAt.Clone(),
});

await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
ResolvedAt = resolvedAt.Clone(),
});

actor.State.LastAppliedEventVersion.Should().Be(4);
actor.State.LastEventId.Should().Be("resp_1:tool:call_1:resolved");
actor.State.ForwardedToolCalls.Should().ContainSingle();
var persisted = actor.State.ForwardedToolCalls[0];
persisted.Status.Should().Be(LlmSessionForwardedToolCallStatus.Resolved);
persisted.EmittedAt.Should().Be(emittedAt);
persisted.ReceivedAt.Should().Be(receivedAt);
persisted.ResolvedAt.Should().Be(resolvedAt);
ResponsesJsonValues.ToBoundaryJson(persisted.Result).Should().Be("""{"temperature":28}""");
}

[Fact]
public async Task HandleReceiveForwardedToolResultAsync_ShouldRejectSchemaMismatch()
{
Expand Down Expand Up @@ -258,6 +352,51 @@ await actor.HandleUpdateStatusAsync(new UpdateResponseSessionStatusRequested
.Which.Status.Should().Be(LlmSessionForwardedToolCallStatus.Cancelled);
}

[Fact]
public async Task HandleReceiveForwardedToolResultAsync_ShouldRejectCancelledCall_AndPreserveTerminalState()
{
var actor = CreateActor("resp_1");
await actor.HandleRegisterAsync(new RegisterResponseSessionRequested
{
Record = BuildRecord("resp_1"),
});
await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested
{
ResponseId = "resp_1",
Call = BuildToolCall("call_1"),
});
await actor.HandleUpdateStatusAsync(new UpdateResponseSessionStatusRequested
{
ResponseId = "resp_1",
Status = LlmSessionStatus.Cancelled,
});
var versionAfterCancel = actor.State.LastAppliedEventVersion;

var receive = () => actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
SchemaHash = "schema-1",
Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""),
});
var resolve = () => actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
});

await receive.Should().ThrowAsync<InvalidOperationException>();
await resolve.Should().ThrowAsync<InvalidOperationException>();

actor.State.LastAppliedEventVersion.Should().Be(versionAfterCancel);
actor.State.Record!.Status.Should().Be(LlmSessionStatus.Cancelled);
actor.State.ForwardedToolCalls.Should().ContainSingle();
var call = actor.State.ForwardedToolCalls[0];
call.Status.Should().Be(LlmSessionForwardedToolCallStatus.Cancelled);
call.Result.Should().BeNull();
call.ResolvedAt.Should().BeNull();
}

[Fact]
public async Task HandleExpireResponseSessionAsync_ShouldExpirePendingToolCallsWithSyntheticError()
{
Expand Down Expand Up @@ -290,6 +429,54 @@ await actor.HandleExpireResponseSessionAsync(new ExpireResponseSessionRequested
call.ReceivedAt.Should().NotBeNull();
}

[Fact]
public async Task HandleReceiveForwardedToolResultAsync_ShouldRejectExpiredCall_AndPreserveExpiredState()
{
var actor = CreateActor("resp_1");
var record = BuildRecord("resp_1");
record.CreatedAt = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-2));
record.Ttl = Duration.FromTimeSpan(TimeSpan.FromHours(1));
await actor.HandleRegisterAsync(new RegisterResponseSessionRequested
{
Record = record,
});
await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested
{
ResponseId = "resp_1",
Call = BuildToolCall("call_1"),
});
await actor.HandleExpireResponseSessionAsync(new ExpireResponseSessionRequested
{
ResponseId = "resp_1",
ObservedAt = Timestamp.FromDateTime(DateTime.UtcNow),
});
var versionAfterExpire = actor.State.LastAppliedEventVersion;

var receive = () => actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
SchemaHash = "schema-1",
Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""),
});
var resolve = () => actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested
{
ResponseId = "resp_1",
CallId = "call_1",
});

await receive.Should().ThrowAsync<InvalidOperationException>();
await resolve.Should().ThrowAsync<InvalidOperationException>();

actor.State.LastAppliedEventVersion.Should().Be(versionAfterExpire);
actor.State.Record!.Status.Should().Be(LlmSessionStatus.Expired);
actor.State.ForwardedToolCalls.Should().ContainSingle();
var call = actor.State.ForwardedToolCalls[0];
call.Status.Should().Be(LlmSessionForwardedToolCallStatus.Expired);
call.Result.Should().BeNull();
call.ResolvedAt.Should().BeNull();
}

private static LlmSessionGAgent CreateActor(string responseId) =>
GAgentServiceTestKit.CreateStatefulAgent<LlmSessionGAgent, LlmSessionState>(
new InMemoryEventStore(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,66 @@ await actor.HandleRecordTaskAsync(new RecordResponsesTaskRequested
.Should().Be("""{"status":"accepted"}""");
}

[Fact]
public async Task HandleApplyTodoWriteAsync_ShouldIgnoreDuplicateWrite_AndKeepDurableStateStable()
{
var actor = CreateActor();
await RegisterAsync(actor);

var observedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00"));
const string argumentsJson = """{"todos":[{"id":"todo-1","content":"Ship","status":"pending"}]}""";
var apply = new ApplyResponsesTodoWriteRequested
{
ScopeId = "scope-1",
OwnerSubject = "owner-1",
SourceResponseId = "resp_1",
Arguments = ResponsesJsonValues.ParseBoundaryPayload(argumentsJson),
ObservedAt = observedAt,
};
apply.TodoItems.AddRange(ResponsesTodoItemParser.Parse(argumentsJson, "resp_1", observedAt));

await actor.HandleApplyTodoWriteAsync(apply);
var versionAfterFirstWrite = actor.State.LastAppliedEventVersion;
var updatedAtAfterFirstWrite = actor.State.Record!.UpdatedAt!.Clone();

await actor.HandleApplyTodoWriteAsync(apply);

actor.State.LastAppliedEventVersion.Should().Be(versionAfterFirstWrite);
actor.State.Record!.UpdatedAt.Should().Be(updatedAtAfterFirstWrite);
actor.State.TodoItems.Should().ContainSingle();
actor.State.TodoItems[0].Id.Should().Be("todo-1");
actor.State.TodoItems[0].SourceResponseId.Should().Be("resp_1");
}

[Fact]
public async Task HandleRecordTaskAsync_ShouldIgnoreDuplicateTaskRecord_AndReusePersistedTrace()
{
var actor = CreateActor();
await RegisterAsync(actor);

var command = new RecordResponsesTaskRequested
{
SourceResponseId = "resp_1",
TaskId = "task_1",
ChildActorId = "responses-agent-tools-scope-task-1",
Description = "summarize",
Arguments = ResponsesJsonValues.ParseBoundaryPayload("""{"prompt":"summarize"}"""),
Result = ResponsesJsonValues.ParseBoundaryPayload("""{"status":"accepted"}"""),
Status = ResponsesAgentToolTaskStatus.Accepted,
ObservedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00")),
};

await actor.HandleRecordTaskAsync(command);
var versionAfterFirstRecord = actor.State.LastAppliedEventVersion;

await actor.HandleRecordTaskAsync(command);

actor.State.LastAppliedEventVersion.Should().Be(versionAfterFirstRecord);
actor.State.TaskTraces.Should().ContainSingle();
actor.State.TaskTraces[0].TaskId.Should().Be("task_1");
actor.State.TaskTraces[0].Status.Should().Be(ResponsesAgentToolTaskStatus.Accepted);
}

private static ResponsesAgentToolStateGAgent CreateActor() =>
GAgentServiceTestKit.CreateStatefulAgent<ResponsesAgentToolStateGAgent, ResponsesAgentToolState>(
new InMemoryEventStore(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,47 @@ await projector.ProjectAsync(
cache!.ResultJson.Should().Be("""{"content":"fresh"}""");
}

[Fact]
public async Task QueryReader_ShouldRemapFreshSnapshotFromPersistedReadModelAcrossRepeatedReads()
{
var store = new RecordingDocumentStore<ResponsesAgentToolStateCurrentStateReadModel>(x => x.Id);
var projector = new ResponsesAgentToolStateCurrentStateProjector(
store,
new FixedProjectionClock(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00")));
var observedAt = DateTimeOffset.Parse("2026-05-12T00:01:00+00:00");

await projector.ProjectAsync(
new ResponsesAgentToolStateCurrentStateProjectionContext
{
RootActorId = ActorId,
ProjectionKind = "responses-agent-tools",
},
WrapCommittedState(observedAt));

var reader = new ResponsesAgentToolStateQueryReader(store);
var first = await reader.GetAsync(ScopeId, OwnerSubject);
var persisted = await store.GetAsync(ActorId);

persisted.Should().NotBeNull();
persisted!.Todos[0].Content = "Store changed";
persisted.Tasks[0].ChildActorId = "child-2";
persisted.WebCacheEntries[0].HitCount = 9;

var second = await new ResponsesAgentToolStateQueryReader(store).GetAsync(ScopeId, OwnerSubject);

first.Should().NotBeNull();
second.Should().NotBeNull();
first!.Todos.Should().ContainSingle(x => x.Content == "Ship");
first.Tasks.Should().ContainSingle(x => x.ChildActorId == "child-1");
first.WebCacheEntries.Should().ContainSingle(x => x.HitCount == 0);
second.Should().NotBeSameAs(first);
second!.ActorId.Should().Be(ActorId);
second.StateVersion.Should().Be(4);
second.Todos.Should().ContainSingle(x => x.Id == "todo-1" && x.Content == "Store changed");
second.Tasks.Should().ContainSingle(x => x.TaskId == "task_1" && x.ChildActorId == "child-2");
second.WebCacheEntries.Should().ContainSingle(x => x.CacheKey == "cache-1" && x.HitCount == 9);
}

private static EventEnvelope WrapCommittedState(DateTimeOffset observedAt)
{
var state = new ResponsesAgentToolState
Expand Down
Loading
Loading