feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -0,0 +1,66 @@
package tech.easyflow.chatlog.service;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.Mockito;
import tech.easyflow.chatlog.cache.ChatHotStateService;
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.List;
/**
* {@link ChatPersistDispatcher} 会话删除可靠持久化顺序测试。
*/
public class ChatPersistDispatcherTest {
/**
* 验证 MySQL 同步删除失败时不发送异步事件。
*/
@Test
public void deleteShouldStopBeforeProducerWhenMysqlApplyFails() {
Fixture fixture = fixture();
Mockito.doThrow(new IllegalStateException("mysql unavailable"))
.when(fixture.applyService).apply(Mockito.anyList());
Assert.assertThrows(BusinessException.class, () -> fixture.dispatcher.deleteSession(
BigInteger.ONE, BigInteger.TWO, BigInteger.TWO));
Mockito.verify(fixture.eventProducer, Mockito.never()).send(Mockito.any());
}
/**
* 验证消息发送失败发生在 MySQL 同步删除成功之后并继续向上抛出。
*/
@Test
public void deleteShouldPersistBeforePropagatingProducerFailure() {
Fixture fixture = fixture();
Mockito.doThrow(new IllegalStateException("mq unavailable"))
.when(fixture.eventProducer).send(Mockito.any());
Assert.assertThrows(IllegalStateException.class, () -> fixture.dispatcher.deleteSession(
BigInteger.ONE, BigInteger.TWO, BigInteger.TWO));
InOrder order = Mockito.inOrder(fixture.applyService, fixture.eventProducer);
order.verify(fixture.applyService).apply(Mockito.<List<ChatPersistEvent>>any());
order.verify(fixture.eventProducer).send(Mockito.any());
}
private Fixture fixture() {
ChatHotStateService hotStateService = Mockito.mock(ChatHotStateService.class);
ChatPersistEventProducer eventProducer = Mockito.mock(ChatPersistEventProducer.class);
ChatPersistMySqlApplyService applyService = Mockito.mock(ChatPersistMySqlApplyService.class);
ChatJsonSupport jsonSupport = Mockito.mock(ChatJsonSupport.class);
Mockito.when(jsonSupport.toJson(Mockito.any())).thenReturn("{}");
return new Fixture(eventProducer, applyService,
new ChatPersistDispatcher(hotStateService, eventProducer, applyService, jsonSupport));
}
private record Fixture(ChatPersistEventProducer eventProducer,
ChatPersistMySqlApplyService applyService,
ChatPersistDispatcher dispatcher) {
}
}

View File

@@ -0,0 +1,42 @@
package tech.easyflow.chatlog.service.impl;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import java.math.BigInteger;
import java.util.List;
/**
* {@link ChatHistoryQueryServiceImpl} 归档历史安全投影测试。
*/
public class ChatHistoryQueryServiceImplTest {
/**
* 验证 UserCenter/Admin 归档历史返回前统一执行一次会话批量投影。
*/
@Test
public void shouldProjectAnalyticalHistoryOnce() {
ChatAnalyticalDBRepository repository = Mockito.mock(ChatAnalyticalDBRepository.class);
ChatSessionQueryService sessionQueryService = Mockito.mock(ChatSessionQueryService.class);
ChatSessionExtensionDispatcher dispatcher = Mockito.mock(ChatSessionExtensionDispatcher.class);
ChatHistoryPage page = new ChatHistoryPage();
page.setRecords(List.of(new ChatMessageRecord()));
ChatSessionSummary summary = new ChatSessionSummary();
Mockito.when(repository.queryHistory(Mockito.eq(BigInteger.ONE), Mockito.any())).thenReturn(page);
Mockito.when(sessionQueryService.getSessionSummary(BigInteger.ONE)).thenReturn(summary);
ChatHistoryQueryServiceImpl service =
new ChatHistoryQueryServiceImpl(repository, sessionQueryService, dispatcher);
Assert.assertSame(page, service.queryHistoryMessages(BigInteger.ONE, new ChatPageQuery()));
Mockito.verify(dispatcher, Mockito.times(1)).projectMessages(summary, page.getRecords());
}
}

View File

