test(agent): v2 迁移全量回归 + vendor R4 核对 + in-flight interrupt 集成测试

Task 9(agentscope v2 RC3 迁移最后一任务):

A. vendor Spec R4 核对:4 vendor 模块 -am compile 全 SUCCESS。
   逐 vendor 核对 resolve() builder 方法名——现状已全部符合 R4
   (OpenAI=generateOptions+stream;Anthropic/DashScope=defaultOptions+stream;
   Gemini=defaultOptions+streamEnabled),未改 ModelSpec 抽象、未改 OpenAISpec。

B. 补 Task 7.1 推迟的 in-flight interrupt 集成测试(race-free,非降级):
   - BlockingChunksModel:Flux.create 两段阻塞 mock,emit chunk1 后阻塞在 gate。
   - InFlightInterruptTest:异步 call → await chunk1 → interrupt(cid,akey)(flag=true)
     → releaseSecondChunk → chunk2 进 concatMap 的 checkInterrupted() → InterruptedException
     → ReActAgent.handleInterrupt 返回恢复 Msg。断言返回文本 == RC3 固定恢复消息。
   - 时序无并发窗口(flag 写入严格早于 chunk2 到达检查点),5/5 GREEN,~150ms 稳定。

C. 全量回归:
   - C.1 5 react-agent 模块 clean package -DskipTests → 9 模块 BUILD SUCCESS。
   - C.2 testcase-el-react-agent v2 全测试(15 类显式列表)→ 49 tests PASS。
   - C.3 compile-17+ profile react-agent-core + testcase test-compile → 12 模块 SUCCESS。

未改 skipTests(CLI -Dmaven.test.skip=false),未改生产代码,未 push。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
everywhere.z 2026-06-20 12:37:39 +08:00
parent bccf1426e8
commit 290ab03471
2 changed files with 319 additions and 0 deletions

View File

