Files
yunhai-agent-service/src/test/java/com/yunhai/agent/IntentDirectFormTest.java
T
lijing 006003c842
build / build-push (push) Successful in 1m15s
feat(表单): 五个模块正文换成模块页同款富文本,公告/宿舍的接收对象换成自绘控件
彻底对齐各模块「新建 / 发布」页里的两类控件(此前只有通知公告搬到位):

正文 → wangEditor(widget=richtext)
  · 校园公告 content、宿舍通知 content(发布 + 编辑)、家校活动 content(发布 + 编辑)、
    作业 description
  · 教师专业发展 remark:只声明形态、**不声明** .richText(...) —— 该模块 file 域只有
    Upload/Preview/Down(没有 UploadImage),模块页 RecordEdit.vue 也没配 uploadImage,
    所以下发空地址,前端据此把工具栏的「图片」组整组摘掉(模块页就是这个形态)

接收对象 → 自绘控件(widget=scopepicker / roompicker)
  · 校园公告 pushScopes:换成 ClassScopeInput 同款(全部班级 / 指定班级 + 弹窗班级树);
    提交口径不变,仍是 [{scopeType,scopeId,scopeName}]
  · 宿舍通知 receiveList:换成模块页「选择接收宿舍」弹窗同款(左楼栋 / 右按楼层分组的房间);
    v-model 是字符串(房间 id 逗号串或 ALL 哨兵),与模块 receiveList 的真实契约一致

基建
  · ToolParam 新增 richText(upload,preview) / scopePicker(type) / roomPicker() /
    pickerSource(页签名, 模块内相对路径);对应 FormField 四个字段
  · AgentOrchestrator:新增 resolveModulePath 通用形态,richtext 与 pickerSources 的地址
    一律只写模块内相对路径、前缀在这里拼;richtext **没有兜底**(不声明就是空)
  · 带 optionCall 的字段会被改写成 select 的白名单改用 isSelfDrawnPicker()
    (原先是硬编码 userpicker),并放开为 public 供契约测试复用生产口径

测试(139 tests, 0 failures)
  · ModuleFormParityTest 新增两条通用不变量:正文图片地址与弹窗数据源都必须带模块前缀、
    不许自己带前缀、不许双斜杠;另加「专业发展不得拿到兜底上传地址」
  · FormFieldWidgetTest 新增 richtext(含「不声明 = 空」反向断言)与自绘控件透传两条
  · IntentDirectFormTest / DormActivityPublishIntentTest 同步改成新控件口径
2026-10-14 16:56:07 +08:00

