diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/agent/component/MemoryStoragePersistenceTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/agent/component/MemoryStoragePersistenceTest.java new file mode 100644 index 000000000..73c46c3f2 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/agent/component/MemoryStoragePersistenceTest.java @@ -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 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 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"); + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/agent/component/ReActAgentComponentTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/agent/component/ReActAgentComponentTest.java index d7e9f1f9c..f21b6094a 100644 --- a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/agent/component/ReActAgentComponentTest.java +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/agent/component/ReActAgentComponentTest.java @@ -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 diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/AnthropicAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/AnthropicAgentCmp.java new file mode 100644 index 000000000..c64744c50 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/AnthropicAgentCmp.java @@ -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; } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/DashScopeAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/DashScopeAgentCmp.java new file mode 100644 index 000000000..4aa604d1e --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/DashScopeAgentCmp.java @@ -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; } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/DeepSeekAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/DeepSeekAgentCmp.java new file mode 100644 index 000000000..59c140344 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/DeepSeekAgentCmp.java @@ -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 Agent(OpenAI 兼容协议)。从 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; } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/GeminiAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/GeminiAgentCmp.java new file mode 100644 index 000000000..a159004ed --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/GeminiAgentCmp.java @@ -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; } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/IsMathCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/IsMathCmp.java new file mode 100644 index 000000000..4a0f11f91 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/IsMathCmp.java @@ -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.*"); + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/MathAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/MathAgentCmp.java new file mode 100644 index 000000000..89ff542c7 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/MathAgentCmp.java @@ -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 tools(ReActAgentContext ctx) { + return List.of(new CalculatorTool()); + } + + @Override protected boolean enableShellTool() { return false; } + @Override protected boolean enableWorkspaceFileTools() { return false; } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/OpenAIAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/OpenAIAgentCmp.java new file mode 100644 index 000000000..f00ae0f6a --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/OpenAIAgentCmp.java @@ -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; } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/PrepareCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/PrepareCmp.java new file mode 100644 index 000000000..23a4e566c --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/PrepareCmp.java @@ -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); + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/RecordReplyCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/RecordReplyCmp.java new file mode 100644 index 000000000..4f759d425 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/RecordReplyCmp.java @@ -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); + } + } +} +