test(react-agent): migrate test cmps to ModelSpec API

Replaces buildModel(ctx) overrides with model(ctx) returning
DeepSeek/OpenAI/Anthropic/Gemini/DashScope spec instances.
Also updates TestAgent inner classes in ReActAgentComponentTest
and MemoryStoragePersistenceTest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
everywhere.z
2026-04-29 18:30:21 +08:00
parent ca9608f793
commit c612dfaf72
11 changed files with 407 additions and 2 deletions

View File

@@ -0,0 +1,120 @@
package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.flow.element.Node;
import com.yomahub.liteflow.property.LiteflowConfig;
import com.yomahub.liteflow.property.LiteflowConfigGetter;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import com.yomahub.liteflow.property.agent.ShellMode;
import com.yomahub.liteflow.slot.DataBus;
import com.yomahub.liteflow.slot.Slot;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Verifies that {@link MemoryStorageMode#WORKSPACE} persists and restores
* conversation history across simulated process restarts within the same JVM.
*/
class MemoryStoragePersistenceTest {
@TempDir Path tmp;
@AfterEach
void cleanup() {
ReActAgentComponent.AgentSessionManagerHolder.resetForTesting();
LiteflowConfigGetter.clean();
}
private void primeConfig(MemoryStorageMode mode) {
AgentConfig agent = new AgentConfig();
agent.getWorkspace().setRoot(tmp.toString());
agent.getWorkspace().setCleanupOnSessionExpire(false);
agent.getShell().setMode(ShellMode.DISABLED);
agent.getSession().getMemory().setMode(mode);
LiteflowConfig cfg = new LiteflowConfig();
cfg.setAgent(agent);
LiteflowConfigGetter.setLiteflowConfig(cfg);
DataBus.init();
}
static class TestAgent extends ReActAgentComponent {
@Override
@SuppressWarnings("rawtypes")
protected ModelSpec model(ReActAgentContext ctx) {
return new ModelSpec() {
@Override public io.agentscope.core.model.Model resolve(AgentConfig cfg) {
return new FakeEchoModel();
}
};
}
@Override protected String systemPrompt(ReActAgentContext ctx) { return "you are helpful"; }
@Override protected String userPrompt(ReActAgentContext ctx) {
return (String) ctx.getSlot().getChainReqData(ctx.getSlot().getChainId());
}
}
private void runOnce(String sid, String userInput) throws Exception {
int slotIndex = DataBus.offerSlotByBean(Collections.emptyList());
try {
Slot slot = DataBus.getSlot(slotIndex);
slot.putRequestId(sid);
String chainId = "testChain";
slot.setChainId(chainId);
slot.setChainReqData(chainId, userInput);
TestAgent agent = new TestAgent();
agent.setNodeId("testAgent");
Node node = new Node();
node.setInstance(agent);
node.setSlotIndex(slotIndex);
node.setCurrChainId(chainId);
agent.setRefNode(node);
agent.process();
} finally {
DataBus.releaseSlot(slotIndex);
}
}
@Test
void workspace_mode_persists_messages_across_restart() throws Exception {
primeConfig(MemoryStorageMode.WORKSPACE_FILE);
runOnce("sid-A", "hello");
runOnce("sid-A", "goodbye");
// Persisted JSONL file should exist with at least the two user messages.
Path persisted = tmp.resolve(".agent-session").resolve("sid-A").resolve("memory_messages.jsonl");
assertTrue(Files.isRegularFile(persisted), "persisted memory file should exist");
List<String> lines = Files.readAllLines(persisted);
long userLines = lines.stream().filter(l -> l.contains("\"hello\"") || l.contains("\"goodbye\"")).count();
assertEquals(2, userLines, "both user prompts must be persisted");
// Simulate a JVM restart by resetting the manager singleton.
ReActAgentComponent.AgentSessionManagerHolder.resetForTesting();
// Acquire again with the same sid; loadIfExists should restore messages
// before the next call appends new ones.
runOnce("sid-A", "third");
List<String> after = Files.readAllLines(persisted);
assertTrue(after.size() >= lines.size() + 1,
"after restart, new messages should append on top of restored history (was "
+ lines.size() + ", now " + after.size() + ")");
}
@Test
void none_mode_skips_persistence() throws Exception {
primeConfig(MemoryStorageMode.NONE);
runOnce("sid-B", "hi");
Path persistedDir = tmp.resolve(".agent-session");
assertFalse(Files.exists(persistedDir),
"NONE mode must not create the persistence sub-directory");
}
}

View File

@@ -1,5 +1,6 @@
package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.core.NodeComponent;
import com.yomahub.liteflow.flow.element.Node;
import com.yomahub.liteflow.property.LiteflowConfig;
@@ -8,7 +9,6 @@ import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.ShellMode;
import com.yomahub.liteflow.slot.DataBus;
import com.yomahub.liteflow.slot.Slot;
import io.agentscope.core.model.Model;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
@@ -45,7 +45,14 @@ class ReActAgentComponentTest {
*/
static class TestAgent extends ReActAgentComponent {
@Override
protected Model buildModel(ReActAgentContext ctx) { return new FakeEchoModel(); }
@SuppressWarnings("rawtypes")
protected ModelSpec model(ReActAgentContext ctx) {
return new ModelSpec() {
@Override public io.agentscope.core.model.Model resolve(AgentConfig cfg) {
return new FakeEchoModel();
}
};
}
@Override
protected String systemPrompt(ReActAgentContext ctx) { return "you are helpful"; }
@Override

View File

@@ -0,0 +1,33 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.agent.anthropic.Anthropic;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.component.ReActAgentContext;
import com.yomahub.liteflow.agent.model.ModelSpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component("anthropicAgent")
public class AnthropicAgentCmp extends ReActAgentComponent {
@Value("${test.anthropic.model:claude-sonnet-4-5}")
private String modelName;
@Override
protected ModelSpec<?> model(ReActAgentContext ctx) {
return Anthropic.of(modelName);
}
@Override
protected String systemPrompt(ReActAgentContext ctx) {
return "你是一名严谨的技术作者,回答简短而准确。";
}
@Override
protected String userPrompt(ReActAgentContext ctx) {
return String.valueOf(ctx.getSlot().getChainReqData(ctx.getSlot().getChainId()));
}
@Override protected boolean enableShellTool() { return false; }
@Override protected boolean enableWorkspaceFileTools() { return false; }
}

View File

@@ -0,0 +1,33 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.component.ReActAgentContext;
import com.yomahub.liteflow.agent.dashscope.DashScope;
import com.yomahub.liteflow.agent.model.ModelSpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component("dashscopeAgent")
public class DashScopeAgentCmp extends ReActAgentComponent {
@Value("${test.dashscope.model:qwen-plus}")
private String modelName;
@Override
protected ModelSpec<?> model(ReActAgentContext ctx) {
return DashScope.of(modelName);
}
@Override
protected String systemPrompt(ReActAgentContext ctx) {
return "你是阿里云通义千问助手,请用简短中文回答。";
}
@Override
protected String userPrompt(ReActAgentContext ctx) {
return String.valueOf(ctx.getSlot().getChainReqData(ctx.getSlot().getChainId()));
}
@Override protected boolean enableShellTool() { return false; }
@Override protected boolean enableWorkspaceFileTools() { return false; }
}

View File

@@ -0,0 +1,37 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.component.ReActAgentContext;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.agent.openai.DeepSeek;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* DeepSeek AgentOpenAI 兼容协议)。从 application.properties 读模型名,
* apiKey 由 liteflow.agent.openai-compatible.deepseek.api-key 注入到 AgentConfig。
*/
@Component("deepseekAgent")
public class DeepSeekAgentCmp extends ReActAgentComponent {
@Value("${test.deepseek.model:deepseek-chat}")
private String modelName;
@Override
protected ModelSpec<?> model(ReActAgentContext ctx) {
return DeepSeek.of(modelName);
}
@Override
protected String systemPrompt(ReActAgentContext ctx) {
return "你是一名简洁的中文助理,回答严格控制在两句话以内。";
}
@Override
protected String userPrompt(ReActAgentContext ctx) {
return String.valueOf(ctx.getSlot().getChainReqData(ctx.getSlot().getChainId()));
}
@Override protected boolean enableShellTool() { return false; }
@Override protected boolean enableWorkspaceFileTools() { return false; }
}

View File

@@ -0,0 +1,33 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.component.ReActAgentContext;
import com.yomahub.liteflow.agent.gemini.Gemini;
import com.yomahub.liteflow.agent.model.ModelSpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component("geminiAgent")
public class GeminiAgentCmp extends ReActAgentComponent {
@Value("${test.gemini.model:gemini-2.5-flash}")
private String modelName;
@Override
protected ModelSpec<?> model(ReActAgentContext ctx) {
return Gemini.of(modelName);
}
@Override
protected String systemPrompt(ReActAgentContext ctx) {
return "You are a concise assistant. Answer in Chinese, one sentence.";
}
@Override
protected String userPrompt(ReActAgentContext ctx) {
return String.valueOf(ctx.getSlot().getChainReqData(ctx.getSlot().getChainId()));
}
@Override protected boolean enableShellTool() { return false; }
@Override protected boolean enableWorkspaceFileTools() { return false; }
}

View File

@@ -0,0 +1,19 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.core.NodeBooleanComponent;
import org.springframework.stereotype.Component;
/**
* 布尔节点:判定本次提问是否是计算题(用于 IF EL 路由演示)。
*/
@Component("isMath")
public class IsMathCmp extends NodeBooleanComponent {
@Override
public boolean processBoolean() {
Object req = this.getRequestData();
if (req == null) return false;
String s = req.toString();
return s.matches(".*\\d.*[+\\-*/].*\\d.*");
}
}

View File

@@ -0,0 +1,45 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.component.ReActAgentContext;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.agent.openai.DeepSeek;
import com.yomahub.liteflow.test.agent.tool.CalculatorTool;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 数学 Agent复用 DeepSeek 后端,但额外注入 {@link CalculatorTool}。
* 演示通过覆写 tools() 把自定义 @Tool 对象交给 ReAct 推理。
*/
@Component("mathAgent")
public class MathAgentCmp extends ReActAgentComponent {
@Value("${test.deepseek.model:deepseek-chat}")
private String modelName;
@Override
protected ModelSpec<?> model(ReActAgentContext ctx) {
return DeepSeek.of(modelName);
}
@Override
protected String systemPrompt(ReActAgentContext ctx) {
return "你是一名计算助理,凡是涉及算术,必须调用 calculator 工具,不要心算。";
}
@Override
protected String userPrompt(ReActAgentContext ctx) {
return String.valueOf(ctx.getSlot().getChainReqData(ctx.getSlot().getChainId()));
}
@Override
protected List<Object> tools(ReActAgentContext ctx) {
return List.of(new CalculatorTool());
}
@Override protected boolean enableShellTool() { return false; }
@Override protected boolean enableWorkspaceFileTools() { return false; }
}

View File

@@ -0,0 +1,33 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.component.ReActAgentContext;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.agent.openai.OpenAI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component("openaiAgent")
public class OpenAIAgentCmp extends ReActAgentComponent {
@Value("${test.openai.model:gpt-4o-mini}")
private String modelName;
@Override
protected ModelSpec<?> model(ReActAgentContext ctx) {
return OpenAI.of(modelName);
}
@Override
protected String systemPrompt(ReActAgentContext ctx) {
return "You are a concise assistant. Answer in Chinese, max two sentences.";
}
@Override
protected String userPrompt(ReActAgentContext ctx) {
return String.valueOf(ctx.getSlot().getChainReqData(ctx.getSlot().getChainId()));
}
@Override protected boolean enableShellTool() { return false; }
@Override protected boolean enableWorkspaceFileTools() { return false; }
}

View File

@@ -0,0 +1,20 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.core.NodeComponent;
import org.springframework.stereotype.Component;
/**
* 准备节点:把链入参原样作为 chainReqData
* 同时模拟一些前置工作(打日志、生成 requestId 等)。
*/
@Component("prepare")
public class PrepareCmp extends NodeComponent {
@Override
public void process() {
Object req = this.getRequestData();
// 把 request data 写到 chainReqData让 agent 通过 ctx.getSlot().getChainReqData(...) 取到
this.getSlot().setChainReqData(this.getSlot().getChainId(), req);
System.out.println("[prepare] question=" + req);
}
}

View File

@@ -0,0 +1,25 @@
package com.yomahub.liteflow.test.agent.cmp;
import com.yomahub.liteflow.core.NodeComponent;
import org.springframework.stereotype.Component;
/**
* 后处理节点:从 slot 取出 agent 写入的 responseData 打印出来,
* 并把回复字符串塞回 slot.metaDataMap 以便测试断言(也可走 ContextBean
*/
@Component("recordReply")
public class RecordReplyCmp extends NodeComponent {
public static final String NODE_ID = "recordReply";
@Override
public void process() {
Object reply = this.getSlot().getResponseData();
System.out.println("[recordReply] reply=" + reply);
// 把 reply 作为本节点 output 存入 slot测试可通过 slot.getOutput(NODE_ID) 取出
if (reply != null) {
this.getSlot().setOutput(NODE_ID, reply);
}
}
}