@ -0,0 +1,114 @@
package com.yomahub.liteflow.test.agent.v2;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.ToolSchema;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* 可手动控制分两段 emit的阻塞型 {@link io.agentscope.core.model.Model}专供
* {@code InFlightInterruptTest}Task 9<b>无网络无真实 LLM</b> 的前提下端到端验证
* in-flight {@code call()} 被外部 {@code interrupt(cid, akey)} 打断后以恢复 Msg 结束
*
* <p><b>设计依据findings R-enh + RC3 源码通读</b>RC3 {@code ReActAgent} reasoning
* 阶段对模型流做 {@code concatMap(chunk -> checkInterrupted().thenReturn(chunk))}
* {@code ReActAgent.java} 2049 <b>每处理完一个 chunk下一个 chunk 进入 concatMap
* 都会调 {@code checkInterrupted()} session-scoped {@code InterruptControl} flag</b>
* 故只要在 chunk1 被消费后chunk2 到达 concatMap <b>之前</b> flag truechunk2 触发的
* {@code checkInterrupted()} 就会抛 {@code InterruptedException}
* {@code AgentBase.createErrorHandler} {@code ReActAgent.handleInterrupt} 转成恢复 Msg
* "I noticed that you have interrupted me. What can I do for you?"
*
* <p><b>时序race-free</b>
* <ol>
* <li>{@link #stream} emit chunk1含一段文本随后阻塞在 {@link #gate}CountDownLatch
* 初始 closedchunk2 暂不发</li>
* <li>emit chunk1 后立即 {@code chunk1Emitted.countDown()}让测试线程确认 chunk1 已离开模型</li>
* <li>测试线程等待 {@link #chunk1Emitted} {@code component.interrupt(cid, akey)}flag=true
* {@code gate.countDown()} 放行 chunk2</li>
* <li>{@link #stream} chunk2进入 concatMap {@code checkInterrupted()}flag true
* {@code InterruptedException} 恢复 Msg</li>
* </ol>
*
* <p>整个序列对 reactor 时序<b>不敏感</b>中断 flag 的写入严格早于 chunk2 到达
* {@code checkInterrupted()}两者都在测试线程内顺序执行无并发窗口故测试不 flaky
*
* <p>无状态字段以外{@link #gate}/{@link #chunk1Emitted} <b>每实例</b>的一次性夹具
* 一个实例只服务于一次 {@code call()}不复用线程安全{@link FluxSink} latch 均线程安全
*/
final class BlockingChunksModel implements io.agentscope.core.model.Model {
/** 恢复 Msg 的固定文本RC3 {@code ReActAgent.handleInterrupt} 硬编码)。 */
static final String RECOVERY_TEXT =
"I noticed that you have interrupted me. What can I do for you?";
private final String modelName;
private final CountDownLatch gate = new CountDownLatch(1);
private final CountDownLatch chunk1Emitted = new CountDownLatch(1);
private final AtomicReference<Throwable> streamError = new AtomicReference<>();
BlockingChunksModel(String modelName) {
this.modelName = modelName;
}
/** 放行 chunk2 的发出(测试线程在 interrupt 之后调用)。 */
void releaseSecondChunk() {
gate.countDown();
}
/** 等待 chunk1 已被 emit测试线程据此确认可以安全触发 interrupt。 */
boolean awaitFirstChunkEmitted(long timeoutMs) throws InterruptedException {
return chunk1Emitted.await(timeoutMs, TimeUnit.MILLISECONDS);
}
/** 模型流内部若意外异常,记下供测试诊断。 */
Throwable streamError() {
return streamError.get();
}
@Override
public Flux<ChatResponse> stream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
ChatResponse chunk1 = ChatResponse.builder()
.id("blk-1-" + System.nanoTime())
.content(List.<ContentBlock>of(TextBlock.builder().text("thinking...").build()))
.build();
ChatResponse chunk2 = ChatResponse.builder()
.id("blk-2-" + System.nanoTime())
.content(List.<ContentBlock>of(TextBlock.builder().text("final answer").build()))
.finishReason("stop")
.build();
return Flux.<ChatResponse>create(sink -> {
// chunk1 立即发进入 concatMap checkInterrupted() 检查此时 flag=false通过
sink.next(chunk1);
chunk1Emitted.countDown();
// 阻塞等测试线程放行放行前 chunk2 永不到达 concatMap checkInterrupted 不会被
// 第二次读到flag 的写入严格早于 chunk2 race
try {
gate.await();
} catch (InterruptedException ie) {
streamError.set(ie);
sink.error(ie);
return;
}
// 放行后发 chunk2此刻 flag 已被 interrupt trueconcatMap checkInterrupted
// InterruptedException handleInterrupt 恢复 Msg
sink.next(chunk2);
sink.complete();
}, FluxSink.OverflowStrategy.BUFFER);
}
@Override
public String getModelName() {
return modelName;
}
}

View File

@ -0,0 +1,205 @@
package com.yomahub.liteflow.test.agent.v2;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.component.ReactAgentFactory;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.property.agent.AgentConfig;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.UserMessage;
import io.agentscope.core.model.Model;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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.assertTrue;
/**
* Task 9 补遗Task 7.1 推迟的<b>集成级</b> in-flight interrupt 测试
*
* <p> {@link BlockingChunksModel}race-free 两段阻塞模型见其 javadoc RC3 concatMap
* 中断检查点分析启动一个 agent {@code call()} chunk1 被消费后chunk2 到达前从外部调
* {@link ReActAgentComponent#interrupt(String, String)}断言该 call <b>在合理超时内完成</b>
* 且返回的 Msg RC3 {@code ReActAgent.handleInterrupt} 产出的恢复消息
* {@link BlockingChunksModel#RECOVERY_TEXT}"in-flight call 被中断后以恢复 Msg 结束"
*
* <p><b>不依赖 Spring 上下文</b>直接经 {@link ReactAgentFactory#getOrCreate} agent 单例
* 手工 {@code agent.call(...)} 并阻塞跳过 {@code process()} slot/chain 装配仅验证
* agent + interrupt reactor 链路 {@code InterruptTest}单元级"不抛 + 参数透传"互补
*
* <p><b>时序race-free {@link BlockingChunksModel}</b>interrupt flag 写入严格早于
* chunk2 到达 {@code checkInterrupted()}两者都在测试线程内顺序执行无并发窗口故不 flaky
*
* <p><b>降级brief Step 2 允许</b>若因 reactor 调度在某些 CI 环境下 chunk2 concatMap
* 检查时序异常{@link #AWAIT_TIMEOUT_MS} 超时后测试失败而非 hang测试不依赖 wall-clock 精度
*/
class InFlightInterruptTest {
/** call().get() 的硬超时(秒);远大于正常完成所需,仅作防 hang 兜底。 */
private static final long CALL_TIMEOUT_SECONDS = 15;
/** 等模型 emit chunk1 的超时(毫秒)。 */
private static final long CHUNK1_AWAIT_MS = 3000;
/** 最小可中断组件buildModel 返回 BlockingChunksModel每实例一次性夹具。 */
static class InterruptableCmp extends ReActAgentComponent {
final BlockingChunksModel model;
InterruptableCmp(BlockingChunksModel model) {
this.model = model;
}
@Override
@SuppressWarnings("rawtypes")
protected ModelSpec model() {
return new ModelSpec() {
@Override
public Model resolve(AgentConfig c) {
return model;
}
};
}
@Override
protected Model buildModel() {
return model;
}
@Override
protected String systemPrompt() {
return "test";
}
@Override
protected String userPrompt() {
return "hello";
}
@Override
protected boolean enableShellTool() {
return false;
}
@Override
protected boolean enableWorkspaceFileTools() {
return false;
}
@Override
protected boolean enableReActLogging() {
return false;
}
}
private ExecutorService executor;
@BeforeEach
void reset() {
ReactAgentFactory.resetForTesting();
executor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "inflight-interrupt-call");
t.setDaemon(true);
return t;
});
}
@AfterEach
void cleanup() {
if (executor != null) {
executor.shutdownNow();
}
ReactAgentFactory.resetForTesting();
}
/**
* 主断言in-flight {@code call()} 被外部 {@code interrupt(cid, akey)} 打断后在合理超时内
* RC3 恢复 Msg{@link BlockingChunksModel#RECOVERY_TEXT}结束而非 hang抛异常
* 或返回 chunk2 "final answer"
*/
@Test
void inFlightCall_interruptedReturnsRecoveryMessage() throws Exception {
BlockingChunksModel model = new BlockingChunksModel("blocking");
AgentConfig cfg = HarnessFixture.minimalConfig();
InterruptableCmp cmp = new InterruptableCmp(model);
// 构建 agent 单例 cmp.getClass() 缓存
ReActAgent agent = ReactAgentFactory.getOrCreate(cmp, cfg);
assertNotNull(agent, "agent must be built & cached");
String cid = "conv-inflight";
String akey = "agent-key-inflight";
RuntimeContext rc = RuntimeContext.builder().userId(cid).sessionId(akey).build();
// 异步启动 call()进入 reasoning 模型 emit chunk1 concatMap checkInterrupted(通过)
// 模型阻塞在 gate chunk2 放行
AtomicReference<Msg> result = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>();
Future<?> callFuture = executor.submit(() -> {
try {
Msg reply = agent.call(List.of(new UserMessage("hello")), rc).block();
result.set(reply);
} catch (Throwable t) {
error.set(t);
}
});
// 等模型 emit chunk1确认 call 真正进入模型流chunk1 已被 concatMap 消费
boolean chunk1Sent = model.awaitFirstChunkEmitted(CHUNK1_AWAIT_MS);
assertTrue(chunk1Sent,
"model must emit chunk1 within " + CHUNK1_AWAIT_MS + "ms; stream error="
+ model.streamError());
// 关键在放行 chunk2 <b>之前</b>触发中断flag=true这一步在测试线程内chunk2 到达
// concatMap 之前完成race-free
cmp.interrupt(cid, akey);
// 放行 chunk2模型 emit chunk2 concatMap checkInterrupted flag=true
// InterruptedException handleInterrupt 恢复 Msg
model.releaseSecondChunk();
// 等待 call 完成硬超时兜底防 hang
try {
callFuture.get(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException te) {
failWithDiagnostics("call did not complete within " + CALL_TIMEOUT_SECONDS + "s", model, error);
return;
}
// 断言call 以恢复 Msg 结束不抛不返回 chunk2 final answer
assertNullError(error, "call should not throw on user interrupt; it should return recovery Msg");
Msg reply = result.get();
assertNotNull(reply, "call must return a (recovery) Msg after interrupt, not null");
assertNotNull(reply.getTextContent(), "recovery Msg must carry text content");
assertEquals(BlockingChunksModel.RECOVERY_TEXT, reply.getTextContent(),
"call result must be RC3 handleInterrupt recovery message, not chunk2's final answer");
// 附加断言未返回 chunk2 final answer双保险防误判
assertFalse(reply.getTextContent().contains("final answer"),
"interrupted call must not have returned chunk2's final answer text");
}
private static void assertNullError(AtomicReference<Throwable> error, String msg) {
Throwable t = error.get();
if (t != null) {
throw new AssertionError(msg + " — got: "
+ t.getClass().getSimpleName() + ": " + t.getMessage(), t);
}
}
private static void failWithDiagnostics(String msg, BlockingChunksModel model,
AtomicReference<Throwable> error) {
throw new AssertionError(msg
+ " | streamError=" + model.streamError()
+ " | callError=" + (error.get() == null ? "<none>" : error.get()));
}
}