449 lines
24 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.agent;
import com.alibaba.fastjson.JSONArray;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.llm.LlmClient;
import com.yunhai.agent.llm.dto.ChatMessage;
import com.yunhai.agent.llm.dto.ChatResult;
import com.yunhai.agent.model.ChatRequest;
import com.yunhai.agent.model.ChatResponse;
import com.yunhai.agent.service.AgentAuditService;
import com.yunhai.agent.service.AgentOrchestrator;
import com.yunhai.agent.service.EventSink;
import com.yunhai.agent.service.IntentShortcut;
import com.yunhai.agent.service.SessionStore;
import com.yunhai.agent.tool.CallerContext;
import com.yunhai.agent.tool.FormField;
import com.yunhai.agent.tool.ToolExecutor;
import com.yunhai.agent.tool.ToolRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* 裸意图在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。
*
* <p>覆盖已落地的意图:
* <ul>
* <li>「我要请假」→ 学生拿请假申请、教职工拿请假登记(帮学生请假);</li>
* <li>「我要发布校园公告」→ 教职工拿发布表单,学生落回模型;</li>
* <li>「我要发布通知公告」→ 同上,但落到**另一个应用**(通知公告,按用户下发)。</li>
* </ul>
*
* <p>这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
* 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。
* 因此测试里的 {@link LlmClient} 被设成「一旦被调用就失败」,用来证明这条链路真的没走模型。
*/
class IntentDirectFormTest {
private AgentSettings settings;
private ToolRegistry registry;
private SessionStore sessionStore;
private AgentOrchestrator orchestrator;
/** 被调用即失败的模型客户端:证明「意图直达」路径压根不碰大模型 */
private static final LlmClient EXPLODING_LLM = new LlmClient() {
@Override
public ChatResult chat(List<ChatMessage> messages, JSONArray tools) {
throw new AssertionError("裸意图(我要请假)不应调用大模型");
}
};
/** 正常应答的模型客户端:用于断言「非裸意图」确实回落到模型流程 */
private static final LlmClient ECHO_LLM = (messages, tools) -> {
ChatResult r = new ChatResult();
r.setContent("(模型回复)");
return r;
};
@BeforeEach
void setUp() {
settings = new AgentSettings();
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice");
settings.getModules().put("notice", "http://base.yunhaixingtong.com/notice");
settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice");
settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity");
settings.getModules().put("profession", "http://base.yunhaixingtong.com/profession");
registry = new ToolRegistry(settings);
registry.init();
sessionStore = Mockito.mock(SessionStore.class);
SessionStore.SessionState state = new SessionStore.SessionState();
state.setSessionId("s-test");
state.setMessages(new ArrayList<>());
when(sessionStore.getOrCreate(any(), any(), any(), anyString())).thenReturn(state);
}
private AgentOrchestrator orchestrator(LlmClient llm) {
return new AgentOrchestrator(settings, llm, registry,
new ToolExecutor(settings, new RestTemplate()), sessionStore,
Mockito.mock(AgentAuditService.class), new IntentShortcut());
}
private CallerContext caller(AgentRole... roles) {
CallerContext c = new CallerContext();
Set<AgentRole> set = new LinkedHashSet<>();
Collections.addAll(set, roles);
c.setRoles(set);
c.setUserId("10008");
c.setUserName("测试用户");
return c;
}
private ChatResponse chat(LlmClient llm, String message, CallerContext caller) {
ChatRequest req = new ChatRequest();
req.setMessage(message);
return orchestrator(llm).chat(req, "姓名 测试用户", caller, null, EventSink.NOOP);
}
private FormField field(ChatResponse resp, String name) {
for (FormField f : resp.getForm().getFields()) {
if (name.equals(f.getName())) {
return f;
}
}
throw new AssertionError("表单里没有字段 " + name + ",实际:" + resp.getForm().getFields());
}
/* ==================== 学生:请假申请 ==================== */
@Test
void studentSayingWantLeaveGetsApplicationFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.STUDENT));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("leave.submit", resp.getForm().getTool(), "学生走「提交请假申请」");
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/my", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("请假申请"), "引导语应说明这是请假申请,实际:" + resp.getReply());
// 学生表单:类型 / 起止时间 / 事由;不应出现「学生」这种教师侧字段
assertNotNull(field(resp, "leaveType"));
assertNotNull(field(resp, "startDate"));
assertNotNull(field(resp, "endDate"));
assertNotNull(field(resp, "remarks"));
for (FormField f : resp.getForm().getFields()) {
assertFalse("stuId".equals(f.getName()), "学生自查请假不该出现「学生」字段");
}
}
/* ==================== 教师:帮学生登记 ==================== */
@Test
void teacherSayingWantLeaveGetsRegisterFormWithClassAndStudentPickers() {
ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("leave.register", resp.getForm().getTool(), "教师走「帮学生登记请假」");
assertEquals("/register", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("登记"), "引导语应说明这是登记,实际:" + resp.getReply());
// 教师表单必须能选到人:班级 + 学生 都是下拉,且学生依赖班级
FormField classId = field(resp, "classId");
assertEquals("select", classId.getWidget());
assertNotNull(classId.getOptionCall(), "班级下拉应带上「代取地址」");
assertEquals("/homeleave/api/leave/MyClasses", classId.getOptionCall().getPathAndQuery());
FormField stuId = field(resp, "stuId");
assertEquals("select", stuId.getWidget());
assertEquals(Collections.singletonList("classId"), stuId.getDependsOn());
assertNotNull(stuId.getOptionCall(), "学生下拉应带上「代取地址」");
assertEquals("/homeleave/api/leave/ClassStudents?classId={{classId}}",
stuId.getOptionCall().getPathAndQuery(),
"学生名单的地址要留 {{classId}} 占位符,前端选完班级再替换");
assertEquals("stuId", stuId.getValueKey());
assertEquals("stuName", stuId.getLabelKey());
assertNotNull(field(resp, "leaveType"));
assertNotNull(field(resp, "startDate"));
assertNotNull(field(resp, "endDate"));
assertNotNull(field(resp, "remarks"));
}
/* ==================== 教师:发布校园公告 ==================== */
@Test
void teacherSayingPublishNoticeGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布校园公告", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("notice.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/list/new", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("发布"), "引导语应说明这是发布,实际:" + resp.getReply());
// 公告表单:标题(单行)+ 正文(富文本,与模块页 NewNotice.vue 的 wangEditor 一致)+ 发文部门(下拉)
assertEquals("text", field(resp, "title").getWidget());
FormField content = field(resp, "content");
assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
assertEquals("/campusnotice/api/file/UploadImage", content.getRichTextUploadPath(),
"正文插图要走模块真实前缀 /campusnotice(且不是附件的 FileUpload)");
assertEquals("/campusnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath(),
"图片预览地址模板同样要带前缀,{id} 留给前端替换");
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget(), "发文部门应是下拉,不该让用户手填部门编号");
assertNotNull(deptId.getOptionCall(), "发文部门下拉应带上「代取地址」");
assertEquals("/campusnotice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(),
"字典地址必须带 type=1,否则取不回发文部门");
assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey());
}
@Test
void studentSayingPublishNoticeFallsThroughToModel() {
// 学生没有发布公告能力 → 不直达,交给模型如实说明需要教师/管理员身份
ChatResponse resp = chat(ECHO_LLM, "我要发布校园公告", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布公告表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布通知(通知公告模块) ==================== */
/**
* 「通知公告」(key={@code notice})与「校园公告」({@code campusnotice})是**两个独立应用**:
* 前者按「用户」下发、后者按「班级」下发。同一句「我要发布通知公告」必须落到前者的发布表单,
* 且表单上**不能**出现只有校园公告才有的字段(推送范围 / 回执 / 发布方式)。
*/
@Test
void teacherSayingPublishNotifyGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布通知公告", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("notify.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/list/new", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("发布通知"), "引导语应说明这是发布通知,实际:" + resp.getReply());
// 标题(单行)+ 正文(富文本,与模块页的 wangEditor 一致)
assertEquals("text", field(resp, "title").getWidget());
FormField content = field(resp, "content");
assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
assertEquals("/notice/api/file/UploadImage", content.getRichTextUploadPath(),
"正文里插图片要打模块真实前缀 /notice(且不是附件的 FileUpload)");
assertEquals("/notice/api/file/Preview?id={id}", content.getRichTextPreviewPath(),
"图片预览地址模板同样要带前缀,{id} 留给前端替换");
// 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类)
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget(), "来源部门应是下拉,不该让用户手填部门编号");
assertNotNull(deptId.getOptionCall(), "来源部门下拉应带上「代取地址」");
assertEquals("/notice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(),
"字典地址必须带模块前缀 /notice 与 type=1,否则取不回来源部门");
assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey());
// 通知对象:模块页同款自绘控件(radio 全部用户/指定用户 + 选人弹窗)。
// ★ 它也有 optionCall,必须确认**没有被降级成 select** ——
// AgentOrchestrator 对「带 optionCall 的字段」会把控件改写成 select(防止取回字典后还是输入框),
// userpicker 靠白名单豁免;漏了就变成普通下拉,正是这次要避免的。
FormField receiveList = field(resp, "receiveList");
assertEquals("userpicker", receiveList.getWidget(),
"通知对象应是自绘的「全部用户 / 指定用户」+ 选人弹窗,不能被降级成 select");
assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址(弹窗里用它取原始树)");
assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery());
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵值");
// ★ 选人弹窗有两个页签(模块页 UserCheckInput.vue 的 sources)——这里钉住**拼好前缀**的结果。
// 与附件上传同一个坑:只写相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。
// 注意「自定义组」的路径里带 query,拼前缀时不能被吃掉。
assertNotNull(receiveList.getPickerSources(), "选人弹窗的数据源页签要下发到前端");
assertEquals(2, receiveList.getPickerSources().size());
FormField.PickerSource tab0 = receiveList.getPickerSources().get(0);
assertEquals("基础平台", tab0.getName());
assertNotNull(tab0.getCall());
assertEquals("GET", tab0.getCall().getMethod(), "取组织树是只读 GET");
assertEquals("/notice/api/base/GetSchoolAll", tab0.getCall().getPathAndQuery(),
"代取地址必须带模块前缀 /notice");
FormField.PickerSource tab1 = receiveList.getPickerSources().get(1);
assertEquals("自定义组", tab1.getName());
assertEquals("/notice/api/contacts/GetGroupTree?type=1", tab1.getCall().getPathAndQuery(),
"自定义组树同样要带前缀,且 type=1 要保留");
// 校园公告独有的字段绝不能串到通知公告的表单上
for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) {
for (FormField f : resp.getForm().getFields()) {
assertFalse(foreign.equals(f.getName()),
"通知公告表单不该出现校园公告字段:" + foreign);
}
}
}
@Test
void studentSayingPublishNotifyFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布通知公告", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布通知表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布宿舍通知 ==================== */
@Test
void teacherSayingPublishDormNoticeGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布宿舍通知", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("dorm.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/list/new", resp.getForm().getLandingPath());
// 标题 + 正文(富文本,模块页 NewNotice.vue 用 wangEditor)+ 通知类型(下拉)+ 发文部门(下拉)
assertEquals("text", field(resp, "title").getWidget());
FormField content = field(resp, "content");
assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
assertEquals("/dormnotice/api/file/UploadImage", content.getRichTextUploadPath(),
"正文插图走模块真实前缀 /dormnotice");
assertEquals("/dormnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath());
FormField noticeType = field(resp, "noticeType");
assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号");
assertNotNull(noticeType.getOptionCall());
assertEquals("/dormnotice/api/dict/List?type=noticeType",
noticeType.getOptionCall().getPathAndQuery(), "字典地址必须带 type=noticeType");
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget());
assertNotNull(deptId.getOptionCall());
assertEquals("/dormnotice/api/dict/List?type=dept", deptId.getOptionCall().getPathAndQuery());
assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey());
}
@Test
void studentSayingPublishDormNoticeFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布宿舍通知", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布宿舍通知表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布家校活动 ==================== */
@Test
void teacherSayingPublishActivityGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布活动", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("activity.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite());
assertEquals("/list/new", resp.getForm().getLandingPath());
// 活动表单:标题 + 说明(**富文本**,模块页 PublishActivity.vue 用 wangEditor)+
// 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间
assertEquals("text", field(resp, "title").getWidget());
FormField content = field(resp, "content");
assertEquals("richtext", content.getWidget(), "活动说明用富文本编辑器(模块页 wangEditor)");
assertEquals("/familyactivity/api/file/UploadImage", content.getRichTextUploadPath(),
"正文插图走模块真实前缀 /familyactivity(不是附件的 FileUpload)");
assertEquals("/familyactivity/api/file/Preview?id={id}", content.getRichTextPreviewPath());
FormField category = field(resp, "category");
assertEquals("select", category.getWidget());
assertNotNull(category.getOptionCall());
assertEquals("/familyactivity/api/dict/List?type=2", category.getOptionCall().getPathAndQuery());
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget());
assertNotNull(deptId.getOptionCall());
assertEquals("/familyactivity/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery());
assertNotNull(field(resp, "venue"));
assertNotNull(field(resp, "startTime"));
assertNotNull(field(resp, "endTime"));
}
@Test
void studentSayingPublishActivityFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布活动", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布活动表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布专业发展记录 ==================== */
/**
* 「我要发布专业发展」必须**直接**弹表单,而不是先被模型反问「你要发到哪个栏目」。
*
* <p>用户反馈的原话:说「我要发布专业发展」被问了一轮栏目,答完才出表单,
* 表单里却还留着空的「所属栏目」要再选一次 —— 同一件事问了两遍。
* 栏目是学校自建的(各校 id 不同),所以直达时不预置,直接交给表单里的下拉选。
*/
@Test
void teacherSayingPublishProfessionRecordGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布专业发展", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("profession.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/record/new", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("所属栏目"),
"引导语要点明表单里就能选栏目,实际:" + resp.getReply());
// 「所属栏目」是必填下拉,且带着「用用户自己的登录态去取栏目树」的地址
FormField moduleId = field(resp, "moduleId");
assertEquals("select", moduleId.getWidget(), "栏目应是下拉,不该让用户手填栏目编号");
assertTrue(moduleId.isRequired(), "栏目必填");
assertNotNull(moduleId.getOptionCall(), "栏目下拉应带上「代取地址」");
assertEquals("/profession/api/moudule/ModuleTree", moduleId.getOptionCall().getPathAndQuery());
// 栏目**不能**被直达预置:各校栏目 id 不同,硬编码必然出错
assertFalse(resp.getForm().getPreset().containsKey("moduleId"),
"直达不应预置栏目,实际 preset:" + resp.getForm().getPreset());
// 发布页是全字段表单(与模块页 RecordEdit.vue 对齐)
assertTrue(resp.getForm().isAllFields(), "发布表单应渲染全部字段");
assertNotNull(field(resp, "title"));
assertNotNull(field(resp, "remark"));
assertNotNull(field(resp, "fileIds"));
}
@Test
void studentSayingPublishProfessionRecordFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布专业发展", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布专业发展表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 不该被劫持的情况 ==================== */
@Test
void detailedSentenceStillGoesToTheModel() {
// 带时间与事由的句子由模型处理(它能一次把参数填全),不能被直达规则截走
ChatResponse resp = chat(ECHO_LLM, "我要请10月12日上午的病假,发烧就医", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "含细节的句子不应被直达成空表单");
assertEquals("(模型回复)", resp.getReply());
}
@Test
void guestSayingWantLeaveFallsThroughToModel() {
// 访客无权使用请假工具 → 不直达,交给模型如实说明需要什么身份
ChatResponse resp = chat(ECHO_LLM, "我要请假", caller(AgentRole.GUEST));
assertNull(resp.getForm(), "访客不该拿到请假表单");
assertEquals("(模型回复)", resp.getReply());
}
@Test
void unrelatedMessageStillGoesToTheModel() {
ChatResponse resp = chat(ECHO_LLM, "这周有什么新的校园公告?", caller(AgentRole.STUDENT));
assertNull(resp.getForm());
assertEquals("(模型回复)", resp.getReply());
}
}