feat(harness): add chat application use case

This commit is contained in:
zhuyongxin
2026-07-22 00:57:39 +08:00
parent ee0949d464
commit f8809cb7dd
56 changed files with 2815 additions and 6 deletions
@@ -0,0 +1,215 @@
package com.superbiz.agent.harness.application;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.agent.HarnessEvidenceTools;
import com.superbiz.agent.harness.application.executor.KnowledgeQueryExecutor;
import com.superbiz.agent.harness.application.executor.KnowledgeQueryLimits;
import com.superbiz.agent.harness.application.executor.SingleTurnExecutorLimits;
import com.superbiz.agent.harness.application.executor.SystemChatExecutor;
import com.superbiz.agent.harness.application.routing.IntentRouter;
import com.superbiz.agent.harness.application.routing.IntentRouterInput;
import com.superbiz.agent.harness.application.routing.IntentRouterLimits;
import com.superbiz.agent.harness.application.routing.IntentRoutingException;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.contract.IntentType;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunBudgetLimits;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
import com.superbiz.agent.harness.retry.RetryAttempt;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryErrorCode;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ApplicationExecutorsTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final ExecutorService executor = Executors.newCachedThreadPool();
@AfterEach
void shutdown() {
executor.shutdownNow();
}
@Test
void routerRetriesInvalidOutputWithSameMinimalInput() {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-router", "run-router");
ScriptedModel model = new ScriptedModel(
"{\"intent\":\"OTHER\"}", "{\"intent\":\"DIAGNOSIS\"}");
List<RetryAttempt> attempts = new ArrayList<>();
IntentRouter router = router(core, model, attempts::add);
IntentType intent = router.route(context,
new IntentRouterInput("那退款服务呢?", IntentType.DIAGNOSIS, "订单支付为什么超时?"));
assertEquals(IntentType.DIAGNOSIS, intent);
assertEquals(2, model.calls.get());
assertEquals(model.prompts.get(0), model.prompts.get(1));
assertTrue(model.prompts.get(0).contains("那退款服务呢?"));
assertFalse(model.prompts.get(0).contains("tool_call_id"));
assertEquals(2, attempts.size());
}
@Test
void routerNeverDefaultsToDiagnosisAfterTwoInvalidOutputs() {
DiagnosisHarnessCore core = core();
ScriptedModel model = new ScriptedModel("bad", "still-bad");
IntentRouter router = router(core, model, ignored -> { });
assertThrows(IntentRoutingException.class, () -> router.route(
core.startRun("session-fail", "run-fail"),
new IntentRouterInput("排查故障", null, null)));
assertEquals(2, model.calls.get());
}
@Test
void systemChatUsesOneDirectModelCall() {
DiagnosisHarnessCore core = core();
ScriptedModel model = new ScriptedModel("你好,我可以帮助查询知识和诊断只读数据。");
SystemChatExecutor system = new SystemChatExecutor(
core, new GuardModelCall(core, model, executor),
new SingleTurnExecutorLimits(10_000, 10_000, Duration.ofSeconds(2)));
SystemChatContent content = system.execute(
core.startRun("session-system", "run-system"), "你能做什么?");
assertTrue(content.answer().contains("知识"));
assertEquals(1, model.calls.get());
}
@Test
void knowledgeAnswerValidatesExactReferencesAndRemovesToolId() throws Exception {
DiagnosisHarnessCore core = core();
AtomicInteger lookupCalls = new AtomicInteger();
HarnessEvidenceTools tools = tools(lookupCalls, EvidenceStatus.EVIDENCE_FOUND);
ScriptedModel model = new ScriptedModel("""
{"answer_items":[{"text":"先检查连接池等待线程。","tool_call_id":"knowledge-1",
"document_ids":["doc-1"]}],"limitations":["未查询实时日志"]}
""");
KnowledgeQueryExecutor knowledge = knowledge(core, tools, model);
KnowledgeContent content = knowledge.execute(
core.startRun("session-knowledge", "run-knowledge"), "连接池超时怎么排查?");
assertEquals(1, lookupCalls.get());
assertEquals(1, model.calls.get());
assertEquals("doc-1", content.references().get(0).documentId());
assertFalse(objectMapper.writeValueAsString(content).contains("knowledge-1"));
assertFalse(objectMapper.writeValueAsString(content).contains("tool_call_id"));
}
@Test
void knowledgeNoEvidenceSkipsModelAndFabricatedDocumentFailsClosed() {
DiagnosisHarnessCore core = core();
AtomicInteger emptyCalls = new AtomicInteger();
ScriptedModel unused = new ScriptedModel();
KnowledgeContent empty = knowledge(core, tools(emptyCalls, EvidenceStatus.NO_EVIDENCE), unused)
.execute(core.startRun("session-empty", "run-empty"), "不存在的手册");
assertEquals(1, emptyCalls.get());
assertEquals(0, unused.calls.get());
assertTrue(empty.references().isEmpty());
ScriptedModel fabricated = new ScriptedModel("""
{"answer_items":[{"text":"fabricated","tool_call_id":"knowledge-1",
"document_ids":["unknown-doc"]}],"limitations":[]}
""");
assertThrows(ChatApplicationException.class, () ->
knowledge(core, tools(new AtomicInteger(), EvidenceStatus.EVIDENCE_FOUND), fabricated)
.execute(core.startRun("session-fake", "run-fake"), "排查手册"));
}
private IntentRouter router(DiagnosisHarnessCore core, ChatModel model,
java.util.function.Consumer<RetryAttempt> recorder) {
return new IntentRouter(
core, new HarnessRetryExecutor(core),
new GuardModelCall(core, model, executor), objectMapper,
new IntentRouterLimits(20_000, 2_000,
Duration.ofSeconds(2), Duration.ofSeconds(3)), recorder);
}
private KnowledgeQueryExecutor knowledge(DiagnosisHarnessCore core,
HarnessEvidenceTools tools,
ChatModel model) {
return new KnowledgeQueryExecutor(
core, tools, new GuardModelCall(core, model, executor), objectMapper,
() -> "knowledge-1",
new KnowledgeQueryLimits(50_000, 20_000, Duration.ofSeconds(2)));
}
private HarnessEvidenceTools tools(AtomicInteger calls, EvidenceStatus status) {
var rag = (com.superbiz.agent.harness.agent.EvidenceToolInvoker)
(context, callId, arguments) -> {
calls.incrementAndGet();
if (status == EvidenceStatus.NO_EVIDENCE) {
return ToolBoundaryResult.ready(callId, """
{"evidence_status":"NO_EVIDENCE","tool_call_id":"knowledge-1",
"query":"missing","evidence":[],"returned_count":0,"truncated":false}
""", status);
}
return ToolBoundaryResult.ready(callId, """
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"knowledge-1",
"query":"pool timeout","evidence":[{"document_id":"doc-1",
"source":"runbook.md","title":"连接池手册","breadcrumb":"DB > Pool",
"excerpt":"active=50 max=50"}],"returned_count":1,"truncated":false}
""", status);
};
var unavailable = (com.superbiz.agent.harness.agent.EvidenceToolInvoker)
(context, callId, arguments) ->
ToolBoundaryResult.error(callId, ToolBoundaryErrorCode.INVALID_REQUEST);
return new HarnessEvidenceTools(rag, unavailable, unavailable);
}
private DiagnosisHarnessCore core() {
return new DiagnosisHarnessCore(
Clock.systemUTC(), () -> "unused", Duration.ofMinutes(5),
new RunBudgetLimits(20, 10, 10, 100_000, 100_000, 200_000, 1_000_000),
HarnessRetryPolicies.strict());
}
private static final class ScriptedModel implements ChatModel {
private final List<String> responses;
private final List<String> prompts = new ArrayList<>();
private final AtomicInteger calls = new AtomicInteger();
private ScriptedModel(String... responses) {
this.responses = List.of(responses);
}
@Override
public ChatResponse call(Prompt prompt) {
prompts.add(prompt.getContents());
int index = calls.getAndIncrement();
if (index >= responses.size()) {
throw new AssertionError("unexpected model call");
}
return new ChatResponse(
List.of(new Generation(new AssistantMessage(responses.get(index)))),
ChatResponseMetadata.builder().usage(new DefaultUsage(3, 2)).build());
}
}
}
@@ -0,0 +1,252 @@
package com.superbiz.agent.harness.application;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.application.persistence.ChatRunStore;
import com.superbiz.agent.harness.application.persistence.RoutingHistory;
import com.superbiz.agent.harness.application.routing.IntentRoutingException;
import com.superbiz.agent.harness.contract.AnalysisKind;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.contract.IntentType;
import com.superbiz.agent.harness.contract.PreviousTurn;
import com.superbiz.agent.harness.contract.PublishedResult;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import com.superbiz.agent.harness.contract.SourceDocument;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunBudgetLimits;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.guard.semantic.SemanticDraftView;
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatApplicationUseCaseTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void dispatchesAllThreeIntentsToOnlyTheirFixedExecutor() {
for (IntentType selected : IntentType.values()) {
FakeStore store = new FakeStore();
AtomicInteger systemCalls = new AtomicInteger();
AtomicInteger knowledgeCalls = new AtomicInteger();
AtomicInteger diagnosisCalls = new AtomicInteger();
ChatApplicationUseCase useCase = useCase(
store,
(context, input) -> selected,
(context, query) -> {
systemCalls.incrementAndGet();
return new SystemChatContent("system:" + query);
},
(context, query) -> {
knowledgeCalls.incrementAndGet();
return new KnowledgeContent("knowledge:" + query, List.of(), List.of());
},
(context, query, previous, status) -> {
diagnosisCalls.incrementAndGet();
return diagnosisSuccess(query);
});
ChatApplicationResult result = useCase.execute(
new ChatApplicationRequest("original query", "session-fixed"));
assertEquals(selected, result.intent());
assertEquals("session-fixed", result.sessionId());
assertEquals(selected == IntentType.SYSTEM_CHAT ? 1 : 0, systemCalls.get());
assertEquals(selected == IntentType.KNOWLEDGE_QUERY ? 1 : 0, knowledgeCalls.get());
assertEquals(selected == IntentType.DIAGNOSIS ? 1 : 0, diagnosisCalls.get());
assertEquals(ReleaseOutcome.SUCCESS, store.finishedOutcome);
assertEquals(result.runId(), store.startedContext.runId());
}
}
@Test
void priorSafeTurnIsReadBeforeCurrentRunAndOnlyDiagnosisReceivesIt() {
FakeStore store = new FakeStore();
store.history = new RoutingHistory(IntentType.DIAGNOSIS, "old query");
store.previous = new PreviousTurn(
"old query", "old conclusion", "old scope", List.of("old limitation"),
List.of(new SourceDocument("doc-1", "guide")));
AtomicReference<PreviousTurn> received = new AtomicReference<>();
AtomicReference<String> routedQuery = new AtomicReference<>();
List<String> observerEvents = new ArrayList<>();
ChatApplicationUseCase useCase = useCase(
store,
(context, input) -> {
routedQuery.set(input.query());
assertEquals(IntentType.DIAGNOSIS, input.lastIntent());
assertEquals("old query", input.lastUserQuery());
return IntentType.DIAGNOSIS;
},
(context, query) -> new SystemChatContent("unused"),
(context, query) -> new KnowledgeContent("unused", List.of(), List.of()),
(context, query, previous, status) -> {
received.set(previous);
return diagnosisSuccess(query);
});
ChatApplicationResult result = useCase.execute(
new ChatApplicationRequest("那退款服务呢?", "session-history"),
observer(observerEvents));
assertEquals("那退款服务呢?", routedQuery.get());
assertEquals(store.previous, received.get());
assertEquals(List.of("history", "previous", "start"), store.events.subList(0, 3));
assertEquals("started:" + result.runId(), observerEvents.get(0));
assertEquals("status:ROUTING", observerEvents.get(1));
assertEquals(IntentType.DIAGNOSIS, store.finishedIntent);
assertNotNull(store.finishedPublishedResult);
}
@Test
void routingFailurePersistsFailedAndNeverCallsAnyExecutor() {
FakeStore store = new FakeStore();
AtomicInteger executors = new AtomicInteger();
ChatApplicationUseCase useCase = useCase(
store,
(context, input) -> { throw new IntentRoutingException(new RuntimeException("invalid")); },
(context, query) -> { executors.incrementAndGet(); return new SystemChatContent("x"); },
(context, query) -> { executors.incrementAndGet(); return new KnowledgeContent("x", List.of(), List.of()); },
(context, query, previous, status) -> { executors.incrementAndGet(); return diagnosisSuccess(query); });
ChatApplicationException failure = assertThrows(ChatApplicationException.class, () ->
useCase.execute(new ChatApplicationRequest("route me", "session-route-fail")));
assertEquals(ChatFailureCode.ROUTING_UNAVAILABLE, failure.code());
assertEquals(0, executors.get());
assertEquals(ReleaseOutcome.FAILED, store.finishedOutcome);
assertEquals(com.superbiz.agent.harness.core.RunState.FAILED,
store.startedContext.lifecycle().state());
}
@Test
void clientDisconnectControlCancelsSameRunAndPersistsCancelled() {
FakeStore store = new FakeStore();
ChatApplicationUseCase useCase = useCase(
store,
(context, input) -> {
assertTrue(context.cancellation().isCancelled());
throw new com.superbiz.agent.harness.core.RunAbortedException(
context.lifecycle().termination().orElseThrow());
},
(context, query) -> new SystemChatContent("unused"),
(context, query) -> new KnowledgeContent("unused", List.of(), List.of()),
(context, query, previous, status) -> diagnosisSuccess(query));
ChatApplicationObserver cancelOnStart = new ChatApplicationObserver() {
@Override
public void onStarted(ChatRunControl runControl) {
assertEquals(store.startedContext.runId(), runControl.runId());
assertTrue(runControl.cancelClientDisconnect());
}
@Override
public void onStatus(ChatApplicationStatus status) {
}
};
ChatApplicationException failure = assertThrows(ChatApplicationException.class, () ->
useCase.execute(new ChatApplicationRequest("cancel", "session-cancel"), cancelOnStart));
assertEquals(ChatFailureCode.RUN_CANCELLED, failure.code());
assertEquals(ReleaseOutcome.CANCELLED, store.finishedOutcome);
assertEquals(com.superbiz.agent.harness.core.RunState.CANCELLED,
store.startedContext.lifecycle().state());
}
private ChatApplicationUseCase useCase(FakeStore store,
IntentRouting routing,
SystemChatOperation system,
KnowledgeQueryOperation knowledge,
DiagnosisOperation diagnosis) {
AtomicInteger ids = new AtomicInteger();
DiagnosisHarnessCore core = new DiagnosisHarnessCore(
Clock.systemUTC(), () -> "run-app-" + ids.incrementAndGet(), Duration.ofMinutes(5),
new RunBudgetLimits(20, 20, 20, 100_000, 100_000, 200_000, 1_000_000),
HarnessRetryPolicies.strict());
return new ChatApplicationUseCase(
core, () -> "generated-session", store, routing,
system, knowledge, diagnosis, objectMapper);
}
private DiagnosisExecutionResult diagnosisSuccess(String query) {
DiagnosisDraft draft = new DiagnosisDraft(
new DiagnosisDraft.Conclusion("safe conclusion", List.of("a-1")),
List.of(new DiagnosisDraft.AnalysisItem(
"a-1", AnalysisKind.NORMAL, "supported analysis", List.of("internal-call"))),
List.of(), List.of(), new DiagnosisDraft.Limitations("scope", List.of()));
PublishedResult published = new PublishedResult(
query, "safe conclusion", "scope", List.of(), List.of());
return new DiagnosisExecutionResult(
ReleaseOutcome.SUCCESS,
new DiagnosisContent(SemanticDraftView.from(draft), List.of()),
published);
}
private ChatApplicationObserver observer(List<String> events) {
return new ChatApplicationObserver() {
@Override
public void onStarted(ChatRunControl runControl) {
events.add("started:" + runControl.runId());
}
@Override
public void onStatus(ChatApplicationStatus status) {
events.add("status:" + status.name());
}
};
}
private static final class FakeStore implements ChatRunStore {
private final List<String> events = new ArrayList<>();
private RoutingHistory history;
private PreviousTurn previous;
private RunContext startedContext;
private IntentType finishedIntent;
private ReleaseOutcome finishedOutcome;
private PublishedResult finishedPublishedResult;
@Override
public Optional<RoutingHistory> findLatestRoutingHistory(String sessionId) {
events.add("history");
return Optional.ofNullable(history);
}
@Override
public Optional<PreviousTurn> findPreviousTurn(String sessionId) {
events.add("previous");
return Optional.ofNullable(previous);
}
@Override
public void start(RunContext context, String query) {
events.add("start");
startedContext = context;
}
@Override
public void markIntent(String runId, IntentType intent) {
events.add("intent:" + intent);
}
@Override
public void finish(RunContext context, IntentType intent, ReleaseOutcome outcome,
String safeContentJson, PublishedResult publishedResult, int durationMs) {
events.add("finish:" + outcome);
finishedIntent = intent;
finishedOutcome = outcome;
finishedPublishedResult = publishedResult;
}
}
}
@@ -0,0 +1,135 @@
package com.superbiz.agent.harness.application;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.DiagnosisRun;
import com.superbiz.agent.harness.application.persistence.JpaChatRunStore;
import com.superbiz.agent.harness.application.persistence.PreviousTurnLimits;
import com.superbiz.agent.harness.application.persistence.PublishedResultPolicy;
import com.superbiz.agent.harness.contract.AnalysisKind;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.contract.IntentType;
import com.superbiz.agent.harness.contract.PublishedResult;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import com.superbiz.agent.harness.contract.SourceDocument;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunBudgetLimits;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.guard.evidence.VerifiedAnalysisEvidence;
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidence;
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
import com.superbiz.agent.repository.ChatSessionRepository;
import com.superbiz.agent.repository.DiagnosisRunRepository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PublishedResultPersistenceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void policyBuildsBoundedRagOnlyPublishedResultWithoutInternalEvidence() throws Exception {
PublishedResultPolicy policy = new PublishedResultPolicy(
new PreviousTurnLimits(8, 10, 12, 1, 6, 1, 10, 8));
DiagnosisDraft draft = new DiagnosisDraft(
new DiagnosisDraft.Conclusion("connection pool exhausted", List.of("a-1")),
List.of(new DiagnosisDraft.AnalysisItem(
"a-1", AnalysisKind.NORMAL, "active=50", List.of("call-1"))),
List.of(), List.of(),
new DiagnosisDraft.Limitations("order-service last 30 minutes", List.of("missing slow SQL")));
VerifiedEvidenceSnapshot snapshot = new VerifiedEvidenceSnapshot(List.of(
new VerifiedAnalysisEvidence("a-1", "active=50", AnalysisKind.NORMAL, List.of(
new VerifiedEvidence("RAG", "guide.md", "pool", null, "excerpt",
Map.of("document_id", "document-123", "title", "Long guide title")),
new VerifiedEvidence("LOG", "APPLICATION", "30m", null, "raw log", Map.of())))));
PublishedResult result = policy.create("why pool exhausted", draft, snapshot).orElseThrow();
assertEquals(8, result.userQuery().length());
assertEquals(10, result.publishedConclusion().length());
assertEquals(1, result.sourceDocuments().size());
assertEquals(10, result.sourceDocuments().get(0).documentId().length());
String json = objectMapper.writeValueAsString(result);
assertFalse(json.contains("tool_call_id"));
assertFalse(json.contains("raw log"));
assertFalse(json.contains("excerpt"));
}
@Test
void storeUsesExactSafePreviousTurnQueryAndCorruptJsonFailsClosed() {
DiagnosisRunRepository runs = mock(DiagnosisRunRepository.class);
ChatSessionRepository sessions = mock(ChatSessionRepository.class);
DiagnosisRun corrupt = DiagnosisRun.builder()
.runId("run-old").sessionId("session-1").query("q")
.intent(IntentType.DIAGNOSIS).releaseOutcome(ReleaseOutcome.SUCCESS)
.publishedResult("not-json").build();
when(runs.findFirstBySessionIdAndIntentAndReleaseOutcomeAndPublishedResultIsNotNullOrderByCreatedAtDescIdDesc(
"session-1", IntentType.DIAGNOSIS, ReleaseOutcome.SUCCESS))
.thenReturn(Optional.of(corrupt));
JpaChatRunStore store = new JpaChatRunStore(
sessions, runs, objectMapper,
new PublishedResultPolicy(PreviousTurnLimits.defaults()));
assertTrue(store.findPreviousTurn("session-1").isEmpty());
verify(runs).findFirstBySessionIdAndIntentAndReleaseOutcomeAndPublishedResultIsNotNullOrderByCreatedAtDescIdDesc(
"session-1", IntentType.DIAGNOSIS, ReleaseOutcome.SUCCESS);
}
@Test
void fallbackCompletionStoresSafeAnswerButNeverPublishedResult() {
DiagnosisRunRepository runs = mock(DiagnosisRunRepository.class);
ChatSessionRepository sessions = mock(ChatSessionRepository.class);
DiagnosisRun entity = DiagnosisRun.builder()
.runId("run-fallback").sessionId("session-1").query("q").status("RUNNING").build();
when(runs.findByRunId("run-fallback")).thenReturn(Optional.of(entity));
when(runs.save(any(DiagnosisRun.class))).thenAnswer(invocation -> invocation.getArgument(0));
JpaChatRunStore store = new JpaChatRunStore(sessions, runs, objectMapper);
RunContext context = core().startRun("session-1", "run-fallback");
store.finish(context, IntentType.DIAGNOSIS, ReleaseOutcome.FALLBACK,
"{\"type\":\"SEMANTIC_UNAVAILABLE\"}",
new PublishedResult("q", "c", "scope", List.of(),
List.of(new SourceDocument("doc", "title"))), 12);
assertEquals("SUCCESS", entity.getStatus());
assertEquals(ReleaseOutcome.FALLBACK, entity.getReleaseOutcome());
assertNull(entity.getPublishedResult());
assertEquals(12, entity.getTotalDurationMs());
}
@Test
void migrationContainsEntityColumnsAndPreviousTurnIndex() throws Exception {
String sql;
try (var input = new ClassPathResource(
"db/migration/V012__add_chat_release_contract.sql").getInputStream()) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("ADD COLUMN intent VARCHAR(32)"));
assertTrue(sql.contains("ADD COLUMN release_outcome VARCHAR(16)"));
assertTrue(sql.contains("ADD COLUMN published_result JSON"));
assertTrue(sql.contains("idx_diagnosis_run_previous_turn"));
}
private DiagnosisHarnessCore core() {
return new DiagnosisHarnessCore(
Clock.systemUTC(), () -> "unused", Duration.ofMinutes(5),
new RunBudgetLimits(10, 10, 10, 10_000, 10_000, 20_000, 100_000),
HarnessRetryPolicies.strict());
}
}
@@ -109,10 +109,10 @@ class SemanticGuardTest {
}
@Test
void attemptTimeoutCancelsBothPermittedModelCalls() throws Exception {
void attemptTimeoutCancelsEveryPermittedAttempt() throws Exception {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-timeout", "run-timeout");
BlockingChatModel model = new BlockingChatModel(2);
BlockingChatModel model = new BlockingChatModel(1);
List<RetryAttempt> attempts = new ArrayList<>();
SemanticGuard guard = new SemanticGuard(
core, new HarnessRetryExecutor(core),
@@ -128,8 +128,10 @@ class SemanticGuardTest {
assertEquals(com.superbiz.agent.harness.retry.RetryFailure.TIMEOUT, failure.failure());
assertEquals(2, failure.attempts());
assertTrue(model.interrupted.await(2, TimeUnit.SECONDS));
assertEquals(2, model.calls.get());
assertEquals(2, attempts.size());
assertTrue(attempts.stream().allMatch(attempt ->
attempt.failure() == com.superbiz.agent.harness.retry.RetryFailure.TIMEOUT));
assertEquals(2, context.budget().snapshot().modelCalls());
}
@Test