package vip.mate.agent; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.UserMessage; import vip.mate.llm.service.ModelCapabilityService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; import java.util.EnumSet; import java.util.List; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** * Issue #44 regression: when a video attachment cannot be passed to the model * (because the agent's resolved {@link ModelCapabilityService.Modality#VIDEO} * capability is absent), the user message must include a system notice listing * the skipped attachment and instructing the agent NOT to invent a tool call to * read it. * *
The original bug: silent skip ({@code log.debug} only) → agent saw * {@code [附件] xxx.mp4} placeholder text in history but no actual media → it * picked {@code BrowserUseTool} or similar to "open" the file, which never * produced useful results. * *
These tests pin the contract that the skip path mutates the prompt text, * not just a log line. * *
Issue #87 update: the previous "禁止调用任何工具" sentence is no longer
* emitted unconditionally — when the agent has any media-capable tool bound,
* the LLM is allowed to delegate to it. With no tools (this test scaffold's
* default), the notice falls back to a "switch models" suggestion only.
*/
class BaseAgentMultimodalSkipNoticeTest {
@Test
@DisplayName("Video attachment + model lacks VIDEO capability → skipped, system notice in prompt text")
void videoSkipped_emitsSystemNotice() {
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
MessageEntity msg = userMessage("看看这段视频");
when(agent.conversationService.parseMessageParts(msg))
.thenReturn(List.of(MessageContentPart.video("media-1", "demo.mp4")));
UserMessage result = agent.callBuildUserMessage(msg, "看看这段视频");
assertNotNull(result);
String text = result.getText();
assertTrue(text.contains("[系统提示]"),
"skipped video must surface a system notice in the prompt text — issue #44");
assertTrue(text.contains("demo.mp4"),
"notice must name the skipped file so the agent can tell the user");
assertTrue(text.contains("不支持视频输入"),
"reason string must name the modality the model cannot consume");
assertTrue(text.contains("建议切换"),
"notice must tell the agent to recommend switching models when no media tool is bound");
assertFalse(text.contains("不要调用任何工具"),
"issue #87: the hard tool ban must be gone — bound media tools should still be usable");
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
"video must NOT be injected as Media when capability is absent");
}
@Test
@DisplayName("Image attachment + model lacks VISION capability → skipped, system notice")
void imageSkipped_emitsSystemNotice() {
// Regression for the "GLM-5-Turbo + image upload" failure: when a user
// uploads an image to a text-only model, we used to pass the image through
// anyway and let the API 400. Now we skip + notify, same as the video gate.
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
MessageEntity msg = userMessage("看看这张图");
when(agent.conversationService.parseMessageParts(msg))
.thenReturn(List.of(MessageContentPart.file("media-1", "poster.png", "image/png")));
UserMessage result = agent.callBuildUserMessage(msg, "看看这张图");
String text = result.getText();
assertTrue(text.contains("poster.png"));
assertTrue(text.contains("不支持图片输入"),
"vision-skip notice must use 不支持图片输入 wording");
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
"image must NOT be injected when model has no VISION capability");
}
@Test
@DisplayName("Vision-capable model + image with only a remote URL (never downloaded) → actionable 'enable media download' hint")
void remoteOnlyImage_emitsDownloadHint() {
// Issue #303 follow-up: WeCom/aibot delivers images as short-lived,
// AES-encrypted COS URLs. With channel media download off, the part is
// stored URL-only (path=null, mediaId=https://...). The model supports
// vision, so we reach the file-resolution branch — which must surface an
// actionable hint instead of a dead-end "文件未找到".
TestAgent agent = newAgentWithCaps(
EnumSet.of(ModelCapabilityService.Modality.VISION, ModelCapabilityService.Modality.TEXT));
MessageEntity msg = userMessage("看看这张图");
MessageContentPart remote = new MessageContentPart();
remote.setType("image");
remote.setContentType("image/jpeg");
remote.setFileName("image.jpg");
remote.setMediaId("https://ww-aibot-img.cos.example.com/x?sign=abc");
when(agent.conversationService.parseMessageParts(msg)).thenReturn(List.of(remote));
UserMessage result = agent.callBuildUserMessage(msg, "看看这张图");
String text = result.getText();
assertTrue(text.contains("未下载到本地"),
"remote-only attachment must hint that it was never downloaded locally");
assertTrue(text.contains("开启") && text.contains("媒体下载"),
"hint must tell the user to enable channel media download");
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
"a remote URL must not be injected as Media");
}
@Test
@DisplayName("No attachments → no system notice, prompt text unchanged")
void noAttachments_noNoticeAdded() {
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
MessageEntity msg = userMessage("hello");
when(agent.conversationService.parseMessageParts(msg)).thenReturn(List.of());
UserMessage result = agent.callBuildUserMessage(msg, "hello");
assertFalse(result.getText().contains("[系统提示]"),
"no skipped attachments → no notice; clean prompt for normal text-only turns");
}
@Test
@DisplayName("Capable model + video → no system notice (notice only fires on actual skip)")
void videoCapable_noNotice_attemptInjection() {
// VIDEO capability present → no skip-on-capability-grounds. The injection itself
// may still fail downstream (file path doesn't exist in this test) — when that
// happens, the file-not-found / load-failure branch surfaces its OWN notice with
// a different reason string. We assert the capability-skip reason is absent here.
TestAgent agent = newAgentWithCaps(
EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT));
MessageEntity msg = userMessage("分析视频");
when(agent.conversationService.parseMessageParts(msg))
.thenReturn(List.of(MessageContentPart.video("media-1", "ok.mp4")));
UserMessage result = agent.callBuildUserMessage(msg, "分析视频");
assertFalse(result.getText().contains("不支持视频输入"),
"capable model must not be flagged as missing video capability");
}
@Test
@DisplayName("History replay (injectMedia=false) returns text-only — no Media accumulation")
void historyReplay_dropsMedia() {
// Regression for Zhipu GLM-5V "code:1210 input videos exceeds limit": each
// historical user message previously re-injected its video Media on every
// turn, so a 2-turn conversation hit the per-request 1-video cap. The
// history path must drop Media even when the model supports video.
TestAgent agent = newAgentWithCaps(
EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT));
MessageEntity msg = userMessage("上一轮的视频");
// parseMessageParts is irrelevant when injectMedia=false; verify by NOT stubbing it.
UserMessage result = agent.callBuildUserMessage(msg, "上一轮的视频", false);
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
"history replay must NOT carry Media — even capable models cap video count per request");
assertFalse(result.getText().contains("[系统提示]"),
"history replay must NOT add the skip notice — the skip notice is a current-turn concern");
}
// ---------- Test scaffold ----------
private static MessageEntity userMessage(String content) {
MessageEntity m = new MessageEntity();
m.setRole("user");
m.setContent(content);
return m;
}
private static TestAgent newAgentWithCaps(EnumSet