@@ -8,6 +8,8 @@ import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
import tech.easyflow.chatlog.service.ChatRoundCommandService;
import tech.easyflow.chatlog.service.ChatRoundQueryService;
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.chatlog.support.ChatConstants;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -32,7 +34,8 @@ public class ChatRoundOperateServiceImplTest {
queryService.latestRound = queryService.round;
queryService.targetVariant = message(BigInteger.valueOf(3002), 2);
FakeRoundCommandService commandService = new FakeRoundCommandService();
ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, commandService);
ProjectionFixture projection = withProjection(new ChatRoundOperateServiceImpl(queryService, commandService));
ChatRoundOperateServiceImpl service = projection.service;
ChatMessageRecord selected = service.selectVariant(
BigInteger.valueOf(1001),
@@ -49,6 +52,8 @@ public class ChatRoundOperateServiceImplTest {
Assert.assertEquals(0, queryService.listRoundVariantsCalls);
Assert.assertNotNull(commandService.selectedCommand);
Assert.assertEquals(BigInteger.valueOf(3002), commandService.selectedCommand.getSelectedAssistantMessageId());
org.mockito.Mockito.verify(projection.dispatcher).projectMessages(
projection.summary, List.of(selected));
}
/**
@@ -60,7 +65,9 @@ public class ChatRoundOperateServiceImplTest {
queryService.round = round(BigInteger.valueOf(1001), BigInteger.valueOf(2001), 2, ChatConstants.ROUND_STATUS_READY);
queryService.latestRound = queryService.round;
queryService.variants = List.of(message(BigInteger.valueOf(3001), 1), message(BigInteger.valueOf(3002), 2));
ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService());
ProjectionFixture projection = withProjection(
new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService()));
ChatRoundOperateServiceImpl service = projection.service;
List<ChatMessageRecord> variants = service.listVariants(BigInteger.valueOf(1001), BigInteger.valueOf(2001));
@@ -70,6 +77,7 @@ public class ChatRoundOperateServiceImplTest {
Assert.assertEquals(Integer.valueOf(2), variant.getSelectedVariantIndex());
Assert.assertEquals(Boolean.TRUE, variant.getSwitchable());
}
org.mockito.Mockito.verify(projection.dispatcher).projectMessages(projection.summary, variants);
}
/**
@@ -134,6 +142,23 @@ public class ChatRoundOperateServiceImplTest {
return round;
}
private ProjectionFixture withProjection(ChatRoundOperateServiceImpl service) {
ChatSessionQueryService sessionQueryService = org.mockito.Mockito.mock(ChatSessionQueryService.class);
ChatSessionExtensionDispatcher dispatcher = org.mockito.Mockito.mock(ChatSessionExtensionDispatcher.class);
tech.easyflow.chatlog.domain.dto.ChatSessionSummary summary =
new tech.easyflow.chatlog.domain.dto.ChatSessionSummary();
summary.setId(BigInteger.valueOf(1001));
org.mockito.Mockito.when(sessionQueryService.getSessionSummary(BigInteger.valueOf(1001)))
.thenReturn(summary);
service.setProjectionDependencies(sessionQueryService, dispatcher);
return new ProjectionFixture(service, dispatcher, summary);
}
private record ProjectionFixture(ChatRoundOperateServiceImpl service,
ChatSessionExtensionDispatcher dispatcher,
tech.easyflow.chatlog.domain.dto.ChatSessionSummary summary) {
}
private static ChatMessageRecord message(BigInteger id, int variantIndex) {
ChatMessageRecord record = new ChatMessageRecord();
record.setId(id);

View File

@@ -0,0 +1,66 @@
package tech.easyflow.chatlog.service.impl;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.Mockito;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import java.math.BigInteger;
/**
* {@link ChatSessionCommandServiceImpl} 删除扩展顺序与失败传播测试。
*/
public class ChatSessionCommandServiceImplTest {
/**
* 验证所有删除入口共享 before、删除、after 的固定顺序。
*/
@Test
public void deleteShouldInvokeLifecycleHooksAroundPersistDispatch() {
Fixture fixture = fixture();
fixture.service.deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO);
InOrder order = Mockito.inOrder(fixture.extensions, fixture.persistDispatcher);
order.verify(fixture.extensions).beforeDelete(fixture.summary, BigInteger.TWO, BigInteger.TWO);
order.verify(fixture.persistDispatcher).deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO);
order.verify(fixture.extensions).afterDelete(fixture.summary, BigInteger.TWO, BigInteger.TWO);
}
/**
* 验证会话删除失败时不会提前执行 after hook 标记关联资源删除。
*/
@Test
public void deleteFailureShouldNotInvokeAfterHook() {
Fixture fixture = fixture();
Mockito.doThrow(new IllegalStateException("mq unavailable"))
.when(fixture.persistDispatcher).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
Assert.assertThrows(IllegalStateException.class,
() -> fixture.service.deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO));
Mockito.verify(fixture.extensions, Mockito.never())
.afterDelete(Mockito.any(), Mockito.any(), Mockito.any());
}
private Fixture fixture() {
ChatPersistDispatcher persistDispatcher = Mockito.mock(ChatPersistDispatcher.class);
ChatSessionQueryService queryService = Mockito.mock(ChatSessionQueryService.class);
ChatSessionExtensionDispatcher extensions = Mockito.mock(ChatSessionExtensionDispatcher.class);
ChatSessionSummary summary = new ChatSessionSummary();
summary.setId(BigInteger.ONE);
Mockito.when(queryService.getSessionSummary(BigInteger.ONE)).thenReturn(summary);
return new Fixture(persistDispatcher, extensions, summary,
new ChatSessionCommandServiceImpl(persistDispatcher, queryService, extensions));
}
private record Fixture(ChatPersistDispatcher persistDispatcher,
ChatSessionExtensionDispatcher extensions,
ChatSessionSummary summary,
ChatSessionCommandServiceImpl service) {
}
}

View File

@@ -14,6 +14,7 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
import java.math.BigInteger;
import java.time.YearMonth;
@@ -144,6 +145,28 @@ public class ChatSessionQueryServiceImplTest {
Assert.assertEquals(4, page.getTotal());
}
/**
* 验证消息分页返回前仅执行一次会话级批量安全投影。
*/
@Test
public void pageMainlineMessagesShouldProjectRecordsOnce() {
FakeSessionRepository sessionRepository = new FakeSessionRepository();
sessionRepository.summary = session(BigInteger.valueOf(2003), 1);
FakeLogRepository logRepository = new FakeLogRepository();
logRepository.records = List.of(message(5001));
ChatSessionQueryServiceImpl service = new ChatSessionQueryServiceImpl(
sessionRepository, logRepository,
new FakeTableManager(List.of(YearMonth.of(2026, 5))), new FakeHotStateService());
ChatSessionExtensionDispatcher dispatcher =
org.mockito.Mockito.mock(ChatSessionExtensionDispatcher.class);
service.setExtensionDispatcher(dispatcher);
ChatHistoryPage page = service.pageMainlineMessages(BigInteger.valueOf(2003), new ChatPageQuery());
org.mockito.Mockito.verify(dispatcher, org.mockito.Mockito.times(1))
.projectMessages(sessionRepository.summary, page.getRecords());
}
private static ChatSessionSummary session(BigInteger id, int messageCount) {
ChatSessionSummary summary = new ChatSessionSummary();
summary.setId(id);